Files
clinicpro/assets/admin/components/InvoiceSummaryModal.test.tsx
T
hamedandClaude Opus 5 1f58b1b9b3 feat(insurance): bill an appointment with a chosen service kind and insurance
An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:50:14 +03:30

132 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
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 InvoiceSummaryModal from './InvoiceSummaryModal';
const get = api.get as ReturnType<typeof vi.fn>;
const baseInvoice = {
uuid: 'iv1', status: 'finalized', issued_at: 1700000000, total_rials: 2_400_000,
base_insurance_rials: 0, supplementary_rials: 0, patient_rials: 900_000,
items: [{ uuid: 'it1', title: 'فول بادی', quantity: 1, total_rials: 2_400_000, patient_rials: 900_000 }],
};
const fullSession = {
session_at: 1700000000, paid_at: 1700100000,
services_total_rials: 2_400_000, consumables_total_rials: 40_000,
discount_rials: 200_000, final_price_rials: 2_240_000, paid_total_rials: 1_500_000,
// API واقعی همیشه این را می‌فرستد: max(0, final discount paid)
remaining_rials: 540_000,
payments: [
{ uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' },
],
consumables: [
{ uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 },
],
};
beforeEach(() => {
get.mockReset();
});
function mockInvoice(invoice: object) {
get.mockResolvedValue({ success: true, data: { data: invoice } });
}
describe('InvoiceSummaryModal', () => {
it('با session کامل: جدول کالای مصرفی، پرداختی‌ها و تخفیف را نشان می‌دهد', async () => {
mockInvoice({ ...baseInvoice, session: fullSession });
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
// کالای مصرفی
expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument();
expect(screen.getByText('عینک')).toBeInTheDocument();
// پرداختی‌ها با label فارسی روش و ثبت‌کننده
expect(screen.getByText('پرداختی ها')).toBeInTheDocument();
expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument();
expect(screen.getByText('منشی تست')).toBeInTheDocument();
// خلاصه مالی با ستون تخفیف و جمع کالا
expect(screen.getByText('تخفیف')).toBeInTheDocument();
expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument();
// وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰):
// پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۵۴٬۰۰۰
// (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است)
expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument();
});
it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ می‌شود', async () => {
mockInvoice({ ...baseInvoice, session: null });
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
// جدول‌های session-محور رندر نمی‌شوند
expect(screen.queryByText('اطلاعات کالای مصرفی')).not.toBeInTheDocument();
expect(screen.queryByText('پرداختی ها')).not.toBeInTheDocument();
// خلاصه مالی قدیمی با سهم بیمار
expect(screen.getByText('سهم بیمار')).toBeInTheDocument();
});
it('session با payments/consumables خالی: ردیف خط تیره', async () => {
mockInvoice({
...baseInvoice,
session: { ...fullSession, payments: [], consumables: [], paid_total_rials: 0, discount_rials: 0 },
});
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument();
expect(screen.getByText('پرداختی ها')).toBeInTheDocument();
// ردیف‌های '-' برای هر دو جدول خالی + ستون تخفیف صفر
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(8);
});
it('invoiceUuid=null: مودال بسته و بدون fetch', () => {
renderWithProviders(<InvoiceSummaryModal invoiceUuid={null} onClose={() => {}} />);
expect(get).not.toHaveBeenCalled();
expect(screen.queryByText('خلاصه فاکتور')).not.toBeInTheDocument();
});
it('فاکتور بیمه‌ای: نوع خدمت، نام بیمه و سهم‌ها درج می‌شوند', async () => {
mockInvoice({
...baseInvoice,
base_insurance_rials: 1_785_600,
patient_rials: 4_166_400,
total_rials: 5_952_000,
service_category: 'inpatient',
service_category_label: 'خدمات بستری',
base_insurance_name: 'بیمه ایران',
items: [{ uuid: 'it1', title: 'ویزیت', quantity: 1, total_rials: 5_952_000, patient_rials: 4_166_400 }],
});
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات بیمه')).toBeInTheDocument());
expect(screen.getByText('نوع خدمت بیمه')).toBeInTheDocument();
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
});
it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => {
mockInvoice(baseInvoice);
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
expect(screen.queryByText('اطلاعات بیمه')).not.toBeInTheDocument();
});
});