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>
26 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). - درصد پوشش به تفکیک نوع خدمت و از زنجیرهٔ resolve توضیحدادهشده در insurance.md گرفته میشود: هر خدمت با
ServiceItem.service_categoryخودش، و ویزیت باPatientSession.insurance_service_category(نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی). invoices.service_categoryهمان نوع را snapshot میکند و درtoArray()بهصورتservice_category/service_category_labelبرمیگردد.- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ تکمیلی روی سهم بیمار.
بیمهٔ پایه صرفاً درصدی است:
سهم بیمهٔ پایه = 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.
همچنین برای درج روی فاکتور: 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_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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.