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
@@ -65,3 +65,68 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => {
})));
});
});
// ── بیمهٔ نوبت ────────────────────────────────────────────────────────────────
const CONTRACT = {
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 },
};
/** همان mock بالا + payload بیمه؛ `enabled` تعیین می‌کند چند نوع خدمت فعال است. */
function mockWithInsurance(enabledCategories: string[]) {
const categories = [
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: enabledCategories.includes('outpatient') },
{ key: 'inpatient', label: 'خدمات بستری', enabled: enabledCategories.includes('inpatient') },
];
get.mockImplementation((url: string) => {
if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: {
uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4,
visit_price_rials: 5_952_000, service_items: [],
insurance_service_category: 'inpatient', insurance_base_id: 3,
} } });
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: {
service_categories: categories,
default_service_category: enabledCategories.length === 1 ? enabledCategories[0] : null,
} });
if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
return Promise.resolve({ success: true, data: [] });
});
}
describe('AppointmentEditPage — بیمه', () => {
it('نوع خدمت و بیمه از نوبت پیش‌پر می‌شوند و سهم‌ها نمایش داده می‌شوند', async () => {
mockWithInsurance(['outpatient', 'inpatient']);
renderEdit();
expect(await screen.findByText('بیمه:')).toBeInTheDocument();
expect(screen.getByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ → سهم بیمه ۱٬۷۸۵٬۶۰۰ و سهم بیمار ۴٬۱۶۶٬۴۰۰
expect(screen.getByText('سهم بیمه / سهم بیمار')).toBeInTheDocument();
});
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
mockWithInsurance(['outpatient']);
renderEdit();
expect(await screen.findByText('بیمه:')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
});
it('PATCH فیلدهای بیمه را می‌فرستد', async () => {
mockWithInsurance(['outpatient', 'inpatient']);
renderEdit();
// تا فرم از نوبت پر نشود دکمه غیرفعال است؛ همان را معیار آماده‌بودن می‌گیریم.
await waitFor(() => expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
insurance_service_category: 'inpatient',
insurance_base_id: 3,
})));
});
});
+72 -1
View File
@@ -9,7 +9,9 @@ import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { WalletChargeLink } from '../components/AppointmentActions';
import { rialToToman, tomanToRial } from '../lib/utils';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
interface Option { uuid: string; name?: string; full_name?: string }
@@ -20,6 +22,10 @@ interface AppointmentDetail {
user?: { uuid: string; mobile: string } | null;
deposit_required?: boolean; deposit_amount_rials?: number | null;
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
visit_price_rials?: number | null;
service_items?: { uuid: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null;
insurance_service_category?: string | null;
insurance_base_id?: number | null;
}
const isoDate = (ts: number) => {
@@ -55,6 +61,8 @@ export default function AppointmentEditPage() {
const [depositToman, setDepositToman] = useState(0);
const [status, setStatus] = useState('');
const [note, setNote] = useState('');
const [serviceCategory, setServiceCategory] = useState('');
const [insuranceId, setInsuranceId] = useState('');
// hydrate once the appointment arrives
useEffect(() => {
@@ -69,8 +77,25 @@ export default function AppointmentEditPage() {
setDepositToman(rialToToman(a.deposit_amount_rials ?? 0));
setStatus(a.status);
setNote(a.note ?? '');
setServiceCategory(a.insurance_service_category ?? '');
setInsuranceId(a.insurance_base_id ? String(a.insurance_base_id) : '');
}, [a]);
const insurance = useAppointmentInsurance(!!uuid);
// نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور).
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
const shares = insurance.breakdown([
// نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را می‌گیرد (مثل سرور).
{ total: insurance.visitPriceOf(a?.visit_price_rials), category: effectiveCategory, insured: true },
...(a?.service_items ?? []).map((s) => ({
total: Number(s.price_rials ?? 0),
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
insured: s.insurance_covered !== false,
})),
], insuranceId);
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-items', sectionUuid],
@@ -89,6 +114,8 @@ export default function AppointmentEditPage() {
deposit_required: depositRequired,
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
note,
insurance_service_category: serviceCategory || null,
insurance_base_id: insuranceId ? Number(insuranceId) : null,
...(status !== a?.status ? { status } : {}),
version: a?.version,
}),
@@ -166,6 +193,50 @@ export default function AppointmentEditPage() {
</div>
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیمه:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
{insurance.needsCategoryChoice && (
<div>
<label style={label}>نوع خدمت</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={insurance.categoryOptions}
value={serviceCategory || null}
onChange={v => setServiceCategory(v ? String(v) : '')}
placeholder="انتخاب نوع خدمت"
isClearable
height={38}
/>
</div>
</div>
)}
<div>
<label style={label}>بیمه</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={insurance.insuranceOptions}
value={insuranceId || null}
onChange={v => setInsuranceId(v ? String(v) : '')}
placeholder="بدون بیمه"
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
isClearable
height={38}
/>
</div>
</div>
{insuranceId !== '' && (
<div>
<label style={label}>سهم بیمه / سهم بیمار</label>
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, minHeight: 38, fontSize: 13 }}>
<span style={{ color: 'var(--success)', fontWeight: 700 }}>{formatRial(shares.insurance)}</span>
<span style={{ color: 'var(--text-3)' }}>/</span>
<span style={{ color: 'var(--primary)', fontWeight: 700 }}>{formatRial(shares.patient)}</span>
</div>
</div>
)}
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
<div>
+2 -1
View File
@@ -33,7 +33,8 @@ describe('BlogsPage — قرارداد PaginatedResponse', () => {
expect(await screen.findByText('مقاله اول')).toBeInTheDocument();
expect(screen.getByText('مقاله دوم')).toBeInTheDocument();
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/blogs'));
// اندپوینت ادمین، نه عمومی: لیست پنل باید همهٔ وضعیت‌ها را بیاورد.
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/admin/blogs'));
});
it('پاسخ خالی → پیام «هیچ مقاله‌ای یافت نشد»', async () => {
@@ -1,4 +1,5 @@
import PageHeader from '../components/ui/PageHeader';
import InsuranceServiceCategoriesCard from '../components/InsuranceServiceCategoriesCard';
import TenantInsuranceContracts from '../components/TenantInsuranceContracts';
import FeatureGate from '../components/ui/FeatureGate';
import SettingsLayout from '../components/layout/SettingsLayout';
@@ -12,6 +13,7 @@ export default function InsurancePricingPage() {
title="مدیریت بیمه"
description="قراردادهای بیمه پایه و تکمیلی"
/>
<InsuranceServiceCategoriesCard />
<TenantInsuranceContracts />
</div>
</FeatureGate>