Add migration to enhance session_payments table with payment_method_uuid and reference fields for split-payment details

This commit is contained in:
hamed
2026-07-23 15:37:58 +03:30
parent 1d1c3d5f30
commit a0a2eb1799
23 changed files with 2257 additions and 1432 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
@@ -7,8 +7,12 @@ vi.mock('../../lib/api', () => ({
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const appointment = {
uuid: 'a1',
version: 1,
@@ -23,7 +27,17 @@ function render() {
);
}
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
get.mockResolvedValue({ success: true, data: [] });
post.mockResolvedValue({ success: true, data: {} });
});
/** همهٔ فیلدهای مبلغ (تومان) در ردیف‌های پرداخت. */
function amountInputs() {
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
}
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
@@ -34,33 +48,70 @@ describe('ConfirmAppointmentModal', () => {
expect(screen.getByText('جمع کل')).toBeInTheDocument();
});
it('مبلغ پرداختی پیش‌فرض برابر باقی‌مانده (کل هزینه) است', () => {
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => {
render();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(screen.getByPlaceholderText('0')).toHaveValue('۳۰۰٬۰۰۰');
expect(amountInputs()[0]).toHaveValue('۳۰۰٬۰۰۰');
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ، باقی‌مانده دوباره محاسبه می‌شود', () => {
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => {
render();
fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '100000' } });
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
// ۳۰۰٬۰۰۰ − ۱۰۰٬۰۰۰ تومان باقی‌مانده ⇒ ۲٬۰۰۰٬۰۰۰ ریال
expect(screen.getByText(/باقی‌مانده پس از این پرداخت/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'بدون پرداخت' }));
expect(screen.getByText('بدون پرداخت', { selector: 'span' })).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مبلغ بیشتر از جمع کل غیرفعال می‌شود', () => {
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => {
render();
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
expect(submit).not.toBeDisabled();
// مبلغ بالاتر از جمع کل (۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان < کل؛ پس عدد بزرگ‌تر می‌دهیم)
fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '9000000' } });
expect(screen.getByText('مبلغ پرداخت از جمع کل بیشتر است.')).toBeInTheDocument();
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
expect(screen.getByText('مجموع پرداخت‌ها از جمع کل بیشتر است.')).toBeInTheDocument();
expect(submit).toBeDisabled();
});
it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => {
render();
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
payments: [{ method: 'cash', amount_rials: 1_000_000 }],
}));
});
it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => {
render();
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم
fireEvent.change(amountInputs()[0], { target: { value: '200000' } });
// افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
const inputs = amountInputs();
expect(inputs).toHaveLength(2);
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
payments: [
{ method: 'cash', amount_rials: 2_000_000 },
{ method: 'cash', amount_rials: 1_000_000 },
],
}));
});
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => {
render();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
expect(amountInputs()).toHaveLength(2);
fireEvent.click(screen.getAllByLabelText('حذف روش پرداخت')[0]);
expect(amountInputs()).toHaveLength(1);
});
});
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { UserCircleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
@@ -41,6 +42,17 @@ interface Props {
queryKey?: unknown[];
}
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
interface PaymentRow {
id: number;
method: string;
amountToman: number;
/** uuid کارت‌خوان (pos) یا حساب بانکیِ (card) ثبت‌شده. */
methodUuid: string;
/** شناسه تراکنش / شماره پیگیری. */
reference: string;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
@@ -59,10 +71,11 @@ const STATE_TONE: Record<string, { fg: string; bg: string }> = {
};
/**
* «قطعی کردن نوبت» — هزینه‌های نوبت را نشان می‌دهد، پرداخت کامل یا جزئی می‌گیرد و
* نوبت را از «ثبت شده» به «قطعی شده» می‌برد.
* «قطعی کردن نوبت» — هزینه‌های نوبت را نشان می‌دهد، پرداخت را بین چند روش
* (نقدی/کارت‌خوان/کارت‌به‌کارت/کیف پول) تقسیم می‌کند و نوبت را «قطعی» می‌کند.
*
* سرور همین یک درخواست را اتمیک انجام می‌دهد: وضعیت + پرونده/مراجعه + پرداخت‌ها.
* مبلغ پرداختی می‌تواند کمتر از جمع کل باشد (پرداخت جزئی)، ولی نباید بیشتر شود.
*/
export default function ConfirmAppointmentModal({
open,
@@ -72,9 +85,12 @@ export default function ConfirmAppointmentModal({
queryKey,
}: Props) {
const qc = useQueryClient();
const [method, setMethod] = useState('cash');
const [amountToman, setAmountToman] = useState(0);
/** تا وقتی کاربر مبلغ را دست نزده، فیلد با باقی‌ماندهٔ نوبت پر می‌ماند. */
const nextId = useRef(1);
const makeRow = (over: Partial<PaymentRow> = {}): PaymentRow => ({
id: nextId.current++, method: 'cash', amountToman: 0, methodUuid: '', reference: '', ...over,
});
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر می‌ماند. */
const [touched, setTouched] = useState(false);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
@@ -85,6 +101,30 @@ export default function ConfirmAppointmentModal({
enabled: open && !appointment,
});
// روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است.
const posQuery = useQuery<ApiResponse<Pos[]>>({
queryKey: ['payment-methods', 'pos'],
queryFn: () => api.get('/api/v1/my/payment-methods/pos'),
enabled: open,
});
const bankQuery = useQuery<ApiResponse<BankAccount[]>>({
queryKey: ['payment-methods', 'bank-accounts'],
queryFn: () => api.get('/api/v1/my/payment-methods/bank-accounts'),
enabled: open,
});
const posOptions = useMemo(
() => ((posQuery.data?.data ?? []) as Pos[])
.filter(p => p.is_active)
.map(p => ({ value: p.uuid, label: `${p.bank_name}${p.terminal_number}` })),
[posQuery.data],
);
const bankOptions = useMemo(
() => ((bankQuery.data?.data ?? []) as BankAccount[])
.filter(b => b.is_active)
.map(b => ({ value: b.uuid, label: `${b.bank_name}${b.card_number ? `${b.card_number}` : ''}` })),
[bankQuery.data],
);
const appt: AppointmentLike | null = appointment
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
@@ -96,18 +136,21 @@ export default function ConfirmAppointmentModal({
);
const total = visitPrice + servicesTotal;
// جمع کل تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر کل هزینه است.
useEffect(() => {
if (!open || touched || total <= 0) return;
setAmountToman(rialToToman(total));
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r)));
}, [open, touched, total]);
const amountRials = tomanToRial(amountToman);
const remaining = Math.max(0, total - amountRials);
const overpaid = amountRials > total;
const paidRials = useMemo(
() => rows.reduce((sum, r) => sum + tomanToRial(r.amountToman), 0),
[rows],
);
const remaining = Math.max(0, total - paidRials);
const overpaid = paidRials > total;
const paymentState = amountRials === 0
const paymentState = paidRials === 0
? 'بدون پرداخت'
: remaining === 0
? 'تسویه کامل'
@@ -117,7 +160,14 @@ export default function ConfirmAppointmentModal({
mutationFn: () =>
api.post<ApiResponse<unknown>>(`/api/v1/appointment/${appointmentUuid}/confirm`, {
version: appt?.version,
payments: amountRials > 0 ? [{ method, amount_rials: amountRials }] : [],
payments: rows
.filter(r => tomanToRial(r.amountToman) > 0)
.map(r => ({
method: r.method,
amount_rials: tomanToRial(r.amountToman),
...(r.methodUuid ? { payment_method_uuid: r.methodUuid } : {}),
...(r.reference.trim() ? { reference: r.reference.trim() } : {}),
})),
}),
onSuccess: () => {
toast.success('نوبت قطعی شد');
@@ -131,15 +181,30 @@ export default function ConfirmAppointmentModal({
});
function reset() {
setAmountToman(0);
setMethod('cash');
nextId.current = 1;
setRows([makeRow()]);
setTouched(false);
}
/** تغییر دستی مبلغ: از این به بعد پیش‌فرضِ خودکار دیگر روی فیلد ننشیند. */
function changeAmount(next: number) {
function patchRow(id: number, patch: Partial<PaymentRow>) {
setTouched(true);
setAmountToman(next);
setRows(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r)));
}
/** روش که عوض شد، جزئیاتِ مخصوصِ روشِ قبلی بی‌معنا می‌شود. */
function changeMethod(id: number, method: string) {
patchRow(id, { method, methodUuid: '', reference: '' });
}
function addRow() {
setTouched(true);
// ردیفِ جدید پیش‌فرض با باقی‌مانده پر می‌شود تا تسویه سریع‌تر باشد.
setRows(prev => [...prev, makeRow({ amountToman: rialToToman(remaining) })]);
}
function removeRow(id: number) {
setTouched(true);
setRows(prev => (prev.length > 1 ? prev.filter(r => r.id !== id) : prev));
}
function handleClose() {
@@ -217,51 +282,122 @@ export default function ConfirmAppointmentModal({
</div>
</div>
{/* پرداخت */}
<div
style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 14, marginBottom: 10,
}}
>
<div className="field-block">
<label>روش پرداخت</label>
<SearchableSelect
value={method}
onChange={(v) => setMethod(String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
height={40}
/>
</div>
<div className="field-block">
<label>مبلغ پرداختی (تومان)</label>
<div className="field" style={overpaid ? { borderColor: 'var(--danger)' } : undefined}>
<PriceInput value={amountToman} onChange={changeAmount} suffix="تومان" max={rialToToman(total)} />
</div>
<span className="field-hint">باقیمانده پس از این پرداخت: {formatRial(remaining)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<button
type="button"
className="btn soft sm"
onClick={() => changeAmount(rialToToman(total))}
>
پرداخت کامل
</button>
{amountToman > 0 && (
<button type="button" className="btn ghost sm" onClick={() => changeAmount(0)}>
بدون پرداخت
{/* پرداخت‌ها — تقسیم بین چند روش */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<label style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--text)' }}>روشهای پرداخت</label>
<button type="button" className="btn ghost sm" onClick={addRow}>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن روش
</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((r) => (
<div
key={r.id}
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
padding: 12, display: 'flex', flexDirection: 'column', gap: 10,
}}
>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div className="field-block" style={{ flex: 1 }}>
<label>روش پرداخت</label>
<SearchableSelect
value={r.method}
onChange={(v) => changeMethod(r.id, String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
height={40}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label>مبلغ (تومان)</label>
<div className="field">
<PriceInput
value={r.amountToman}
onChange={(v) => patchRow(r.id, { amountToman: v })}
suffix="تومان"
/>
</div>
</div>
{rows.length > 1 && (
<button
type="button"
className="btn ghost sm"
aria-label="حذف روش پرداخت"
onClick={() => removeRow(r.id)}
style={{ marginBottom: 2, color: 'var(--danger)' }}
>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</div>
{/* جزئیاتِ کارت‌خوان: انتخاب دستگاهِ ثبت‌شده + شناسه تراکنش */}
{r.method === 'pos' && (
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label>کارتخوان</label>
<SearchableSelect
value={r.methodUuid}
onChange={(v) => patchRow(r.id, { methodUuid: String(v ?? '') })}
options={posOptions}
placeholder={posOptions.length ? 'انتخاب کارت‌خوان' : 'کارت‌خوانی ثبت نشده'}
height={40}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label>شناسه تراکنش (اختیاری)</label>
<div className="field">
<input
type="text"
value={r.reference}
onChange={(e) => patchRow(r.id, { reference: e.target.value })}
placeholder="شماره پیگیری"
style={{ direction: 'ltr' }}
/>
</div>
</div>
</div>
)}
{/* جزئیاتِ کارت‌به‌کارت: انتخاب حساب بانکیِ ثبت‌شده + شناسه تراکنش */}
{r.method === 'card' && (
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label>حساب بانکی (اختیاری)</label>
<SearchableSelect
value={r.methodUuid}
onChange={(v) => patchRow(r.id, { methodUuid: String(v ?? '') })}
options={bankOptions}
placeholder={bankOptions.length ? 'انتخاب حساب' : 'حسابی ثبت نشده'}
height={40}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label>شناسه تراکنش (اختیاری)</label>
<div className="field">
<input
type="text"
value={r.reference}
onChange={(e) => patchRow(r.id, { reference: e.target.value })}
placeholder="شماره پیگیری"
style={{ direction: 'ltr' }}
/>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
{overpaid && (
<p className="field-err" style={{ marginBottom: 14 }}>
مبلغ پرداخت از جمع کل بیشتر است.
مجموع پرداختها از جمع کل بیشتر است.
</p>
)}
@@ -274,7 +410,7 @@ export default function ConfirmAppointmentModal({
>
<div style={rowStyle}>
<span>پرداختشده</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(amountRials, total))}</strong>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, total))}</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>باقیمانده</span>