feat(migrations): add national_code_verified flag to users and normalize bank_account representation

- Added a new column `national_code_verified` to the `users` table.
- Normalized the `bank_account` field in the `representations` table from a single object to an array of IBANs with a default `verified` status of false.

feat(ApiIrService): implement identity verification client for api.ir

- Created `ApiIrService` to handle identity verification via api.ir.
- Implemented methods for matching national code with mobile and IBAN with national code and birth date.
- Added error handling and logging for external API requests.
This commit is contained in:
hamed
2026-06-25 19:38:47 +03:30
parent 9b608aaeac
commit c2c6ae4d02
515 changed files with 194899 additions and 55 deletions
@@ -1487,6 +1487,8 @@ class AdminApiController extends BaseController
'status' => $s['status'],
'bank_card' => $s['bankAccount']['card'] ?? null,
'bank_name' => $s['bankAccount']['bank_name'] ?? null,
'bank_iban' => $s['bankAccount']['iban'] ?? null,
'bank_owner' => $s['bankAccount']['owner_name'] ?? null,
'reject_reason' => $s['adminNote'],
'requested_at' => date('c', (int) $s['createdAt']),
'processed_at' => $s['reviewedAt'] ? date('c', (int) $s['reviewedAt']) : null,
+15 -1
View File
@@ -35,6 +35,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(name: 'national_code_verified', type: 'boolean')]
private bool $nationalCodeVerified = false;
#[ORM\Column(type: 'json')]
private array $roles = ['ROLE_USER'];
@@ -61,6 +64,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function getEmail(): ?string { return $this->email; }
public function getRealName(): ?string { return $this->realName; }
public function getNationalCode(): ?string { return $this->nationalCode; }
public function isNationalCodeVerified(): bool { return $this->nationalCodeVerified; }
public function getStatus(): int { return $this->status; }
public function getCreatedAt(): int { return $this->createdAt; }
@@ -81,7 +85,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function setEmail(?string $email): self { $this->email = $email; return $this; }
public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
public function setNationalCode(?string $code): self { $this->nationalCode = $code !== null && $code !== '' ? $code : null; $this->updatedAt = time(); return $this; }
public function setNationalCode(?string $code): self
{
$normalized = $code !== null && $code !== '' ? $code : null;
if ($normalized !== $this->nationalCode) {
$this->nationalCodeVerified = false; // تغییر کد ملی، تأیید قبلی را باطل می‌کند
}
$this->nationalCode = $normalized;
$this->updatedAt = time();
return $this;
}
public function setNationalCodeVerified(bool $v): self { $this->nationalCodeVerified = $v; $this->updatedAt = time(); return $this; }
public function setPasswordHash(?string $hash): self { $this->passwordHash = $hash; $this->updatedAt = time(); return $this; }
public function setRoles(array $roles): self { $this->roles = $roles; $this->updatedAt = time(); return $this; }
public function setStatus(int $status): self { $this->status = $status; $this->updatedAt = time(); return $this; }
@@ -35,6 +35,7 @@ class RepresentationActionController extends BaseController
private readonly \App\Subscription\Service\SubscriptionService $subscriptionService,
private readonly \App\Sms\Service\SmsService $smsService,
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
private readonly \App\Shared\Service\ApiIrService $apiIr,
) {}
#[OA\Get(
@@ -54,7 +55,177 @@ class RepresentationActionController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده‌ای برای این کاربر یافت نشد', 404);
}
return $this->success(['data' => $rep->toArray()]);
return $this->success(['data' => $this->repProfile($rep, $user)]);
}
/** خروجی پروفایل نماینده + وضعیت هویت کاربر. */
private function repProfile(\App\Representation\Entity\Representation $rep, User $user): array
{
return $rep->toArray() + [
'national_code' => $user->getNationalCode(),
'national_code_verified' => $user->isNationalCodeVerified(),
];
}
#[OA\Post(
path: '/api/v1/representation/verify-national-code',
summary: 'تأیید کد ملی نماینده با استعلام شاهکار',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['national_code'],
properties: [new OA\Property(property: 'national_code', type: 'string', example: '0012345678')]
)
),
responses: [
new OA\Response(response: 200, description: 'کد ملی تأیید شد'),
new OA\Response(response: 422, description: 'کد ملی نامعتبر یا متعلق به این موبایل نیست'),
new OA\Response(response: 502, description: 'خطا در استعلام'),
new OA\Response(response: 503, description: 'سرویس استعلام پیکربندی نشده'),
]
)]
#[Route('/api/v1/representation/verify-national-code', methods: ['POST'])]
public function verifyNationalCode(Request $request, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUser($user);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده‌ای برای این کاربر یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$nationalCode = preg_replace('/\D/', '', (string) ($data['national_code'] ?? ''));
if (strlen($nationalCode) !== 10) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422, 'national_code');
}
if (!$this->apiIr->shahkarMatch($nationalCode, $user->getMobileNumber())) {
return $this->error(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, ErrorCodes::message(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH), 422, 'national_code');
}
$user->setNationalCode($nationalCode);
$user->setNationalCodeVerified(true);
$this->em->flush();
return $this->success(['data' => $this->repProfile($rep, $user)]);
}
#[OA\Post(
path: '/api/v1/representation/iban',
summary: 'افزودن شماره شبا (با تطبیق مالکیت IbanMatch)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['iban', 'birth_date'],
properties: [
new OA\Property(property: 'iban', type: 'string', example: 'IR000000000000000000000000'),
new OA\Property(property: 'birth_date', type: 'string', example: '1370/01/01', description: 'تاریخ تولد شمسی — فقط برای استعلام، ذخیره نمی‌شود'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'شبا اضافه شد'),
new OA\Response(response: 409, description: 'کد ملی تأیید نشده یا سقف ۲ شبا'),
new OA\Response(response: 422, description: 'شبا/تاریخ تولد نامعتبر یا شبا متعلق به نماینده نیست'),
]
)]
#[Route('/api/v1/representation/iban', methods: ['POST'])]
public function addIban(Request $request, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUser($user);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده‌ای برای این کاربر یافت نشد', 404);
}
if (!$user->isNationalCodeVerified()) {
return $this->error(ErrorCodes::ERR_NATIONAL_CODE_UNVERIFIED, ErrorCodes::message(ErrorCodes::ERR_NATIONAL_CODE_UNVERIFIED), 409);
}
if (count($rep->getIbans()) >= 2) {
return $this->error(ErrorCodes::ERR_IBAN_LIMIT, ErrorCodes::message(ErrorCodes::ERR_IBAN_LIMIT), 409);
}
$data = json_decode($request->getContent(), true) ?? [];
$iban = $this->normalizeIban((string) ($data['iban'] ?? ''));
if ($iban === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر است', 422, 'iban');
}
// تاریخ تولد فقط برای استعلام IbanMatch لازم است و ذخیره نمی‌شود.
$birthDate = $this->normalizeBirthDate((string) ($data['birth_date'] ?? ''));
if ($birthDate === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ تولد نامعتبر است (نمونه: 1370/01/01)', 422, 'birth_date');
}
$match = $this->apiIr->ibanMatch($iban, (string) $user->getNationalCode(), $birthDate);
if (!$match['matched']) {
return $this->error(ErrorCodes::ERR_IBAN_MISMATCH, ErrorCodes::message(ErrorCodes::ERR_IBAN_MISMATCH), 422, 'iban');
}
$rep->addIban([
'iban' => $iban,
'bank_name' => $match['bank_name'],
'owner_name' => $match['owner_name'] ?? $rep->getFullName(),
'verified' => true,
]);
$this->em->flush();
return $this->success(['data' => $this->repProfile($rep, $user)]);
}
#[OA\Delete(
path: '/api/v1/representation/iban/{id}',
summary: 'حذف یک شماره شبا',
security: [['bearerAuth' => []]],
parameters: [new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [new OA\Response(response: 200, description: 'شبا حذف شد')]
)]
#[Route('/api/v1/representation/iban/{id}', methods: ['DELETE'])]
public function removeIban(string $id, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUser($user);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده‌ای برای این کاربر یافت نشد', 404);
}
$rep->removeIban($id);
$this->em->flush();
return $this->success(['data' => $this->repProfile($rep, $user)]);
}
/** نرمال‌سازی و اعتبارسنجی شکلی شبا. خروجی null یعنی نامعتبر. */
private function normalizeIban(string $raw): ?string
{
$iban = strtoupper(preg_replace('/\s+/', '', $raw));
if (!str_starts_with($iban, 'IR')) {
$iban = 'IR' . ltrim($iban, 'irIR');
}
return preg_match('/^IR\d{24}$/', $iban) === 1 ? $iban : null;
}
/**
* نرمال‌سازی تاریخ تولد شمسی به فرمت Y/m/d (نمونه: 1370/01/01).
* ارقام فارسی، جداکننده‌های - یا / و طول ناقص پذیرفته و یکدست می‌شوند. خروجی null یعنی نامعتبر.
*/
private function normalizeBirthDate(string $raw): ?string
{
$digits = strtr(trim($raw), ['۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9']);
$parts = preg_split('/[\/\-.]/', $digits);
if (count($parts) !== 3) {
return null;
}
[$y, $m, $d] = $parts;
if (!ctype_digit($y) || !ctype_digit($m) || !ctype_digit($d)) {
return null;
}
$y = (int) $y; $m = (int) $m; $d = (int) $d;
if ($y < 1280 || $y > 1450 || $m < 1 || $m > 12 || $d < 1 || $d > 31) {
return null;
}
return sprintf('%04d/%02d/%02d', $y, $m, $d);
}
#[OA\Post(
@@ -34,6 +34,10 @@ class Representation
#[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)]
private string $commissionPercent = '10.00';
/**
* آرایه‌ی ۰ تا ۲ شماره شبای نماینده.
* هر آیتم: { id, iban, bank_name, owner_name, verified, created_at }
*/
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
private ?array $bankAccount = null;
@@ -72,6 +76,53 @@ class Representation
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** @return array<int,array<string,mixed>> */
public function getIbans(): array { return $this->bankAccount ?? []; }
/**
* افزودن یک شبا (حداکثر ۲). id خودکار تولید می‌شود.
* @param array{iban:string,bank_name:?string,owner_name:?string,verified?:bool} $iban
*/
public function addIban(array $iban): self
{
$ibans = $this->getIbans();
if (count($ibans) >= 2) {
throw new \DomainException('iban_limit');
}
$ibans[] = [
'id' => Uuid::v4()->toRfc4122(),
'iban' => $iban['iban'],
'bank_name' => $iban['bank_name'] ?? null,
'owner_name' => $iban['owner_name'] ?? null,
'verified' => $iban['verified'] ?? false,
'created_at' => time(),
];
$this->bankAccount = $ibans;
$this->touch();
return $this;
}
public function removeIban(string $id): self
{
$this->bankAccount = array_values(array_filter(
$this->getIbans(),
fn(array $i) => ($i['id'] ?? null) !== $id
));
$this->touch();
return $this;
}
/** @return array<string,mixed>|null یک شبای تأییدشده با این id */
public function findVerifiedIban(string $id): ?array
{
foreach ($this->getIbans() as $iban) {
if (($iban['id'] ?? null) === $id && ($iban['verified'] ?? false)) {
return $iban;
}
}
return null;
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
@@ -24,6 +24,7 @@ class SettlementController extends BaseController
public function __construct(
private readonly SettlementRepository $settlementRepo,
private readonly WalletTransactionRepository $walletRepo,
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
private readonly \App\Shared\Service\FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
@@ -131,17 +132,34 @@ class SettlementController extends BaseController
{
$data = json_decode($request->getContent(), true) ?? [];
$amountRials = (int) ($data['amount_rials'] ?? 0);
$bankAccount = $data['bank_account'] ?? null;
$ibanId = trim((string) ($data['iban_id'] ?? ''));
if ($amountRials <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بیشتر از صفر باشد', 422);
}
if ($ibanId === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب شماره شبا الزامی است', 422, 'iban_id');
}
$rep = $this->representationRepo->findByUser($user);
$iban = $rep?->findVerifiedIban($ibanId);
if ($iban === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر یا تأییدنشده است', 422, 'iban_id');
}
$balance = $this->settlementRepo->getWalletBalance($user);
if ($amountRials > $balance) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'موجودی کافی نیست', 422);
}
// snapshot شبای انتخابی در خود رکورد ذخیره می‌شود تا حذف بعدی شبا، این رکورد را خراب نکند
$bankAccount = [
'iban' => $iban['iban'],
'bank_name' => $iban['bank_name'] ?? null,
'owner_name' => $iban['owner_name'] ?? null,
];
$settlement = new Settlement($user, $amountRials, $bankAccount);
$this->settlementRepo->save($settlement);
@@ -401,6 +419,50 @@ class SettlementController extends BaseController
return $this->success(['data' => $settlement->toArray()]);
}
#[OA\Post(
path: '/api/v1/settlement/{uuid}/receipt',
summary: 'ذخیره‌ی رسید پرداخت بدون نهایی‌سازی (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['receipt'],
properties: [new OA\Property(property: 'receipt', type: 'string', description: 'URL رسید آپلودشده')]
)
),
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'رسید ذخیره شد'),
new OA\Response(response: 404, description: 'یافت نشد'),
new OA\Response(response: 422, description: 'وضعیت نامعتبر یا رسید خالی'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/settlement/{uuid}/receipt', methods: ['POST'])]
public function saveReceipt(string $uuid, Request $request): JsonResponse
{
$settlement = $this->settlementRepo->findByUuid($uuid);
if ($settlement === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
}
// فقط روی تسویه‌ی تأییدشده می‌توان رسید گذاشت (قبل از تکمیل).
if ($settlement->getStatus() !== Settlement::STATUS_APPROVED) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فقط تسویه‌ی تأییدشده قابل ثبت رسید است', 422);
}
$data = json_decode($request->getContent(), true) ?? [];
$receipt = trim((string) ($data['receipt'] ?? ''));
if ($receipt === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'رسید پرداخت الزامی است', 422, 'receipt');
}
$settlement->setReceipt($receipt);
$this->settlementRepo->save($settlement);
return $this->success(['data' => $settlement->toArray()]);
}
private function handleFileUpload(Request $request, string $subDir): JsonResponse
{
$content = $request->getContent();
+7
View File
@@ -74,6 +74,13 @@ class Settlement
public function getAdminNote(): ?string { return $this->adminNote; }
public function getReceipt(): ?string { return $this->receipt; }
public function setReceipt(?string $receipt): self
{
$this->receipt = $receipt;
$this->updatedAt = time();
return $this;
}
public function approve(int $adminUserId, ?string $note = null): self
{
$this->status = self::STATUS_APPROVED;
+14
View File
@@ -75,6 +75,14 @@ class ErrorCodes
// Rating
public const ERR_RATING_NOT_ELIGIBLE = 'ERR_RATING_NOT_ELIGIBLE';
// External Inquiry (s.api.ir)
public const ERR_EXTERNAL_001 = 'ERR_EXTERNAL_001';
public const ERR_EXTERNAL_NOT_CONFIGURED = 'ERR_EXTERNAL_002';
public const ERR_NATIONAL_CODE_MISMATCH = 'ERR_IDENTITY_001';
public const ERR_IBAN_MISMATCH = 'ERR_IDENTITY_002';
public const ERR_IBAN_LIMIT = 'ERR_IDENTITY_003';
public const ERR_NATIONAL_CODE_UNVERIFIED = 'ERR_IDENTITY_004';
public static function message(string $code): string
{
return match ($code) {
@@ -113,6 +121,12 @@ class ErrorCodes
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید',
self::ERR_EXTERNAL_001 => 'خطا در استعلام. لطفاً بعداً تلاش کنید',
self::ERR_EXTERNAL_NOT_CONFIGURED => 'سرویس استعلام پیکربندی نشده است',
self::ERR_NATIONAL_CODE_MISMATCH => 'کد ملی متعلق به این شماره موبایل نیست',
self::ERR_IBAN_MISMATCH => 'شماره شبا متعلق به شما نیست',
self::ERR_IBAN_LIMIT => 'حداکثر دو شماره شبا می‌توانید ثبت کنید',
self::ERR_NATIONAL_CODE_UNVERIFIED => 'ابتدا کد ملی خود را تأیید کنید',
default => 'خطای ناشناخته',
};
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Shared\Service;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* کلاینت استعلام هویت api.ir (s.api.ir).
*
* دو سرویس: شاهکار (تطبیق کد ملی با موبایل) و IbanMatch (تطبیق شبا با کد ملی).
*
* نگاشت فیلدها بر اساس قرارداد متداول api.ir پیاده شده است. اگر پاسخ واقعی
* سرویس نام فیلد متفاوتی داشت، فقط همین کلاس (متدهای parse*) باید اصلاح شود.
*
* رفتار «fail-closed»: اگر توکن پیکربندی نشده باشد، استعلام انجام نمی‌شود و
* خطا برمی‌گردد — هرگز به‌صورت پیش‌فرض «تأییدشده» برنمی‌گرداند تا مسیر پولی
* (مالکیت شبا) به‌اشتباه تأیید نشود.
*/
class ApiIrService
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $baseUrl, // https://s.api.ir
private readonly string $token, // توکن api.ir — از .env
) {}
public function isConfigured(): bool
{
return $this->token !== '';
}
/**
* تطبیق کد ملی با موبایل (شاهکار). true یعنی هر دو متعلق به یک نفر است.
*/
public function shahkarMatch(string $nationalCode, string $mobile): bool
{
// پاسخ: {"data": true|false, "success": true, "code": 0, "message": "..."}
$data = $this->post('/api/sw1/ShahkarLite', [
'nationalCode' => $nationalCode,
'mobile' => $mobile,
]);
return $this->extractMatched($data);
}
/**
* تطبیق شبا با کد ملی و تاریخ تولد.
*
* پاسخ سرویس فقط نتیجه‌ی boolean (`data`) می‌دهد و نام بانک/صاحب حساب را برنمی‌گرداند.
*
* @param string $birthDate تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1370/01/01)
* @return array{matched: bool, bank_name: ?string, owner_name: ?string}
*/
public function ibanMatch(string $iban, string $nationalCode, string $birthDate): array
{
$data = $this->post('/api/sw1/IbanMatch', [
'iban' => $iban,
'nationalCode' => $nationalCode,
'birthDate' => $birthDate,
]);
return [
'matched' => $this->extractMatched($data),
'bank_name' => null,
'owner_name' => null,
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function post(string $path, array $payload): array
{
if (!$this->isConfigured()) {
throw new AppException(ErrorCodes::ERR_EXTERNAL_NOT_CONFIGURED, null, 503);
}
try {
$response = $this->httpClient->request('POST', rtrim($this->baseUrl, '/') . $path, [
'json' => $payload,
'headers' => ['Authorization' => 'Bearer ' . $this->token],
'timeout' => 10,
]);
$status = $response->getStatusCode();
if ($status >= 500) {
throw new AppException(ErrorCodes::ERR_EXTERNAL_001, null, 502);
}
return $response->toArray(false);
} catch (AppException $e) {
throw $e;
} catch (\Throwable $e) {
$this->logger->error('api.ir inquiry failed', ['path' => $path, 'error' => $e->getMessage()]);
throw new AppException(ErrorCodes::ERR_EXTERNAL_001, null, 502);
}
}
/**
* استخراج نتیجه‌ی boolean از پاسخ api.ir.
*
* قالب پاسخ: {"data": true|false, "success": true, "code": 0, "message": "..."}
* - success=false یعنی ورودی نامعتبر/خطای درخواست → نتیجه «عدم تطبیق».
* - data همان نتیجه‌ی boolean تطبیق است.
*
* @param array<string,mixed> $data
*/
private function extractMatched(array $data): bool
{
if (($data['success'] ?? false) !== true) {
return false;
}
return filter_var($data['data'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
}