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 { describe, it, expect, vi } from 'vitest';
|
||||||
import { screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { renderWithProviders } from '../test/utils';
|
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';
|
import WalletTransactionModal from './WalletTransactionModal';
|
||||||
|
|
||||||
// موجودی نمونه: ۳۰۰٬۰۰۰ ریال = ۳۰٬۰۰۰ تومان
|
// موجودی نمونه: ۳۰۰٬۰۰۰ ریال = ۳۰٬۰۰۰ تومان
|
||||||
@@ -23,11 +29,11 @@ describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => {
|
|||||||
expect(screen.getAllByRole('button', { name: /تومان/ }).length).toBe(4);
|
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();
|
const onSubmit = open();
|
||||||
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '100000' } });
|
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '100000' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
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', () => {
|
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.click(screen.getByRole('button', { name: 'برداشت از کیف پول' }));
|
||||||
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '20000' } });
|
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '20000' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
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', () => {
|
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 { 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 SearchableSelect from './ui/SearchableSelect';
|
||||||
import PersianDateInput from './ui/PersianDateInput';
|
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
|
||||||
import { formatRial, formatNumber, tomanToRial } from '../lib/utils';
|
import { formatRial, formatNumber, tomanToRial } from '../lib/utils';
|
||||||
|
|
||||||
export type WalletMode = 'charge' | 'withdraw';
|
export type WalletMode = 'charge' | 'withdraw';
|
||||||
|
|
||||||
|
export interface WalletModalSubmit {
|
||||||
|
mode: WalletMode;
|
||||||
|
amount_rials: number;
|
||||||
|
description?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
balanceRials: number;
|
balanceRials: number;
|
||||||
submitting?: boolean;
|
submitting?: boolean;
|
||||||
onClose: () => void;
|
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];
|
const QUICK_TOMANS = [100_000, 200_000, 300_000, 400_000];
|
||||||
|
|
||||||
// روش پرداخت / حساب مقصد صرفاً UI هستند (در tauri هم mock بودند و بکاندی ندارند).
|
interface MethodOption { value: string; label: string; method: string; reference: string | null }
|
||||||
const PAYMENT_METHOD_OPTS = [
|
|
||||||
{ value: 'card', label: 'کارت به کارت' },
|
|
||||||
{ value: 'cash', label: 'نقدی' },
|
|
||||||
{ value: 'pos', label: 'دستگاه پوز' },
|
|
||||||
{ value: 'gateway', label: 'درگاه اینترنتی' },
|
|
||||||
];
|
|
||||||
const ACCOUNT_OPTS = [{ value: 'main', label: 'حساب اصلی' }];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* مودال شارژ/برداشت کیف پول بیمار — پورتشده از tauri AddTransactionModal.
|
* مودال شارژ/برداشت کیف پول بیمار — با طراحیِ پنل و روشهای پرداختِ واقعیِ کلینیک
|
||||||
* یک مودال با toggle «شارژ کیف پول / برداشت از کیف پول»، مبالغ سریع، و فیلدهای فرم.
|
* (حساب بانکی/کارتخوان از /my/payment-methods + نقدی). مبلغ به تومان وارد و هنگام
|
||||||
* مبلغ به تومان وارد و هنگام ثبت به ریال (واحد API) تبدیل میشود.
|
* ثبت به ریال (واحد API) تبدیل میشود.
|
||||||
* روش پرداخت/حساب/تاریخ/ساعت فقط UI هستند (بدون ذخیره)، مطابق مبدأ.
|
|
||||||
*/
|
*/
|
||||||
export default function WalletTransactionModal({ open, balanceRials, submitting, onClose, onSubmit }: Props) {
|
export default function WalletTransactionModal({ open, balanceRials, submitting, onClose, onSubmit }: Props) {
|
||||||
const [mode, setMode] = useState<WalletMode>('charge');
|
const [mode, setMode] = useState<WalletMode>('charge');
|
||||||
const [amountToman, setAmountToman] = useState(0);
|
const [amountToman, setAmountToman] = useState(0);
|
||||||
const [method, setMethod] = useState<string>('');
|
const [methodValue, setMethodValue] = useState<string>('cash');
|
||||||
const [account, setAccount] = useState<string>('');
|
|
||||||
const [date, setDate] = useState('');
|
|
||||||
const [time, setTime] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
// با هر بار باز شدن، فرم ریست شود.
|
const banksQ = useBankAccounts();
|
||||||
useEffect(() => {
|
const posQ = usePosDevices();
|
||||||
if (open) {
|
|
||||||
setMode('charge'); setAmountToman(0); setMethod(''); setAccount('');
|
const methodOptions = useMemo<MethodOption[]>(() => {
|
||||||
setDate(''); setTime(''); setDescription('');
|
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]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,14 +72,16 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
const isWithdraw = mode === 'withdraw';
|
const isWithdraw = mode === 'withdraw';
|
||||||
const overBalance = isWithdraw && amountRials > balanceRials;
|
const overBalance = isWithdraw && amountRials > balanceRials;
|
||||||
const canSubmit = amountToman > 0 && !overBalance && !submitting;
|
const canSubmit = amountToman > 0 && !overBalance && !submitting;
|
||||||
const amountLabel = isWithdraw ? 'مبلغ برداشت:' : 'مبلغ شارژ:';
|
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
const opt = methodOptions.find((o) => o.value === methodValue);
|
||||||
onSubmit({
|
onSubmit({
|
||||||
mode,
|
mode,
|
||||||
amount_rials: amountRials,
|
amount_rials: amountRials,
|
||||||
...(description.trim() ? { description: description.trim() } : {}),
|
...(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"
|
type="button"
|
||||||
onClick={() => setMode(key)}
|
onClick={() => setMode(key)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, borderRadius: 10, padding: '9px 0', border: 'none', cursor: 'pointer',
|
flex: 1, borderRadius: 'var(--r-sm)', padding: '9px 0', border: 'none', cursor: 'pointer',
|
||||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 600, zIndex: 2, background: 'transparent',
|
fontFamily: 'inherit', fontSize: 13.5, fontWeight: 600, zIndex: 2, background: 'transparent',
|
||||||
color: on ? '#fff' : 'var(--text)', transition: 'color .3s',
|
color: on ? 'var(--on-primary)' : 'var(--text-2)', transition: 'color .25s var(--ease)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -93,7 +104,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<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">
|
<div className="modal-head">
|
||||||
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
||||||
<button type="button" className="mini-btn" onClick={onClose}>
|
<button type="button" className="mini-btn" onClick={onClose}>
|
||||||
@@ -102,100 +113,79 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body" dir="rtl">
|
<div className="modal-body" dir="rtl">
|
||||||
{/* بنر موجودی (معادل هدر گرادیانی مبدأ) */}
|
{/* موجودی — کارت ساده مطابق پنل (بدون گرادیان) */}
|
||||||
<div style={{
|
<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',
|
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 }}>
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>موجودی کیف پول</span>
|
||||||
<WalletIcon style={{ width: 22, opacity: 0.9 }} />
|
<span style={{ fontSize: 18, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</span>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>موجودی کیف پول</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 800, direction: 'ltr' }}>{formatRial(balanceRials)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* toggle شارژ/برداشت */}
|
{/* 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={{
|
<div style={{
|
||||||
position: 'absolute', top: 4, bottom: 4, width: 'calc(50% - 4px)',
|
position: 'absolute', top: 4, bottom: 4, width: 'calc(50% - 4px)',
|
||||||
right: isWithdraw ? 'calc(50% + 0px)' : 4, left: isWithdraw ? 4 : 'calc(50% + 0px)',
|
right: isWithdraw ? 'calc(50%)' : 4, left: isWithdraw ? 4 : 'calc(50%)',
|
||||||
background: 'var(--primary)', borderRadius: 10, transition: 'all .3s var(--ease)', zIndex: 1,
|
background: 'var(--primary)', borderRadius: 'var(--r-sm)', transition: 'all .25s var(--ease)', zIndex: 1,
|
||||||
}} />
|
}} />
|
||||||
{tab('charge', 'شارژ کیف پول')}
|
{tab('charge', 'شارژ کیف پول')}
|
||||||
{tab('withdraw', 'برداشت از کیف پول')}
|
{tab('withdraw', 'برداشت از کیف پول')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مبلغ + مبالغ سریع */}
|
{/* مبلغ + مبالغ سریع */}
|
||||||
<div style={{ marginBottom: 18 }}>
|
<label className="field-label">{isWithdraw ? 'مبلغ برداشت (تومان)' : 'مبلغ شارژ (تومان)'}</label>
|
||||||
<label className="field-label">{amountLabel}</label>
|
<div style={{ display: 'flex', gap: 8, margin: '8px 0 10px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', gap: 8, margin: '8px 0 12px', flexWrap: 'wrap' }}>
|
{QUICK_TOMANS.map((t) => {
|
||||||
{QUICK_TOMANS.map((t) => (
|
const on = amountToman === t;
|
||||||
<button
|
return (
|
||||||
key={t}
|
<button key={t} type="button" onClick={() => setAmountToman(t)} style={{
|
||||||
type="button"
|
flex: '1 1 0', minWidth: 92, padding: '8px 6px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
|
||||||
onClick={() => setAmountToman(t)}
|
border: `1px solid ${on ? 'var(--primary)' : 'var(--border)'}`,
|
||||||
style={{
|
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||||
flex: '1 1 0', minWidth: 90, padding: '8px 6px', borderRadius: 10, cursor: 'pointer',
|
color: on ? 'var(--primary)' : 'var(--text-2)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
|
||||||
border: `1px solid ${amountToman === t ? 'var(--primary)' : 'var(--border)'}`,
|
}}>{formatNumber(t)} تومان</button>
|
||||||
background: amountToman === t ? 'var(--primary-soft)' : 'var(--surface)',
|
);
|
||||||
color: amountToman === t ? 'var(--primary)' : 'var(--text-2)',
|
})}
|
||||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
|
</div>
|
||||||
}}
|
<div className="field">
|
||||||
>
|
<input
|
||||||
{formatNumber(t)} تومان
|
inputMode="numeric"
|
||||||
</button>
|
value={amountToman > 0 ? formatNumber(amountToman) : ''}
|
||||||
))}
|
onChange={(e) => {
|
||||||
</div>
|
const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, '');
|
||||||
<div className="field">
|
setAmountToman(raw ? parseInt(raw, 10) : 0);
|
||||||
<input
|
}}
|
||||||
inputMode="numeric"
|
placeholder="مبلغ دلخواه (تومان)"
|
||||||
value={amountToman > 0 ? formatNumber(amountToman) : ''}
|
style={{ textAlign: 'center', direction: 'ltr' }}
|
||||||
onChange={(e) => {
|
/>
|
||||||
const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, '');
|
</div>
|
||||||
setAmountToman(raw ? parseInt(raw, 10) : 0);
|
{overBalance && <div style={{ color: 'var(--danger)', fontSize: 12, marginTop: 6 }}>موجودی کیف پول کافی نیست</div>}
|
||||||
}}
|
|
||||||
placeholder="مبلغ دلخواه (تومان)"
|
{/* روش پرداخت واقعی */}
|
||||||
style={{ textAlign: 'center', direction: 'ltr' }}
|
<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>
|
</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>
|
||||||
|
|
||||||
{/* توضیحات */}
|
{/* توضیحات */}
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">توضیحات</label>
|
<label className="field-label">توضیحات</label>
|
||||||
<div className="field" style={{ height: 'auto', marginTop: 8 }}>
|
<div className="field" style={{ height: 'auto', marginTop: 8 }}>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={3}
|
rows={2}
|
||||||
placeholder="توضیحات"
|
placeholder="دلیل تراکنش (اختیاری)"
|
||||||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
|
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface WalletTxn {
|
|||||||
type: 'credit' | 'debit' | string;
|
type: 'credit' | 'debit' | string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
balance_after: number;
|
balance_after: number;
|
||||||
|
created_by_name?: string | null;
|
||||||
|
payment_method?: string | null;
|
||||||
|
reference?: string | null;
|
||||||
|
status?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +23,8 @@ interface WalletData {
|
|||||||
export interface WalletTxnInput {
|
export interface WalletTxnInput {
|
||||||
amount_rials: number;
|
amount_rials: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
reference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'همه' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'همه' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'واریزی' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'واریزی' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'برداشت' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'برداشت' })).toBeInTheDocument();
|
||||||
// تراکنش credit اولیه دیده میشود
|
// تراکنش credit اولیه دیده میشود (سطر جدول async لود میشود)
|
||||||
expect(screen.getByText('شارژ')).toBeInTheDocument();
|
expect(await screen.findByText('شارژ')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters out the credit transaction when the برداشت filter is selected', async () => {
|
it('filters out the credit transaction when the برداشت filter is selected', async () => {
|
||||||
@@ -193,7 +193,19 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
|||||||
expect(await screen.findByRole('button', { name: 'برداشت از کیف پول' })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'برداشت از کیف پول' })).toBeInTheDocument();
|
||||||
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '50000' } });
|
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '50000' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
||||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', { amount_rials: 500000 }));
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', { amount_rials: 500000, payment_method: 'cash' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles a session from the wallet via the payment-method chooser', async () => {
|
||||||
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
|
patch.mockResolvedValue({ success: true, data: {} });
|
||||||
|
renderDetail();
|
||||||
|
await loaded();
|
||||||
|
// تب سرویسها پیشفرض است؛ کارت پرداختنشده → «تکمیل پرداخت»
|
||||||
|
fireEvent.click(await screen.findByText('تکمیل پرداخت'));
|
||||||
|
expect(await screen.findByText('روش پرداخت مراجعه')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'کیف پول بیمار' }));
|
||||||
|
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { payment_method: 'wallet' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the messages tab with a send box', async () => {
|
it('renders the messages tab with a send box', async () => {
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import { api } from '../lib/api';
|
|||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { PatientRecord } from '../types';
|
import type { PatientRecord } from '../types';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { formatDate, formatRial } from '../lib/utils';
|
import { formatDate, formatRial, formatTime, formatNumber } from '../lib/utils';
|
||||||
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
|
import type { WalletTxn } from '../hooks/usePatientWallet';
|
||||||
|
import type { WalletModalSubmit } from '../components/WalletTransactionModal';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
@@ -76,6 +79,7 @@ export default function PatientDetailPage() {
|
|||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||||
|
const [settleTarget, setSettleTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
|
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
|
||||||
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینههای فرم.
|
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینههای فرم.
|
||||||
@@ -140,8 +144,15 @@ export default function PatientDetailPage() {
|
|||||||
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
|
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
|
||||||
|
|
||||||
const settle = useMutation({
|
const settle = useMutation({
|
||||||
mutationFn: (sessionUuid: string) => api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: 'cash' }),
|
mutationFn: ({ sessionUuid, method }: { sessionUuid: string; method: string }) =>
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] }); toast.success('پرداخت ثبت شد'); },
|
api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: method }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] });
|
||||||
|
// پرداخت از کیف پول موجودی را کم میکند → دفتر کیف پول را هم تازه کن.
|
||||||
|
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
|
||||||
|
toast.success('پرداخت ثبت شد');
|
||||||
|
setSettleTarget(null);
|
||||||
|
},
|
||||||
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت پرداخت'),
|
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت پرداخت'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +230,7 @@ export default function PatientDetailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
||||||
{sessions.map((s) => (
|
{sessions.map((s) => (
|
||||||
<SessionServiceCard key={s.uuid} session={s} settling={settle.isPending} onSettle={(u) => settle.mutate(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
|
<SessionServiceCard key={s.uuid} session={s} settling={settle.isPending} onSettle={(u) => setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -243,6 +254,28 @@ export default function PatientDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
|
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
|
||||||
|
|
||||||
|
{/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */}
|
||||||
|
<Modal open={settleTarget !== null} title="روش پرداخت مراجعه" onClose={() => setSettleTarget(null)}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>روش تسویهٔ این مراجعه را انتخاب کنید:</p>
|
||||||
|
{[
|
||||||
|
{ method: 'cash', label: 'نقدی' },
|
||||||
|
{ method: 'card', label: 'کارت به کارت' },
|
||||||
|
{ method: 'wallet', label: 'کیف پول بیمار' },
|
||||||
|
].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.method}
|
||||||
|
className="btn"
|
||||||
|
style={{ justifyContent: 'flex-start' }}
|
||||||
|
disabled={settle.isPending}
|
||||||
|
onClick={() => settleTarget && settle.mutate({ sessionUuid: settleTarget, method: m.method })}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -609,9 +642,17 @@ const WALLET_FILTERS: { key: WalletFilter; label: string }[] = [
|
|||||||
{ key: 'debit', label: 'برداشت' },
|
{ key: 'debit', label: 'برداشت' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const METHOD_LABEL: Record<string, string> = {
|
||||||
|
card: 'کارت به کارت', pos: 'کارتخوان', cash: 'نقدی', wallet: 'کیف پول', gateway: 'درگاه اینترنتی',
|
||||||
|
};
|
||||||
|
const STATUS_LABEL: Record<string, string> = { confirmed: 'تأیید شده' };
|
||||||
|
|
||||||
|
type WalletRow = WalletTxn & { row_no: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* کیف پول — کارت موجودی + مودال شارژ/برداشت (toggle) + فیلتر همه/واریزی/برداشت
|
* کیف پول — کارت موجودی + مودال شارژ/برداشت (روش پرداخت واقعی) + فیلتر
|
||||||
* روی دفتر تراکنشهای اخیر. پورتشده از tauri WalletSection + AddTransactionModal.
|
* همه/واریزی/برداشت روی دفترِ کاملِ تراکنشها (DataTable با ستون ثبتکننده،
|
||||||
|
* روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل.
|
||||||
*/
|
*/
|
||||||
function WalletTab({ uuid }: { uuid: string }) {
|
function WalletTab({ uuid }: { uuid: string }) {
|
||||||
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
|
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
|
||||||
@@ -620,36 +661,56 @@ function WalletTab({ uuid }: { uuid: string }) {
|
|||||||
|
|
||||||
const submitting = charge.isPending || withdraw.isPending;
|
const submitting = charge.isPending || withdraw.isPending;
|
||||||
|
|
||||||
const handleSubmit = ({ mode, amount_rials, description }: { mode: 'charge' | 'withdraw'; amount_rials: number; description?: string }) => {
|
const handleSubmit = ({ mode, ...body }: WalletModalSubmit) => {
|
||||||
const mut = mode === 'charge' ? charge : withdraw;
|
const mut = mode === 'charge' ? charge : withdraw;
|
||||||
mut.mutate(
|
mut.mutate(body, {
|
||||||
{ amount_rials, ...(description ? { description } : {}) },
|
onSuccess: () => {
|
||||||
{
|
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
|
||||||
onSuccess: () => {
|
setModalOpen(false);
|
||||||
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
|
|
||||||
setModalOpen(false);
|
|
||||||
},
|
|
||||||
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت تراکنش'),
|
|
||||||
},
|
},
|
||||||
);
|
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت تراکنش'),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
|
||||||
|
|
||||||
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
|
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
|
||||||
|
const rows: WalletRow[] = shown.map((t, i) => ({ ...t, row_no: i + 1 }));
|
||||||
|
|
||||||
|
const columns: Column<WalletRow>[] = [
|
||||||
|
{ key: 'row_no', header: 'ردیف', className: 'w-[60px]', render: (r) => formatNumber(r.row_no) },
|
||||||
|
{
|
||||||
|
key: 'amount', header: 'مبلغ', render: (r) => (
|
||||||
|
<span style={{ fontWeight: 700, direction: 'ltr', color: r.type === 'credit' ? 'var(--success)' : 'var(--danger)' }}>
|
||||||
|
{r.type === 'credit' ? '+' : '−'}{formatRial(r.amount_rials)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type', header: 'نوع تراکنش', render: (r) => (
|
||||||
|
<span className={`badge ${r.type === 'credit' ? 'green' : 'red'}`}>{r.type === 'credit' ? 'واریز' : 'برداشت'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'method', header: 'روش پرداخت', render: (r) => (r.payment_method ? METHOD_LABEL[r.payment_method] ?? r.payment_method : '—') },
|
||||||
|
{ key: 'reason', header: 'دلیل', render: (r) => r.description || '—' },
|
||||||
|
{ key: 'by', header: 'ثبتکننده', render: (r) => r.created_by_name || '—' },
|
||||||
|
{ key: 'date', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
||||||
|
{ key: 'time', header: 'ساعت', render: (r) => formatTime(r.created_at) },
|
||||||
|
{ key: 'status', header: 'وضعیت', render: (r) => <span className="badge gray">{STATUS_LABEL[r.status ?? ''] ?? 'تأیید شده'}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, marginBottom: 16, maxWidth: 320 }}>
|
{/* موجودی + دکمه تراکنش جدید */}
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
<div className="card card-pad" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balanceRials)}</div>
|
<div>
|
||||||
<button className="btn sm" style={{ marginTop: 12, color: '#fff', border: 'none', background: '#5559ce' }}
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
||||||
onClick={() => setModalOpen(true)}>
|
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
|
||||||
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
|
</div>
|
||||||
|
<button className="btn primary" onClick={() => setModalOpen(true)}>
|
||||||
|
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فیلتر تراکنشها: همه / واریزی / برداشت (معادل tauri filters) */}
|
{/* فیلتر تراکنشها */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>تراکنشها:</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>تراکنشها:</span>
|
||||||
{WALLET_FILTERS.map((f) => {
|
{WALLET_FILTERS.map((f) => {
|
||||||
@@ -673,26 +734,12 @@ function WalletTab({ uuid }: { uuid: string }) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{shown.length === 0 ? (
|
<DataTable<WalletRow>
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
|
columns={columns}
|
||||||
) : (
|
data={rows}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
loading={isLoading}
|
||||||
{shown.map((t) => {
|
emptyMessage="تراکنشی ثبت نشده است"
|
||||||
const credit = t.type === 'credit';
|
/>
|
||||||
return (
|
|
||||||
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '12px 14px' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13.5, fontWeight: 600 }}>{t.description || (credit ? 'واریز' : 'برداشت')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(t.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13.5, fontWeight: 700, direction: 'ltr', color: credit ? 'var(--success)' : 'var(--danger)' }}>
|
|
||||||
{credit ? '+' : '−'}{formatRial(t.amount_rials)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -589,24 +589,29 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme
|
|||||||
لیست پرداختهای درگاهیِ بیمار (paginated). Query: `page`, `limit` (≤100)، `status` (اختیاری: `pending|success|failed|canceled|refunded`).
|
لیست پرداختهای درگاهیِ بیمار (paginated). Query: `page`, `limit` (≤100)، `status` (اختیاری: `pending|success|failed|canceled|refunded`).
|
||||||
Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at }], meta: { totalRecords, totalPages, currentPage } }`
|
Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at }], meta: { totalRecords, totalPages, currentPage } }`
|
||||||
|
|
||||||
|
هر تراکنش برای شفافیت این فیلدها را دارد: `type` (credit/debit)، `payment_method` (card/pos/cash/gateway/wallet یا null)، `description` (دلیل)، `reference` (مرجعِ ماشینی مثل `session:{uuid}`)، `created_by_name` (کاربرِ ثبتکننده)، `status` (`confirmed`)، `balance_after`، `created_at`.
|
||||||
|
|
||||||
### GET `/api/v1/patient/{uuid}/wallet`
|
### GET `/api/v1/patient/{uuid}/wallet`
|
||||||
موجودی + ۱۰ تراکنش اخیر (تب کیفپول). `balance_rials` = مجموع credit − debit.
|
موجودی + ۱۰ تراکنش اخیر (تب کیفپول). `balance_rials` = مجموع credit − debit.
|
||||||
Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amount_rials, type, description, balance_after, created_at }] } }`
|
Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amount_rials, type, description, balance_after, created_by_name, payment_method, reference, status, created_at }] } }`
|
||||||
|
|
||||||
### POST `/api/v1/patient/{uuid}/wallet/charge`
|
### POST `/api/v1/patient/{uuid}/wallet/charge`
|
||||||
شارژ دستی کیفپول (مثلاً بیعانهٔ حضوری). یک تراکنش `credit` برای کاربرِ صاحب رکورد میسازد.
|
شارژ دستی کیفپول (مثلاً بیعانهٔ حضوری). یک تراکنش `credit` برای کاربرِ صاحب رکورد میسازد؛ کاربرِ درخواستکننده بهعنوان `created_by` ثبت میشود.
|
||||||
```json
|
```json
|
||||||
{ "amount_rials": 300000, "description": "بیعانه نوبت (اختیاری، پیشفرض «شارژ کیف پول»)" }
|
{ "amount_rials": 300000, "description": "بیعانه نوبت (اختیاری)", "payment_method": "card", "reference": "(اختیاری)" }
|
||||||
```
|
```
|
||||||
`amount_rials` باید > 0 باشد وگرنه `422`. Response `201`: `{ success, data: { transaction, balance_rials } }`
|
`amount_rials` باید > 0 باشد وگرنه `422`. `payment_method` ناشناخته نادیده گرفته میشود (null). Response `201`: `{ success, data: { transaction, balance_rials } }`
|
||||||
|
|
||||||
### POST `/api/v1/patient/{uuid}/wallet/withdraw`
|
### POST `/api/v1/patient/{uuid}/wallet/withdraw`
|
||||||
برداشت دستی از کیفپول (مثلاً عودت وجه حضوری). یک تراکنش `debit` برای کاربرِ صاحب رکورد میسازد.
|
برداشت دستی از کیفپول (مثلاً عودت وجه حضوری). یک تراکنش `debit` با ثبتِ کاربرِ عامل و روش پرداخت میسازد.
|
||||||
```json
|
```json
|
||||||
{ "amount_rials": 200000, "description": "عودت (اختیاری، پیشفرض «برداشت از کیف پول»)" }
|
{ "amount_rials": 200000, "description": "عودت (اختیاری)", "payment_method": "cash" }
|
||||||
```
|
```
|
||||||
`amount_rials` باید > 0 باشد وگرنه `422`. اگر مبلغ از موجودی فعلی بیشتر باشد `422` با کد `ERR_WALLET_INSUFFICIENT`. Response `201`: `{ success, data: { transaction, balance_rials } }`
|
`amount_rials` باید > 0 باشد وگرنه `422`. اگر مبلغ از موجودی فعلی بیشتر باشد `422` با کد `ERR_WALLET_INSUFFICIENT`. Response `201`: `{ success, data: { transaction, balance_rials } }`
|
||||||
|
|
||||||
|
### PATCH `/api/v1/session/{uuid}` — پرداخت مراجعه از کیف پول
|
||||||
|
با `{"payment_method": "wallet"}` سهمِ نهاییِ بیمار (`final_price_rials`) از کیف پول کسر میشود: یک تراکنشِ `debit` با `payment_method=wallet`، `reference=session:{uuid}` و دلیلِ «پرداخت سرویس: …» ثبت میگردد. فقط وقتی مراجعه هنوز تسویه نشده و مبلغ > 0 باشد. موجودیِ ناکافی → `422` `ERR_WALLET_INSUFFICIENT` (مراجعه تسویه نمیشود).
|
||||||
|
|
||||||
### GET `/api/v1/patient/{uuid}/wallet/transactions`
|
### GET `/api/v1/patient/{uuid}/wallet/transactions`
|
||||||
دفترِ کاملِ تراکنشهای کیفپول (paginated). Query: `page`, `limit` (≤100).
|
دفترِ کاملِ تراکنشهای کیفپول (paginated). Query: `page`, `limit` (≤100).
|
||||||
Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }`
|
Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }`
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260716083939 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE wallet_transactions ADD created_by_name VARCHAR(120) DEFAULT NULL, ADD payment_method VARCHAR(20) DEFAULT NULL, ADD reference VARCHAR(120) DEFAULT NULL, ADD status VARCHAR(15) DEFAULT \'confirmed\' NOT NULL, ADD created_by_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE wallet_transactions ADD CONSTRAINT FK_A50205E2B03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A50205E2B03A8386 ON wallet_transactions (created_by_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE wallet_transactions DROP FOREIGN KEY FK_A50205E2B03A8386');
|
||||||
|
$this->addSql('DROP INDEX IDX_A50205E2B03A8386 ON wallet_transactions');
|
||||||
|
$this->addSql('ALTER TABLE wallet_transactions DROP created_by_name, DROP payment_method, DROP reference, DROP status, DROP created_by_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ class PatientController extends BaseController
|
|||||||
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
|
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
|
||||||
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
|
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
|
||||||
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
||||||
|
private readonly \App\Settlement\Service\WalletService $walletService,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -155,24 +156,24 @@ class PatientController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$patient = $record->getUser();
|
$patient = $record->getUser();
|
||||||
$balance = $this->settlementRepo->getWalletBalance($patient) + $amount;
|
$txn = $this->walletService->charge(
|
||||||
|
$patient, $amount, $user,
|
||||||
$txn = new \App\Settlement\Entity\WalletTransaction($patient, $amount, 'credit', $balance);
|
trim((string) ($data['description'] ?? '')) ?: null,
|
||||||
$description = trim((string) ($data['description'] ?? ''));
|
$this->normalizeMethod($data['payment_method'] ?? null),
|
||||||
$txn->setDescription($description !== '' ? $description : 'شارژ کیف پول');
|
trim((string) ($data['reference'] ?? '')) ?: null,
|
||||||
$this->walletRepo->save($txn);
|
);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'transaction' => $txn->toArray(),
|
'transaction' => $txn->toArray(),
|
||||||
'balance_rials' => $balance,
|
'balance_rials' => $txn->getBalanceAfter(),
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual wallet withdrawal (برداشت از کیف پول) — e.g. a refund or cash
|
* Manual wallet withdrawal (برداشت از کیف پول) — e.g. a refund or cash
|
||||||
* hand-back at the desk. Creates a debit WalletTransaction for the record's
|
* hand-back at the desk. Creates a debit WalletTransaction for the record's
|
||||||
* owner User. Rejected (422) when the amount exceeds the current balance,
|
* owner User (with acting user + payment method recorded). Rejected (422)
|
||||||
* mirroring the offline app's balance guard.
|
* when the amount exceeds the current balance, mirroring the offline app.
|
||||||
*/
|
*/
|
||||||
#[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
|
#[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
|
||||||
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
@@ -189,25 +190,28 @@ class PatientController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بزرگتر از صفر باشد', 422, 'amount_rials');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بزرگتر از صفر باشد', 422, 'amount_rials');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insufficient balance → WalletService throws AppException (422), handled globally.
|
||||||
$patient = $record->getUser();
|
$patient = $record->getUser();
|
||||||
$current = $this->settlementRepo->getWalletBalance($patient);
|
$txn = $this->walletService->withdraw(
|
||||||
if ($amount > $current) {
|
$patient, $amount, $user,
|
||||||
return $this->error(ErrorCodes::ERR_WALLET_INSUFFICIENT, ErrorCodes::message(ErrorCodes::ERR_WALLET_INSUFFICIENT), 422, 'amount_rials');
|
trim((string) ($data['description'] ?? '')) ?: null,
|
||||||
}
|
$this->normalizeMethod($data['payment_method'] ?? null),
|
||||||
|
trim((string) ($data['reference'] ?? '')) ?: null,
|
||||||
$balance = $current - $amount;
|
);
|
||||||
|
|
||||||
$txn = new \App\Settlement\Entity\WalletTransaction($patient, $amount, 'debit', $balance);
|
|
||||||
$description = trim((string) ($data['description'] ?? ''));
|
|
||||||
$txn->setDescription($description !== '' ? $description : 'برداشت از کیف پول');
|
|
||||||
$this->walletRepo->save($txn);
|
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'transaction' => $txn->toArray(),
|
'transaction' => $txn->toArray(),
|
||||||
'balance_rials' => $balance,
|
'balance_rials' => $txn->getBalanceAfter(),
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** روش پرداختِ مجاز برای کیف پول؛ ورودیِ ناشناخته نادیده گرفته میشود. */
|
||||||
|
private function normalizeMethod(mixed $method): ?string
|
||||||
|
{
|
||||||
|
$method = is_string($method) ? trim($method) : '';
|
||||||
|
return in_array($method, ['card', 'pos', 'cash', 'gateway', 'wallet'], true) ? $method : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Call center (کال سنتر) ─────────────────────────────────────────────────
|
// ── Call center (کال سنتر) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** List the patient's call log (newest first, optional ?outcome=success|missed). */
|
/** List the patient's call log (newest first, optional ?outcome=success|missed). */
|
||||||
@@ -958,8 +962,20 @@ class PatientController extends BaseController
|
|||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
|
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
|
||||||
if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); }
|
|
||||||
|
if (isset($data['payment_method'])) {
|
||||||
|
$method = (string) $data['payment_method'];
|
||||||
|
// پرداخت از کیف پول: سهمِ بیمار را از موجودی کسر کن (فقط یکبار، اگر
|
||||||
|
// مراجعه هنوز تسویه نشده). موجودیِ ناکافی → AppException (۴۲۲).
|
||||||
|
if ($method === 'wallet'
|
||||||
|
&& $session->getPaymentMethod() === 'pending'
|
||||||
|
&& $session->getFinalPriceRials() > 0
|
||||||
|
) {
|
||||||
|
$this->walletService->settleSessionFromWallet($session, $user);
|
||||||
|
}
|
||||||
|
$session->setPaymentMethod($method);
|
||||||
|
}
|
||||||
|
|
||||||
$this->sessionRepo->save($session);
|
$this->sessionRepo->save($session);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class WalletTransaction
|
|||||||
public const TYPE_CREDIT = 'credit';
|
public const TYPE_CREDIT = 'credit';
|
||||||
public const TYPE_DEBIT = 'debit';
|
public const TYPE_DEBIT = 'debit';
|
||||||
|
|
||||||
|
public const STATUS_CONFIRMED = 'confirmed';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
@@ -45,6 +47,26 @@ class WalletTransaction
|
|||||||
#[ORM\Column(name: 'balance_after', type: 'integer')]
|
#[ORM\Column(name: 'balance_after', type: 'integer')]
|
||||||
private int $balanceAfter;
|
private int $balanceAfter;
|
||||||
|
|
||||||
|
/** کاربرِ عاملِ تراکنش (منشی/دکتر که شارژ/برداشت را ثبت کرده) — برای شفافیت. */
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'created_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $createdBy = null;
|
||||||
|
|
||||||
|
/** نامِ denormalizedِ کاربرِ عامل، تا خواندنِ دفتر تراکنش join نخواهد. */
|
||||||
|
#[ORM\Column(name: 'created_by_name', type: 'string', length: 120, nullable: true)]
|
||||||
|
private ?string $createdByName = null;
|
||||||
|
|
||||||
|
/** روش پرداخت: card | pos | cash | gateway | wallet. */
|
||||||
|
#[ORM\Column(name: 'payment_method', type: 'string', length: 20, nullable: true)]
|
||||||
|
private ?string $paymentMethod = null;
|
||||||
|
|
||||||
|
/** مرجع/دلیلِ ماشینی تراکنش (مثلاً session:{uuid} برای کسر بابت سرویس). */
|
||||||
|
#[ORM\Column(type: 'string', length: 120, nullable: true)]
|
||||||
|
private ?string $reference = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 15)]
|
||||||
|
private string $status = self::STATUS_CONFIRMED;
|
||||||
|
|
||||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
private int $createdAt;
|
private int $createdAt;
|
||||||
|
|
||||||
@@ -66,19 +88,33 @@ class WalletTransaction
|
|||||||
public function getType(): string { return $this->type; }
|
public function getType(): string { return $this->type; }
|
||||||
public function getDescription(): ?string { return $this->description; }
|
public function getDescription(): ?string { return $this->description; }
|
||||||
public function getBalanceAfter(): int { return $this->balanceAfter; }
|
public function getBalanceAfter(): int { return $this->balanceAfter; }
|
||||||
|
public function getCreatedBy(): ?User { return $this->createdBy; }
|
||||||
|
public function getCreatedByName(): ?string { return $this->createdByName; }
|
||||||
|
public function getPaymentMethod(): ?string { return $this->paymentMethod; }
|
||||||
|
public function getReference(): ?string { return $this->reference; }
|
||||||
|
public function getStatus(): string { return $this->status; }
|
||||||
|
|
||||||
public function setPayment(?Payment $p): self { $this->payment = $p; return $this; }
|
public function setPayment(?Payment $p): self { $this->payment = $p; return $this; }
|
||||||
public function setDescription(?string $d): self { $this->description = $d; return $this; }
|
public function setDescription(?string $d): self { $this->description = $d; return $this; }
|
||||||
|
public function setCreatedBy(?User $u): self { $this->createdBy = $u; return $this; }
|
||||||
|
public function setCreatedByName(?string $n): self { $this->createdByName = $n; return $this; }
|
||||||
|
public function setPaymentMethod(?string $m): self { $this->paymentMethod = $m; return $this; }
|
||||||
|
public function setReference(?string $r): self { $this->reference = $r; return $this; }
|
||||||
|
public function setStatus(string $s): self { $this->status = $s; return $this; }
|
||||||
|
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'uuid' => $this->uuid,
|
'uuid' => $this->uuid,
|
||||||
'amount_rials' => $this->amountRials,
|
'amount_rials' => $this->amountRials,
|
||||||
'type' => $this->type,
|
'type' => $this->type,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'balance_after' => $this->balanceAfter,
|
'balance_after' => $this->balanceAfter,
|
||||||
'created_at' => $this->createdAt,
|
'created_by_name' => $this->createdByName,
|
||||||
|
'payment_method' => $this->paymentMethod,
|
||||||
|
'reference' => $this->reference,
|
||||||
|
'status' => $this->status,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Settlement\Service;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Patient\Entity\PatientSession;
|
||||||
|
use App\Patient\Entity\SessionService;
|
||||||
|
use App\Settlement\Entity\WalletTransaction;
|
||||||
|
use App\Settlement\Repository\SettlementRepository;
|
||||||
|
use App\Settlement\Repository\WalletTransactionRepository;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Exception\AppException;
|
||||||
|
use App\UserProfile\Repository\UserProfileRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* منطق کیف پولِ بیمار: موجودی، شارژ (credit)، برداشت (debit) و تسویهٔ سرویس از
|
||||||
|
* کیف پول — همگی با ثبتِ کاربرِ عامل، روش پرداخت و دلیل برای شفافیت کامل.
|
||||||
|
* موجودی همیشه از مجموع credit − debit مشتق میشود (SettlementRepository).
|
||||||
|
*/
|
||||||
|
class WalletService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WalletTransactionRepository $walletRepo,
|
||||||
|
private readonly SettlementRepository $settlementRepo,
|
||||||
|
private readonly UserProfileRepository $profileRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function balance(User $patient): int
|
||||||
|
{
|
||||||
|
return $this->settlementRepo->getWalletBalance($patient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نامِ نمایشیِ کاربرِ عامل (برای ستون «ثبتکننده»)؛ در نبودِ پروفایل، شماره موبایل. */
|
||||||
|
public function resolveActorName(?User $actor): ?string
|
||||||
|
{
|
||||||
|
if ($actor === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$profile = $this->profileRepo->findByUser($actor);
|
||||||
|
$name = trim(($profile?->getLabel() ?? '') . ' ' . ($profile?->getFamily() ?? ''));
|
||||||
|
|
||||||
|
return $name !== '' ? $name : $actor->getMobileNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شارژِ کیف پول (credit). فرض بر مثبت بودنِ مبلغ است (اعتبارسنجی در Controller). */
|
||||||
|
public function charge(
|
||||||
|
User $patient,
|
||||||
|
int $amountRials,
|
||||||
|
?User $actor = null,
|
||||||
|
?string $description = null,
|
||||||
|
?string $paymentMethod = null,
|
||||||
|
?string $reference = null,
|
||||||
|
): WalletTransaction {
|
||||||
|
$balanceAfter = $this->balance($patient) + $amountRials;
|
||||||
|
|
||||||
|
return $this->record(
|
||||||
|
$patient, $amountRials, WalletTransaction::TYPE_CREDIT, $balanceAfter, $actor,
|
||||||
|
$description !== null && $description !== '' ? $description : 'شارژ کیف پول',
|
||||||
|
$paymentMethod, $reference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* برداشت از کیف پول (debit). اگر مبلغ از موجودی بیشتر باشد
|
||||||
|
* AppException با کد ERR_WALLET_INSUFFICIENT (۴۲۲) پرتاب میشود.
|
||||||
|
*/
|
||||||
|
public function withdraw(
|
||||||
|
User $patient,
|
||||||
|
int $amountRials,
|
||||||
|
?User $actor = null,
|
||||||
|
?string $description = null,
|
||||||
|
?string $paymentMethod = null,
|
||||||
|
?string $reference = null,
|
||||||
|
): WalletTransaction {
|
||||||
|
$current = $this->balance($patient);
|
||||||
|
if ($amountRials > $current) {
|
||||||
|
throw new AppException(ErrorCodes::ERR_WALLET_INSUFFICIENT, null, 422, 'amount_rials');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->record(
|
||||||
|
$patient, $amountRials, WalletTransaction::TYPE_DEBIT, $current - $amountRials, $actor,
|
||||||
|
$description !== null && $description !== '' ? $description : 'برداشت از کیف پول',
|
||||||
|
$paymentMethod, $reference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسویهٔ یک مراجعه از کیف پول: مبلغِ نهاییِ سهمِ بیمار را بهصورت debit کسر
|
||||||
|
* میکند و دلیل را به نامِ سرویسها و شناسهٔ مراجعه گره میزند. موجودیِ ناکافی → ۴۲۲.
|
||||||
|
*/
|
||||||
|
public function settleSessionFromWallet(PatientSession $session, ?User $actor = null): WalletTransaction
|
||||||
|
{
|
||||||
|
$patient = $session->getRecord()->getUser();
|
||||||
|
$amount = $session->getFinalPriceRials();
|
||||||
|
|
||||||
|
$names = array_values(array_filter(array_map(
|
||||||
|
fn(SessionService $s) => $s->toArray()['service_name'] ?? null,
|
||||||
|
$session->getServices()->toArray(),
|
||||||
|
)));
|
||||||
|
$label = $names !== [] ? implode('، ', $names) : 'ویزیت';
|
||||||
|
|
||||||
|
return $this->withdraw(
|
||||||
|
$patient, $amount, $actor,
|
||||||
|
'پرداخت سرویس: ' . $label,
|
||||||
|
'wallet',
|
||||||
|
'session:' . $session->getUuid(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function record(
|
||||||
|
User $patient,
|
||||||
|
int $amountRials,
|
||||||
|
string $type,
|
||||||
|
int $balanceAfter,
|
||||||
|
?User $actor,
|
||||||
|
string $description,
|
||||||
|
?string $paymentMethod,
|
||||||
|
?string $reference,
|
||||||
|
): WalletTransaction {
|
||||||
|
$txn = new WalletTransaction($patient, $amountRials, $type, $balanceAfter);
|
||||||
|
$txn->setDescription($description)
|
||||||
|
->setCreatedBy($actor)
|
||||||
|
->setCreatedByName($this->resolveActorName($actor))
|
||||||
|
->setPaymentMethod($paymentMethod)
|
||||||
|
->setReference($reference)
|
||||||
|
->setStatus(WalletTransaction::STATUS_CONFIRMED);
|
||||||
|
$this->walletRepo->save($txn);
|
||||||
|
|
||||||
|
return $txn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,31 @@ class PatientFinancialsTest extends ApiTestCase
|
|||||||
self::assertSame(422, $this->responseCode());
|
self::assertSame(422, $this->responseCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testChargeRecordsActingUserPaymentMethodAndStatus(): void
|
||||||
|
{
|
||||||
|
[$owner, $record] = $this->recordFor();
|
||||||
|
|
||||||
|
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
|
||||||
|
'amount_rials' => 300000, 'payment_method' => 'card',
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
$txn = $res['data']['transaction'];
|
||||||
|
self::assertSame('card', $txn['payment_method']);
|
||||||
|
self::assertSame('confirmed', $txn['status']);
|
||||||
|
// بدون پروفایل → نامِ ثبتکننده = شماره موبایلِ کاربرِ عامل
|
||||||
|
self::assertSame($owner->getMobileNumber(), $txn['created_by_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChargeIgnoresUnknownPaymentMethod(): void
|
||||||
|
{
|
||||||
|
[$owner, $record] = $this->recordFor();
|
||||||
|
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
|
||||||
|
'amount_rials' => 100000, 'payment_method' => 'bitcoin',
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
self::assertNull($res['data']['transaction']['payment_method']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testFinancialsAreOwnershipScoped(): void
|
public function testFinancialsAreOwnershipScoped(): void
|
||||||
{
|
{
|
||||||
[, $record] = $this->recordFor();
|
[, $record] = $this->recordFor();
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Patient;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Patient\Entity\PatientRecord;
|
||||||
|
use App\Patient\Entity\PatientSession;
|
||||||
|
use App\Settlement\Entity\WalletTransaction;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسویهٔ یک مراجعه (PatientSession) از کیف پول: PATCH /session/{uuid} با
|
||||||
|
* payment_method=wallet مبلغِ نهایی را بهصورت debit از موجودی کسر میکند و در
|
||||||
|
* صورتِ ناکافی بودن موجودی رد میشود.
|
||||||
|
*/
|
||||||
|
class PatientWalletSessionSettleTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||||
|
private function recordFor(): array
|
||||||
|
{
|
||||||
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($owner, 'دکتر');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$patient = $this->createUser(['ROLE_USER']);
|
||||||
|
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||||
|
$this->em->persist($record);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return [$owner, $record, $patient];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sessionFor(PatientRecord $record, int $finalPriceRials): PatientSession
|
||||||
|
{
|
||||||
|
$session = new PatientSession($record);
|
||||||
|
$session->setFinalPriceRials($finalPriceRials);
|
||||||
|
$this->em->persist($session);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSettleSessionFromWalletDebitsBalanceAndMarksPaid(): void
|
||||||
|
{
|
||||||
|
[$owner, $record, $patient] = $this->recordFor();
|
||||||
|
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$session = $this->sessionFor($record, 400_000);
|
||||||
|
|
||||||
|
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||||
|
'payment_method' => 'wallet',
|
||||||
|
]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
self::assertSame('wallet', $res['data']['payment_method']);
|
||||||
|
self::assertTrue($res['data']['is_paid']);
|
||||||
|
|
||||||
|
// موجودی از ۱٬۰۰۰٬۰۰۰ به ۶۰۰٬۰۰۰ رسید و یک تراکنشِ debitِ گرهخورده به مراجعه ثبت شد.
|
||||||
|
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||||
|
self::assertSame(600_000, $wallet['data']['balance_rials']);
|
||||||
|
$debit = $wallet['data']['recent_transactions'][0];
|
||||||
|
self::assertSame('debit', $debit['type']);
|
||||||
|
self::assertSame('wallet', $debit['payment_method']);
|
||||||
|
self::assertSame('session:' . $session->getUuid(), $debit['reference']);
|
||||||
|
self::assertSame($owner->getMobileNumber(), $debit['created_by_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSettleSessionFromWalletRejectedWhenInsufficient(): void
|
||||||
|
{
|
||||||
|
[$owner, $record, $patient] = $this->recordFor();
|
||||||
|
$this->em->persist(new WalletTransaction($patient, 100_000, 'credit', 100_000));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$session = $this->sessionFor($record, 400_000);
|
||||||
|
|
||||||
|
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||||
|
'payment_method' => 'wallet',
|
||||||
|
]);
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
|
||||||
|
|
||||||
|
// مراجعه تسویه نشد و موجودی دستنخورده ماند.
|
||||||
|
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||||
|
self::assertSame(100_000, $wallet['data']['balance_rials']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSettleSessionWithCashDoesNotTouchWallet(): void
|
||||||
|
{
|
||||||
|
[$owner, $record, $patient] = $this->recordFor();
|
||||||
|
$this->em->persist(new WalletTransaction($patient, 500_000, 'credit', 500_000));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$session = $this->sessionFor($record, 300_000);
|
||||||
|
|
||||||
|
$this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, ['payment_method' => 'cash']);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||||
|
self::assertSame(500_000, $wallet['data']['balance_rials']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user