diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx index 9e3f42bf..73b7aefb 100644 --- a/assets/admin/pages/AppointmentDetailPage.tsx +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { Appointment, AppointmentStatus } from '../types'; -import { formatDate, formatDateTime } from '../lib/utils'; +import { formatDate, formatDateTime, toDate } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -22,6 +22,13 @@ const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [ { value: 'expired', label: 'منقضی' }, ]; +const timeOf = (ts?: number | null) => { + const d = toDate(ts ?? null); + return d + ? new Intl.DateTimeFormat('fa-IR-u-nu-latn', { hour: '2-digit', minute: '2-digit', hour12: false }).format(d) + : '—'; +}; + function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -64,7 +71,8 @@ export default function AppointmentDetailPage() { onError: (err: Error) => toast.error(err.message), }); - const appt = data?.data; + // پاسخ single تودرتو است: { data: { data: {...} } } + const appt: any = (data?.data as any)?.data ?? data?.data; return (
@@ -96,10 +104,12 @@ export default function AppointmentDetailPage() {

اطلاعات بیمار

{appt.patient_mobile}} /> - - - - + + + + + {appt.patient_reason && } + {appt.note && }
diff --git a/assets/admin/pages/PaymentDetailPage.tsx b/assets/admin/pages/PaymentDetailPage.tsx index d7e256dd..1f8febda 100644 --- a/assets/admin/pages/PaymentDetailPage.tsx +++ b/assets/admin/pages/PaymentDetailPage.tsx @@ -9,6 +9,12 @@ import { formatDate, formatDateTime, formatRial } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; +const PAYMENT_TYPE_LABELS: Record = { + appointment: 'نوبت', + subscription: 'اشتراک', + sms_wallet: 'کیف پول پیامک', +}; + function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -24,7 +30,7 @@ export default function PaymentDetailPage() { const { data, isLoading } = useQuery({ queryKey: ['payment', uuid], - queryFn: () => api.get>(`/api/v1/payment/${uuid}`), + queryFn: () => api.get>(`/api/v1/admin/payments/${uuid}`), enabled: !!uuid, }); @@ -58,7 +64,10 @@ export default function PaymentDetailPage() {
{payment.uuid}} /> + {payment.order_id} : null} /> + {payment.patient_mobile}} /> + } /> {payment.gateway}} /> diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 165d366e..84986202 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -122,6 +122,9 @@ export interface Payment { appointment_uuid: string | null; paid_at: string | null; created_at: string; + order_id?: string; + patient_name?: string | null; + type?: string; } export type SettlementStatus = "pending" | "approved" | "rejected"; diff --git a/docs/api/admin.md b/docs/api/admin.md index 48c649d7..4a4202de 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -600,6 +600,11 @@ List all payments. | `limit` | integer | ❌ | Default: 20 | | `status` | string | ❌ | `"pending"`, `"paid"`, `"failed"`, `"cancelled"` | +### Query Parameters (تکمیل) +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id` یا `order_id` | + ### Response `200` ```json { @@ -607,17 +612,55 @@ List all payments. "data": [ { "uuid": "...", - "order_id": "CLINICPRO-...", - "amount_rials": 500000, - "status": "paid", + "amount": 500000, + "status": "success", "gateway": "mellat", - "created_at": 1717000000 + "ref_id": "1234567", + "patient_mobile": "0912...", + "paid_at": "2026-07-02T09:00:00+03:30", + "created_at": "2026-07-02T08:55:00+03:30" } ], "meta": { "totalRecords": 7800, "totalPages": 390, "currentPage": 1 } } ``` +> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر این‌صورت `null`. تاریخ‌ها ISO-8601. + +--- + +### GET `/api/v1/admin/payments/{uuid}` + +جزئیات یک پرداخت. **پاسخ تخت است** (`data` مستقیم آبجکت پرداخت، نه nested). + +**Permission:** `ROLE_ADMIN` + +### Response `200` +```json +{ + "success": true, + "data": { + "uuid": "...", + "order_id": "ORD-XXXX", + "amount": 500000, + "status": "success", + "gateway": "mellat", + "type": "appointment", + "ref_id": "1234567", + "patient_mobile": "0912...", + "patient_name": "علی احمدی", + "appointment_uuid": "...", + "paid_at": "2026-07-02T09:00:00+03:30", + "created_at": "2026-07-02T08:55:00+03:30" + } +} +``` + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | پرداخت یافت نشد | + --- ## Settlement Management diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 23daeb14..137e87b0 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -874,6 +874,54 @@ class AdminApiController extends BaseController return $this->paginated($items, (int) $total, $page, $limit); } + #[OA\Get( + path: '/api/v1/admin/payments/{uuid}', + summary: 'Payment detail by UUID', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Payment detail'), + new OA\Response(response: 404, description: 'Payment not found'), + ] + )] + #[Route('/api/v1/admin/payments/{uuid}', methods: ['GET'])] + public function paymentDetail(string $uuid): JsonResponse + { + $rows = $this->em->createQueryBuilder() + ->select( + 'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.createdAt, p.updatedAt', + 'u.mobileNumber as patient_mobile, u.realName as patient_name', + 'a.uuid as appointment_uuid', + ) + ->from(Payment::class, 'p') + ->join('p.user', 'u') + ->leftJoin('p.appointment', 'a') + ->where('p.uuid = :uuid')->setParameter('uuid', $uuid) + ->getQuery()->getArrayResult(); + + if (empty($rows)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404); + } + + $p = $rows[0]; + return $this->success([ + 'uuid' => $p['uuid'], + 'order_id' => $p['orderId'], + 'amount' => (int) $p['amountRials'], + 'status' => $p['status'], + 'gateway' => $p['gateway'], + 'type' => $p['type'], + 'ref_id' => $p['referenceId'], + 'patient_mobile' => $p['patient_mobile'], + 'patient_name' => $p['patient_name'], + 'appointment_uuid' => $p['appointment_uuid'], + 'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null, + 'created_at' => date('c', (int) $p['createdAt']), + ]); + } + // ── Representations ─────────────────────────────────────────────────────── #[OA\Get( diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php index 2ce4a392..dac80e30 100644 --- a/src/Payment/Gateway/MellatGateway.php +++ b/src/Payment/Gateway/MellatGateway.php @@ -14,12 +14,15 @@ class MellatGateway implements PaymentGatewayInterface private readonly HttpClientInterface $httpClient, private readonly SiteConfigRepository $configRepo, private readonly LoggerInterface $logger, - private readonly string $terminalId = '', - private readonly string $username = '', - private readonly string $password = '', + private readonly ?string $terminalId = '', + private readonly ?string $username = '', + private readonly ?string $password = '', ) {} - public function getName(): string { return 'mellat'; } + public function getName(): string + { + return 'mellat'; + } public function isConfigured(): bool { @@ -28,16 +31,18 @@ class MellatGateway implements PaymentGatewayInterface && $this->cfg('mellat_password', $this->password) !== ''; } - private function cfg(string $key, string $envFallback): string + private function cfg(string $key, ?string $envFallback): string { - return $this->configRepo->get($key) ?: $envFallback; + return (string) ($this->configRepo->get($key) ?: $envFallback ?? ''); } public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult { try { - $response = $this->httpClient->request('POST', - 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [ + $response = $this->httpClient->request( + 'POST', + 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', + [ 'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl), 'headers' => ['Content-Type' => 'text/xml; charset=utf-8'], 'timeout' => 10, @@ -82,8 +87,10 @@ class MellatGateway implements PaymentGatewayInterface } try { - $response = $this->httpClient->request('POST', - 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [ + $response = $this->httpClient->request( + 'POST', + 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', + [ 'body' => $this->buildVerifyPayload($refId), 'headers' => ['Content-Type' => 'text/xml; charset=utf-8'], 'timeout' => 10, @@ -164,6 +171,12 @@ XML; return trim($parts[1] ?? ''); } - private function date(): string { return date('Ymd'); } - private function time(): string { return date('His'); } + private function date(): string + { + return date('Ymd'); + } + private function time(): string + { + return date('His'); + } } diff --git a/src/Payment/Gateway/SepGateway.php b/src/Payment/Gateway/SepGateway.php index be55a015..660b700f 100644 --- a/src/Payment/Gateway/SepGateway.php +++ b/src/Payment/Gateway/SepGateway.php @@ -15,19 +15,22 @@ class SepGateway implements PaymentGatewayInterface private readonly HttpClientInterface $httpClient, private readonly SiteConfigRepository $configRepo, private readonly LoggerInterface $logger, - private readonly string $terminalId = '', + private readonly ?string $terminalId = '', ) {} - public function getName(): string { return 'sep'; } + public function getName(): string + { + return 'sep'; + } public function isConfigured(): bool { return $this->cfg('sep_terminal_id', $this->terminalId) !== ''; } - private function cfg(string $key, string $envFallback): string + private function cfg(string $key, ?string $envFallback): string { - return $this->configRepo->get($key) ?: $envFallback; + return (string) ($this->configRepo->get($key) ?: $envFallback ?? ''); } public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult diff --git a/tests/Payment/MellatGatewayTest.php b/tests/Payment/MellatGatewayTest.php new file mode 100644 index 00000000..9ac2eb0c --- /dev/null +++ b/tests/Payment/MellatGatewayTest.php @@ -0,0 +1,25 @@ +createMock(HttpClientInterface::class); + $configRepo = $this->createMock(SiteConfigRepository::class); + $logger = $this->createMock(LoggerInterface::class); + + $configRepo->method('get')->willReturn(null); + + $gateway = new MellatGateway($httpClient, $configRepo, $logger, null, null, null); + + $this->assertFalse($gateway->isConfigured()); + } +}