feat: port wallet charge/withdraw modal from tauri to patient admin page
Add manual wallet withdrawal (debit) endpoint mirroring the offline app's
balance guard, and rebuild the patient کیف پول tab around a single
charge/withdraw toggle modal (quick amounts, تومان→ریال conversion,
transaction filters).
Backend:
- POST /api/v1/patient/{uuid}/wallet/withdraw — creates a debit
WalletTransaction; 422 ERR_WALLET_INSUFFICIENT when amount exceeds balance.
- ErrorCodes: ERR_WALLET_INSUFFICIENT ('موجودی کیف پول کافی نیست').
- docs/api/patient.md updated.
Frontend:
- usePatientWallet hook (balance + charge/withdraw mutations).
- WalletTransactionModal (toggle, quick amounts, UI-only payment fields).
- WalletTab: charge button, همه/واریزی/برداشت filters.
Tests: backend withdraw success/insufficient/non-positive/ownership;
frontend modal + wallet tab interactions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
import WalletTransactionModal from './WalletTransactionModal';
|
||||||
|
|
||||||
|
// موجودی نمونه: ۳۰۰٬۰۰۰ ریال = ۳۰٬۰۰۰ تومان
|
||||||
|
const BALANCE_RIALS = 300_000;
|
||||||
|
|
||||||
|
function open(onSubmit = vi.fn()) {
|
||||||
|
renderWithProviders(
|
||||||
|
<WalletTransactionModal open balanceRials={BALANCE_RIALS} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||||
|
);
|
||||||
|
return onSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => {
|
||||||
|
it('renders both mode tabs, the balance banner and the quick amounts', () => {
|
||||||
|
open();
|
||||||
|
expect(screen.getByRole('button', { name: 'شارژ کیف پول' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'برداشت از کیف پول' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('موجودی کیف پول')).toBeInTheDocument();
|
||||||
|
// چهار چیپ مبلغ سریع، هر کدام «... تومان»
|
||||||
|
expect(screen.getAllByRole('button', { name: /تومان/ }).length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a charge with the amount converted from toman to rials', () => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a withdraw (debit) when the برداشت tab is active and amount is within balance', () => {
|
||||||
|
const onSubmit = open();
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a withdraw above the balance and shows the insufficient-funds hint', () => {
|
||||||
|
const onSubmit = open();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'برداشت از کیف پول' }));
|
||||||
|
// ۴۰٬۰۰۰ تومان = ۴۰۰٬۰۰۰ ریال > موجودی ۳۰۰٬۰۰۰ ریال
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '40000' } });
|
||||||
|
expect(screen.getByText('موجودی کیف پول کافی نیست')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت تراکنش' })).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps submit disabled when no amount is entered', () => {
|
||||||
|
open();
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت تراکنش' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { XMarkIcon, WalletIcon } from '@heroicons/react/24/outline';
|
||||||
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
|
import PersianDateInput from './ui/PersianDateInput';
|
||||||
|
import { formatRial, formatNumber, tomanToRial } from '../lib/utils';
|
||||||
|
|
||||||
|
export type WalletMode = 'charge' | 'withdraw';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
balanceRials: number;
|
||||||
|
submitting?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: { mode: WalletMode; amount_rials: number; description?: string }) => 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: 'حساب اصلی' }];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مودال شارژ/برداشت کیف پول بیمار — پورتشده از tauri AddTransactionModal.
|
||||||
|
* یک مودال با toggle «شارژ کیف پول / برداشت از کیف پول»، مبالغ سریع، و فیلدهای فرم.
|
||||||
|
* مبلغ به تومان وارد و هنگام ثبت به ریال (واحد API) تبدیل میشود.
|
||||||
|
* روش پرداخت/حساب/تاریخ/ساعت فقط UI هستند (بدون ذخیره)، مطابق مبدأ.
|
||||||
|
*/
|
||||||
|
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 [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
// با هر بار باز شدن، فرم ریست شود.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMode('charge'); setAmountToman(0); setMethod(''); setAccount('');
|
||||||
|
setDate(''); setTime(''); setDescription('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const amountRials = tomanToRial(amountToman);
|
||||||
|
const isWithdraw = mode === 'withdraw';
|
||||||
|
const overBalance = isWithdraw && amountRials > balanceRials;
|
||||||
|
const canSubmit = amountToman > 0 && !overBalance && !submitting;
|
||||||
|
const amountLabel = isWithdraw ? 'مبلغ برداشت:' : 'مبلغ شارژ:';
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onSubmit({
|
||||||
|
mode,
|
||||||
|
amount_rials: amountRials,
|
||||||
|
...(description.trim() ? { description: description.trim() } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tab = (key: WalletMode, label: string) => {
|
||||||
|
const on = mode === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="overlay" onClick={onClose}>
|
||||||
|
<div className="modal" style={{ maxWidth: 620 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
||||||
|
<button type="button" className="mini-btn" onClick={onClose}>
|
||||||
|
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||||
|
</button>
|
||||||
|
</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',
|
||||||
|
}}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* toggle شارژ/برداشت */}
|
||||||
|
<div style={{ position: 'relative', display: 'flex', background: 'var(--primary-soft)', borderRadius: 12, padding: 4, marginBottom: 20 }}>
|
||||||
|
<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,
|
||||||
|
}} />
|
||||||
|
{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' }}
|
||||||
|
/>
|
||||||
|
</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 }}>
|
||||||
|
<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="توضیحات"
|
||||||
|
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-foot">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>انصراف</button>
|
||||||
|
<button type="button" className="btn primary" disabled={!canSubmit} onClick={submit}>
|
||||||
|
{submitting ? 'در حال ثبت...' : 'ثبت تراکنش'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
|
||||||
|
export interface WalletTxn {
|
||||||
|
uuid: string;
|
||||||
|
amount_rials: number;
|
||||||
|
type: 'credit' | 'debit' | string;
|
||||||
|
description?: string | null;
|
||||||
|
balance_after: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalletData {
|
||||||
|
balance_rials: number;
|
||||||
|
recent_transactions: WalletTxn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletTxnInput {
|
||||||
|
amount_rials: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کیف پول بیمار: موجودی + تراکنشهای اخیر، و mutationهای شارژ (credit) و برداشت (debit).
|
||||||
|
* منبع: GET /api/v1/patient/{uuid}/wallet و POST .../wallet/{charge|withdraw}.
|
||||||
|
* کلید کوئری: ['patient-wallet', uuid]؛ پس از هر mutation موفق invalidate میشود.
|
||||||
|
*/
|
||||||
|
export function usePatientWallet(uuid: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const queryKey = ['patient-wallet', uuid];
|
||||||
|
|
||||||
|
const query = useQuery<ApiResponse<WalletData>>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`),
|
||||||
|
enabled: !!uuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey });
|
||||||
|
|
||||||
|
const charge = useMutation<ApiResponse<unknown>, unknown, WalletTxnInput>({
|
||||||
|
mutationFn: (body) => api.post(`/api/v1/patient/${uuid}/wallet/charge`, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withdraw = useMutation<ApiResponse<unknown>, unknown, WalletTxnInput>({
|
||||||
|
mutationFn: (body) => api.post(`/api/v1/patient/${uuid}/wallet/withdraw`, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
balanceRials: query.data?.data?.balance_rials ?? 0,
|
||||||
|
transactions: query.data?.data?.recent_transactions ?? [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
charge,
|
||||||
|
withdraw,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -158,12 +158,42 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
|||||||
expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument();
|
expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows wallet balance on the wallet tab', async () => {
|
it('shows wallet balance, charge button and transaction filters on the wallet tab', async () => {
|
||||||
renderDetail();
|
renderDetail();
|
||||||
await loaded();
|
await loaded();
|
||||||
fireEvent.click(screen.getByText('کیف پول'));
|
fireEvent.click(screen.getByText('کیف پول'));
|
||||||
expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument();
|
expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('شارژ')).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 اولیه دیده میشود
|
||||||
|
expect(screen.getByText('شارژ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out the credit transaction when the برداشت filter is selected', async () => {
|
||||||
|
renderDetail();
|
||||||
|
await loaded();
|
||||||
|
fireEvent.click(screen.getByText('کیف پول'));
|
||||||
|
await screen.findByText('موجودی کیف پول');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'برداشت' }));
|
||||||
|
expect(screen.getByText('تراکنشی ثبت نشده است')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the charge/withdraw modal and posts a charge', async () => {
|
||||||
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
post.mockResolvedValue({ success: true, data: {} });
|
||||||
|
renderDetail();
|
||||||
|
await loaded();
|
||||||
|
fireEvent.click(screen.getByText('کیف پول'));
|
||||||
|
await screen.findByText('موجودی کیف پول');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /شارژ کیف پول/ }));
|
||||||
|
// مودال باز شد → تب برداشت هم دیده میشود
|
||||||
|
expect(await screen.findByRole('button', { name: 'برداشت از کیف پول' })).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '50000' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
||||||
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', { amount_rials: 500000 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the messages tab with a send box', async () => {
|
it('renders the messages tab with a send box', async () => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { formatDate, formatRial } from '../lib/utils';
|
|||||||
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';
|
||||||
import PriceInput from '../components/ui/PriceInput';
|
|
||||||
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
||||||
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
||||||
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
||||||
@@ -29,6 +28,8 @@ import {
|
|||||||
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
|
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
|
||||||
} from '../components/icons/FilesServiceIcons';
|
} from '../components/icons/FilesServiceIcons';
|
||||||
import PatientRecordInfoForm from '../components/PatientRecordInfoForm';
|
import PatientRecordInfoForm from '../components/PatientRecordInfoForm';
|
||||||
|
import WalletTransactionModal from '../components/WalletTransactionModal';
|
||||||
|
import { usePatientWallet } from '../hooks/usePatientWallet';
|
||||||
import {
|
import {
|
||||||
profileToFormValues, formValuesToPayload,
|
profileToFormValues, formValuesToPayload,
|
||||||
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
|
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
|
||||||
@@ -600,66 +601,83 @@ function CallCenterTab({ uuid }: { uuid: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WalletTxn { uuid: string; amount_rials: number; type: string; description?: string | null; balance_after: number; created_at: number }
|
type WalletFilter = 'all' | 'credit' | 'debit';
|
||||||
|
|
||||||
/** کیف پول — patient wallet balance card + manual top-up + recent-transaction ledger. */
|
const WALLET_FILTERS: { key: WalletFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'همه' },
|
||||||
|
{ key: 'credit', label: 'واریزی' },
|
||||||
|
{ key: 'debit', label: 'برداشت' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کیف پول — کارت موجودی + مودال شارژ/برداشت (toggle) + فیلتر همه/واریزی/برداشت
|
||||||
|
* روی دفتر تراکنشهای اخیر. پورتشده از tauri WalletSection + AddTransactionModal.
|
||||||
|
*/
|
||||||
function WalletTab({ uuid }: { uuid: string }) {
|
function WalletTab({ uuid }: { uuid: string }) {
|
||||||
const qc = useQueryClient();
|
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
|
||||||
const [chargeOpen, setChargeOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [amountRials, setAmountRials] = useState(0);
|
const [filter, setFilter] = useState<WalletFilter>('all');
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<ApiResponse<{ balance_rials: number; recent_transactions: WalletTxn[] }>>({
|
const submitting = charge.isPending || withdraw.isPending;
|
||||||
queryKey: ['patient-wallet', uuid],
|
|
||||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`),
|
|
||||||
enabled: !!uuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
const charge = useMutation({
|
const handleSubmit = ({ mode, amount_rials, description }: { mode: 'charge' | 'withdraw'; amount_rials: number; description?: string }) => {
|
||||||
mutationFn: () => api.post(`/api/v1/patient/${uuid}/wallet/charge`, {
|
const mut = mode === 'charge' ? charge : withdraw;
|
||||||
amount_rials: amountRials,
|
mut.mutate(
|
||||||
...(description.trim() ? { description: description.trim() } : {}),
|
{ amount_rials, ...(description ? { description } : {}) },
|
||||||
}),
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
|
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
|
||||||
toast.success('کیف پول شارژ شد');
|
setModalOpen(false);
|
||||||
setChargeOpen(false); setAmountRials(0); setDescription('');
|
},
|
||||||
},
|
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>;
|
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||||||
const balance = data?.data?.balance_rials ?? 0;
|
|
||||||
const txns = data?.data?.recent_transactions ?? [];
|
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
|
||||||
|
|
||||||
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={{ 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 style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balance)}</div>
|
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balanceRials)}</div>
|
||||||
<button className="btn sm" style={{ marginTop: 12, color: 'var(--accent)', border: '1px solid var(--accent)', background: 'var(--accent-bg)' }}
|
<button className="btn sm" style={{ marginTop: 12, color: '#fff', border: 'none', background: '#5559ce' }}
|
||||||
onClick={() => setChargeOpen(true)}>
|
onClick={() => setModalOpen(true)}>
|
||||||
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
|
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal open={chargeOpen} title="شارژ کیف پول" onClose={() => setChargeOpen(false)}>
|
{/* فیلتر تراکنشها: همه / واریزی / برداشت (معادل tauri filters) */}
|
||||||
<div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مبلغ شارژ (تومان)</label>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>تراکنشها:</span>
|
||||||
<div style={{ margin: '6px 0 12px' }}><PriceInput value={amountRials} onChange={setAmountRials} /></div>
|
{WALLET_FILTERS.map((f) => {
|
||||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>توضیحات</label>
|
const on = filter === f.key;
|
||||||
<div className="field" style={{ margin: '6px 0 16px' }}>
|
return (
|
||||||
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="مثلاً: بیعانه نوبت" />
|
<button key={f.key} onClick={() => setFilter(f.key)} style={{
|
||||||
</div>
|
padding: '6px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer',
|
||||||
<button className="btn primary" style={{ width: '100%' }} disabled={amountRials <= 0 || charge.isPending} onClick={() => charge.mutate()}>
|
fontFamily: 'inherit', fontSize: 13, fontWeight: on ? 700 : 500,
|
||||||
ثبت شارژ
|
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||||
</button>
|
color: on ? 'var(--primary)' : 'var(--text-2)',
|
||||||
</div>
|
}}>{f.label}</button>
|
||||||
</Modal>
|
);
|
||||||
{txns.length === 0 ? (
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WalletTransactionModal
|
||||||
|
open={modalOpen}
|
||||||
|
balanceRials={balanceRials}
|
||||||
|
submitting={submitting}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shown.length === 0 ? (
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
|
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{txns.map((t) => {
|
{shown.map((t) => {
|
||||||
const credit = t.type === 'credit';
|
const credit = t.type === 'credit';
|
||||||
return (
|
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 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' }}>
|
||||||
|
|||||||
@@ -600,6 +600,13 @@ Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amoun
|
|||||||
```
|
```
|
||||||
`amount_rials` باید > 0 باشد وگرنه `422`. Response `201`: `{ success, data: { transaction, balance_rials } }`
|
`amount_rials` باید > 0 باشد وگرنه `422`. Response `201`: `{ success, data: { transaction, balance_rials } }`
|
||||||
|
|
||||||
|
### POST `/api/v1/patient/{uuid}/wallet/withdraw`
|
||||||
|
برداشت دستی از کیفپول (مثلاً عودت وجه حضوری). یک تراکنش `debit` برای کاربرِ صاحب رکورد میسازد.
|
||||||
|
```json
|
||||||
|
{ "amount_rials": 200000, "description": "عودت (اختیاری، پیشفرض «برداشت از کیف پول»)" }
|
||||||
|
```
|
||||||
|
`amount_rials` باید > 0 باشد وگرنه `422`. اگر مبلغ از موجودی فعلی بیشتر باشد `422` با کد `ERR_WALLET_INSUFFICIENT`. Response `201`: `{ success, data: { transaction, balance_rials } }`
|
||||||
|
|
||||||
### 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 } }`
|
||||||
@@ -608,6 +615,8 @@ Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_af
|
|||||||
| HTTP | Code | Description |
|
| HTTP | Code | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| 404 | `ERR_PATIENT_001` | رکورد یافت نشد یا متعلق به مالک دیگر |
|
| 404 | `ERR_PATIENT_001` | رکورد یافت نشد یا متعلق به مالک دیگر |
|
||||||
|
| 422 | `ERR_VALIDATION_001` | مبلغ شارژ/برداشت ≤ 0 |
|
||||||
|
| 422 | `ERR_WALLET_INSUFFICIENT` | مبلغ برداشت از موجودی کیفپول بیشتر است |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,46 @@ class PatientController extends BaseController
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual wallet withdrawal (برداشت از کیف پول) — e.g. a refund or cash
|
||||||
|
* hand-back at the desk. Creates a debit WalletTransaction for the record's
|
||||||
|
* owner User. Rejected (422) when the amount exceeds the current balance,
|
||||||
|
* mirroring the offline app's balance guard.
|
||||||
|
*/
|
||||||
|
#[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
|
||||||
|
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||||
|
$record = $this->recordRepo->findByUuid($uuid);
|
||||||
|
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$amount = (int) ($data['amount_rials'] ?? 0);
|
||||||
|
if ($amount <= 0) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بزرگتر از صفر باشد', 422, 'amount_rials');
|
||||||
|
}
|
||||||
|
|
||||||
|
$patient = $record->getUser();
|
||||||
|
$current = $this->settlementRepo->getWalletBalance($patient);
|
||||||
|
if ($amount > $current) {
|
||||||
|
return $this->error(ErrorCodes::ERR_WALLET_INSUFFICIENT, ErrorCodes::message(ErrorCodes::ERR_WALLET_INSUFFICIENT), 422, 'amount_rials');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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([
|
||||||
|
'transaction' => $txn->toArray(),
|
||||||
|
'balance_rials' => $balance,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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). */
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class ErrorCodes
|
|||||||
// SMS Wallet
|
// SMS Wallet
|
||||||
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
|
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
|
||||||
|
|
||||||
|
// Patient Wallet
|
||||||
|
public const ERR_WALLET_INSUFFICIENT = 'ERR_WALLET_INSUFFICIENT';
|
||||||
|
|
||||||
// Rate Limit
|
// Rate Limit
|
||||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||||
|
|
||||||
@@ -144,6 +147,7 @@ class ErrorCodes
|
|||||||
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
|
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
|
||||||
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
||||||
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
||||||
|
self::ERR_WALLET_INSUFFICIENT => 'موجودی کیف پول کافی نیست',
|
||||||
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
||||||
self::ERR_EXTERNAL_001 => 'خطا در استعلام. لطفاً بعداً تلاش کنید',
|
self::ERR_EXTERNAL_001 => 'خطا در استعلام. لطفاً بعداً تلاش کنید',
|
||||||
self::ERR_EXTERNAL_NOT_CONFIGURED => 'سرویس استعلام پیکربندی نشده است',
|
self::ERR_EXTERNAL_NOT_CONFIGURED => 'سرویس استعلام پیکربندی نشده است',
|
||||||
|
|||||||
@@ -100,6 +100,61 @@ class PatientFinancialsTest extends ApiTestCase
|
|||||||
self::assertSame(422, $this->responseCode());
|
self::assertSame(422, $this->responseCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testWithdrawWalletCreatesDebitAndReducesBalance(): void
|
||||||
|
{
|
||||||
|
[$owner, $record, $patient] = $this->recordFor();
|
||||||
|
|
||||||
|
$this->em->persist(new WalletTransaction($patient, 500000, 'credit', 500000));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
|
||||||
|
'amount_rials' => 200000, 'description' => 'عودت وجه',
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
self::assertSame(300000, $res['data']['balance_rials']);
|
||||||
|
self::assertSame('debit', $res['data']['transaction']['type']);
|
||||||
|
self::assertSame('عودت وجه', $res['data']['transaction']['description']);
|
||||||
|
|
||||||
|
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||||
|
self::assertSame(300000, $wallet['data']['balance_rials']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawWalletDefaultsDescription(): void
|
||||||
|
{
|
||||||
|
[$owner, $record, $patient] = $this->recordFor();
|
||||||
|
|
||||||
|
$this->em->persist(new WalletTransaction($patient, 400000, 'credit', 400000));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
|
||||||
|
'amount_rials' => 400000,
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
self::assertSame(0, $res['data']['balance_rials']);
|
||||||
|
self::assertSame('برداشت از کیف پول', $res['data']['transaction']['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawWalletRejectsAmountAboveBalance(): void
|
||||||
|
{
|
||||||
|
[$owner, $record, $patient] = $this->recordFor();
|
||||||
|
|
||||||
|
$this->em->persist(new WalletTransaction($patient, 100000, 'credit', 100000));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
|
||||||
|
'amount_rials' => 150000,
|
||||||
|
]);
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawWalletRejectsNonPositiveAmount(): void
|
||||||
|
{
|
||||||
|
[$owner, $record] = $this->recordFor();
|
||||||
|
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, ['amount_rials' => 0]);
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
public function testFinancialsAreOwnershipScoped(): void
|
public function testFinancialsAreOwnershipScoped(): void
|
||||||
{
|
{
|
||||||
[, $record] = $this->recordFor();
|
[, $record] = $this->recordFor();
|
||||||
@@ -110,6 +165,8 @@ class PatientFinancialsTest extends ApiTestCase
|
|||||||
self::assertSame(404, $this->responseCode());
|
self::assertSame(404, $this->responseCode());
|
||||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $other, ['amount_rials' => 1000]);
|
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $other, ['amount_rials' => 1000]);
|
||||||
self::assertSame(404, $this->responseCode());
|
self::assertSame(404, $this->responseCode());
|
||||||
|
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $other, ['amount_rials' => 1000]);
|
||||||
|
self::assertSame(404, $this->responseCode());
|
||||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $other);
|
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $other);
|
||||||
self::assertSame(404, $this->responseCode());
|
self::assertSame(404, $this->responseCode());
|
||||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $other);
|
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $other);
|
||||||
|
|||||||
Reference in New Issue
Block a user