feat(patients): phase C — patient financial tabs (payments, wallet, transactions)
The generic wallet/payment endpoints are bound to #[CurrentUser] (the
requester's own money), so they cannot serve a record owner viewing a patient's
finances. Add three record-owner-gated read endpoints on PatientController that
reuse the existing repositories:
GET /patient/{uuid}/payments — paginated gateway payments (?status)
GET /patient/{uuid}/wallet — balance + 10 recent transactions
GET /patient/{uuid}/wallet/transactions — full paginated ledger
Wire the previously-placeholder "پرداختها" and "کیف پول" tabs on the patient
detail page: payments via the shared TabList, wallet via a new WalletTab (balance
card + credit/debit ledger). PatientFinancialsTest covers the happy path, the
credit−debit balance, and ownership scoping (404 for a different owner).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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('ساغر صابری');
|
||||
|
||||
@@ -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<string, string> = { male: 'مرد', female: 'زن' };
|
||||
|
||||
const PAYMENT_STATUS: Record<string, string> = {
|
||||
pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت',
|
||||
};
|
||||
|
||||
function Placeholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||||
@@ -74,6 +78,11 @@ export default function PatientDetailPage() {
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`),
|
||||
enabled: !!uuid && tab === 'appointments',
|
||||
});
|
||||
const paymentsQ = useQuery<ApiResponse<any[]>>({
|
||||
queryKey: ['patient-payments', uuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/payments`),
|
||||
enabled: !!uuid && tab === 'payments',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fade-in" style={{ maxWidth: 1060, margin: '0 auto' }}>
|
||||
@@ -132,6 +141,11 @@ export default function PatientDetailPage() {
|
||||
) : tab === 'appointments' ? (
|
||||
<TabList q={appointmentsQ} emptyLabel="نوبتی ثبت نشده است"
|
||||
row={(a) => ({ 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' ? (
|
||||
<TabList q={paymentsQ} emptyLabel="پرداختی ثبت نشده است"
|
||||
row={(p) => ({ 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' ? (
|
||||
<WalletTab uuid={uuid!} />
|
||||
) : tab === 'attach' ? (
|
||||
<AttachmentsTab uuid={uuid!} />
|
||||
) : 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<ApiResponse<{ balance_rials: number; recent_transactions: WalletTxn[] }>>({
|
||||
queryKey: ['patient-wallet', uuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||||
const balance = data?.data?.balance_rials ?? 0;
|
||||
const txns = data?.data?.recent_transactions ?? [];
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, marginBottom: 16, maxWidth: 320 }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balance)}</div>
|
||||
</div>
|
||||
{txns.length === 0 ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{txns.map((t) => {
|
||||
const credit = t.type === 'credit';
|
||||
return (
|
||||
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '12px 14px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600 }}>{t.description || (credit ? 'واریز' : 'برداشت')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(t.created_at)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, direction: 'ltr', color: credit ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{credit ? '+' : '−'}{formatRial(t.amount_rials)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabList({ q, emptyLabel, row }: {
|
||||
q: { data?: ApiResponse<any[]>; isLoading: boolean };
|
||||
emptyLabel: string;
|
||||
|
||||
@@ -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` | رکورد یافت نشد یا متعلق به مالک دیگر |
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Record-owner-gated reads of the patient's finances:
|
||||
* payments list, wallet balance, and wallet-transaction ledger.
|
||||
*/
|
||||
class PatientFinancialsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user