feat: enhance payment status handling and summary in MyPaymentsPage

- Added support for 'partial' payment status in MyPaymentsPage and related components.
- Updated API responses to include 'paid_rials' and 'summary' for invoices.
- Introduced InvoicePaymentStatus service to derive payment status based on actual payments.
- Enhanced tests to cover new payment scenarios including partial payments and payment methods.
- Updated documentation to reflect changes in payment status and API responses.
This commit is contained in:
hamed
2026-07-19 10:38:29 +03:30
parent 5c8fe8ece4
commit 357adb1d14
18 changed files with 620 additions and 188 deletions
@@ -1,5 +1,6 @@
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { formatDate, formatRial } from '../lib/utils';
import { PAYMENT_METHOD_LABELS } from '../lib/paymentMethods';
import { FilesServicePaymentsCheck } from './icons/FilesServiceIcons';
export interface SessionPaymentData {
@@ -14,10 +15,7 @@ export interface SessionPaymentData {
updated_at?: number;
}
const PAYMENT_LABELS: Record<string, string> = {
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
wallet: 'کیف پول', pos: 'کارتخوان',
};
const PAYMENT_LABELS = PAYMENT_METHOD_LABELS;
/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */
function VDivider({ h = 24 }: { h?: number }) {
+33 -18
View File
@@ -23,6 +23,8 @@ interface Props<T> {
sortKey?: string | null;
sortDir?: 'asc' | 'desc';
onSort?: (key: string) => void;
/** محتوای ردیف بازشونده‌ی زیر هر ردیف؛ `null`/`undefined` یعنی بسته است. */
renderExpanded?: (row: T) => React.ReactNode;
}
function SkeletonRow({ cols }: { cols: number }) {
@@ -51,6 +53,7 @@ export default function DataTable<T extends object>({
sortKey,
sortDir,
onSort,
renderExpanded,
}: Props<T>) {
const allColumns = actions
? [...columns, { key: '__actions', header: 'اقدامات', className: 'w-[120px]' }]
@@ -129,24 +132,36 @@ export default function DataTable<T extends object>({
</td>
</tr>
) : (
data.map((row, rowIdx) => (
<tr key={rowIdx}>
{columns.map((col) => (
<td key={col.key} className={col.className ?? ''}>
{col.render
? col.render(row)
: ((row as Record<string, unknown>)[col.key] as React.ReactNode) ?? ''}
</td>
))}
{actions && (
<td>
<div className="row-actions">
{actions(row)}
</div>
</td>
)}
</tr>
))
data.map((row, rowIdx) => {
const expanded = renderExpanded?.(row);
return (
<React.Fragment key={rowIdx}>
<tr>
{columns.map((col) => (
<td key={col.key} className={col.className ?? ''}>
{col.render
? col.render(row)
: ((row as Record<string, unknown>)[col.key] as React.ReactNode) ?? '—'}
</td>
))}
{actions && (
<td>
<div className="row-actions">
{actions(row)}
</div>
</td>
)}
</tr>
{expanded && (
<tr>
<td colSpan={allColumns.length} style={{ height: 'auto', padding: '12px 16px', background: 'var(--surface-2)' }}>
{expanded}
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
@@ -47,6 +47,7 @@ const claimMap: Record<ClaimStatus, { color: BadgeColor; label: string }> = {
const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }> = {
paid: { color: 'green', label: 'پرداخت شده' },
partial: { color: 'blue', label: 'پرداخت ناقص' },
unsettled: { color: 'amber', label: 'تسویه نشده' },
};
+21 -5
View File
@@ -2,8 +2,11 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
/** Two-state invoice status shown on both the list (node 1) and detail (node 2). */
export type PaymentRowStatus = 'paid' | 'unsettled';
/**
* وضعیت پرداخت صورتحساب — مشتق از مجموع پرداخت‌های مراجعه در برابر سهم بیمار،
* نه ستون `invoices.status` (که فقط چرخه‌ی حیات را نگه می‌دارد).
*/
export type PaymentRowStatus = 'paid' | 'partial' | 'unsettled';
/** One recorded invoice on the flat payments list (node 1). */
export interface PaymentRow {
@@ -13,33 +16,46 @@ export interface PaymentRow {
national_code: string | null;
issued_at: number;
amount_rials: number;
paid_rials: number;
status: PaymentRowStatus;
}
export interface PaymentFilters {
page: number;
national_code?: string;
status?: string; // paid | unsettled
status?: string; // paid | partial | unsettled
from?: number; // unix seconds
to?: number; // unix seconds
}
/** A single invoice status on the detail table (node 2) — two-state. */
export type InvoiceRowStatus = 'paid' | 'unsettled';
/** همان وضعیت مشتق‌شده، روی جدول جزئیات بیمار (node 2). */
export type InvoiceRowStatus = PaymentRowStatus;
export interface PatientInvoiceRow {
uuid: string;
number: number;
issued_at: number;
total_rials: number;
patient_rials: number;
paid_rials: number;
status: InvoiceRowStatus;
service_title: string | null;
items: Array<{ uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }>;
payments: InvoicePaymentRow[];
}
/** یک پرداخت ثبت‌شده روی مراجعه‌ی همان صورتحساب. */
export interface InvoicePaymentRow {
method: string;
amount_rials: number;
paid_at: number;
created_by_name: string | null;
}
export interface PatientInvoicesPayload {
patient: { uuid: string; name: string | null; national_code: string | null };
data: PatientInvoiceRow[];
summary: PaymentsSummary;
meta: { totalRecords: number; totalPages: number; currentPage: number };
}
+13
View File
@@ -0,0 +1,13 @@
/** برچسب فارسی روش‌های پرداخت — تنها مرجع؛ در اکاردئون مراجعه و صفحه‌ی پرداخت‌ها مشترک است. */
export const PAYMENT_METHOD_LABELS: Record<string, string> = {
cash: 'نقدی',
card: 'کارت',
pos: 'کارتخوان',
wallet: 'کیف پول',
insurance: 'بیمه',
online: 'آنلاین',
pending: 'در انتظار',
};
export const paymentMethodLabel = (method: string): string =>
PAYMENT_METHOD_LABELS[method] ?? method;
+49 -10
View File
@@ -14,12 +14,20 @@ const get = api.get as ReturnType<typeof vi.fn>;
const PAYLOAD = {
patient: { uuid: 'abc', name: 'دنیا خلیلی', national_code: '1744023654' },
data: [
{ uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, status: 'paid',
service_title: 'روکش دندان',
items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }] },
{ uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, status: 'unsettled',
service_title: 'طرح لبخند', items: [] },
{ uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, patient_rials: 2350000,
paid_rials: 2350000, status: 'paid', service_title: 'روکش دندان',
items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }],
payments: [
{ method: 'cash', amount_rials: 350000, paid_at: 1717000000, created_by_name: 'منشی' },
{ method: 'pos', amount_rials: 2000000, paid_at: 1717000500, created_by_name: null },
] },
{ uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, patient_rials: 6000000,
paid_rials: 0, status: 'unsettled', service_title: 'طرح لبخند', items: [], payments: [] },
{ uuid: 'i3', number: 52615, issued_at: 1719000000, total_rials: 1000000, patient_rials: 1000000,
paid_rials: 400000, status: 'partial', service_title: 'جرم‌گیری', items: [],
payments: [{ method: 'wallet', amount_rials: 400000, paid_at: 1719000000, created_by_name: null }] },
],
summary: { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 },
meta: { totalRecords: 2, totalPages: 1, currentPage: 1 },
};
@@ -40,31 +48,62 @@ beforeEach(() => {
describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => {
it('renders the patient header and invoice rows', async () => {
renderPage();
expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument();
// the name shows twice: breadcrumb + identity card
expect(await screen.findAllByText('دنیا خلیلی')).toHaveLength(2);
expect(screen.getByText(/1744023654/)).toBeInTheDocument();
expect(screen.getByText('روکش دندان')).toBeInTheDocument();
expect(screen.getByText('طرح لبخند')).toBeInTheDocument();
expect(screen.getByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
expect(screen.getByText('پرداخت ناقص')).toBeInTheDocument();
expect(screen.getByText(/پرداخت‌شده:/)).toBeInTheDocument();
});
it('renders the summary stat cards', async () => {
renderPage();
expect(await screen.findByText('مجموع صورتحساب‌ها')).toBeInTheDocument();
expect(screen.getByText('پرداخت‌شده')).toBeInTheDocument();
expect(screen.getByText('تسویه‌نشده')).toBeInTheDocument();
expect(screen.getByText('تعداد صورتحساب')).toBeInTheDocument();
});
it('fetches invoices for the patient uuid from the route', async () => {
renderPage();
await screen.findByText('دنیا خلیلی');
await screen.findAllByText('دنیا خلیلی');
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices');
});
it('expands a row to show its line items on بیشتر', async () => {
it('expands a row to show its line items and payment methods on بیشتر', async () => {
renderPage();
await screen.findByText('روکش دندان');
fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[0]);
// the item breakdown appears (title repeated inside the expanded sub-row)
expect(await screen.findAllByText('روکش دندان')).toHaveLength(2);
// and each payment shows its method in Persian
expect(screen.getByText('روش‌های پرداخت')).toBeInTheDocument();
expect(screen.getByText('نقدی')).toBeInTheDocument();
expect(screen.getByText('کارتخوان')).toBeInTheDocument();
});
it('says so when an invoice has no recorded payment', async () => {
renderPage();
await screen.findByText('طرح لبخند');
fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[1]);
expect(await screen.findByText('پرداختی ثبت نشده است.')).toBeInTheDocument();
});
it('shows an empty state when the patient has no invoices', async () => {
get.mockResolvedValue({ success: true, data: { patient: PAYLOAD.patient, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } } });
get.mockResolvedValue({
success: true,
data: {
patient: PAYLOAD.patient,
data: [],
summary: { total_rials: 0, paid_rials: 0, unsettled_rials: 0, invoices_count: 0 },
meta: { totalRecords: 0, totalPages: 0, currentPage: 1 },
},
});
renderPage();
expect(await screen.findByText('صورتحسابی ثبت نشده است')).toBeInTheDocument();
expect(await screen.findByText('صورتحسابی ثبت نشده است.')).toBeInTheDocument();
});
});
+138 -97
View File
@@ -1,20 +1,20 @@
import { Fragment, useState } from 'react';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChevronRightIcon, ChevronDownIcon, PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import StatCard from '../components/ui/StatCard';
import StatusBadge from '../components/ui/StatusBadge';
import DataTable, { type Column } from '../components/ui/DataTable';
import { formatRial, formatDate, formatNumber } from '../lib/utils';
import { paymentMethodLabel } from '../lib/paymentMethods';
import {
usePatientInvoices,
MY_PAYMENTS_LIMIT,
type PatientInvoiceRow,
type InvoiceRowStatus,
} from '../hooks/useMyPayments';
const STATUS_LABEL: Record<InvoiceRowStatus, string> = { paid: 'پرداخت شده', unsettled: 'تسویه نشده' };
const STATUS_BADGE: Record<InvoiceRowStatus, string> = { paid: 'green', unsettled: 'amber' };
const EMPTY: PatientInvoiceRow[] = [];
/** HH:MM (Persian digits) from a unix timestamp. */
@@ -22,7 +22,73 @@ function formatTime(unix: number): string {
return new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(unix * 1000));
}
/** پرداخت‌های ثبت‌شده — a single patient's recorded invoices (node 2). */
function Avatar({ name }: { name: string | null }) {
return (
<div style={{
width: 44, height: 44, borderRadius: '50%', flexShrink: 0,
background: 'linear-gradient(145deg, var(--primary), var(--primary-700, var(--primary)))',
display: 'grid', placeItems: 'center', color: '#fff', fontSize: 17, fontWeight: 700,
}}>
{(name ?? '؟').charAt(0)}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ minWidth: 220, flex: '1 1 260px' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-3)', marginBottom: 8 }}>{title}</div>
{children}
</div>
);
}
/** آیتم‌های صورتحساب و پرداخت‌های ثبت‌شده‌ی آن، کنار هم در ردیف بازشونده. */
function InvoiceBreakdown({ row }: { row: PatientInvoiceRow }) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
<Section title="آیتم‌های صورتحساب">
{row.items.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>آیتمی ثبت نشده است.</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{row.items.map((it) => (
<div key={it.uuid} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span style={{ color: 'var(--text-2)' }}>
{it.title}{it.quantity > 1 ? ` × ${formatNumber(it.quantity)}` : ''}
</span>
<span style={{ fontWeight: 600 }}>{formatRial(it.total_rials)}</span>
</div>
))}
</div>
)}
</Section>
<Section title="روش‌های پرداخت">
{row.payments.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرداختی ثبت نشده است.</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{row.payments.map((p, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-2)' }}>
<span className="badge gray"><span className="bdot" />{paymentMethodLabel(p.method)}</span>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }} dir="ltr">{formatDate(p.paid_at)}</span>
</span>
<span style={{ fontWeight: 600 }}>{formatRial(p.amount_rials)}</span>
</div>
))}
</div>
)}
</Section>
</div>
);
}
/**
* پرداخت‌های ثبت‌شده‌ی یک بیمار — سربرگ بیمار، کارت‌های آمار و جدول صورتحساب‌ها
* با ردیف بازشونده‌ی آیتم‌ها و روش‌های پرداخت.
*/
export default function MyPaymentDetailPage() {
const { patientUuid } = useParams<{ patientUuid: string }>();
const navigate = useNavigate();
@@ -33,15 +99,41 @@ export default function MyPaymentDetailPage() {
const payload = data?.data;
const patient = payload?.patient;
const rows = payload?.data ?? EMPTY;
const summary = payload?.summary;
const total = payload?.meta?.totalRecords ?? 0;
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
const td: React.CSSProperties = { padding: '12px 16px' };
const columns: Column<PatientInvoiceRow>[] = [
{ key: 'number', header: 'شماره صورتحساب', render: (r) => <span style={{ fontWeight: 600 }} dir="ltr">#{formatNumber(r.number)}</span> },
{ key: 'issued_at', header: 'تاریخ', render: (r) => formatDate(r.issued_at) },
{ key: 'time', header: 'ساعت', render: (r) => <span dir="ltr">{formatTime(r.issued_at)}</span> },
{ key: 'service_title', header: 'نوع خدمت', render: (r) => <span style={{ color: 'var(--text-2)' }}>{r.service_title ?? '—'}</span> },
{
key: 'total_rials',
header: 'مبلغ کل',
render: (r) => (
<div>
<div style={{ fontWeight: 600 }}>{formatRial(r.total_rials)}</div>
{r.status === 'partial' && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پرداختشده: {formatRial(r.paid_rials)}
</div>
)}
</div>
),
},
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
];
return (
<>
<PageHeader
title="پرداخت‌های ثبت‌شده"
description="صورتحساب‌های ثبت‌شده‌ی این بیمار"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin' },
{ label: 'لیست پرداخت‌ها', to: '/admin/my-payments' },
{ label: patient?.name ?? 'بیمار' },
]}
action={
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn sm" onClick={() => navigate('/admin/my-payments')}>
@@ -54,97 +146,46 @@ export default function MyPaymentDetailPage() {
}
/>
{/* سربرگ بیمار */}
<div style={{
display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center',
marginBottom: 16, padding: '14px 16px',
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)',
}}>
<span style={{ fontSize: 14 }}>
نام بیمار: <b style={{ fontWeight: 700 }}>{patient?.name ?? '—'}</b>
</span>
<span style={{ fontSize: 14, color: 'var(--text-2)' }} dir="ltr">
کدملی: {patient?.national_code ?? '—'}
</span>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px', marginBottom: 'var(--gap)' }}>
<Avatar name={patient?.name ?? null} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{patient?.name ?? '—'}</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 3 }} dir="ltr">
{patient?.national_code ?? '—'}
</div>
</div>
</div>
{isLoading ? (
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری</div>
) : rows.length === 0 ? (
<div className="card" style={{ padding: '60px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 6, color: 'var(--text-2)' }}>صورتحسابی ثبت نشده است</div>
<div style={{ fontSize: 13 }}>برای این بیمار هنوز پرداخت ثبتشدهای وجود ندارد.</div>
</div>
) : (
<>
<div className="card" style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 12 }}>
<th style={th}>شماره صورتحساب</th>
<th style={th}>تاریخ</th>
<th style={th}>ساعت</th>
<th style={th}>نوع خدمت</th>
<th style={th}>مبلغ کل (تومان)</th>
<th style={th}>وضعیت</th>
<th style={{ ...th, textAlign: 'left' }}>جزئیات</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const open = expanded === row.uuid;
return (
<Fragment key={row.uuid}>
<tr style={{ borderBottom: open ? 'none' : '1px solid var(--border)' }}>
<td style={{ ...td, fontWeight: 600 }} dir="ltr">#{formatNumber(row.number)}</td>
<td style={td}>{formatDate(row.issued_at)}</td>
<td style={td} dir="ltr">{formatTime(row.issued_at)}</td>
<td style={{ ...td, color: 'var(--text-2)' }}>{row.service_title ?? '—'}</td>
<td style={{ ...td, fontWeight: 600 }}>{formatRial(row.total_rials)}</td>
<td style={td}>
<span className={`badge ${STATUS_BADGE[row.status]}`}><span className="bdot" />{STATUS_LABEL[row.status]}</span>
</td>
<td style={{ ...td, textAlign: 'left' }}>
<button
className="btn sm ghost"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--primary)' }}
onClick={() => setExpanded(open ? null : row.uuid)}
>
بیشتر <ChevronDownIcon style={{ width: 15, transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
</button>
</td>
</tr>
{open && (
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<td colSpan={7} style={{ padding: '10px 16px 14px' }}>
{row.items.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>آیتمی ثبت نشده است.</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{row.items.map((it) => (
<div key={it.uuid} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span style={{ color: 'var(--text-2)' }}>
{it.title}{it.quantity > 1 ? ` × ${formatNumber(it.quantity)}` : ''}
</span>
<span style={{ fontWeight: 600 }}>{formatRial(it.total_rials)}</span>
</div>
))}
</div>
)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
<div style={{ marginTop: 16 }}>
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
</div>
</>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard tone="violet" label="مجموع صورتحساب‌ها" value={formatRial(summary?.total_rials ?? 0)} />
<StatCard tone="green" label="پرداخت‌شده" value={formatRial(summary?.paid_rials ?? 0)} />
<StatCard tone="pink" label="تسویه‌نشده" value={formatRial(summary?.unsettled_rials ?? 0)} />
<StatCard tone="amber" label="تعداد صورتحساب" value={formatNumber(summary?.invoices_count ?? 0)} />
</div>
<div className="card" style={{ padding: 18 }}>
<DataTable
columns={columns}
data={rows}
loading={isLoading}
emptyMessage="صورتحسابی ثبت نشده است."
actions={(row) => (
<button
className="btn ghost sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--primary)' }}
onClick={() => setExpanded(expanded === row.uuid ? null : row.uuid)}
>
بیشتر
<ChevronDownIcon style={{ width: 15, transform: expanded === row.uuid ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
</button>
)}
renderExpanded={(row) => (expanded === row.uuid ? <InvoiceBreakdown row={row} /> : null)}
/>
{total > MY_PAYMENTS_LIMIT && (
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
)}
</div>
</>
);
}
+11 -3
View File
@@ -16,9 +16,11 @@ const get = api.get as ReturnType<typeof vi.fn>;
const ROWS = [
{ invoice_uuid: 'iv1', patient_uuid: 'p1', patient_name: 'دنیا خلیلی', national_code: '1744023654',
issued_at: 1717000000, amount_rials: 2350000, status: 'paid' },
issued_at: 1717000000, amount_rials: 2350000, paid_rials: 2350000, status: 'paid' },
{ invoice_uuid: 'iv2', patient_uuid: 'p2', patient_name: 'علی بدیعی', national_code: '2200112233',
issued_at: 1718000000, amount_rials: 6000000, status: 'unsettled' },
issued_at: 1718000000, amount_rials: 6000000, paid_rials: 0, status: 'unsettled' },
{ invoice_uuid: 'iv3', patient_uuid: 'p3', patient_name: 'مریم رضایی', national_code: '3300112233',
issued_at: 1719000000, amount_rials: 1000000, paid_rials: 400000, status: 'partial' },
];
const SUMMARY = { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 };
@@ -47,10 +49,16 @@ describe('MyPaymentsPage (لیست پرداخت‌ها)', () => {
expect(screen.getByText('2200112233')).toBeInTheDocument();
});
it('renders the two-state invoice status badge', async () => {
it('renders the derived payment status badge for each state', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
expect(screen.getByText('پرداخت ناقص')).toBeInTheDocument();
});
it('shows how much was collected on a partially paid invoice', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText(/پرداخت‌شده:/)).toBeInTheDocument();
});
it('renders the summary stat cards', async () => {
+15 -1
View File
@@ -18,6 +18,7 @@ import {
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'paid', label: 'پرداخت شده' },
{ value: 'partial', label: 'پرداخت ناقص' },
{ value: 'unsettled', label: 'تسویه نشده' },
];
@@ -108,7 +109,20 @@ export default function MyPaymentsPage() {
header: 'تاریخ',
render: (r) => <span dir="ltr">{formatDate(r.issued_at)} - {formatTime(r.issued_at)}</span>,
},
{ key: 'amount_rials', header: 'مبلغ', render: (r) => <span style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</span> },
{
key: 'amount_rials',
header: 'مبلغ',
render: (r) => (
<div>
<div style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</div>
{r.status === 'partial' && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پرداختشده: {formatRial(r.paid_rials)}
</div>
)}
</div>
),
},
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
];
+5 -2
View File
@@ -204,8 +204,11 @@ export type SettlementStatus = "pending" | "approved" | "rejected";
/** `mixed` فقط در نمای تجمیعی بیمار معنا دارد: مطالبات آن بیمار وضعیت یکسان ندارند. */
export type ClaimStatus = "pending" | "submitted" | "approved" | "rejected" | "paid" | "mixed";
/** وضعیت دوحالته‌ی صورتحساب در فهرست پرداخت‌ها — با `PaymentStatus` درگاه فرق دارد. */
export type InvoiceListStatus = "paid" | "unsettled";
/**
* وضعیت پرداخت صورتحساب — از مجموع پرداخت‌های مراجعه در برابر سهم بیمار مشتق
* می‌شود (ستون `invoices.status` فقط چرخه‌ی حیات است). با `PaymentStatus` درگاه فرق دارد.
*/
export type InvoiceListStatus = "paid" | "partial" | "unsettled";
export interface ClaimPatientRow {
patient_uuid: string;
+30 -6
View File
@@ -325,7 +325,7 @@
| param | توضیح |
|-------|-------|
| `national_code` | جست‌وجوی جزئی روی کد ملی بیمار (`LIKE`) |
| `status` | `paid` (پرداخت‌شده) · `unsettled` (تسویه‌نشده = `finalized`) |
| `status` | وضعیت **مشتق‌شده‌ی** پرداخت: `paid` · `partial` · `unsettled` |
| `from` / `to` | بازه‌ی `issued_at` بر حسب ثانیه‌ی Unix |
| `page` / `limit` | صفحه‌بندی (پیش‌فرض ۱ / ۲۰، سقف ۱۰۰) |
@@ -336,12 +336,21 @@
"data": [
{ "invoice_uuid": "…", "patient_uuid": "…", "patient_name": "دنیا خلیلی",
"national_code": "1744023654", "issued_at": 1717000000,
"amount_rials": 2350000, "status": "paid" }
"amount_rials": 2350000, "paid_rials": 2350000, "status": "paid" }
],
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
}
```
> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب. `status` دو حالته: `paid` یا `unsettled`.
> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب · `paid_rials` = مجموع پرداخت‌های ثبت‌شده‌ی مراجعه‌ی متناظر.
>
> **وضعیت پرداخت مشتق است، ذخیره نمی‌شود.** ستون `invoices.status` فقط چرخه‌ی حیات صورتحساب را نگه می‌دارد (`draft` → `finalized` → `void`) و هرگز `paid` نمی‌شود؛ پول واقعی در `session_payments` ثبت می‌شود. قاعده در `App\Billing\Service\InvoicePaymentStatus` است:
> | حالت | شرط |
> |------|-----|
> | `paid` | `paid_rials >= amount_rials` (شامل صورتحساب صفرریالی) |
> | `partial` | `0 < paid_rials < amount_rials` |
> | `unsettled` | `paid_rials = 0` و `amount_rials > 0` |
>
> صورتحساب بدون مراجعه (`patient_session_id = null`) هیچ پرداختی ندارد، پس `unsettled` می‌ماند.
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
@@ -362,7 +371,7 @@
}
}
```
> مبالغ جمع `patient_rials` هستند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر می‌شود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همه‌ی مقادیر `0` برمی‌گردند.
> مبالغ جمع `patient_rials` هستند. `paid_rials` = جمع صورتحساب‌هایی که **کاملاً** وصول شده‌اند؛ صورتحساب نیمه‌پرداخت (`partial`) کامل در `unsettled_rials` می‌نشیند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر می‌شود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همه‌ی مقادیر `0` برمی‌گردند.
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
@@ -377,14 +386,29 @@
"patient": { "uuid": "…", "name": "دنیا خلیلی", "national_code": "1744023654" },
"data": [
{ "uuid": "…", "number": 12345, "issued_at": 1717000000, "total_rials": 2350000,
"patient_rials": 2350000, "paid_rials": 2350000,
"status": "paid", "service_title": "روکش دندان",
"items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ] }
"items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ],
"payments": [
{ "method": "cash", "amount_rials": 350000, "paid_at": 1717000000, "created_by_name": "منشی" },
{ "method": "pos", "amount_rials": 2000000, "paid_at": 1717000500, "created_by_name": null }
] }
],
"summary": {
"total_rials": 8350000,
"paid_rials": 2350000,
"unsettled_rials": 6000000,
"invoices_count": 3
},
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
}
```
> `status` دو حالته: `paid` (پرداخت‌شده) یا `unsettled` (تسویه‌نشده = `finalized`). `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد).
> `payments` پرداخت‌های ثبت‌شده‌ی مراجعه‌ی همان صورتحساب است (قدیمی‌ترین اول)؛ `method` یکی از `wallet` · `pos` · `cash` · `card` — برچسب فارسی سمت کلاینت در `assets/admin/lib/paymentMethods.ts`. صورتحساب بدون مراجعه آرایه‌ی خالی می‌گیرد.
>
> `status` همان وضعیت مشتق‌شده‌ی بالاست (`paid` · `partial` · `unsettled`) و همیشه بر مبنای `patient_rials` سنجیده می‌شود، حتی در این نما که ستون مبلغش `total_rials` است. `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد).
>
> `summary` روی **همه‌ی** صورتحساب‌های همان بیمار محاسبه می‌شود (نه فقط صفحه‌ی جاری) و جمع `total_rials` صورتحساب‌هاست — بر خلاف `summary` اندپوینت لیست پرداخت‌ها که سهم بیمار (`patient_rials`) را جمع می‌زند. `invoices_count` همان `meta.totalRecords` است.
**Errors:** `403` پروفایل tenant یافت نشد · `404` (`ERR_NOT_FOUND_001`) بیمار متعلق به این tenant نیست/یافت نشد.
+3 -2
View File
@@ -217,7 +217,7 @@ class BillingController extends BaseController
/**
* پرداخت‌های ثبت‌شده‌ی یک بیمار — سربرگ بیمار + فهرست صفحه‌بندی‌شده‌ی صورتحساب‌ها.
* فقط مالک رکورد (همان tenant) اجازه دارد؛ در غیر این صورت ۴۰۴.
* پاسخ: { patient:{uuid,name,national_code}, data:[صورتحساب‌ها], meta:{...} }.
* پاسخ: { patient:{uuid,name,national_code}, data:[صورتحساب‌ها], summary:{...}, meta:{...} }.
*/
#[Route('/api/v1/my/billing/patients/{patientUuid}/invoices', methods: ['GET'])]
public function listPatientInvoices(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -244,7 +244,8 @@ class BillingController extends BaseController
'name' => $patient->getRealName(),
'national_code' => $patient->getNationalCode(),
],
'data' => $result['items'],
'data' => $result['items'],
'summary' => $result['summary'],
'meta' => [
'totalRecords' => $result['total'],
'totalPages' => (int) ceil($result['total'] / $limit),
+1
View File
@@ -93,6 +93,7 @@ class Invoice
public function getTotalRials(): int { return $this->totalRials; }
public function getPatientRials(): int { return $this->patientRials; }
public function getIssuedAt(): int { return $this->issuedAt; }
public function getPatientSessionId(): ?int { return $this->patientSessionId; }
/** @return Collection<int, InvoiceItem> */
public function getItems(): Collection { return $this->items; }
+114 -20
View File
@@ -3,7 +3,9 @@
namespace App\Billing\Repository;
use App\Billing\Entity\Invoice;
use App\Billing\Service\InvoicePaymentStatus;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\SessionPayment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -40,7 +42,8 @@ class InvoiceRepository extends ServiceEntityRepository
->select(
'i.uuid AS invoice_uuid', 'r.uuid AS patient_uuid', 'u.realName AS patient_name',
'u.nationalCode AS national_code', 'i.issuedAt AS issued_at',
'i.patientRials AS amount_rials', 'i.status AS status',
'i.patientRials AS amount_rials',
sprintf('%s AS paid_rials', self::paidSumDql('sp_row')),
)
->orderBy('i.issuedAt', 'DESC')
->setFirstResult(($page - 1) * $limit)
@@ -55,10 +58,78 @@ class InvoiceRepository extends ServiceEntityRepository
'national_code' => $r['national_code'],
'issued_at' => (int) $r['issued_at'],
'amount_rials' => (int) $r['amount_rials'],
'status' => $r['status'] === Invoice::STATUS_PAID ? 'paid' : 'unsettled',
'paid_rials' => (int) $r['paid_rials'],
'status' => InvoicePaymentStatus::resolve((int) $r['amount_rials'], (int) $r['paid_rials']),
], $rows);
}
/**
* زیرپرس‌وجوی مجموع پرداخت‌های مراجعه‌ی همان صورتحساب. صورتحساب بدون مراجعه
* هیچ پرداختی ندارد، پس `COALESCE` صفر می‌دهد و «تسویه‌نشده» می‌ماند.
*
* @param string $alias نام یکتا؛ استفاده‌ی دوبار از یک alias در یک query خطای semantical می‌دهد.
*/
private static function paidSumDql(string $alias): string
{
return sprintf(
'(SELECT COALESCE(SUM(%1$s.amountRials), 0) FROM %2$s %1$s WHERE IDENTITY(%1$s.session) = i.patientSessionId)',
$alias,
SessionPayment::class,
);
}
/**
* پرداخت‌های ثبت‌شده‌ی هر صورتحساب (از راه مراجعه‌اش)، در یک کوئری برای کل صفحه.
* مبلغ وصول‌شده هم از همین ردیف‌ها جمع می‌شود تا کوئری دوم لازم نباشد.
*
* @param list<Invoice> $invoices
* @return array<int, list<array{method:string,amount_rials:int,paid_at:int,created_by_name:?string}>>
* کلید = شناسه‌ی صورتحساب؛ قدیمی‌ترین پرداخت اول.
*/
public function paymentsForInvoices(array $invoices): array
{
$sessionIds = [];
foreach ($invoices as $invoice) {
$sessionId = $invoice->getPatientSessionId();
if ($sessionId !== null) {
$sessionIds[$invoice->getId()] = $sessionId;
}
}
if ($sessionIds === []) {
return [];
}
$rows = $this->getEntityManager()->createQueryBuilder()
->select(
'IDENTITY(sp.session) AS session_id', 'sp.method AS method',
'sp.amountRials AS amount_rials', 'sp.paidAt AS paid_at',
'sp.createdByName AS created_by_name',
)
->from(SessionPayment::class, 'sp')
->where('IDENTITY(sp.session) IN (:sessions)')
->setParameter('sessions', array_values($sessionIds))
->orderBy('sp.paidAt', 'ASC')
->getQuery()
->getArrayResult();
$bySession = [];
foreach ($rows as $row) {
$bySession[(int) $row['session_id']][] = [
'method' => $row['method'],
'amount_rials' => (int) $row['amount_rials'],
'paid_at' => (int) $row['paid_at'],
'created_by_name' => $row['created_by_name'],
];
}
$byInvoice = [];
foreach ($sessionIds as $invoiceId => $sessionId) {
$byInvoice[$invoiceId] = $bySession[$sessionId] ?? [];
}
return $byInvoice;
}
public function countTenantInvoices(string $entityType, int $entityId, array $filters): int
{
return (int) $this->tenantInvoicesQuery($entityType, $entityId, $filters)
@@ -76,18 +147,43 @@ class InvoiceRepository extends ServiceEntityRepository
*/
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
{
$row = $this->tenantInvoicesQuery($entityType, $entityId, $filters)
return $this->summarize($this->tenantInvoicesQuery($entityType, $entityId, $filters), 'patientRials');
}
/**
* Aggregate totals over one patient's invoices — the same set {@see invoicesForPatient}
* pages through, so the detail page's cards match its table.
*
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
*/
public function patientInvoiceSummary(string $entityType, int $entityId, int $recordId): array
{
return $this->summarize($this->patientInvoicesQuery($entityType, $entityId, $recordId), 'totalRials');
}
/**
* Sum `$field` over a prepared invoice query, split by paid vs. still unsettled.
*
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
*/
private function summarize(QueryBuilder $qb, string $field): array
{
$row = (clone $qb)
->select(
'COALESCE(SUM(i.patientRials), 0) AS total_rials',
'COALESCE(SUM(CASE WHEN i.status = :paidStatus THEN i.patientRials ELSE 0 END), 0) AS paid_rials',
sprintf('COALESCE(SUM(i.%s), 0) AS total_rials', $field),
'COUNT(i.id) AS invoices_count',
)
->setParameter('paidStatus', Invoice::STATUS_PAID)
->getQuery()
->getSingleResult();
// «پرداخت‌شده» = مجموع صورتحساب‌هایی که سهم بیمارشان کامل وصول شده
$paid = (int) (clone $qb)
->select(sprintf('COALESCE(SUM(i.%s), 0)', $field))
->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_sum')))
->getQuery()
->getSingleScalarResult();
$total = (int) $row['total_rials'];
$paid = (int) $row['paid_rials'];
return [
'total_rials' => $total,
@@ -118,11 +214,17 @@ class InvoiceRepository extends ServiceEntityRepository
if (!empty($filters['to'])) {
$qb->andWhere('i.issuedAt <= :to')->setParameter('to', (int) $filters['to']);
}
if (($filters['status'] ?? null) === 'paid') {
$qb->andWhere('i.status = :st')->setParameter('st', Invoice::STATUS_PAID);
} elseif (($filters['status'] ?? null) === 'unsettled') {
$qb->andWhere('i.status = :st')->setParameter('st', Invoice::STATUS_FINALIZED);
}
// وضعیت پرداخت مشتق است (پرداخت‌های مراجعه در برابر سهم بیمار)، نه ستون status
match ($filters['status'] ?? null) {
InvoicePaymentStatus::PAID => $qb->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_f1'))),
InvoicePaymentStatus::PARTIAL => $qb->andWhere(sprintf(
'%s > 0 AND %s < i.patientRials',
self::paidSumDql('sp_f1'),
self::paidSumDql('sp_f2'),
)),
InvoicePaymentStatus::UNSETTLED => $qb->andWhere(sprintf('%s <= 0 AND i.patientRials > 0', self::paidSumDql('sp_f1'))),
default => null,
};
return $qb;
}
@@ -141,14 +243,6 @@ class InvoiceRepository extends ServiceEntityRepository
->getResult();
}
public function countInvoicesForPatient(string $entityType, int $entityId, int $recordId): int
{
return (int) $this->patientInvoicesQuery($entityType, $entityId, $recordId)
->select('COUNT(i.id)')
->getQuery()
->getSingleScalarResult();
}
private function patientInvoicesQuery(string $entityType, int $entityId, int $recordId): QueryBuilder
{
return $this->createQueryBuilder('i')
@@ -0,0 +1,28 @@
<?php
namespace App\Billing\Service;
/**
* وضعیت پرداخت یک صورتحساب.
*
* ستون `invoices.status` چرخه‌ی حیات صورتحساب را نگه می‌دارد (draft → finalized → void)
* و هرگز به `paid` نمی‌رود؛ پول واقعی در `session_payments` ثبت می‌شود. بنابراین وضعیت
* پرداخت **مشتق** است: مجموع پرداخت‌های مراجعه در برابر سهم بیمار (`patient_rials`).
* همین‌جا تنها مرجع این قاعده است تا نماها از هم واگرا نشوند.
*/
final class InvoicePaymentStatus
{
public const PAID = 'paid';
public const PARTIAL = 'partial';
public const UNSETTLED = 'unsettled';
/** @return self::PAID|self::PARTIAL|self::UNSETTLED */
public static function resolve(int $dueRials, int $paidRials): string
{
if ($paidRials >= $dueRials) {
return self::PAID;
}
return $paidRials > 0 ? self::PARTIAL : self::UNSETTLED;
}
}
+18 -6
View File
@@ -126,11 +126,14 @@ class InvoiceService
* time, a single service title (first item, "+ more" when several), total,
* a two-state status (paid|unsettled), and the full item breakdown.
*
* @return array{items: list<array<string, mixed>>, total: int}
* @return array{items: list<array<string, mixed>>, total: int, summary: array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}}
*/
public function patientInvoiceList(string $entityType, int $entityId, int $recordId, int $page, int $limit): array
{
$items = array_map(function (Invoice $invoice): array {
$invoices = $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit);
$paymentsById = $this->invoiceRepo->paymentsForInvoices($invoices);
$items = array_map(function (Invoice $invoice) use ($paymentsById): array {
$lineItems = array_map(static fn(InvoiceItem $i) => $i->toArray(), $invoice->getItems()->toArray());
$title = match (count($lineItems)) {
0 => null,
@@ -138,20 +141,29 @@ class InvoiceService
default => $lineItems[0]['title'] . ' و موارد دیگر',
};
$payments = $paymentsById[$invoice->getId()] ?? [];
$paid = array_sum(array_column($payments, 'amount_rials'));
return [
'uuid' => $invoice->getUuid(),
'number' => $invoice->getId(),
'issued_at' => $invoice->getIssuedAt(),
'total_rials' => $invoice->getTotalRials(),
'status' => $invoice->getStatus() === Invoice::STATUS_PAID ? 'paid' : 'unsettled',
'patient_rials' => $invoice->getPatientRials(),
'paid_rials' => $paid,
'status' => InvoicePaymentStatus::resolve($invoice->getPatientRials(), $paid),
'service_title' => $title,
'items' => $lineItems,
'payments' => $payments,
];
}, $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit));
}, $invoices);
$summary = $this->invoiceRepo->patientInvoiceSummary($entityType, $entityId, $recordId);
return [
'items' => $items,
'total' => $this->invoiceRepo->countInvoicesForPatient($entityType, $entityId, $recordId),
'items' => $items,
'total' => $summary['invoices_count'],
'summary' => $summary,
];
}
}
+79 -6
View File
@@ -8,6 +8,8 @@ use App\Billing\Entity\InvoiceItem;
use App\Billing\ValueObject\ShareBreakdown;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionPayment;
use App\Tests\ApiTestCase;
/**
@@ -48,6 +50,10 @@ class PatientPaymentsTest extends ApiTestCase
return $record->getUser()->getNationalCode();
}
/**
* `$status` چرخه‌ی حیات صورتحساب است (draft/finalized/void). وضعیت پرداختِ
* نمایش‌داده‌شده از `$paidRials` مشتق می‌شود — پرداخت واقعی روی مراجعه.
*/
private function invoice(
Doctor $doctor,
PatientRecord $record,
@@ -56,9 +62,13 @@ class PatientPaymentsTest extends ApiTestCase
?int $issuedAt = null,
?string $itemTitle = null,
int $totalRials = 0,
int $paidRials = 0,
): Invoice {
$invoice = new Invoice('doctor', $doctor->getId());
$invoice->setPatientRecordId($record->getId());
if ($paidRials > 0) {
$invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId());
}
if ($itemTitle !== null) {
// Item added only for its title (service_title); totals set below by hand.
$invoice->addItem(new InvoiceItem($invoice, $itemTitle, $totalRials, 1, new ShareBreakdown($totalRials, 0, 0, $patientRials), null));
@@ -75,6 +85,20 @@ class PatientPaymentsTest extends ApiTestCase
return $invoice;
}
/** مراجعه‌ای با یک پرداخت نقدی ثبت‌شده — منبع واقعیِ «چقدر وصول شده». */
private function paidSession(PatientRecord $record, int $paidRials): PatientSession
{
$session = new PatientSession($record);
$this->em->persist($session);
$this->em->flush();
$payment = new SessionPayment($session, 'cash', $paidRials, time());
$this->em->persist($payment);
$this->em->flush();
return $session;
}
private function setField(object $obj, string $prop, mixed $value): void
{
$ref = new \ReflectionProperty($obj, $prop);
@@ -87,8 +111,8 @@ class PatientPaymentsTest extends ApiTestCase
[$owner, $doctor] = $this->doctor();
$a = $this->patientRecord($doctor, 'دنیا خلیلی');
$this->invoice($doctor, $a, Invoice::STATUS_PAID, 100000, 2000);
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000);
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 100000, 2000, null, 0, 100000); // fully paid
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000); // nothing paid
$this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999, 3000); // excluded
$this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999, 3000); // excluded
@@ -111,10 +135,12 @@ class PatientPaymentsTest extends ApiTestCase
public function testFiltersByNationalCodeAndStatus(): void
{
[$owner, $doctor] = $this->doctor();
$paid = $this->patientRecord($doctor, 'بیمار پرداخت');
$unpaid = $this->patientRecord($doctor, 'بیمار بدهکار');
$this->invoice($doctor, $paid, Invoice::STATUS_PAID, 100000);
$paid = $this->patientRecord($doctor, 'بیمار پرداخت');
$unpaid = $this->patientRecord($doctor, 'بیمار بدهکار');
$partial = $this->patientRecord($doctor, 'بیمار نیمه‌پرداخت');
$this->invoice($doctor, $paid, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 100000);
$this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000);
$this->invoice($doctor, $partial, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 40000);
$byCode = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $this->nationalCodeOf($paid), $owner);
self::assertSame(1, $byCode['meta']['totalRecords']);
@@ -127,6 +153,12 @@ class PatientPaymentsTest extends ApiTestCase
$onlyUnsettled = $this->authJson('GET', '/api/v1/my/billing/payments?status=unsettled', $owner);
self::assertSame(1, $onlyUnsettled['meta']['totalRecords']);
self::assertSame($unpaid->getUuid(), $onlyUnsettled['data'][0]['patient_uuid']);
$onlyPartial = $this->authJson('GET', '/api/v1/my/billing/payments?status=partial', $owner);
self::assertSame(1, $onlyPartial['meta']['totalRecords']);
self::assertSame($partial->getUuid(), $onlyPartial['data'][0]['patient_uuid']);
self::assertSame('partial', $onlyPartial['data'][0]['status']);
self::assertSame(40000, $onlyPartial['data'][0]['paid_rials']);
}
public function testEmptyWhenNoInvoices(): void
@@ -152,7 +184,7 @@ class PatientPaymentsTest extends ApiTestCase
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor, 'دنیا خلیلی');
$this->invoice($doctor, $record, Invoice::STATUS_PAID, 235000, 1000, 'روکش دندان', 235000);
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 235000, 1000, 'روکش دندان', 235000, 235000);
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 600000, 2000, 'طرح لبخند', 600000);
$this->invoice($doctor, $record, Invoice::STATUS_DRAFT, 111, 3000, 'پیش‌نویس', 111); // excluded
@@ -174,6 +206,47 @@ class PatientPaymentsTest extends ApiTestCase
self::assertSame('paid', $rows[1]['status']);
}
public function testPatientInvoiceRowsCarryTheirPaymentMethods(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor, 'دنیا خلیلی');
$session = $this->paidSession($record, 60000);
$extra = new SessionPayment($session, 'pos', 40000, time() + 60);
$this->em->persist($extra);
$this->em->flush();
$invoice = $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'روکش دندان', 100000);
$invoice->setPatientSessionId($session->getId());
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
$row = $res['data']['data'][0];
self::assertSame('paid', $row['status']);
self::assertSame(100000, $row['paid_rials']);
self::assertCount(2, $row['payments']);
// oldest payment first
self::assertSame('cash', $row['payments'][0]['method']);
self::assertSame(60000, $row['payments'][0]['amount_rials']);
self::assertSame('pos', $row['payments'][1]['method']);
self::assertSame(40000, $row['payments'][1]['amount_rials']);
}
public function testPatientInvoiceWithoutSessionHasNoPayments(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor, 'بدون مراجعه');
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'ویزیت', 100000);
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
$row = $res['data']['data'][0];
self::assertSame([], $row['payments']);
self::assertSame(0, $row['paid_rials']);
self::assertSame('unsettled', $row['status']);
}
public function testPatientInvoicesNotFoundForOtherTenant(): void
{
[, $doctor] = $this->doctor();
+59 -8
View File
@@ -6,6 +6,8 @@ use App\Auth\Entity\User;
use App\Billing\Entity\Invoice;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionPayment;
use App\Tests\ApiTestCase;
/**
@@ -45,10 +47,38 @@ class PaymentsSummaryTest extends ApiTestCase
$ref->setValue($obj, $value);
}
private function invoice(Doctor $doctor, PatientRecord $record, int $patientRials, string $status, int $issuedAt): Invoice
/** مراجعه‌ای با یک پرداخت نقدی ثبت‌شده — منبع واقعیِ «چقدر وصول شده». */
private function paidSession(PatientRecord $record, int $paidRials): PatientSession
{
$session = new PatientSession($record);
$this->em->persist($session);
$this->em->flush();
$payment = new SessionPayment($session, 'cash', $paidRials, 1_700_000_000);
$this->em->persist($payment);
$this->em->flush();
return $session;
}
/**
* `totalRials` دو برابر سهم بیمار است تا دو خلاصه از هم قابل‌تفکیک بمانند.
* `$paidRials` پرداخت واقعی روی مراجعه است؛ وضعیت پرداخت از همین مشتق می‌شود.
*/
private function invoice(
Doctor $doctor,
PatientRecord $record,
int $patientRials,
string $status,
int $issuedAt,
int $paidRials = 0,
): Invoice {
$invoice = new Invoice('doctor', $doctor->getId());
$invoice->setPatientRecordId($record->getId());
if ($paidRials > 0) {
$invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId());
}
$this->setField($invoice, 'totalRials', $patientRials * 2);
$this->setField($invoice, 'patientRials', $patientRials);
$this->setField($invoice, 'status', $status);
$this->setField($invoice, 'issuedAt', $issuedAt);
@@ -63,10 +93,10 @@ class PaymentsSummaryTest extends ApiTestCase
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000);
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000);
$this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_100);
// draft invoices are not part of the payments list, so they must not count
$this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200);
$this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200, 999_000);
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner);
self::assertSame(200, $this->responseCode());
@@ -83,9 +113,9 @@ class PaymentsSummaryTest extends ApiTestCase
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000);
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000);
$this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_000);
$this->invoice($doctor, $record, 700_000, Invoice::STATUS_PAID, 1_800_000_000);
$this->invoice($doctor, $record, 700_000, Invoice::STATUS_FINALIZED, 1_800_000_000, 700_000);
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?status=paid', $owner);
self::assertSame(1_700_000, $res['data']['total_rials']);
@@ -106,8 +136,8 @@ class PaymentsSummaryTest extends ApiTestCase
$mine = $this->patientRecord($doctor, $code);
$other = $this->patientRecord($doctor, (string) random_int(1_000_000_000, 9_999_999_999));
$this->invoice($doctor, $mine, 500_000, Invoice::STATUS_PAID, 1_700_000_000);
$this->invoice($doctor, $other, 800_000, Invoice::STATUS_PAID, 1_700_000_000);
$this->invoice($doctor, $mine, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 500_000);
$this->invoice($doctor, $other, 800_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 800_000);
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?national_code=' . $code, $owner);
self::assertSame(500_000, $res['data']['total_rials']);
@@ -126,11 +156,32 @@ class PaymentsSummaryTest extends ApiTestCase
);
}
public function testPatientInvoicesCarryTheirOwnSummary(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000);
$this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_100);
$this->invoice($doctor, $record, 900_000, Invoice::STATUS_DRAFT, 1_700_000_200, 900_000);
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
self::assertSame(200, $this->responseCode());
// the patient view sums total_rials (invoice grand total), not the patient share
$summary = $res['data']['summary'];
self::assertSame(3_000_000, $summary['total_rials']);
self::assertSame(2_000_000, $summary['paid_rials']);
self::assertSame(1_000_000, $summary['unsettled_rials']);
self::assertSame(2, $summary['invoices_count']);
self::assertSame(2, $res['data']['meta']['totalRecords']);
}
public function testSummaryExcludesOtherTenants(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$this->invoice($doctor, $record, 300_000, Invoice::STATUS_PAID, 1_700_000_000);
$this->invoice($doctor, $record, 300_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 300_000);
[$otherOwner] = $this->doctor();
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $otherOwner);