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
+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);
}
}