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:
@@ -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 }) {
|
||||
|
||||
@@ -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: 'تسویه نشده' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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} /> },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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 نیست/یافت نشد.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user