Files
clinicpro/docs/api/billing.md
T
hamedandClaude Opus 5 58c6d9ac18 feat(insurance): resolve coverage percent per service category
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>
2026-07-25 16:21:19 +03:30

25 KiB
Raw Blame History

Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)

Prefix: /api/v1/billing دامنه: App\Billing. مرجع معماری: docs/architecture/insurance-billing-system.md. tenant از #[CurrentUser] با App\Patient\Security\PatientRecordScopeResolver resolve می‌شود — همان رزولوری که پرونده‌های بیمار استفاده می‌کنند، چون صورتحساب و مطالبه از دل مراجعه بیرون می‌آیند و باید در همان محیط دیده شوند. محیط فعال (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

نهایی‌سازی صورتحساب (draftfinalized). صورتحساب نهایی‌شده مبنای ساخت 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}

انتقال وضعیت. actionsubmit|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 فقط چرخه‌ی حیات صورتحساب را نگه می‌دارد (draftfinalizedvoid) و هرگز paid نمی‌شود؛ پول واقعی در session_payments ثبت می‌شود. قاعده در App\Billing\Service\InvoicePaymentStatus است:

حالت شرط
paid paid_rials >= amount_rials (شامل صورتحساب صفرریالی)
partial 0 < paid_rials < amount_rials
unsettled paid_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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهایی‌شده می‌شود.