feat: unify wallet with real payment methods, full transaction transparency, pay-session-from-wallet
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<WalletMode>('charge');
|
||||
const [amountToman, setAmountToman] = useState(0);
|
||||
const [method, setMethod] = useState<string>('');
|
||||
const [account, setAccount] = useState<string>('');
|
||||
const [date, setDate] = useState('');
|
||||
const [time, setTime] = useState('');
|
||||
const [methodValue, setMethodValue] = useState<string>('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<MethodOption[]>(() => {
|
||||
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(
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 620 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
||||
<button type="button" className="mini-btn" onClick={onClose}>
|
||||
@@ -102,100 +113,79 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
||||
</div>
|
||||
|
||||
<div className="modal-body" dir="rtl">
|
||||
{/* بنر موجودی (معادل هدر گرادیانی مبدأ) */}
|
||||
{/* موجودی — کارت ساده مطابق پنل (بدون گرادیان) */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, var(--primary), var(--primary-700))',
|
||||
borderRadius: 'var(--r-lg)', padding: '18px 22px', color: '#fff', marginBottom: 20,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'var(--primary-soft)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', padding: '12px 16px', marginBottom: 18,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<WalletIcon style={{ width: 22, opacity: 0.9 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>موجودی کیف پول</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, direction: 'ltr' }}>{formatRial(balanceRials)}</div>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>موجودی کیف پول</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</span>
|
||||
</div>
|
||||
|
||||
{/* toggle شارژ/برداشت */}
|
||||
<div style={{ position: 'relative', display: 'flex', background: 'var(--primary-soft)', borderRadius: 12, padding: 4, marginBottom: 20 }}>
|
||||
<div style={{ position: 'relative', display: 'flex', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: 4, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 4, bottom: 4, width: 'calc(50% - 4px)',
|
||||
right: isWithdraw ? 'calc(50% + 0px)' : 4, left: isWithdraw ? 4 : 'calc(50% + 0px)',
|
||||
background: 'var(--primary)', borderRadius: 10, transition: 'all .3s var(--ease)', zIndex: 1,
|
||||
right: isWithdraw ? 'calc(50%)' : 4, left: isWithdraw ? 4 : 'calc(50%)',
|
||||
background: 'var(--primary)', borderRadius: 'var(--r-sm)', transition: 'all .25s var(--ease)', zIndex: 1,
|
||||
}} />
|
||||
{tab('charge', 'شارژ کیف پول')}
|
||||
{tab('withdraw', 'برداشت از کیف پول')}
|
||||
</div>
|
||||
|
||||
{/* مبلغ + مبالغ سریع */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label className="field-label">{amountLabel}</label>
|
||||
<div style={{ display: 'flex', gap: 8, margin: '8px 0 12px', flexWrap: 'wrap' }}>
|
||||
{QUICK_TOMANS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setAmountToman(t)}
|
||||
style={{
|
||||
flex: '1 1 0', minWidth: 90, padding: '8px 6px', borderRadius: 10, cursor: 'pointer',
|
||||
border: `1px solid ${amountToman === t ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: amountToman === t ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
color: amountToman === t ? 'var(--primary)' : 'var(--text-2)',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{formatNumber(t)} تومان
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="field">
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={amountToman > 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' }}
|
||||
<label className="field-label">{isWithdraw ? 'مبلغ برداشت (تومان)' : 'مبلغ شارژ (تومان)'}</label>
|
||||
<div style={{ display: 'flex', gap: 8, margin: '8px 0 10px', flexWrap: 'wrap' }}>
|
||||
{QUICK_TOMANS.map((t) => {
|
||||
const on = amountToman === t;
|
||||
return (
|
||||
<button key={t} type="button" onClick={() => setAmountToman(t)} style={{
|
||||
flex: '1 1 0', minWidth: 92, padding: '8px 6px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
|
||||
border: `1px solid ${on ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
color: on ? 'var(--primary)' : 'var(--text-2)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
|
||||
}}>{formatNumber(t)} تومان</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="field">
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={amountToman > 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' }}
|
||||
/>
|
||||
</div>
|
||||
{overBalance && <div style={{ color: 'var(--danger)', fontSize: 12, marginTop: 6 }}>موجودی کیف پول کافی نیست</div>}
|
||||
|
||||
{/* روش پرداخت واقعی */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">روش پرداخت</label>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<SearchableSelect
|
||||
options={methodOptions.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={methodValue}
|
||||
onChange={(v) => setMethodValue(String(v ?? 'cash'))}
|
||||
placeholder="روش پرداخت"
|
||||
height={46}
|
||||
/>
|
||||
</div>
|
||||
{overBalance && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 12, marginTop: 6 }}>موجودی کیف پول کافی نیست</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* روش پرداخت + حساب مقصد (UI-only) */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label className="field-label">انتخاب روشهای پرداخت:</label>
|
||||
<div style={{ marginTop: 8, marginBottom: 10 }}>
|
||||
<SearchableSelect options={PAYMENT_METHOD_OPTS} value={method} onChange={(v) => setMethod(String(v ?? ''))} placeholder="کارت به کارت" height={46} />
|
||||
</div>
|
||||
<SearchableSelect options={ACCOUNT_OPTS} value={account} onChange={(v) => setAccount(String(v ?? ''))} placeholder="حساب مقصد" height={46} />
|
||||
</div>
|
||||
|
||||
{/* تاریخ + ساعت (UI-only) */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="field-label">تاریخ</label>
|
||||
<div style={{ marginTop: 8 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="field-label">ساعت</label>
|
||||
<div className="field" style={{ marginTop: 8 }}>
|
||||
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* توضیحات */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">توضیحات</label>
|
||||
<div className="field" style={{ height: 'auto', marginTop: 8 }}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="توضیحات"
|
||||
rows={2}
|
||||
placeholder="دلیل تراکنش (اختیاری)"
|
||||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user