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
+39 -19
View File
@@ -27,19 +27,25 @@ export interface TenantContract {
/** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */
export const DEFAULT_SERVICE_CATEGORY = 'outpatient';
/** سهم بیمهٔ پایه از کل مبلغ یک ردیف (با سقف تعهد). */
export function baseShareOf(total: number, base: CoverageRule | null): number {
if (!base || !base.covered) return 0;
const share = Math.round(total * (base.percent / 100));
return base.ceiling !== null ? Math.min(share, base.ceiling) : share;
}
/**
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی.
* فرانشیزِ تکمیلی درصدی از همان مبلغِ تحت پوشش است و از سهم تکمیلی کسر می‌شود،
* نه اینکه روی سهم بیمار سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی. محاسبه زنجیره‌ای است؛
* تکمیلی روی باقیماندهٔ بعد از پایه کار می‌کند، نه روی کل مبلغ. فرانشیزِ تکمیلی
* درصدی از همان باقیمانده است و از سهم تکمیلی کسر می‌شود، نه اینکه روی سهم بیمار
* سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
*/
export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
let baseShare = 0;
let remaining = total;
if (base && base.covered) {
baseShare = Math.round(total * (base.percent / 100));
if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling);
remaining = total - baseShare;
}
const baseShare = baseShareOf(total, base);
const remaining = total - baseShare;
let suppShare = 0;
if (supp && supp.covered) {
suppShare = Math.max(
@@ -80,21 +86,35 @@ export interface BillableLine {
export interface ShareBreakdown {
total: number;
/** مجموع سهم هر دو بیمه — برای نمایشِ یک‌خطی. */
insurance: number;
base: number;
supplementary: number;
patient: number;
}
/** تفکیک سهم بیمه/بیمار برای مجموعه‌ای از ردیف‌ها تحت یک قرارداد پایه. */
export function breakdownOf(lines: BillableLine[], base: TenantContract | null): ShareBreakdown {
/**
* تفکیک سهم برای مجموعه‌ای از ردیف‌ها تحت قرارداد پایه و (اختیاری) تکمیلی.
* محاسبه زنجیره‌ای است: پایه روی کل، تکمیلی روی باقیماندهٔ بعد از پایه.
*/
export function breakdownOf(
lines: BillableLine[],
base: TenantContract | null,
supplementary: TenantContract | null = null,
): ShareBreakdown {
return lines.reduce<ShareBreakdown>((acc, line) => {
const patient = line.insured
? patientShareOf(line.total, ruleOf(base, line.category), null)
: line.total;
const baseRule = line.insured ? ruleOf(base, line.category) : null;
const suppRule = line.insured ? ruleOf(supplementary, line.category) : null;
const patient = line.insured ? patientShareOf(line.total, baseRule, suppRule) : line.total;
const baseShare = baseShareOf(line.total, baseRule);
return {
total: acc.total + line.total,
insurance: acc.insurance + (line.total - patient),
patient: acc.patient + patient,
total: acc.total + line.total,
base: acc.base + baseShare,
supplementary: acc.supplementary + (line.total - patient - baseShare),
insurance: acc.insurance + (line.total - patient),
patient: acc.patient + patient,
};
}, { total: 0, insurance: 0, patient: 0 });
}, { total: 0, base: 0, supplementary: 0, insurance: 0, patient: 0 });
}