Files
clinicpro/assets/admin/pages/SessionPaymentPage.test.tsx
T
hamedandClaude Opus 4.8 27d088c6dd feat: port tauri create-service payment flow to admin session settlement
Port the tauri /files/create-service page (payment mode) to the admin SPA
and back it with real multi-part session settlement:

Backend:
- New SessionPayment entity (session_payments table): partial payments
  per session with method (wallet/pos/cash/card), amount, paid_at, actor
- PatientSession: settlement discount (percent/fixed), discount_rials,
  paid_at, payments relation; remaining debt derived from
  final - discount - paid total
- POST /api/v1/session/{uuid}/payments: register a partial payment;
  wallet method debits the patient wallet; zero remaining marks paid
- PATCH /api/v1/session/{uuid}: accepts discount_type/discount_value
  (null removes) and paid_at, backward compatible
- New error codes: ERR_SESSION_PAYMENT_INVALID/_EXCEEDS,
  ERR_SESSION_DISCOUNT_INVALID
- Migration + 14 functional tests (partial/full/wallet/exceed/discount)

Frontend (admin):
- SessionPaymentPage: two-step stepper (پرداخت ← جزییات) ported from
  tauri AddService payment mode — service cost, settlement discount
  input, Jalali payment date, wallet balance, 4-method payment accordion,
  paid-list box, details summary
- SessionStepper + stepper/payment icons ported verbatim from tauri SVGs
- «تکمیل پرداخت» on SessionServiceCard now navigates to the payment page
  (replaces the small settle modal on PatientDetailPage)
- Routes for patients/ and my-patients/ variants; vitest coverage
- docs/api/patient.md updated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:32:06 +03:30

148 lines
6.0 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import { formatRial } from '../lib/utils';
import SessionPaymentPage from './SessionPaymentPage';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const session = (over: object = {}) => ({
uuid: 's1',
services: [{ service_name: 'کاشت مو' }],
doctor_name: 'دکتر فتحی',
visit_price_rials: 0,
final_price_rials: 2_500_000,
is_paid: false,
patient_debt_rials: 2_300_000,
discount_rials: 200_000,
paid_total_rials: 0,
payments: [] as object[],
created_at: 1_700_000_000,
...over,
});
function mockGets(sessions: object[]) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1') {
return Promise.resolve({ success: true, data: { uuid: 'r1', user_name: 'ساغر صابری', profile: {} } });
}
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: sessions });
if (url === '/api/v1/patient/r1/wallet') {
return Promise.resolve({ success: true, data: { balance_rials: 300_000, recent_transactions: [] } });
}
return Promise.resolve({ success: true, data: [] });
});
}
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/patients/:recordUuid/session/:sessionUuid/pay" element={<SessionPaymentPage />} />
</Routes>,
{ route: '/admin/patients/r1/session/s1/pay' },
);
}
beforeEach(() => {
get.mockReset();
post.mockReset();
patch.mockReset();
});
describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
it('renders payment step with real cost, wallet balance and remaining debt', async () => {
mockGets([session()]);
renderPage();
expect(await screen.findByText('هزینه سرویس:')).toBeInTheDocument();
expect(screen.getAllByText(formatRial(2_500_000)).length).toBeGreaterThan(0);
// استپر دو گام حالت payment
expect(screen.getByText('پرداخت')).toBeInTheDocument();
expect(screen.getByText('جزییات')).toBeInTheDocument();
// موجودی کیف پول
expect(screen.getByText('موجودی کیف پول:')).toBeInTheDocument();
// چهار روش پرداخت tauri
expect(screen.getByText('پرداخت از طریق کیف پول')).toBeInTheDocument();
expect(screen.getByText('پرداخت از طریق کارت خوان')).toBeInTheDocument();
expect(screen.getByText('پرداخت نقدی')).toBeInTheDocument();
expect(screen.getByText('کارت به کارت')).toBeInTheDocument();
// breadcrumb با نام بیمار
expect(screen.getByText('ساغر صابری')).toBeInTheDocument();
});
it('submits a partial payment via the accordion (POST /session/{uuid}/payments)', async () => {
mockGets([session()]);
post.mockResolvedValue({ success: true, data: session({ paid_total_rials: 500_000 }) });
renderPage();
fireEvent.click(await screen.findByText('پرداخت نقدی'));
fireEvent.change(screen.getByPlaceholderText('مبلغ (تومان)'), { target: { value: '500000' } });
fireEvent.click(screen.getByText('ثبت پرداخت'));
await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
const [url, body] = post.mock.calls[0];
expect(url).toBe('/api/v1/session/s1/payments');
expect(body).toMatchObject({ method: 'cash', amount_rials: 500000 });
expect(typeof (body as any).paid_at).toBe('number');
});
it('applies and removes settlement discount (PATCH /session/{uuid})', async () => {
mockGets([session()]);
patch.mockResolvedValue({ success: true, data: session() });
renderPage();
await screen.findByText('هزینه سرویس:');
// حذف تخفیف → discount_type: null
fireEvent.click(screen.getByText('حذف تخفیف'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_type: null }));
});
it('shows registered payments and moves to details step', async () => {
mockGets([session({
payments: [
{ uuid: 'p1', method: 'cash', amount_rials: 1_000_000, paid_at: 1_700_000_000 },
{ uuid: 'p2', method: 'wallet', amount_rials: 300_000, paid_at: 1_700_000_000 },
],
paid_total_rials: 1_300_000,
patient_debt_rials: 1_000_000,
})]);
renderPage();
// «پرداخت نقدی» هم عنوان آکاردئون است هم برچسب پرداخت ثبت‌شده
expect((await screen.findAllByText('پرداخت نقدی')).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument();
// گام جزییات
fireEvent.click(screen.getByText('ثبت و ادامه'));
expect(screen.getByText('صدور فاکتور')).toBeInTheDocument();
expect(screen.getByText('کاشت مو')).toBeInTheDocument();
expect(screen.getByText('دکتر فتحی')).toBeInTheDocument();
expect(screen.getByText('مبلغ باقی مانده:')).toBeInTheDocument();
});
it('renders empty payments state (پرداختی ثبت نشده است)', async () => {
mockGets([session({ payments: [], discount_rials: 0 })]);
renderPage();
expect(await screen.findByText('پرداختی ثبت نشده است')).toBeInTheDocument();
});
it('shows not-found message when session uuid does not exist', async () => {
mockGets([session({ uuid: 'other' })]);
renderPage();
expect(await screen.findByText('مراجعه یافت نشد')).toBeInTheDocument();
});
});