From ef97b2b249cad195c3c8ba9e92738b3f4c52ebb6 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 12:22:37 +0330 Subject: [PATCH] feat: unify wallet with real payment methods, full transaction transparency, pay-session-from-wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address wallet feedback: use the clinic's real payment infrastructure, redesign the tab to match the admin panel, and make every wallet movement fully auditable. Backend: - WalletTransaction: add createdBy (acting user) + createdByName, payment_method, reference, status; toArray exposes them (migration Version20260716083939). - WalletService (Settlement): balance/charge/withdraw + settleSessionFromWallet, records actor/method/reason; insufficient balance throws ERR_WALLET_INSUFFICIENT. - PatientController: charge/withdraw delegate to WalletService and accept payment_method/reference; PATCH /session/{uuid} with payment_method=wallet debits the patient's final share from the wallet (reference=session:{uuid}). - docs/api/patient.md updated. Frontend: - Wallet modal redesigned to panel style (no gradient); payment method now uses the clinic's real bank accounts + POS devices (usePaymentMethods) plus cash. - Wallet tab: panel balance card + DataTable ledger with columns مبلغ/نوع/روش/ دلیل/ثبت‌کننده/تاریخ/ساعت/وضعیت + همه/واریزی/برداشت filters. - Session card «تکمیل پرداخت» opens a payment-method chooser incl. کیف پول. Tests: backend transparency + session-from-wallet (success/insufficient/cash); frontend modal (real methods, toman→rials) + wallet tab + settle chooser. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WalletTransactionModal.test.tsx | 12 +- .../components/WalletTransactionModal.tsx | 192 +++++++++--------- assets/admin/hooks/usePatientWallet.ts | 6 + assets/admin/pages/PatientDetailPage.test.tsx | 18 +- assets/admin/pages/PatientDetailPage.tsx | 137 +++++++++---- docs/api/patient.md | 17 +- migrations/Version20260716083939.php | 35 ++++ src/Patient/Controller/PatientController.php | 62 +++--- src/Settlement/Entity/WalletTransaction.php | 48 ++++- src/Settlement/Service/WalletService.php | 131 ++++++++++++ tests/Patient/PatientFinancialsTest.php | 25 +++ .../PatientWalletSessionSettleTest.php | 102 ++++++++++ 12 files changed, 598 insertions(+), 187 deletions(-) create mode 100644 migrations/Version20260716083939.php create mode 100644 src/Settlement/Service/WalletService.php create mode 100644 tests/Patient/PatientWalletSessionSettleTest.php diff --git a/assets/admin/components/WalletTransactionModal.test.tsx b/assets/admin/components/WalletTransactionModal.test.tsx index 4d4868e2..ca699edb 100644 --- a/assets/admin/components/WalletTransactionModal.test.tsx +++ b/assets/admin/components/WalletTransactionModal.test.tsx @@ -1,6 +1,12 @@ import { describe, it, expect, vi } from 'vitest'; import { screen, fireEvent } from '@testing-library/react'; import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn().mockResolvedValue({ success: true, data: [] }), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + import WalletTransactionModal from './WalletTransactionModal'; // موجودی نمونه: ۳۰۰٬۰۰۰ ریال = ۳۰٬۰۰۰ تومان @@ -23,11 +29,11 @@ describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => { expect(screen.getAllByRole('button', { name: /تومان/ }).length).toBe(4); }); - it('submits a charge with the amount converted from toman to rials', () => { + it('submits a charge with the amount converted from toman to rials and the default cash method', () => { const onSubmit = open(); fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '100000' } }); fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' })); - expect(onSubmit).toHaveBeenCalledWith({ mode: 'charge', amount_rials: 1_000_000 }); + expect(onSubmit).toHaveBeenCalledWith({ mode: 'charge', amount_rials: 1_000_000, payment_method: 'cash' }); }); it('submits a withdraw (debit) when the برداشت tab is active and amount is within balance', () => { @@ -35,7 +41,7 @@ describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => { fireEvent.click(screen.getByRole('button', { name: 'برداشت از کیف پول' })); fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '20000' } }); fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' })); - expect(onSubmit).toHaveBeenCalledWith({ mode: 'withdraw', amount_rials: 200_000 }); + expect(onSubmit).toHaveBeenCalledWith({ mode: 'withdraw', amount_rials: 200_000, payment_method: 'cash' }); }); it('blocks a withdraw above the balance and shows the insufficient-funds hint', () => { diff --git a/assets/admin/components/WalletTransactionModal.tsx b/assets/admin/components/WalletTransactionModal.tsx index 9795cec0..72c31005 100644 --- a/assets/admin/components/WalletTransactionModal.tsx +++ b/assets/admin/components/WalletTransactionModal.tsx @@ -1,53 +1,62 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import { XMarkIcon, WalletIcon } from '@heroicons/react/24/outline'; +import { XMarkIcon } from '@heroicons/react/24/outline'; import SearchableSelect from './ui/SearchableSelect'; -import PersianDateInput from './ui/PersianDateInput'; +import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods'; import { formatRial, formatNumber, tomanToRial } from '../lib/utils'; export type WalletMode = 'charge' | 'withdraw'; +export interface WalletModalSubmit { + mode: WalletMode; + amount_rials: number; + description?: string; + payment_method?: string; + reference?: string; +} + interface Props { open: boolean; balanceRials: number; submitting?: boolean; onClose: () => void; - onSubmit: (payload: { mode: WalletMode; amount_rials: number; description?: string }) => void; + onSubmit: (payload: WalletModalSubmit) => void; } -// مبالغ پیشنهادی سریع، به تومان (معادل tauri quickAmounts). +// مبالغ پیشنهادی سریع، به تومان. const QUICK_TOMANS = [100_000, 200_000, 300_000, 400_000]; -// روش پرداخت / حساب مقصد صرفاً UI هستند (در tauri هم mock بودند و بک‌اندی ندارند). -const PAYMENT_METHOD_OPTS = [ - { value: 'card', label: 'کارت به کارت' }, - { value: 'cash', label: 'نقدی' }, - { value: 'pos', label: 'دستگاه پوز' }, - { value: 'gateway', label: 'درگاه اینترنتی' }, -]; -const ACCOUNT_OPTS = [{ value: 'main', label: 'حساب اصلی' }]; +interface MethodOption { value: string; label: string; method: string; reference: string | null } /** - * مودال شارژ/برداشت کیف پول بیمار — پورت‌شده از tauri AddTransactionModal. - * یک مودال با toggle «شارژ کیف پول / برداشت از کیف پول»، مبالغ سریع، و فیلدهای فرم. - * مبلغ به تومان وارد و هنگام ثبت به ریال (واحد API) تبدیل می‌شود. - * روش پرداخت/حساب/تاریخ/ساعت فقط UI هستند (بدون ذخیره)، مطابق مبدأ. + * مودال شارژ/برداشت کیف پول بیمار — با طراحیِ پنل و روش‌های پرداختِ واقعیِ کلینیک + * (حساب بانکی/کارت‌خوان از /my/payment-methods + نقدی). مبلغ به تومان وارد و هنگام + * ثبت به ریال (واحد API) تبدیل می‌شود. */ export default function WalletTransactionModal({ open, balanceRials, submitting, onClose, onSubmit }: Props) { const [mode, setMode] = useState('charge'); const [amountToman, setAmountToman] = useState(0); - const [method, setMethod] = useState(''); - const [account, setAccount] = useState(''); - const [date, setDate] = useState(''); - const [time, setTime] = useState(''); + const [methodValue, setMethodValue] = useState('cash'); const [description, setDescription] = useState(''); - // با هر بار باز شدن، فرم ریست شود. - useEffect(() => { - if (open) { - setMode('charge'); setAmountToman(0); setMethod(''); setAccount(''); - setDate(''); setTime(''); setDescription(''); + const banksQ = useBankAccounts(); + const posQ = usePosDevices(); + + const methodOptions = useMemo(() => { + const opts: MethodOption[] = [{ value: 'cash', label: 'نقدی', method: 'cash', reference: null }]; + for (const b of banksQ.data?.data ?? []) { + if (!b.is_active) continue; + opts.push({ value: `bank:${b.uuid}`, label: `کارت به کارت — ${b.bank_name}`, method: 'card', reference: `${b.bank_name}${b.card_number ? ` (${b.card_number})` : ''}` }); } + for (const p of posQ.data?.data ?? []) { + if (!p.is_active) continue; + opts.push({ value: `pos:${p.uuid}`, label: `کارت‌خوان — ${p.bank_name}`, method: 'pos', reference: `${p.bank_name} (${p.terminal_number})` }); + } + return opts; + }, [banksQ.data, posQ.data]); + + useEffect(() => { + if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); } }, [open]); useEffect(() => { @@ -63,14 +72,16 @@ export default function WalletTransactionModal({ open, balanceRials, submitting, const isWithdraw = mode === 'withdraw'; const overBalance = isWithdraw && amountRials > balanceRials; const canSubmit = amountToman > 0 && !overBalance && !submitting; - const amountLabel = isWithdraw ? 'مبلغ برداشت:' : 'مبلغ شارژ:'; const submit = () => { if (!canSubmit) return; + const opt = methodOptions.find((o) => o.value === methodValue); onSubmit({ mode, amount_rials: amountRials, ...(description.trim() ? { description: description.trim() } : {}), + ...(opt ? { payment_method: opt.method } : {}), + ...(opt?.reference ? { reference: opt.reference } : {}), }); }; @@ -81,9 +92,9 @@ export default function WalletTransactionModal({ open, balanceRials, submitting, type="button" onClick={() => setMode(key)} style={{ - flex: 1, borderRadius: 10, padding: '9px 0', border: 'none', cursor: 'pointer', - fontFamily: 'inherit', fontSize: 14, fontWeight: 600, zIndex: 2, background: 'transparent', - color: on ? '#fff' : 'var(--text)', transition: 'color .3s', + flex: 1, borderRadius: 'var(--r-sm)', padding: '9px 0', border: 'none', cursor: 'pointer', + fontFamily: 'inherit', fontSize: 13.5, fontWeight: 600, zIndex: 2, background: 'transparent', + color: on ? 'var(--on-primary)' : 'var(--text-2)', transition: 'color .25s var(--ease)', }} > {label} @@ -93,7 +104,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting, return createPortal(
-
e.stopPropagation()}> +
e.stopPropagation()}>

{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}

- {/* بنر موجودی (معادل هدر گرادیانی مبدأ) */} + {/* موجودی — کارت ساده مطابق پنل (بدون گرادیان) */}
-
- - موجودی کیف پول -
-
{formatRial(balanceRials)}
+ موجودی کیف پول + {formatRial(balanceRials)}
{/* toggle شارژ/برداشت */} -
+
{tab('charge', 'شارژ کیف پول')} {tab('withdraw', 'برداشت از کیف پول')}
{/* مبلغ + مبالغ سریع */} -
- -
- {QUICK_TOMANS.map((t) => ( - - ))} -
-
- 0 ? formatNumber(amountToman) : ''} - onChange={(e) => { - const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, ''); - setAmountToman(raw ? parseInt(raw, 10) : 0); - }} - placeholder="مبلغ دلخواه (تومان)" - style={{ textAlign: 'center', direction: 'ltr' }} + +
+ {QUICK_TOMANS.map((t) => { + const on = amountToman === t; + return ( + + ); + })} +
+
+ 0 ? formatNumber(amountToman) : ''} + onChange={(e) => { + const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, ''); + setAmountToman(raw ? parseInt(raw, 10) : 0); + }} + placeholder="مبلغ دلخواه (تومان)" + style={{ textAlign: 'center', direction: 'ltr' }} + /> +
+ {overBalance &&
موجودی کیف پول کافی نیست
} + + {/* روش پرداخت واقعی */} +
+ +
+ ({ value: o.value, label: o.label }))} + value={methodValue} + onChange={(v) => setMethodValue(String(v ?? 'cash'))} + placeholder="روش پرداخت" + height={46} />
- {overBalance && ( -
موجودی کیف پول کافی نیست
- )} -
- - {/* روش پرداخت + حساب مقصد (UI-only) */} -
- -
- setMethod(String(v ?? ''))} placeholder="کارت به کارت" height={46} /> -
- setAccount(String(v ?? ''))} placeholder="حساب مقصد" height={46} /> -
- - {/* تاریخ + ساعت (UI-only) */} -
-
- -
-
-
- -
- setTime(e.target.value)} dir="ltr" /> -
-
{/* توضیحات */} -
+