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>
This commit is contained in:
hamed
2026-07-25 17:50:14 +03:30
co-authored by Claude Opus 5
parent 58c6d9ac18
commit 1f58b1b9b3
47 changed files with 2693 additions and 86 deletions
@@ -67,7 +67,7 @@ describe('ConfirmAppointmentModal', () => {
expect(submit).not.toBeDisabled();
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
expect(screen.getByText('مجموع پرداخت‌ها از جمع کل بیشتر است.')).toBeInTheDocument();
expect(screen.getByText('مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
expect(submit).toBeDisabled();
});
@@ -115,3 +115,147 @@ describe('ConfirmAppointmentModal', () => {
expect(amountInputs()).toHaveLength(1);
});
});
// ── بیمه: نوع خدمت + محاسبهٔ سهم ──────────────────────────────────────────────
const CONTRACT = {
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
/** ویزیت ۵٬۹۵۲٬۰۰۰ ریال، بدون خدمت — سناریوی مرجعِ سهم بیمه. */
const referenceAppointment = {
uuid: 'a1', version: 1, patient_name: 'محمد رضایی',
visit_price_rials: 5_952_000, service_items: [],
};
function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0,
) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({
success: true,
data: {
service_categories: categories,
free_visit_price_rials: freeVisitPriceRials,
default_service_category: categories.filter((c) => c.enabled).length === 1
? categories.find((c) => c.enabled)!.key
: null,
},
});
}
if (url === '/api/v1/billing/tenant-insurances') {
return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
}
return Promise.resolve({ success: true, data: [] });
});
}
const BOTH = [
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: true },
{ key: 'inpatient', label: 'خدمات بستری', enabled: true },
];
function renderReference() {
return renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={referenceAppointment} onClose={() => {}} />,
);
}
/**
* react-select با placeholder به‌عنوان aria-label رندر می‌شود و منو با ArrowDown باز
* می‌شود. انتخاب با role=option انجام می‌شود چون متنِ گزینه در live-region هم تکرار است.
*/
async function pick(selectLabel: string, optionText: string) {
fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' });
fireEvent.click(await screen.findByRole('option', { name: optionText }));
}
describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => {
mockInsurance(BOTH);
renderReference();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه')).toBeInTheDocument();
});
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
mockInsurance([
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: true },
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
]);
renderReference();
expect(await screen.findByText('بیمه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
});
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
mockInsurance(BOTH);
renderReference();
await screen.findByText('نوع خدمت');
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
expect(screen.queryByText(/سهم بیمه/)).not.toBeInTheDocument();
});
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await pick('بدون بیمه', 'بیمه ایران');
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
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,
payments: [{ method: 'cash', amount_rials: 1_785_600 }],
}));
});
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000);
renderWithProviders(
<ConfirmAppointmentModal
open
appointmentUuid="a1"
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
onClose={() => {}}
/>,
);
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
});
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
mockInsurance(BOTH, 9_000_000);
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
});
it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات بستری');
await pick('بدون بیمه', 'بیمه ایران');
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
});
});
@@ -6,6 +6,8 @@ import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
@@ -22,6 +24,8 @@ interface ServiceItem {
uuid: string;
name: string;
price_rials?: number | null;
service_category?: string | null;
insurance_covered?: boolean;
}
interface AppointmentLike {
@@ -30,6 +34,8 @@ interface AppointmentLike {
visit_price_rials?: number | null;
service_items?: ServiceItem[] | null;
patient_name?: string | null;
insurance_service_category?: string | null;
insurance_base_id?: number | null;
}
interface Props {
@@ -128,27 +134,55 @@ export default function ConfirmAppointmentModal({
const appt: AppointmentLike | null = appointment
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
const visitPrice = Number(appt?.visit_price_rials ?? 0);
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
const insurance = useAppointmentInsurance(open);
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند.
const visitPrice = insurance.visitPriceOf(appt?.visit_price_rials);
const services = appt?.service_items ?? [];
const servicesTotal = useMemo(
() => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0),
[services],
);
const total = visitPrice + servicesTotal;
const [serviceCategory, setServiceCategory] = useState<string>('');
const [insuranceId, setInsuranceId] = useState<string>('');
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant.
useEffect(() => {
if (!open) return;
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : '');
}, [open, appt?.uuid, insurance.defaultCategory]);
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
// آینهٔ سرور: ویزیت با نوع انتخابی، هر خدمت با نوع خودش.
const shares = useMemo(() => insurance.breakdown([
{ total: visitPrice, category: effectiveCategory, insured: true },
...services.map(s => ({
total: Number(s.price_rials ?? 0),
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
insured: s.insurance_covered !== false,
})),
], insuranceId), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]);
const payable = insuranceId ? shares.patient : total;
// ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر کل هزینه است.
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است.
useEffect(() => {
if (!open || touched || total <= 0) return;
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r)));
}, [open, touched, total]);
if (!open || touched || payable <= 0) return;
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(payable) } : r)));
}, [open, touched, payable]);
const paidRials = useMemo(
() => rows.reduce((sum, r) => sum + tomanToRial(r.amountToman), 0),
[rows],
);
const remaining = Math.max(0, total - paidRials);
const overpaid = paidRials > total;
const remaining = Math.max(0, payable - paidRials);
const overpaid = paidRials > payable;
const paymentState = paidRials === 0
? 'بدون پرداخت'
@@ -160,6 +194,8 @@ export default function ConfirmAppointmentModal({
mutationFn: () =>
api.post<ApiResponse<unknown>>(`/api/v1/appointment/${appointmentUuid}/confirm`, {
version: appt?.version,
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
payments: rows
.filter(r => tomanToRial(r.amountToman) > 0)
.map(r => ({
@@ -254,6 +290,34 @@ export default function ConfirmAppointmentModal({
</div>
)}
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{insurance.needsCategoryChoice && (
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>نوع خدمت</label>
<SearchableSelect
value={serviceCategory}
onChange={(v) => setServiceCategory(v == null ? '' : String(v))}
options={insurance.categoryOptions}
placeholder="انتخاب نوع خدمت"
height={40}
/>
</div>
)}
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه</label>
<SearchableSelect
value={insuranceId}
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
options={insurance.insuranceOptions}
placeholder="بدون بیمه"
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
isClearable
height={40}
/>
</div>
</div>
{/* هزینه‌ها */}
<div
style={{
@@ -271,14 +335,24 @@ export default function ConfirmAppointmentModal({
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
</div>
))}
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<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>
</div>
)}
<div
style={{
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>جمع کل</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(total)}</strong>
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
</div>
</div>
@@ -397,7 +471,7 @@ export default function ConfirmAppointmentModal({
{overpaid && (
<p className="field-err" style={{ marginBottom: 14 }}>
مجموع پرداختها از جمع کل بیشتر است.
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
</p>
)}
@@ -410,7 +484,7 @@ export default function ConfirmAppointmentModal({
>
<div style={rowStyle}>
<span>پرداختشده</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, total))}</strong>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, payable))}</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>باقیمانده</span>
@@ -7,6 +7,7 @@ vi.mock('../../lib/api', () => ({
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import TurnsTimeline from './TurnsTimeline';
import type { TimelineSlot } from './types';
import type { Appointment } from '../../types';
@@ -17,6 +18,7 @@ const appt = (over: Partial<Appointment> = {}): Appointment => ({
slot_start: 1000, slot_end: 2000, appointment_date: '2024-12-31',
appointment_time: '08:00', end_time: '08:35', status: 'completed',
version: 1, created_at: '', service_item: { uuid: 's1', name: 'ویزیت عمومی' },
...over,
} as unknown as Appointment);
const occupiedSlot: TimelineSlot = {
@@ -67,4 +69,33 @@ describe('TurnsTimeline', () => {
expect(screen.getByText('برنامهٔ این روز در دسترس نیست')).toBeInTheDocument();
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
});
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان می‌دهد', async () => {
(api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) =>
url === '/api/v1/billing/tenant-insurances'
? Promise.resolve({ success: true, data: { data: [{
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
}] } })
: Promise.resolve({ success: true, data: [] }),
);
const insured: TimelineSlot = {
...occupiedSlot,
appointment: appt({
insurance_base_id: 3,
insurance_service_category: 'inpatient',
insurance_service_category_label: 'خدمات بستری',
}),
};
renderWithProviders(<TurnsTimeline slots={[insured]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(await screen.findByText('خدمات بستری · بیمه ایران')).toBeInTheDocument();
});
it('نوبتِ بدون بیمه چیپی نشان نمی‌دهد', () => {
renderWithProviders(<TurnsTimeline slots={[occupiedSlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.queryByText(/بیمه ایران/)).toBeNull();
});
});
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline';
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon, ShieldCheckIcon } from '@heroicons/react/24/outline';
import type { Appointment } from '../../types';
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
import AppointmentActionsMenu from '../AppointmentActions';
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
@@ -105,6 +106,13 @@ function OccupiedCard({
}) {
const cfg = turnStatusConfig(a.status);
const [confirmOpen, setConfirmOpen] = useState(false);
// نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد
// تا لیست‌های نوبت به N+1 نیفتند.
const insurance = useAppointmentInsurance(!!a.insurance_base_id);
const insuranceChip = [
a.insurance_service_category_label,
insurance.insuranceNameOf(a.insurance_base_id),
].filter(Boolean).join(' · ');
return (
<div
onClick={() => onView(a)}
@@ -133,6 +141,18 @@ function OccupiedCard({
سرویس: {a.service_item?.name || '—'}
</span>
</div>
{insuranceChip !== '' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<ShieldCheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 'var(--r-pill)',
background: 'var(--primary-soft)', color: 'var(--primary)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{insuranceChip}
</span>
</div>
)}
</div>
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
@@ -145,6 +165,7 @@ function OccupiedCard({
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={a.uuid}
appointment={a}
onClose={() => setConfirmOpen(false)}
queryKey={queryKey}
/>