diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 51f833c1..5467e88a 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -21,6 +21,13 @@ beforeEach(() => { user_mobile: '09120000000', user_national_code: '1234567890', profile: { gender: 'female', referral_source: 'اینستاگرام', address: 'یزد', description: 'یادداشت' }, } }); + if (url === '/api/v1/patient/r1/payments') return Promise.resolve({ success: true, data: [ + { uuid: 'p1', amount_rials: 250000, status: 'success', gateway: 'mellat', created_at: 1700000000 }, + ], meta: { totalRecords: 1 } }); + if (url === '/api/v1/patient/r1/wallet') return Promise.resolve({ success: true, data: { + balance_rials: 300000, + recent_transactions: [{ uuid: 't1', amount_rials: 500000, type: 'credit', description: 'شارژ', balance_after: 300000, created_at: 1700000000 }], + } }); return Promise.resolve({ success: true, data: [] }); }); }); @@ -83,6 +90,22 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => { expect(link).toHaveAttribute('href', '/admin/patients/r1/session/new'); }); + it('lists patient payments with status label on the payments tab', async () => { + renderDetail(); + await screen.findByText('ساغر صابری'); + fireEvent.click(screen.getByText('پرداخت‌ها')); + expect(await screen.findByText('موفق')).toBeInTheDocument(); + expect(screen.getByText(/mellat/)).toBeInTheDocument(); + }); + + it('shows wallet balance and a transaction on the wallet tab', async () => { + renderDetail(); + await screen.findByText('ساغر صابری'); + fireEvent.click(screen.getByText('کیف پول')); + expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument(); + expect(await screen.findByText('شارژ')).toBeInTheDocument(); + }); + it('renders the messages tab with a send box', async () => { renderDetail(); await screen.findByText('ساغر صابری'); diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index a50ffe25..760ed093 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -13,7 +13,7 @@ import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord } from '../types'; import { useAuthStore } from '../stores/authStore'; -import { formatDate } from '../lib/utils'; +import { formatDate, formatRial } from '../lib/utils'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PersianDateInput from '../components/ui/PersianDateInput'; @@ -34,6 +34,10 @@ const TABS: { key: TabKey; label: string; icon: React.ElementType }[] = [ const GENDER_LABEL: Record = { male: 'مرد', female: 'زن' }; +const PAYMENT_STATUS: Record = { + pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت', +}; + function Placeholder({ label }: { label: string }) { return (
@@ -74,6 +78,11 @@ export default function PatientDetailPage() { queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`), enabled: !!uuid && tab === 'appointments', }); + const paymentsQ = useQuery>({ + queryKey: ['patient-payments', uuid], + queryFn: () => api.get(`/api/v1/patient/${uuid}/payments`), + enabled: !!uuid && tab === 'payments', + }); return (
@@ -132,6 +141,11 @@ export default function PatientDetailPage() { ) : tab === 'appointments' ? ( ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} /> + ) : tab === 'payments' ? ( + ({ title: formatRial(p.amount_rials), meta: [p.created_at ? formatDate(p.created_at) : '', p.gateway].filter(Boolean).join(' · '), badge: PAYMENT_STATUS[p.status] || p.status })} /> + ) : tab === 'wallet' ? ( + ) : tab === 'attach' ? ( ) : tab === 'records' ? ( @@ -389,6 +403,48 @@ function MessagesTab({ uuid }: { uuid: string }) { ); } +interface WalletTxn { uuid: string; amount_rials: number; type: string; description?: string | null; balance_after: number; created_at: number } + +/** کیف پول — patient wallet balance card + recent-transaction ledger. */ +function WalletTab({ uuid }: { uuid: string }) { + const { data, isLoading } = useQuery>({ + queryKey: ['patient-wallet', uuid], + queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`), + enabled: !!uuid, + }); + if (isLoading) return
در حال بارگذاری...
; + const balance = data?.data?.balance_rials ?? 0; + const txns = data?.data?.recent_transactions ?? []; + return ( +
+
+
موجودی کیف پول
+
{formatRial(balance)}
+
+ {txns.length === 0 ? ( +
تراکنشی ثبت نشده است
+ ) : ( +
+ {txns.map((t) => { + const credit = t.type === 'credit'; + return ( +
+
+
{t.description || (credit ? 'واریز' : 'برداشت')}
+
{formatDate(t.created_at)}
+
+
+ {credit ? '+' : '−'}{formatRial(t.amount_rials)} +
+
+ ); + })} +
+ )} +
+ ); +} + function TabList({ q, emptyLabel, row }: { q: { data?: ApiResponse; isLoading: boolean }; emptyLabel: string; diff --git a/docs/api/patient.md b/docs/api/patient.md index 0101464e..94edf47e 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -561,3 +561,28 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme |------|------|-------------| | 422 | `ERR_VALIDATION_001` | متن خالی (`field: body`) | | 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/پیام یافت نشد یا tenant دیگر | + +--- + +## مالی بیمار (Financials: پرداخت / تراکنش / کیف‌پول) + +مالیِ **کاربرِ صاحبِ رکورد** (بیمار)، gate شده به مالکیت رکورد. اندپوینت‌های عمومی `wallet/*` و `my/payments` به `#[CurrentUser]` (پولِ خودِ درخواست‌کننده) بسته‌اند؛ این اندپوینت‌ها مالیِ بیمار را برای دکتر/منشیِ صاحب پرونده برمی‌گردانند. + +**Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد) + +### GET `/api/v1/patient/{uuid}/payments` +لیست پرداخت‌های درگاهیِ بیمار (paginated). Query: `page`, `limit` (≤100)، `status` (اختیاری: `pending|success|failed|canceled|refunded`). +Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at }], meta: { totalRecords, totalPages, currentPage } }` + +### GET `/api/v1/patient/{uuid}/wallet` +موجودی + ۱۰ تراکنش اخیر (تب کیف‌پول). `balance_rials` = مجموع credit − debit. +Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amount_rials, type, description, balance_after, created_at }] } }` + +### GET `/api/v1/patient/{uuid}/wallet/transactions` +دفترِ کاملِ تراکنش‌های کیف‌پول (paginated). Query: `page`, `limit` (≤100). +Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }` + +### Errors +| HTTP | Code | Description | +|------|------|-------------| +| 404 | `ERR_PATIENT_001` | رکورد یافت نشد یا متعلق به مالک دیگر | diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index eaeb9956..dc28f84c 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -54,9 +54,85 @@ class PatientController extends BaseController private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo, private readonly \App\Patient\Repository\PatientMessageRepository $messageRepo, private readonly \App\Shared\Service\FileUploadService $fileUpload, + private readonly \App\Payment\Repository\PaymentRepository $paymentRepo, + private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo, + private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo, private readonly LoggerInterface $logger, ) {} + // ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ──────────────────────────── + // + // The patient's money is scoped to the record's owner User. The generic + // wallet/payment endpoints are bound to #[CurrentUser] (the requester's own + // finances), so a doctor/secretary viewing a record needs these + // record-owner-gated reads to see the *patient's* finances instead. + + /** List gateway payments made by the patient (paginated, optional ?status). */ + #[Route('/api/v1/patient/{uuid}/payments', methods: ['GET'])] + public function listPayments(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 20))); + $status = $request->query->get('status') ?: null; + $patient = $record->getUser(); + + $payments = array_map( + fn(\App\Payment\Entity\Payment $p) => $p->toArray(), + $this->paymentRepo->findByUser($patient, $status, $page, $limit) + ); + + return $this->paginated($payments, $this->paymentRepo->countByUser($patient, $status), $page, $limit); + } + + /** Patient wallet balance + the 10 most recent transactions (کیف‌پول tab). */ + #[Route('/api/v1/patient/{uuid}/wallet', methods: ['GET'])] + public function walletBalance(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + $patient = $record->getUser(); + + return $this->success([ + 'balance_rials' => $this->settlementRepo->getWalletBalance($patient), + 'recent_transactions' => array_map( + fn(\App\Settlement\Entity\WalletTransaction $t) => $t->toArray(), + $this->walletRepo->findByUser($patient, 10) + ), + ]); + } + + /** Full paginated wallet-transaction ledger for the patient (تراکنش tab). */ + #[Route('/api/v1/patient/{uuid}/wallet/transactions', methods: ['GET'])] + public function walletTransactions(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 50))); + $patient = $record->getUser(); + + $txns = array_map( + fn(\App\Settlement\Entity\WalletTransaction $t) => $t->toArray(), + $this->walletRepo->findByUser($patient, $limit, ($page - 1) * $limit) + ); + + return $this->paginated($txns, $this->walletRepo->countByUser($patient), $page, $limit); + } + // ── Messages (پیام‌ها) ───────────────────────────────────────────────────── #[Route('/api/v1/patient/{uuid}/messages', methods: ['GET'])] diff --git a/tests/Patient/PatientFinancialsTest.php b/tests/Patient/PatientFinancialsTest.php new file mode 100644 index 00000000..0299cd94 --- /dev/null +++ b/tests/Patient/PatientFinancialsTest.php @@ -0,0 +1,90 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $record, $patient]; + } + + public function testListsPatientPayments(): void + { + [$owner, $record, $patient] = $this->recordFor(); + + $payment = new Payment($patient, 250000, 'mellat', 'appointment'); + $payment->setStatus(Payment::STATUS_SUCCESS); + $this->em->persist($payment); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/payments', $owner); + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $res['data']); + self::assertSame(250000, $res['data'][0]['amount_rials']); + self::assertSame(1, $res['meta']['totalRecords']); + } + + public function testWalletBalanceReflectsCreditMinusDebit(): void + { + [$owner, $record, $patient] = $this->recordFor(); + + $this->em->persist(new WalletTransaction($patient, 500000, 'credit', 500000)); + $this->em->persist(new WalletTransaction($patient, 200000, 'debit', 300000)); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner); + self::assertSame(200, $this->responseCode()); + self::assertSame(300000, $res['data']['balance_rials']); + self::assertCount(2, $res['data']['recent_transactions']); + } + + public function testListsWalletTransactionsPaginated(): void + { + [$owner, $record, $patient] = $this->recordFor(); + + $this->em->persist(new WalletTransaction($patient, 100000, 'credit', 100000)); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $owner); + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $res['data']); + self::assertSame('credit', $res['data'][0]['type']); + self::assertSame(1, $res['meta']['totalRecords']); + } + + public function testFinancialsAreOwnershipScoped(): void + { + [, $record] = $this->recordFor(); + [$other] = $this->recordFor(); + + // A different owner cannot read this record's finances. + $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/payments', $other); + self::assertSame(404, $this->responseCode()); + $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $other); + self::assertSame(404, $this->responseCode()); + $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $other); + self::assertSame(404, $this->responseCode()); + } +}