# Insurance API

> **Prefix:** `/api/v1/insurances`, `/api/v1/insurance`, `/api/v1/admin/insurance`

Two resource types:
1. **Insurance** — master list of insurance companies managed by admin
2. **DoctorInsurance** — a doctor's acceptance of a specific insurance (with optional price)

---

## GET `/api/v1/insurances`

List all active insurances.

**Permission:** `PUBLIC`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | ❌ | `"basic"` or `"supplementary"` |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "بیمه تأمین اجتماعی",
      "type": "basic",
      "logo_url": "https://...",
      "status": "active"
    },
    {
      "id": 2,
      "name": "بیمه ایران",
      "type": "supplementary",
      "logo_url": "https://...",
      "status": "active"
    }
  ]
}
```

---

## GET `/api/v1/admin/insurances`

List all insurances with pagination (admin view — includes inactive).

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search in name |
| `type` | string | ❌ | `"basic"` or `"supplementary"` |

### Response `200`
```json
{
  "success": true,
  "data": [ ... ],
  "meta": { "totalRecords": 15, "totalPages": 1, "currentPage": 1 }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |

---

## POST `/api/v1/admin/insurance`

Create a new insurance.

**Permission:** `ROLE_ADMIN`

### Request Body (`application/json`)
```json
{
  "name": "بیمه تأمین اجتماعی",
  "type": "basic",
  "logo_url": "https://...",
  "status": "active"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | ✅ | Insurance name |
| `type` | string | ✅ | `"basic"` or `"supplementary"` |
| `logo_url` | string | ❌ | Logo image URL |
| `status` | string | ❌ | `"active"` (default) or `"inactive"` |

### Response `201`
Insurance object.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_VALIDATION_002` | 422 | Missing required field |

---

## PATCH `/api/v1/admin/insurance/{id}`

Update an insurance.

**Permission:** `ROLE_ADMIN`

### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `id` | integer | Insurance ID |

All body fields optional.

### Response `200`
Updated insurance object.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Insurance not found |

---

## DELETE `/api/v1/admin/insurance/{id}`

Delete an insurance.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{ "success": true, "data": { "message": "بیمه حذف شد" } }
```

---

## POST `/api/v1/admin/insurance/{id}/upload-logo`

Upload insurance logo.

**Permission:** `ROLE_ADMIN`

### Request
`Content-Type: multipart/form-data`

| Field | Type | Required |
|-------|------|----------|
| `file` | binary | ✅ |

### Response `200`
```json
{
  "success": true,
  "data": {
    "url": "https://...",
    "uuid": "...",
    "filename": "insurance_logo.png",
    "filemime": "image/png",
    "filesize": 51200
  }
}
```

---

## POST `/api/v1/insurance/`

Add an insurance to a doctor's accepted list.

**Permission:** `AUTH` — must be the doctor (or their secretary with `insurances.create` permission)

### Request Body (`application/json`)
```json
{
  "doctor_id": 42,
  "insurance_id": 1,
  "price": 150000
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_id` | integer | ✅ | Doctor's numeric ID |
| `insurance_id` | integer | ✅ | Insurance ID |
| `price` | integer | ❌ | Visit price for this insurance (Rials) |

### Response `201`
```json
{
  "success": true,
  "data": {
    "id": 10,
    "doctor_id": 42,
    "insurance": { "id": 1, "name": "بیمه تأمین اجتماعی", "type": "basic" },
    "price": 150000
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the doctor |
| `ERR_NOT_FOUND_001` | 404 | Doctor or insurance not found |
| `ERR_CONFLICT_001` | 409 | Insurance already added to doctor |

---

## GET `/api/v1/insurance/{id}`

Get a doctor-insurance link.

**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (otherwise `403 ERR_AUTH_006`). Prevents reading another doctor's negotiated price by id enumeration.

### Response `200`
DoctorInsurance object.

---

## PATCH `/api/v1/insurance/{id}`

Update a doctor-insurance (e.g., change price).

**Permission:** `AUTH` — must be the doctor (or their secretary with `insurances.update` permission)

### Request Body
```json
{ "price": 200000 }
```

### Response `200`
Updated DoctorInsurance object.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the doctor |
| `ERR_NOT_FOUND_001` | 404 | Link not found |

---

## DELETE `/api/v1/insurance/{id}`

Remove an insurance from a doctor's list.

**Permission:** `AUTH` — must be the doctor (or their secretary with `insurances.delete` permission)

### Response `200`
```json
{ "success": true, "data": { "message": "بیمه از لیست حذف شد" } }
```

---

## EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه

قیمت‌گذاری ویزیت برای entity جاری (پزشک یا کلینیک)، با تفکیک:
- **ویزیت آزاد** (بدون بیمه) — یک مبلغ پایه (ردیفی با `insurance_id = null`)
- **سهم بیمار به ازای هر بیمه** پایه/مکمل

entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR` → `doctor`، نقش `ROLE_CLINIC` → `clinic`. ذخیره‌سازی polymorphic در جدول `entity_insurance_pricing` (`entity_type`, `entity_id`, `insurance_id` nullable, `patient_share_rials`).

---

## GET `/api/v1/insurance-pricing`

قیمت‌گذاری بیمه‌ی entity جاری + لیست همه‌ی بیمه‌های فعال (با سهم بیمار اگر تعیین شده).

**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ❌ | قیمت‌گذاری همان پزشک را برمی‌گرداند به‌جای موجودیت کاربر جاری. برای تب‌های نوبت‌دهی پنل کلینیک. |

با `doctor_uuid`، دسترسی این‌گونه بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دست‌نخورده است.

### Response `200`
```json
{
  "success": true,
  "data": {
    "entity_type": "doctor",
    "entity_id": 7,
    "free_visit_price_rials": 5000000,
    "require_visit_price": false,
    "insurances": [
      {
        "insurance_id": 3,
        "insurance_name": "تأمین اجتماعی",
        "type": "basic",
        "patient_share_rials": 1500000
      },
      {
        "insurance_id": 9,
        "insurance_name": "دانا",
        "type": "supplementary",
        "patient_share_rials": null
      }
    ]
  }
}
```

- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد).
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند.

### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد.

---

## PUT `/api/v1/insurance-pricing`

ذخیره/به‌روزرسانی قیمت ویزیت آزاد و سهم بیمار هر بیمه. عملیات upsert؛ ردیفی که `patient_share_rials = null` بفرستد حذف می‌شود.

**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)

### Request Body
> `doctor_uuid` (اختیاری) در بدنه پذیرفته می‌شود و مثل نسخهٔ `GET` عمل می‌کند — همان قواعد دسترسی، با اکشن `services.update`.

```json
{
  "doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "free_visit_price_rials": 5000000,
  "require_visit_price": true,
  "insurances": [
    { "insurance_id": 3, "patient_share_rials": 1500000 },
    { "insurance_id": 9, "patient_share_rials": null }
  ]
}
```

| فیلد | نوع | توضیح |
|------|-----|-------|
| `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمی‌کند. |
| `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیره‌شده حفظ می‌شود. |
| `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). |
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. |

اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیره‌شده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیره‌شده) `<= 0`، درخواست رد می‌شود.

### Response `200`
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).

### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`.

---

## TenantInsurance — قراردادهای بیمه‌ی tenant (فاز ۱ سیستم صورتحساب)

قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخه‌بندی و وضعیت فعال. مبنای محاسبه‌ی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). tenant از `#[CurrentUser]` (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic). جدول `tenant_insurances`.

### GET `/api/v1/billing/tenant-insurances`
لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.

**Permission:** `AUTH` (doctor/clinic)

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "uuid": "…",
        "insurance_id": 3,
        "insurance_name": "تأمین اجتماعی",
        "insurance_kind": "basic",
        "version": 1,
        "is_active": true,
        "coverage_percent": 70,
        "franchise_rials": 0,
        "annual_ceiling_rials": null,
        "kind": "basic",
        "effective_from": 1718900000,
        "effective_to": null
      }
    ]
  }
}
```

### POST `/api/v1/billing/tenant-insurances`
فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید.

**Body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_id` | int | الزامی |
| `coverage_percent` | float | درصد پوشش (۰–۱۰۰) |
| `franchise_rials` | int | فرانشیز ثابت سهم بیمار |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
| `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود |

پاسخ `201`: `{ success, data: { …contract } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.

### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری.

**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active`.

- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate).

### DELETE `/api/v1/billing/tenant-insurances/{uuid}`
غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود.

```json
{ "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } }
```

> **Guard:** `TenantInsuranceService::assertActive()` هنگام پذیرش/صورتحساب فقط بیمه‌های فعالِ همان tenant را مجاز می‌داند؛ در غیر این صورت `422 ERR_VALIDATION_001` («این بیمه برای این کلینیک/پزشک فعال نیست»).

---

## TenantServiceCoverage — پوشش خدمت تحت یک قرارداد بیمه (فاز ۲)

override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فیلدهای `null` از خود قرارداد ارث می‌برند. اگر `covered=false` → آن خدمت تحت آن بیمه پوشش ندارد. جدول `tenant_service_coverage`.

### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
لیست overrideهای پوشش خدمات یک قرارداد.

**Permission:** `AUTH` (مالک قرارداد)

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "uuid": "…",
        "tenant_insurance_id": 4,
        "service_item_id": 12,
        "service_item_uuid": "…",
        "covered": true,
        "coverage_percent": 80,
        "franchise_rials": null,
        "ceiling_rials": null
      }
    ]
  }
}
```

### PUT `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
تنظیم/به‌روزرسانی پوشش یک خدمت (upsert).

**Body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `service_item_uuid` | string | شناسه‌ی خدمت (ترجیحی؛ پنل ادمین فقط uuid دارد) |
| `service_item_id` | int | جایگزین `service_item_uuid` (id داخلی) — یکی از این دو الزامی |
| `covered` | bool | پیش‌فرض true |
| `coverage_percent` | float \| null | null = ارث از قرارداد |
| `franchise_rials` | int \| null | null = ارث از قرارداد |
| `ceiling_rials` | int \| null | null = ارث از قرارداد |

سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (`ServiceItem→section→entity_type/entity_id`).

**اثر جانبی — همگام‌سازی `ServiceItem.insurance_covered`:** پس از ذخیره‌ی ردیف پوشش، پرچم
`insurance_covered` همان خدمت بازمحاسبه می‌شود: اگر زیر **هر** قرارداد بیمه‌ای دست‌کم یک ردیف با
`covered=true` بماند ⇒ `true`، وگرنه `false`. پنل ادمین دیگر این پرچم را دستی نمی‌فرستد (سوییچ «این خدمت
شامل بیمه می‌شود» از فرم سرویس حذف شد)، پس این endpoint تنها منبع حقیقت آن است. پیاده‌سازی:
`TenantInsuranceService::syncServiceItemInsuranceFlag()` + `TenantServiceCoverageRepository::hasActiveCoverage()`.

پاسخ `200`: `{ success, data: { message } }`.
خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` سرویس یافت نشد · `403 ERR_FORBIDDEN_001` سرویس متعلق به شما نیست.

> منطق resolve: `TenantInsuranceService::coverageRuleForService()` ابتدا **پرچم `ServiceItem.insurance_covered`** را چک می‌کند؛ اگر این خدمت «شامل بیمه» نباشد، بدون توجه به override یا قرارداد، `CoverageRule::notCovered()` برمی‌گردد (gate نهایی). سپس override خدمت بررسی می‌شود؛ اگر `covered=false` → `notCovered()`؛ در غیر این صورت فیلدهای null از قرارداد پر می‌شوند. این `CoverageRule` ورودی `BillingCalculator` است و سهم بیمه‌ی هر `InvoiceItem` را تعیین می‌کند؛ همان سهم‌ها در `ClaimService::createFromInvoice()` به `ClaimItem` (مطالبات بیمه) تبدیل می‌شوند. پنل ادمین این endpoint را از مودال «پوشش بیمه» در صفحه سرویس‌های کلینیک فراخوانی می‌کند.

---

## Bulk import / export

Full-table JSON export and strict wipe+replace import for this category live under
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).


### Sorting by id

The admin list endpoint accepts `sort=id&order=asc|desc` to order by `id`
(used by the admin «دسته‌بندی‌ها» page when clicking the «شناسه» column).
Without `sort`, the default ordering (weight/name) is unchanged.
