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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user