approve/pay accepted any approved_rials/paid_rials with no bounds, so the claiming tenant could write arbitrary figures into the insurer-debt ledger (negative, or far above the claimed total). Validate: approved ∈ [0, claimed], paid ∈ [0, approved] → 422 otherwise. (The "force arbitrary status" half of the finding was already prevented by Claim::canTransitionTo.) Regression: tests/Billing/ClaimAmountBoundsTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.8 KiB
Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
Prefix:
/api/v1/billingدامنه:App\Billing. مرجع معماری:docs/architecture/insurance-billing-system.md. tenant از#[CurrentUser]resolve میشود (ROLE_DOCTOR→doctor،ROLE_CLINIC→clinic).
صورتحساب (Invoice) از یک Encounter (PatientSession) ساخته میشود. برای هر آیتم سهم بیمهی پایه، بیمهی مکمل و بیمار با BillingCalculator محاسبه میشود:
- تعرفهی خدمت از
Tariffسال جاری (با fallback بهServiceItem.priceRials). - قانون پوشش از قرارداد بیمهی tenant (
TenantInsurance) + override خدمت (TenantServiceCoverage). - ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل → فرانشیز سهم بیمار.
نمونه: کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
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: همان ساختار بالا.
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}هم اعمال میشود.
POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. action ∈ submit|approve|reject|pay.
| action | body اختیاری | اثر |
|---|---|---|
| submit | — | 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 |
Errors: 422 انتقال نامعتبر، دلیل رد خالی، یا مبلغ approved_rials/paid_rials خارج از بازه (field در پاسخ) · 404 مطالبه یافت نشد.
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 (حداقل صفر).
ارسال مطالبه (ClaimSubmitter)
عملِ submit از طریق interface App\Billing\Contract\ClaimSubmitterInterface انجام میشود. پیادهسازی پیشفرض ManualClaimSubmitter است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکتهای بیمهی ایران کافی است یک پیادهسازی جدید از این interface ساخته و در config/services.yaml bind شود؛ ClaimService تغییر نمیکند (Dependency Inversion). اگر submit ناموفق باشد، انتقال وضعیت با 422 متوقف میشود.