- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController. - Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments. - Updated existing methods in both controllers to utilize the new permission checks. - Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management. - Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
31 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 محاسبه میشود:
- قیمت خدمت از
ServiceItem.priceRials— تنها منبع قیمت. - قانون پوشش از قرارداد بیمهی tenant (
TenantInsurance) + override خدمت (TenantServiceCoverage). - درصد پوشش به تفکیک نوع خدمت و از زنجیرهٔ resolve توضیحدادهشده در insurance.md گرفته میشود: هر خدمت با
ServiceItem.service_categoryخودش، و ویزیت باPatientSession.insurance_service_category(نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی). invoices.service_categoryهمان نوع را snapshot میکند و درtoArray()بهصورتservice_category/service_category_labelبرمیگردد. محاسبه زنجیرهای است، نه همزمان: اول بیمهٔ پایه روی کل مبلغ، بعد بیمهٔ تکمیلی روی باقیماندهٔ سهم بیمار، نه روی کل.
سهم پایه = min(round(کل × درصد پایه ÷ 100), سقف پایه)
باقیمانده = کل − سهم پایه
سهم تکمیلی = min(max(0, round(باقیمانده × درصد تکمیلی ÷ 100) − round(باقیمانده × فرانشیز ÷ 100)), سقف تکمیلی)
سهم بیمار = کل − سهم پایه − سهم تکمیلی
- فرانشیز درصد است، نه مبلغ، و از تعهد بیمهٔ تکمیلی کسر میشود (نه اینکه روی سهم بیمار اضافه شود). با مدل قبلیِ «افزودن به سهم بیمار»، جمع سهمها از کل بیشتر میشد و مطالبهٔ ارسالی به بیمه بیش از سهم واقعیاش بود.
franchise_percentقرارداد پایه در محاسبه بیاثر است.- invariant همیشگی:
کل = سهم پایه + سهم تکمیلی + سهم بیمار.
نمونهها:
- کل ۱۰٬۰۰۰٬۰۰۰ · پایه ۳۰٪ → ۳٬۰۰۰٬۰۰۰ · تکمیلی ۹۰٪ با فرانشیز ۱۰٪ روی باقیماندهٔ ۷٬۰۰۰٬۰۰۰ → ۵٬۶۰۰٬۰۰۰ · بیمار ۱٬۴۰۰٬۰۰۰.
- ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰.
- بدون پایه · تکمیلی ۹۰٪ با فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ → بیمه ۸۰۰٬۰۰۰ · بیمار ۲۰۰٬۰۰۰.
مطالبهٔ خودکار
مطالبهٔ بیمه اثر جانبیِ نهاییشدن صورتحساب است، نه کارِ یک endpoint خاص: InvoiceService::finalize()
رویداد InvoiceFinalized میفرستد و CreateClaimsOnInvoiceFinalized مطالبات جاافتاده را میسازد
(idempotent — مطالبهٔ موجود دستنخورده میماند، و بیمهای که سهمی نبرده مطالبه نمیگیرد).
هر مراجعهٔ بیمهدار هم — چه از «ثبت مراجعه»، چه از ویرایش مراجعه، چه از قطعیکردن نوبت —
با SessionBillingService::ensureFinalizedInvoice() صورتحساب نهایی میگیرد. پیش از این فقط
مسیر «ثبت مراجعه» مطالبه میساخت و بقیهٔ مسیرها بیصدا بدون مطالبه میماندند.
جبران دادههای قدیمی:
ddev exec php bin/console app:billing:backfill-claims --dry-run # گزارش
ddev exec php bin/console app:billing:backfill-claims # ساخت مطالبات جاافتاده
مجوزها
از ۲۰۲۶-۰۸-۰۸ هر روتِ این کنترلر پیش از هر واکشی، مجوزِ payments را با
App\Shared\Controller\PermissionGateTrait میسنجد. پیش از آن هیچ روتی گِیت مجوزی
نداشت: منشیِ payments:false هم پرداختها را میدید، هم صورتحساب میساخت، هم وضعیت
مطالبه را عوض میکرد. جزئیات در docs/security/AUDIT-2026-08-07.md یافتهٔ ۸.
منبعِ مجوز برای صورتحساب و مطالبه یکی است — payments — چون هر دو زیر همان توگلِ
«مدیریت پرداختها»ی پنل نشستهاند و توگل جداگانهای ندارند.
payments.view— خواندن: صورتحساب، فهرست پرداختها و خلاصهشان، فهرست مطالبات، مطالبات به تفکیک بیمار، گزارش بدهی بیمه، صورتحسابهای یک بیمار.payments.create— ساخت: صورتحساب تازه، مطالبهٔ تازه.payments.update— تغییر وضعیت: نهاییکردن صورتحساب، وsubmit/approve/reject/payروی مطالبه.
گِیت پیش از findByUuid() مینشیند. ترتیب عمدی است: اگر بعدش بود، uuidِ ناموجود ۴۰۴
میداد و همان تفاوت ۴۰۳/۴۰۴ به کاربرِ بیمجوز میگفت کدام uuid در این محیط وجود دارد.
نقشهای دیگر اثری نمیگیرند: هر دو checker برای ادمین، مالک کلینیک و پزشک مطب شخصی pass-through هستند و فقط منشی و پزشکِ عضوِ کلینیک را محدود میکنند.
خطای رد: 403 با ERR_FORBIDDEN_001.
POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده میشود (idempotent).
Permission: payments.create + مالکیت محیطِ مراجعه
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.
همچنین برای درج روی فاکتور: service_category / service_category_label (نوع خدمتِ بیمهای) و base_insurance_name / supplementary_insurance_name (نام بیمهها؛ فاکتور خودش فقط شناسه را نگه میدارد).
{
"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_at": 1718990000,
"created_by_name": "منشی تست",
"created_by": { "user_uuid": "…", "name": "منشی تست", "role": "secretary", "doctor_uuid": null }
}
],
"consumables": [
{ "uuid": "…", "item_name": "عینک", "quantity": 2, "price_rials": 20000, "line_total_rials": 40000 }
]
}
}
}
}
ثبتکنندهٔ هر پرداخت (2026-08)
created_by هویتِ کاربری است که پرداخت را ثبت کرده، برای نمایش نام و لینکدادن به
پروفایلش:
| فیلد | توضیح |
|---|---|
user_uuid |
uuid کاربر |
name |
نامِ نمایشی — پروفایل ← real_name ← شمارهٔ موبایل |
role |
نقشِ پرتوان کاربر: admin|clinic|doctor|secretary|staff|representation|user |
doctor_uuid |
فقط برای نقش doctor — تنها نقشی که در پنل صفحهٔ پروفایل مستقل دارد |
created_by_nameروی ردیف عکسِ لحظهٔ ثبت است و برای کاربری که آنوقت پروفایل نداشته ممکن است شمارهٔ موبایل باشد؛ در این پاسخ با نامِ زندهٔ همان کاربر بازنویسی میشود و فقط وقتی دستنخورده میماند کهcreated_byتهی باشد (کاربر حذف شده).- مسیرِ پروفایل عمداً در پاسخ نیست: سرور مسیرهای پنل را نمیشناسد و دسترسیِ هر نقش به هر صفحه کارِ کلاینت است.
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 | — | شناسه بیمه |
kind |
string | — | نوع بیمه: base | supplementary (واژگان مطالبه base است، نه basic). مقدار نامعتبر → 422 ERR_VALIDATION_001 با field: kind |
doctor_id |
int | — | پزشکِ نوبتِ مراجعه |
payment_status |
string | — | paid (وصولشده) | unpaid |
from / to |
int | — | بازهی claims.created_at (unix ثانیه) |
پاسخ paginated استاندارد ({ success, data: [], meta }):
{
"success": true,
"data": [
{
"patient_uuid": "649e24ae-08e2-489d-b502-af07c59dfa2d",
"record_uuid": "8e36035c-d234-42aa-858b-2427918e9c4a",
"full_name": "تست بیمه ۱",
"mobile": "09243243534",
"national_code": null,
"claims_count": 3,
"total_services_rials": 91900000,
"total_insurance_rials": 9877000,
"total_patient_rials": 82023000,
"total_approved_rials": 0,
"total_paid_rials": 0,
"overall_status": "pending",
"insurances": [
{ "insurance_id": 176, "insurance_name": "تامین اجتماعی", "kind": "base" },
{ "insurance_id": 182, "insurance_name": "بیمه ایران", "kind": "supplementary" }
],
"last_activity_at": 1785341593
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1, "limit": 1 }
}
-
insurances: بیمههایی که این بیمار زیرشان مطالبه دارد — یک بیمار میتواند همزمان پایه و تکمیلی داشته باشد، پس ستون «بیمه» یک لیست است نه یک مقدار. -
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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.