Files
clinicpro/docs/api/billing.md
T
hamedandClaude Opus 5 4fe0c4f9bf refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:00:48 +03:30

28 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 محاسبه می‌شود:

  • قیمت خدمت از 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             # ساخت مطالبات جاافتاده

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

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

انتقال وضعیت. 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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهایی‌شده می‌شود.