feat: Enhance insurance billing system to support supplementary insurance

- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials.
- Modified Appointment entity to carry supplementary insurance ID alongside base insurance.
- Implemented SessionBillingService to ensure finalized invoices for insured patient sessions.
- Created InvoiceFinalized event to trigger claims creation upon invoice finalization.
- Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims.
- Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
This commit is contained in:
hamed
2026-07-29 19:57:02 +03:30
parent 9b05c6d1ff
commit e6267080b2
25 changed files with 876 additions and 126 deletions
@@ -130,9 +130,16 @@ const referenceAppointment = {
visit_price_rials: 5_952_000, service_items: [],
};
const SUPP_CONTRACT = {
uuid: 'c2', insurance_id: 9, insurance_name: 'بیمه آسیا', insurance_kind: 'supplementary',
is_active: true, coverage_percent: 90, franchise_percent: 10, annual_ceiling_rials: null,
category_coverages: { outpatient: 90, inpatient: 60 },
};
function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT],
) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/insurance-pricing') {
@@ -148,7 +155,7 @@ function mockInsurance(
});
}
if (url === '/api/v1/billing/tenant-insurances') {
return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
return Promise.resolve({ success: true, data: { data: contracts } });
}
return Promise.resolve({ success: true, data: [] });
});
@@ -180,7 +187,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
renderReference();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
});
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
@@ -190,7 +197,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
]);
renderReference();
expect(await screen.findByText('بیمه')).toBeInTheDocument();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
});
@@ -224,6 +231,52 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
}));
});
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => {
mockInsurance(BOTH);
renderReference();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
});
it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await pick('بدون بیمه', 'بیمه ایران');
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
// پایه ۷۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۱۶۶٬۴۰۰؛ باقیمانده ۱٬۷۸۵٬۶۰۰؛
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
insurance_service_category: 'outpatient',
insurance_base_id: 3,
insurance_supplementary_id: 9,
payments: [{ method: 'cash', amount_rials: 357_120 }],
}));
});
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
});
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000);
renderWithProviders(
@@ -36,6 +36,7 @@ interface AppointmentLike {
patient_name?: string | null;
insurance_service_category?: string | null;
insurance_base_id?: number | null;
insurance_supplementary_id?: number | null;
}
interface Props {
@@ -148,12 +149,14 @@ export default function ConfirmAppointmentModal({
const total = visitPrice + servicesTotal;
const [serviceCategory, setServiceCategory] = useState<string>('');
const [insuranceId, setInsuranceId] = useState<string>('');
const [supplementaryId, setSupplementaryId] = useState<string>('');
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant.
useEffect(() => {
if (!open) return;
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : '');
setSupplementaryId(appt?.insurance_supplementary_id ? String(appt.insurance_supplementary_id) : '');
}, [open, appt?.uuid, insurance.defaultCategory]);
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
@@ -166,9 +169,10 @@ export default function ConfirmAppointmentModal({
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
insured: s.insurance_covered !== false,
})),
], insuranceId), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]);
], insuranceId, supplementaryId), [visitPrice, services, effectiveCategory, insuranceId, supplementaryId, insurance.breakdown]);
const payable = insuranceId ? shares.patient : total;
const hasInsurance = !!insuranceId || !!supplementaryId;
const payable = hasInsurance ? shares.patient : total;
// ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است.
@@ -196,6 +200,7 @@ export default function ConfirmAppointmentModal({
version: appt?.version,
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
...(supplementaryId ? { insurance_supplementary_id: Number(supplementaryId) } : {}),
payments: rows
.filter(r => tomanToRial(r.amountToman) > 0)
.map(r => ({
@@ -305,7 +310,7 @@ export default function ConfirmAppointmentModal({
</div>
)}
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه</label>
<label>بیمه پایه</label>
<SearchableSelect
value={insuranceId}
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
@@ -316,6 +321,19 @@ export default function ConfirmAppointmentModal({
height={40}
/>
</div>
{insurance.hasSupplementary && (
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه تکمیلی</label>
<SearchableSelect
value={supplementaryId}
onChange={(v) => setSupplementaryId(v == null ? '' : String(v))}
options={insurance.supplementaryOptions}
placeholder="بدون بیمه تکمیلی"
isClearable
height={40}
/>
</div>
)}
</div>
{/* هزینه‌ها */}
@@ -339,10 +357,26 @@ export default function ConfirmAppointmentModal({
<span>جمع کل</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
</div>
{/* سهم‌ها زنجیره‌ای‌اند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده
می‌شوند تا معلوم باشد هرکدام چقدر برداشته‌اند. */}
{insuranceId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.insurance)}</strong>
<span>
سهم بیمه پایه
{insurance.insuranceNameOf(insuranceId) ? `${insurance.insuranceNameOf(insuranceId)}` : ''}
{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}
</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.base)}</strong>
</div>
)}
{supplementaryId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>
سهم بیمه تکمیلی
{insurance.insuranceNameOf(supplementaryId) ? `${insurance.insuranceNameOf(supplementaryId)}` : ''}
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}> (روی باقیمانده)</span>
</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.supplementary)}</strong>
</div>
)}
<div
@@ -351,7 +385,7 @@ export default function ConfirmAppointmentModal({
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<span>{hasInsurance ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
</div>
</div>