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>
25 KiB
Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
Prefix:
/api/v1/billingدامنه:App\Billing. مرجع معماری:docs/architecture/insurance-billing-system.md. tenant از#[CurrentUser]باApp\Patient\Security\PatientRecordScopeResolverresolve میشود — همان رزولوری که پروندههای بیمار استفاده میکنند، چون صورتحساب و مطالبه از دل مراجعه بیرون میآیند و باید در همان محیط دیده شوند. محیط فعال (UserActiveContext) تعیینکننده است، نه صرفاً ترتیب نقشها. منشیِ فعال هم میتواند صورتحسابهای همان tenant را ببیند/بسازد. جزئیات جدول محیطها: patient.md.پیش از این، این دامنه ترتیب نقشها را خودش پیاده کرده بود و اول
ROLE_DOCTORرا میگرفت؛ در نتیجه مالک کلینیکی که خودش پزشک هم هست به مطب شخصیاش نگاشت میشد و صورتحساب/مطالبهی کلینیک خودش را404میگرفت. همین رزولور درInsuranceControllerهم استفاده میشود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.
صورتحساب (Invoice) از یک Encounter (PatientSession) ساخته میشود. برای هر آیتم سهم بیمهی پایه، بیمهی مکمل و بیمار با BillingCalculator محاسبه میشود:
- تعرفهی خدمت از
Tariffسال جاری (با fallback بهServiceItem.priceRials). - قانون پوشش از قرارداد بیمهی tenant (
TenantInsurance) + override خدمت (TenantServiceCoverage). - درصد پوشش به تفکیک نوع خدمت (
ServiceItem.service_category؛ ویزیت همیشهoutpatient) و از زنجیرهٔ resolve توضیحدادهشده در insurance.md گرفته میشود. - ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ تکمیلی روی سهم بیمار.
بیمهٔ پایه صرفاً درصدی است:
سهم بیمهٔ پایه = round(کل × درصد پوشش پایه ÷ 100)
سهم بیمار = کل − سهم بیمهٔ پایه
franchise_rials قرارداد پایه در محاسبه بیاثر است (ستون برای سازگاری و قراردادهای تکمیلی میماند).
نمونهها:
- کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
- ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰.
POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده میشود (idempotent).
Permission: AUTH (مالک مراجعه)
Body:
{ "session_uuid": "…" }
Response 201:
{
"success": true,
"data": {
"data": {
"uuid": "…",
"entity_type": "doctor",
"entity_id": 7,
"patient_session_id": 33,
"base_insurance_id": 3,
"supplementary_insurance_id": 9,
"total_rials": 600000,
"base_insurance_rials": 420000,
"supplementary_rials": 120000,
"patient_rials": 60000,
"status": "draft",
"issued_at": 1718900000,
"items": [
{
"uuid": "…",
"service_item_id": 12,
"title": "ویزیت",
"tariff_rials": 600000,
"quantity": 1,
"total_rials": 600000,
"base_insurance_rials": 420000,
"supplementary_rials": 120000,
"patient_rials": 60000
}
]
}
}
}
Errors: 422 ERR_VALIDATION_001 session_uuid الزامی · 404 ERR_NOT_FOUND_001 مراجعه یافت نشد · 403 ERR_FORBIDDEN_001 پروفایل یافت نشد.
GET /api/v1/billing/invoices/{uuid}
دریافت صورتحساب (فقط مالک tenant)، غنیشده با مراجعهی مبدأ.
Response 200: همان ساختار بالا + کلید session — خروجی کامل PatientSession.toArray() مراجعهای که صورتحساب از آن ساخته شده (برای نمایش «خلاصه فاکتور»: پرداختیها، کالای مصرفی، تخفیف، مبالغ پرداختشده). اگر صورتحساب از session ساخته نشده باشد session: null.
{
"success": true,
"data": {
"data": {
"uuid": "…",
"total_rials": 600000,
"status": "finalized",
"items": [ … ],
"session": {
"uuid": "…",
"session_at": 1718900000,
"paid_at": 1718990000,
"services_total_rials": 2400000,
"consumables_total_rials": 40000,
"discount_rials": 200000,
"final_price_rials": 2240000,
"paid_total_rials": 1500000,
"payments": [
{ "uuid": "…", "method": "wallet", "amount_rials": 1500000, "paid_at": 1718990000, "created_by_name": "منشی تست", "created_at": 1718990000 }
],
"consumables": [
{ "uuid": "…", "item_name": "عینک", "quantity": 2, "price_rials": 20000, "line_total_rials": 40000 }
]
}
}
}
}
Errors: 404 ERR_NOT_FOUND_001.
POST /api/v1/billing/invoices/{uuid}/finalize
نهاییسازی صورتحساب (draft → finalized). صورتحساب نهاییشده مبنای ساخت Claim (فاز ۵) است.
Response 200: صورتحساب با status: "finalized".
Errors: 404 ERR_NOT_FOUND_001.
وضعیتهای Invoice
draft (پیشنویس، قابل بازسازی) → finalized (نهایی) → paid (پرداختشده) · void (باطل).
نکات
- پول: integer ریال. تاریخ: Unix timestamp.
BillingCalculatorخالص و واحد-تستشده است (tests/Billing/BillingCalculatorTest.php).- این فاز جایگزین تدریجی
PatientService::calculateFinalPriceاست؛ آن متد فعلاً برای سازگاری باقی مانده.
Claims — مطالبات بیمه (فاز ۵)
مطالبه (Claim) از یک صورتحساب نهاییشده ساخته میشود: یک Claim برای بیمهی پایه و یک Claim برای بیمهی مکمل (فقط اگر سهم بیمه > ۰). هر ClaimItem به یک InvoiceItem ارجاع میدهد. چرخهی وضعیت با state machine.
ساخت خودکار: هنگام ثبت session بیمهدار (POST /api/v1/patient/{uuid}/session)، زنجیرهی صورتحساب→نهاییسازی→مطالبه بهصورت خودکار اجرا میشود؛ نیازی به فراخوانی دستی POST /billing/claims نیست. برای هر صورتحساب تنها یکبار مطالبه ساخته میشود (تلاش مجدد 422).
وضعیتها: pending → submitted → {approved → paid | rejected}. از paid/rejected خروجی ندارد.
POST /api/v1/billing/claims
ساخت مطالبات از یک صورتحساب نهاییشده.
Body: { "invoice_uuid": "…" }
Response 201: { success, data: [ …claims ] } (یک یا دو مطالبه: پایه/مکمل).
Errors: 422 صورتحساب نهایی نشده / سهم بیمه ندارد / مطالبه از قبل ثبت شده · 404 صورتحساب یافت نشد.
هر مطالبه در پاسخ علاوه بر insurance_id، فیلد insurance_name (نام بیمه، یا null اگر بیمه حذف شده باشد) را نیز دارد. در POST و transition و GET یکسان است.
GET /api/v1/billing/claims
لیست مطالبات tenant با فیلترهای اختیاری (query params):
| پارامتر | نوع | توضیح |
|---|---|---|
status |
string | pending|submitted|approved|rejected|paid |
insurance_id |
int | فقط مطالبات یک بیمهگر |
from |
int (Unix) | مطالبات با created_at >= from (شروع بازه) |
to |
int (Unix) | مطالبات با created_at <= to (پایان بازه) |
q |
string | جستجوی بیمار: موبایل، کدملی یا نام (LIKE) |
page |
int | شماره صفحه (پیشفرض ۱) |
limit |
int | تعداد در هر صفحه (پیشفرض ۵۰، حداکثر ۱۰۰) |
صفحهبندی: پاسخ علاوه بر
data.data(آرایهی مطالبات) یکdata.metaباtotalRecords/totalPages/currentPageدارد. پاکت قبلی (data.data) دستنخورده است؛ کلاینتهای موجود بدون تغییر کار میکنند.
بازهی تاریخ شمسی (سال/ماه/روز خاص) در سمت کلاینت به
from/toیونیکس تبدیل میشود؛ backend فقط بازهی یونیکس میگیرد. فیلترqاز طریق زنجیرهclaim → claim_item → invoice_item → invoice → patient_record → userباEXISTSاعمال میشود.
هر مطالبه شامل insurance_name، patient_name، patient_mobile و آرایهی items با جزئیات هر ردیف است.
هر عضو items علاوه بر invoice_item_id، claimed_rials، approved_rials، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
| فیلد | نوع | توضیح |
|---|---|---|
title |
string | null | شرح ردیف؛ «ویزیت» یا نام خدمت |
is_visit |
bool | true اگر ردیف ویزیت باشد (service_item_id تهی) |
quantity |
int | تعداد |
total_rials |
int | null | مبلغ کل آن ردیف (قیمت کامل، قبل از بیمه) |
visit_date |
int | null | تاریخ ثبت مراجعه (Unix؛ نمایش شمسی) |
{
"success": true,
"data": { "data": [
{
"uuid": "…", "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "insurance_kind": "base",
"total_claimed_rials": 240000, "status": "pending",
"items": [
{ "invoice_item_id": 12, "title": "ویزیت", "is_visit": true, "quantity": 1, "total_rials": 300000, "claimed_rials": 240000, "approved_rials": null, "visit_date": 1718000000 },
{ "invoice_item_id": 13, "title": "سرم ۵۰۰cc", "is_visit": false, "quantity": 2, "total_rials": 170000, "claimed_rials": 0, "approved_rials": null, "visit_date": 1718000000 }
]
}
] }
}
این فیلدها با یک کوئری گروهی (
InvoiceItemRepository::detailsForIds+PatientSessionRepository::datesForIds) پر میشوند تا N+1 رخ ندهد. همان enrichment روی پاسخPOST /claimsوPOST /claims/{uuid}/{action}هم اعمال میشود.
GET /api/v1/billing/claims/by-patient
نمای سطحاول داشبورد مطالبات: یک ردیف بهازای هر بیمار (نه هر مطالبه)، با جمعهای تجمیعی.
Query params:
| Param | Type | Default | توضیح |
|---|---|---|---|
page |
int | 1 | شماره صفحه |
limit |
int | 20 | حداکثر ۱۰۰ |
sort |
string | last_activity_at |
full_name | claims_count | total_services_rials | total_insurance_rials | last_activity_at |
dir |
string | desc |
asc | desc |
search |
string | — | نام، موبایل یا کد ملی بیمار |
status |
string | — | pending | submitted | approved | rejected | paid |
insurance_id |
int | — | شناسه بیمه |
doctor_id |
int | — | پزشکِ نوبتِ مراجعه |
payment_status |
string | — | paid (وصولشده) | unpaid |
from / to |
int | — | بازهی claims.created_at (unix ثانیه) |
پاسخ paginated استاندارد ({ success, data: [], meta }):
{
"success": true,
"data": [
{
"patient_uuid": "45064492-...",
"record_uuid": "ad0a3d0e-...",
"full_name": "تست جراحی بینی",
"mobile": "09370671756",
"national_code": null,
"claims_count": 2,
"total_services_rials": 81500000,
"total_insurance_rials": 57050000,
"total_patient_rials": 24450000,
"total_approved_rials": 28000000,
"total_paid_rials": 28000000,
"overall_status": "mixed",
"last_activity_at": 1784404115
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
overall_status: اگر همهی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنهmixed.- ثابت:
total_services_rials = total_insurance_rials + total_patient_rials. - ضدِ double-counting: مبالغ خدمات/سهم بیمار از صورتحسابهای یکتا جمع میشوند، نه از مطالبات. یک صورتحساب میتواند همزمان مطالبهی پایه و مکمل داشته باشد؛ جمعزدن از سمت مطالبه مبلغ خدمات را دوبار میشمرد.
- مطالبه لینک مستقیم به بیمار ندارد؛ زنجیرهی
claim → claim_item → invoice_item → invoice → patient_recordاست.
GET /api/v1/billing/claims/by-patient/{patientUuid}
جزئیات کامل مطالبات یک بیمار (patientUuid = uuid پرونده، همان record_uuid نمای سطحاول). فیلترها همان فیلترهای بالا.
{
"success": true,
"data": {
"patient": { "uuid": "...", "record_uuid": "...", "full_name": "...", "mobile": "...", "national_code": null },
"claims": [
{
"uuid": "b244b506-...",
"invoice_uuid": "15498c14-...",
"visit_date": 1784440200,
"doctor_name": "تست",
"insurance_id": 176,
"insurance_name": "تامین اجتماعی",
"insurance_kind": "base",
"service_base_rials": 41000000,
"coverage_percent": 70,
"insurance_share_rials": 28700000,
"patient_share_rials": 12300000,
"total_approved_rials": 28000000,
"total_paid_rials": 28000000,
"status": "paid",
"tracking_number": "TM-4419-88",
"reject_reason": null,
"submitted_at": 1784404200,
"settled_at": 1784404300,
"created_at": 1784404115,
"allowed_transitions": [],
"logs": [
{ "uuid": "...", "from_status": null, "to_status": "pending", "note": "ایجاد مطالبه", "by": null, "at": 1784404115 },
{ "uuid": "...", "from_status": "pending", "to_status": "submitted", "note": null, "by": "علی بهروزی", "at": 1784404200 }
]
}
]
}
}
coverage_percentمحاسبهشده است:insurance_share_rials / service_base_rials × 100.allowed_transitionsازClaim::TRANSITIONSمیآید؛ پنل دکمهها را از همین میسازد و فهرست مجاز را hardcode نمیکند.logsتاریخچهی کامل تغییر وضعیت از جدولclaim_status_logsاست (بهترتیب زمانی صعودی).
Errors: 404 پرونده بیمار یافت نشد یا متعلق به tenant دیگری است · 403 پروفایل یافت نشد.
POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. action ∈ submit|approve|reject|pay. هر انتقال یک ردیف در claim_status_logs ثبت میکند (وضعیت مبدأ/مقصد، توضیح، کاربر، زمان).
| action | body | اثر |
|---|---|---|
| submit | tracking_number (اختیاری) |
pending → submitted؛ شماره پرونده/پیگیری بیمه روی مطالبه ذخیره میشود |
| approve | approved_rials (اختیاری) |
submitted → approved (پیشفرض = کل ادعا) — باید 0 ≤ approved_rials ≤ total_claimed_rials |
| reject | reason (الزامی) |
submitted → rejected |
| pay | paid_rials (اختیاری) |
approved → paid (پیشفرض = approved) — باید 0 ≤ paid_rials ≤ total_approved_rials |
note (اختیاری) روی همهی اکشنها پذیرفته میشود و در تاریخچه ثبت میگردد؛ برای reject در نبودِ note خودِ reason ثبت میشود.
Errors: 422 انتقال نامعتبر، دلیل رد خالی، یا مبلغ approved_rials/paid_rials خارج از بازه (field در پاسخ) · 404 مطالبه یافت نشد.
اعتبارسنجی انتقال سمت سرور انجام میشود (
Claim::TRANSITIONSمنبع حقیقت است)؛ مخفیکردن دکمه در UI کافی نیست.
GET /api/v1/billing/reports/insurance-debt
گزارش بدهی بیمهها برای tenant (group بر اساس بیمه).
Response 200:
{
"success": true,
"data": {
"data": [
{ "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "claimed": 840000, "approved": 800000, "paid": 500000, "debt": 340000 }
]
}
}
debt = claimed - paid (حداقل صفر).
GET /api/v1/my/billing/payments
«لیست پرداختها» — فهرست مسطح صورتحسابهای ثبتشدهی همان tenant (هر ردیف یک صورتحساب)، جدیدترین اول. فقط finalized/paid؛ draft/void نادیده گرفته میشوند.
Query params:
| param | توضیح |
|---|---|
national_code |
جستوجوی جزئی روی کد ملی بیمار (LIKE) — روی profiles.national_code و در نبود آن users.national_code |
status |
وضعیت مشتقشدهی پرداخت: paid · partial · unsettled |
from / to |
بازهی issued_at بر حسب ثانیهی Unix |
page / limit |
صفحهبندی (پیشفرض ۱ / ۲۰، سقف ۱۰۰) |
Response 200 (صفحهبندیشدهی مسطح):
{
"success": true,
"data": [
{ "invoice_uuid": "…", "patient_uuid": "…", "patient_name": "دنیا خلیلی",
"national_code": "1744023654", "issued_at": 1717000000,
"amount_rials": 2350000, "paid_rials": 2350000, "status": "paid" }
],
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
}
amount_rials= سهم بیمار (patient_rials) همان صورتحساب ·paid_rials= مجموع پرداختهای ثبتشدهی مراجعهی متناظر.
national_codeازprofiles.national_codeخوانده میشود (منبعِ اصلیِ کد ملی؛ بیمارِ ساختهشده در نوبتدهی کد ملی را آنجا دارد نه رویusers)، و در نبودِ آن بهusers.national_codeبرمیگردد.وضعیت پرداخت مشتق است، ذخیره نمیشود. ستون
invoices.statusفقط چرخهی حیات صورتحساب را نگه میدارد (draft→finalized→void) و هرگزpaidنمیشود؛ پول واقعی درsession_paymentsثبت میشود. قاعده درApp\Billing\Service\InvoicePaymentStatusاست:
حالت شرط paidpaid_rials >= amount_rials(شامل صورتحساب صفرریالی)partial0 < paid_rials < amount_rialsunsettledpaid_rials = 0وamount_rials > 0صورتحساب بدون مراجعه (
patient_session_id = null) هیچ پرداختی ندارد، پسunsettledمیماند.
Errors: 403 (ERR_FORBIDDEN_001) پروفایل tenant یافت نشد.
GET /api/v1/my/billing/payments/summary
خلاصهی مالی همان مجموعهی فیلترشدهی GET /api/v1/my/billing/payments — برای کارتهای آمار بالای صفحهی «لیست پرداختها». همان دامنهی رکوردها (فقط finalized/paid همان tenant).
Query params: دقیقاً national_code، status، from، to مثل اندپوینت لیست (بدون page/limit).
Response 200:
{
"success": true,
"data": {
"total_rials": 8350000,
"paid_rials": 2350000,
"unsettled_rials": 6000000,
"invoices_count": 2
}
}
مبالغ جمع
patient_rialsهستند.paid_rials= جمع صورتحسابهایی که کاملاً وصول شدهاند؛ صورتحساب نیمهپرداخت (partial) کامل درunsettled_rialsمینشیند.unsettled_rials = total_rials - paid_rials. با فیلترstatus=paidمقدارunsettled_rialsصفر میشود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همهی مقادیر0برمیگردند.
Errors: 403 (ERR_FORBIDDEN_001) پروفایل tenant یافت نشد.
GET /api/v1/my/billing/patients/{patientUuid}/invoices
«پرداختهای ثبتشده» — سربرگ بیمار + فهرست صفحهبندیشدهی صورتحسابهای finalized/paid او (جدیدترین اول). فقط مالک رکورد (همان tenant) دسترسی دارد.
Response 200:
{
"success": true,
"data": {
"patient": { "uuid": "…", "name": "دنیا خلیلی", "national_code": "1744023654" },
"data": [
{ "uuid": "…", "number": 12345, "issued_at": 1717000000, "total_rials": 2350000,
"patient_rials": 2350000, "paid_rials": 2350000,
"status": "paid", "service_title": "روکش دندان",
"items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ],
"payments": [
{ "method": "cash", "amount_rials": 350000, "paid_at": 1717000000, "created_by_name": "منشی" },
{ "method": "pos", "amount_rials": 2000000, "paid_at": 1717000500, "created_by_name": null }
] }
],
"summary": {
"total_rials": 8350000,
"paid_rials": 2350000,
"unsettled_rials": 6000000,
"invoices_count": 3
},
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
}
paymentsپرداختهای ثبتشدهی مراجعهی همان صورتحساب است (قدیمیترین اول)؛methodیکی ازwallet·pos·cash·card— برچسب فارسی سمت کلاینت درassets/admin/lib/paymentMethods.ts. صورتحساب بدون مراجعه آرایهی خالی میگیرد.
statusهمان وضعیت مشتقشدهی بالاست (paid·partial·unsettled) و همیشه بر مبنایpatient_rialsسنجیده میشود، حتی در این نما که ستون مبلغشtotal_rialsاست.service_titleعنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد).
summaryروی همهی صورتحسابهای همان بیمار محاسبه میشود (نه فقط صفحهی جاری) و جمعtotal_rialsصورتحسابهاست — بر خلافsummaryاندپوینت لیست پرداختها که سهم بیمار (patient_rials) را جمع میزند.invoices_countهمانmeta.totalRecordsاست.
Errors: 403 پروفایل tenant یافت نشد · 404 (ERR_NOT_FOUND_001) بیمار متعلق به این tenant نیست/یافت نشد.
ارسال مطالبه (ClaimSubmitter)
عملِ submit از طریق interface App\Billing\Contract\ClaimSubmitterInterface انجام میشود. پیادهسازی پیشفرض ManualClaimSubmitter است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکتهای بیمهی ایران کافی است یک پیادهسازی جدید از این interface ساخته و در config/services.yaml bind شود؛ ClaimService تغییر نمیکند (Dependency Inversion). اگر submit ناموفق باشد، انتقال وضعیت با 422 متوقف میشود.
جریان صدور فاکتور در پنل ادمین
«صدور فاکتور» (گام «جزییات» ویزارد مراجعه) و «مشاهده فاکتور» (کارت مراجعه / مودال جزئیات) هر دو از useIssueInvoice استفاده میکنند: POST /billing/invoices (idempotent) و سپس finalize اگر draft باشد. یعنی session بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.