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:
hamed
2026-07-13 15:53:04 +03:30
co-authored by Claude Opus 4.8
parent 3036c0bf45
commit 61981a45d0
5 changed files with 271 additions and 1 deletions
@@ -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('ساغر صابری');
+57 -1
View File
@@ -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;