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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user