# 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](patient.md#record-access-model).
>
> پیش از این، این دامنه ترتیب نقش‌ها را خودش پیاده کرده بود و اول `ROLE_DOCTOR` را می‌گرفت؛ در نتیجه **مالک کلینیکی که خودش پزشک هم هست** به مطب شخصی‌اش نگاشت می‌شد و صورتحساب/مطالبه‌ی کلینیک خودش را `404` می‌گرفت. همین رزولور در `InsuranceController` هم استفاده می‌شود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.

صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته می‌شود. برای هر آیتم سهم بیمه‌ی پایه، بیمه‌ی مکمل و بیمار با `BillingCalculator` محاسبه می‌شود:

- قیمت خدمت از `ServiceItem.priceRials` — تنها منبع قیمت.
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `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()` صورتحساب نهایی می‌گیرد. پیش از این فقط
مسیر «ثبت مراجعه» مطالبه می‌ساخت و بقیهٔ مسیرها بی‌صدا بدون مطالبه می‌ماندند.

جبران داده‌های قدیمی:

```bash
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:**
```json
{ "session_uuid": "…" }
```

**Response 201:**
```json
{
  "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` (نام بیمه‌ها؛ فاکتور خودش فقط شناسه را نگه می‌دارد).

```json
{
  "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؛ نمایش شمسی) |

```json
{
  "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 }`):

```json
{
  "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` نمای سطح‌اول). فیلترها همان فیلترهای بالا.

```json
{
  "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:**
```json
{
  "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** (صفحه‌بندی‌شده‌ی مسطح):
```json
{
  "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` است:
> | حالت | شرط |
> |------|-----|
> | `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:**
```json
{
  "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:**
```json
{
  "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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهایی‌شده می‌شود.
