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
@@ -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