Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
115 lines
5.1 KiB
TypeScript
115 lines
5.1 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, fireEvent, 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 CreateStep, { patientShareOf } from './CreateStep';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
|
|
function mockEndpoints(pricing: { free_visit_price_rials: number; require_visit_price: boolean }) {
|
|
get.mockImplementation((url: string) => {
|
|
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: pricing });
|
|
if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } });
|
|
if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [] } });
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
get.mockReset();
|
|
post.mockReset();
|
|
post.mockResolvedValue({ success: true, data: { uuid: 's-1' } });
|
|
});
|
|
|
|
const renderStep = (onCreated = vi.fn()) => {
|
|
renderWithProviders(
|
|
<CreateStep recordUuid="r-1" profile={null} onCreated={onCreated} onCancel={() => {}} />,
|
|
);
|
|
return onCreated;
|
|
};
|
|
|
|
describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ require_visit_price', () => {
|
|
it('فلگ فعال + قیمت صفر → خطای inline، ستاره روی label و عدم ارسال', async () => {
|
|
mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true });
|
|
renderStep();
|
|
await waitFor(() => expect(screen.getByText(/قیمت ویزیت \(تومان\)/)).toBeInTheDocument());
|
|
await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByText('ایجاد سرویس'));
|
|
|
|
expect(await screen.findByText('هزینه ویزیت الزامی است')).toBeInTheDocument();
|
|
expect(post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('فلگ فعال + مقدار معتبر → POST با visit_price_rials ریالی', async () => {
|
|
mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true });
|
|
renderStep();
|
|
await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument());
|
|
|
|
fireEvent.change(screen.getByLabelText('قیمت ویزیت'), { target: { value: '50000' } });
|
|
fireEvent.click(screen.getByText('ایجاد سرویس'));
|
|
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][0]).toBe('/api/v1/patient/r-1/session');
|
|
expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 500_000 });
|
|
});
|
|
|
|
it('فلگ غیرفعال + قیمت صفر → رفتار قبلی: ثبت مجاز', async () => {
|
|
mockEndpoints({ free_visit_price_rials: 0, require_visit_price: false });
|
|
const onCreated = renderStep();
|
|
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/insurance-pricing'));
|
|
|
|
fireEvent.click(screen.getByText('ایجاد سرویس'));
|
|
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 0 });
|
|
await waitFor(() => expect(onCreated).toHaveBeenCalledWith('s-1'));
|
|
expect(screen.queryByText('هزینه ویزیت الزامی است')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('پیشفرض قیمت از «قیمت ویزیت آزاد» پر میشود (ریال → تومان)', async () => {
|
|
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
|
|
renderStep();
|
|
|
|
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
|
|
});
|
|
});
|
|
|
|
describe('patientShareOf — آینهی BillingCalculator', () => {
|
|
it('سناریوی مرجع: ۳۰٪ پوشش پایه روی ۵,۹۵۲,۰۰۰ ریال', () => {
|
|
const share = patientShareOf(5_952_000, { covered: true, percent: 30, franchise: 0, ceiling: null }, null);
|
|
expect(share).toBe(4_166_400);
|
|
});
|
|
|
|
it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمیکند', () => {
|
|
const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null);
|
|
expect(share).toBe(0);
|
|
});
|
|
|
|
it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه میشود', () => {
|
|
const share = patientShareOf(
|
|
600_000,
|
|
{ covered: true, percent: 70, franchise: 90_000, ceiling: null },
|
|
{ covered: true, percent: 100, franchise: 50_000, ceiling: null },
|
|
);
|
|
expect(share).toBe(50_000);
|
|
});
|
|
|
|
it('سقف تعهد سهم بیمه را محدود میکند', () => {
|
|
const share = patientShareOf(600_000, { covered: true, percent: 70, franchise: 0, ceiling: 300_000 }, null);
|
|
expect(share).toBe(300_000);
|
|
});
|
|
|
|
it('notCovered → کل مبلغ سهم بیمار', () => {
|
|
const share = patientShareOf(600_000, { covered: false, percent: 0, franchise: 0, ceiling: null }, null);
|
|
expect(share).toBe(600_000);
|
|
});
|
|
});
|