# سیستم صورتحساب و بیمه (Insurance & Medical Billing) — سند معماری

> وضعیت: **طرح معماری (Design)** — هنوز پیاده‌سازی نشده. بر اساس کد فعلی Clinic Pro (Symfony 7.4 / PHP 8.2+ / Doctrine / MariaDB) و الگوهای موجود نوشته شده.
> دامنه‌های جدید پیشنهادی: `App\Billing\*` (Invoice/InvoiceItem/Claim/Tariff) و توسعه‌ی `App\Insurance\*` (TenantInsurance/Coverage).

---

## ۰. تطبیق با کد فعلی (مبنای واقعی، نه greenfield)

| مفهوم پرامپت | معادل واقعی در Clinic Pro | تصمیم |
|---|---|---|
| **Tenant** | موجودیت مستقل وجود ندارد. tenancy به‌صورت polymorphic `(entityType ∈ {doctor, clinic}, entityId)` پیاده شده (الگوی `PatientRecord`, `EntityInsurancePricing`, `SmsSettings`, `SubscriptionService`). | یک **Value Object `TenantRef(type, id)`** معرفی می‌شود؛ موجودیت Tenant جدید **ساخته نمی‌شود**. resolve از `#[CurrentUser]` مثل `PatientController::resolveEntity`. |
| **InsuranceCompany** | `App\Insurance\Entity\Insurance` (لیست master سراسری، مدیریت‌شده توسط admin، دارای `type: InsuranceType{basic,supplementary}`). | حفظ می‌شود به‌عنوان **master سراسری**. tenant آن را «فعال» می‌کند، تعریف نمی‌کند. |
| **TenantInsurance** | `EntityInsurancePricing` فعلی (polymorphic، فقط `patient_share_rials`) — **ناکافی**. | به **`TenantInsurance`** ارتقا می‌یابد (قرارداد tenant↔insurance با نسخه و وضعیت). `EntityInsurancePricing` migrate می‌شود. |
| **BaseInsurance / SupplementaryInsurance** | `InsuranceType` enum (`basic`/`supplementary`). | همان enum؛ موجودیت جدا لازم نیست (نوع روی `Insurance` است). |
| **Service** | `App\ClinicService\Entity\ServiceItem` (دارای `priceRials`, `active`, متعلق به `ServiceSection`). | حفظ + افزودن `insuranceCovered`. تعرفه به `Tariff` منتقل می‌شود. |
| **Tariff** | وجود ندارد (قیمت مستقیم روی `ServiceItem.priceRials`). | موجودیت جدید `Tariff` با **نسخه‌بندی سالانه** (effective year/range). |
| **Encounter** | `App\Patient\Entity\PatientSession` (مراجعه؛ دارای فیلدهای بیمه/قیمت ساده). | `PatientSession` نقش Encounter را دارد؛ **Invoice** به آن متصل می‌شود. |
| **Invoice / InvoiceItem** | وجود ندارد (محاسبه‌ی ساده در `PatientService::calculateFinalPrice`). | موجودیت‌های جدید `Invoice`/`InvoiceItem` با تفکیک سهم بیمار/پایه/مکمل. |
| **Claim** | وجود ندارد. | موجودیت جدید با state machine (الگوی `Appointment::transitionTo`). |
| **Patient** | `PatientRecord` (+ `UserProfile` برای دموگرافیک/بیمه‌ی بیمار). | حفظ. بیمه‌ی بیمار از `UserProfile.basicInsuranceId/supplementaryInsuranceId`. |
| **Payment** | `App\Payment\Entity\Payment` (درگاه پرداخت). | سهم بیمار از Invoice → می‌تواند به Payment وصل شود. |

**اصول حفظ‌شده:** پول = `int` ریال؛ تاریخ = Unix timestamp (نه DateTime)؛ کنترلرها از `BaseController` (`success/paginated/error`)؛ لیست admin با DQL array hydration؛ خطاها در `ErrorCodes`؛ multi-tenant با `(entityType, entityId)`.

---

## ۱. Bounded Contexts

```
Insurance (master + tenant contracts + coverage)
   Insurance (master, global, admin)         ← موجود
   TenantInsurance (قرارداد tenant↔insurance) ← ارتقای EntityInsurancePricing
   InsuranceCoverage (قانون پوشش هر بیمه)
   TenantServiceCoverage (override پوشش یک خدمت خاص)

Billing (encounter → invoice → claim)
   Tariff (تعرفه‌ی نسخه‌دار سالانه)
   Invoice / InvoiceItem
   Claim (چرخه‌ی مطالبات بیمه)
   BillingCalculator (دامنه‌ی محاسبه — Domain Service)

Patient (موجود)        ClinicService (موجود + insuranceCovered)
Subscription (موجود — gate ویژگی billing)
```

---

## ۲. Domain Model — موجودیت‌ها و فیلدها

### 2.1 Value Objects

```php
// App\Shared\ValueObject\TenantRef
final readonly class TenantRef
{
    public function __construct(public string $type, public int $id) {}   // type: 'doctor'|'clinic'
    public static function doctor(int $id): self { return new self('doctor', $id); }
    public static function clinic(int $id): self { return new self('clinic', $id); }
}

// App\Billing\ValueObject\Money  (ریال، صحیح، بدون منفی)
final readonly class Money
{
    public function __construct(public int $rials) { if ($rials < 0) throw new \InvalidArgumentException('negative money'); }
    public function add(Money $o): self  { return new self($this->rials + $o->rials); }
    public function sub(Money $o): self  { return new self(max(0, $this->rials - $o->rials)); }
    public function percent(float $p): self { return new self((int) round($this->rials * $p / 100)); }
    public function min(Money $o): self  { return new self(min($this->rials, $o->rials)); }
}

// App\Billing\ValueObject\ShareBreakdown  (خروجی محاسبه‌ی یک آیتم)
final readonly class ShareBreakdown
{
    public function __construct(
        public int $totalRials,
        public int $baseInsuranceRials,
        public int $supplementaryRials,
        public int $patientRials,
    ) {}
}

// App\Insurance\ValueObject\CoverageRule
final readonly class CoverageRule
{
    public function __construct(
        public float $coveragePercent,        // درصد پوشش این بیمه
        public int   $franchiseRials,         // فرانشیز ثابت سهم بیمار
        public ?int  $ceilingRials,           // سقف تعهد هر آیتم (null = بی‌نهایت)
        public bool  $covered = true,
    ) {}
}
```

### 2.2 Entities (جداول)

#### TenantInsurance (ارتقای EntityInsurancePricing)
قرارداد یک tenant با یک بیمه. نسخه‌دار، قابل غیرفعال‌سازی بدون حذف.

| ستون | نوع | توضیح |
|---|---|---|
| id | int PK | |
| uuid | string(36) unique | |
| entity_type | string(10) | `doctor`/`clinic` |
| entity_id | int | |
| insurance_id | int FK→insurances | |
| version | int | نسخه‌ی قرارداد (۱، ۲، …) |
| is_active | bool | غیرفعال بدون حذف |
| coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد |
| franchise_rials | int | فرانشیز پیش‌فرض |
| annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه |
| effective_from | int (unix) | شروع اعتبار قرارداد |
| effective_to | int null | پایان (null = جاری) |
| created_at / updated_at | int | |

UniqueConstraint: `(entity_type, entity_id, insurance_id, version)`. Index: `(entity_type, entity_id, is_active)`.

#### TenantServiceCoverage
override پوشش یک خدمت خاص تحت یک بیمه‌ی tenant (آیا پوشش دارد + درصد/فرانشیز/سقف اختصاصی).

| ستون | نوع |
|---|---|
| id, uuid | |
| tenant_insurance_id | int FK→tenant_insurances |
| service_item_id | int FK→service_items |
| covered | bool |
| coverage_percent | decimal(5,2) null (null=ارث از قرارداد) |
| franchise_rials | int null |
| ceiling_rials | int null |

UniqueConstraint: `(tenant_insurance_id, service_item_id)`.

#### Tariff (تعرفه‌ی نسخه‌دار)
تعرفه‌ی یک خدمت برای یک سال. تاریخچه حفظ می‌شود.

| ستون | نوع | توضیح |
|---|---|---|
| id, uuid | | |
| service_item_id | int FK | |
| year | smallint | سال شمسی (۱۴۰۳ …) |
| price_rials | int | تعرفه‌ی آن سال |
| effective_from / effective_to | int (unix) | بازه‌ی اعتبار |
| is_active | bool | |

UniqueConstraint: `(service_item_id, year)`. `ServiceItem.priceRials` به‌عنوان تعرفه‌ی پیش‌فرض/جاری باقی می‌ماند (fallback).

#### Invoice
صورتحساب یک Encounter (`PatientSession`).

| ستون | نوع |
|---|---|
| id, uuid | |
| entity_type, entity_id | tenant |
| patient_session_id | int FK→patient_sessions (Encounter) |
| patient_record_id | int FK |
| base_insurance_id | int null |
| supplementary_insurance_id | int null |
| total_rials | int |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |
| status | string(15) `draft\|finalized\|paid\|void` |
| issued_at | int (unix) |
| created_at, updated_at | int |

#### InvoiceItem
یک خط صورتحساب (خدمت/ویزیت).

| ستون | نوع |
|---|---|
| id, uuid | |
| invoice_id | int FK |
| service_item_id | int null (ویزیت می‌تواند null باشد) |
| title | string |
| service_code | string null |
| tariff_rials | int (تعرفه‌ی واحد) |
| quantity | int |
| total_rials | int (= tariff × qty) |
| base_coverage_percent | decimal(5,2) |
| supp_coverage_percent | decimal(5,2) |
| franchise_rials | int |
| ceiling_rials | int null |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |

#### Claim (مطالبه‌ی بیمه)
گروهی از Invoiceها/InvoiceItemها که به یک بیمه ارسال می‌شود.

| ستون | نوع |
|---|---|
| id, uuid | |
| entity_type, entity_id | tenant |
| insurance_id | int FK |
| insurance_kind | string `base\|supplementary` |
| total_claimed_rials | int |
| total_approved_rials | int null |
| total_paid_rials | int null |
| status | string(15) `pending\|submitted\|approved\|rejected\|paid` |
| reject_reason | text null |
| submitted_at / settled_at | int null |
| created_at, updated_at | int |

#### ClaimItem
ارتباط Claim ↔ InvoiceItem (many-to-many با مبلغ ادعاشده per item).

| ستون | نوع |
|---|---|
| id | |
| claim_id | int FK |
| invoice_item_id | int FK |
| claimed_rials | int |
| approved_rials | int null |

---

## ۳. Entity Diagram (ERD متنی)

```
Insurance (master, global)
   ▲ insurance_id
   │
TenantInsurance ──< TenantServiceCoverage >── ServiceItem ──< Tariff
 (entity_type,id)                                  │ service_item_id
                                                   │
PatientRecord ──< PatientSession(Encounter) ──1:1 Invoice ──< InvoiceItem
                                                   │              ▲ invoice_item_id
                                                   │              │
                                              base/supp ins   ClaimItem >── Claim ── Insurance
                                                              (entity_type,id)
UserProfile.basicInsuranceId / supplementaryInsuranceId  → بیمه‌ی پیش‌فرض بیمار
```

---

## ۴. Aggregates (DDD)

| Aggregate Root | شامل | Invariantها |
|---|---|---|
| **Invoice** | InvoiceItem[] | جمع سهم‌ها = total هر آیتم؛ مجموع سطرها = total فاکتور؛ تغییر فقط در `draft`. |
| **Claim** | ClaimItem[] | فقط InvoiceItemهای finalized؛ transition وضعیت طبق state machine؛ `approved_rials ≤ claimed_rials`. |
| **TenantInsurance** | TenantServiceCoverage[] | فقط بیمه‌های فعالِ همان tenant؛ نسخه‌ی جدید قبلی را غیرفعال نمی‌کند مگر صریح. |

Encounter (`PatientSession`) خارج از Aggregate صورتحساب است؛ Invoice به آن **ارجاع** می‌دهد (نه ownership).

---

## ۵. منطق محاسبه‌ی سهم (Domain Service)

```php
// App\Billing\Service\BillingCalculator
final class BillingCalculator
{
    /**
     * محاسبه‌ی سهم برای یک آیتم.
     * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده‌ی بیمار → پوشش مکمل روی باقیمانده → فرانشیز ثابت سهم بیمار.
     */
    public function calculateItem(
        Money $total,
        ?CoverageRule $base,            // قانون بیمه‌ی پایه (یا null)
        ?CoverageRule $supplementary,   // قانون بیمه‌ی مکمل (یا null)
    ): ShareBreakdown {
        $baseShare = new Money(0);
        $remaining = $total;

        if ($base !== null && $base->covered) {
            $baseShare = $total->percent($base->coveragePercent);
            if ($base->ceilingRials !== null) {
                $baseShare = $baseShare->min(new Money($base->ceilingRials));
            }
            $remaining = $total->sub($baseShare);
        }

        $suppShare = new Money(0);
        if ($supplementary !== null && $supplementary->covered) {
            $suppShare = $remaining->percent($supplementary->coveragePercent);
            if ($supplementary->ceilingRials !== null) {
                $suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
            }
            $remaining = $remaining->sub($suppShare);
        }

        // فرانشیز همیشه سهم بیمار است (به remaining اضافه می‌شود، از سهم بیمه کم نمی‌کند مگر طراحی دیگر)
        $franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0));
        $patient   = $remaining->add($franchise)->min($total);  // سهم بیمار از کل بیشتر نشود

        // اصلاح: اگر فرانشیز باعث شد جمع > total شود، سهم بیمه‌ها کم نمی‌شود؛ این Invariant باید تست شود.
        return new ShareBreakdown(
            totalRials:          $total->rials,
            baseInsuranceRials:  $baseShare->rials,
            supplementaryRials:  $suppShare->rials,
            patientRials:        $patient->rials,
        );
    }
}
```

**نمونه (مطابق پرامپت):** کل 600,000؛ پایه 70% → 420,000؛ مکمل روی باقیمانده‌ی 180,000 با ≈66.7% → 120,000؛ سهم بیمار 60,000.

> نکته‌ی طراحی: تعامل فرانشیز و سقف باید با Test پوشش داده شود؛ منطق بالا یک baseline است و قابل تنظیم per-insurance.

---

## ۶. Repository / Service / DTO

**Repositoryها** (الگوی `ServiceEntityRepository` + `save/remove`):
`TenantInsuranceRepository` (findActiveByTenant, findContractFor), `TenantServiceCoverageRepository`, `TariffRepository` (findForServiceYear), `InvoiceRepository`, `ClaimRepository` (findByStatus, debtReport).

**Service Layer:**
- `TenantInsuranceService` — فعال‌سازی/غیرفعال/نسخه‌بندی قرارداد، گرفتن `CoverageRule` برای (tenant, insurance, serviceItem).
- `BillingCalculator` — محاسبه‌ی خالص (بالا).
- `InvoiceService` — ساخت Invoice از Encounter: برای هر خدمت → resolve تعرفه (Tariff سال) + CoverageRule بیمه‌های بیمار → `BillingCalculator` → InvoiceItem؛ finalize.
- `ClaimService` — گروه‌بندی InvoiceItemها بر اساس بیمه، ساخت Claim، transition وضعیت، گزارش بدهی.

**DTOها:** `CreateInvoiceRequest`, `InvoiceItemDTO`, `TenantInsuranceDTO`, `CoverageRuleDTO`, `ClaimDTO`, `BillingPreviewResponse` (پیش‌نمایش زنده‌ی محاسبه قبل از ثبت).

---

## ۷. REST API (تحت `/api/v1`, `BaseController`, tenant از `#[CurrentUser]`)

### TenantInsurance (قراردادهای بیمه‌ی tenant)
- `GET  /api/v1/billing/tenant-insurances` — لیست قراردادهای فعال tenant
- `POST /api/v1/billing/tenant-insurances` — فعال‌سازی بیمه برای tenant (با coverage_percent/franchise/ceiling)
- `PATCH /api/v1/billing/tenant-insurances/{uuid}` — ویرایش (نسخه‌ی جدید)
- `DELETE /api/v1/billing/tenant-insurances/{uuid}` — غیرفعال‌سازی (soft)
- `PUT  /api/v1/billing/tenant-insurances/{uuid}/service-coverage` — تنظیم پوشش خدمات

### Tariff
- `GET  /api/v1/billing/services/{serviceUuid}/tariffs`
- `PUT  /api/v1/billing/services/{serviceUuid}/tariffs/{year}` — ثبت/ویرایش تعرفه‌ی سال

### Invoice
- `POST /api/v1/billing/invoices/preview` — پیش‌نمایش محاسبه (بدون ذخیره)
- `POST /api/v1/billing/invoices` — ساخت از Encounter
- `GET  /api/v1/billing/invoices/{uuid}`
- `POST /api/v1/billing/invoices/{uuid}/finalize`

### Claim
- `POST /api/v1/billing/claims` — ساخت از InvoiceItemهای finalized یک بیمه
- `POST /api/v1/billing/claims/{uuid}/submit|approve|reject|pay`
- `GET  /api/v1/billing/claims?status=...`
- `GET  /api/v1/billing/reports/insurance-debt` — گزارش بدهی بیمه‌ها

**Guard مهم (نیازمندی ۱۴):** هنگام پذیرش بیمار و ساخت Invoice، فقط بیمه‌هایی مجازند که `TenantInsurance.is_active = true` برای همان tenant. در غیر این صورت `ERR_VALIDATION` («این بیمه برای این کلینیک فعال نیست»).

---

## ۸. Workflow

### ثبت صورتحساب
```
Encounter(PatientSession) ثبت می‌شود
  → بیمه‌ی بیمار از UserProfile یا انتخاب منشی (محدود به TenantInsurance فعال)
  → InvoiceService.createFromEncounter:
       برای هر خدمت:
         tariff = TariffRepository.findForServiceYear(service, سالِ جاری) ?? service.priceRials
         coverage_base = TenantInsuranceService.coverageRule(tenant, baseIns, service)
         coverage_supp = TenantInsuranceService.coverageRule(tenant, suppIns, service)
         breakdown = BillingCalculator.calculateItem(...)
         → InvoiceItem
  → Invoice (status=draft) → finalize (status=finalized)
```

### Claim
```
InvoiceItemهای finalized یک بیمه → ClaimService.create (status=pending)
  → submit (submitted) → [پاسخ بیمه] approve/reject → pay (paid)
گزارش بدهی = جمع claimed - paid بر اساس بیمه
```

state machine Claim (الگوی `Appointment::canTransitionTo`):
`pending → submitted → {approved → paid | rejected}`؛ از `paid`/`rejected` خروج ندارد.

---

## ۹. Events / Use Cases

**Domain Events:** `InvoiceFinalized`, `ClaimSubmitted`, `ClaimApproved`, `ClaimRejected`, `ClaimPaid`, `TenantInsuranceDeactivated`.
کاربرد: SMS/notification به بیمار، به‌روزرسانی گزارش بدهی، آینده: ارسال خودکار به API بیمه.

**Use Cases اصلی:** فعال‌سازی بیمه برای tenant؛ تنظیم پوشش خدمت؛ ثبت تعرفه‌ی سالانه؛ پیش‌نمایش محاسبه‌ی سهم؛ ثبت Invoice از Encounter؛ ساخت/پیگیری Claim؛ گزارش بدهی.

---

## ۱۰. آینده: اتصال به API بیمه (نیازمندی ۱۰)
`ClaimSubmitter` به‌صورت interface طراحی شود؛ پیاده‌سازی فعلی manual، آینده `IranInsuranceApiSubmitter` بدون تغییر در دامنه (Dependency Inversion).

---

## ۱۱. فازبندی پیاده‌سازی (پیشنهادی)

1. **فاز ۱ — قرارداد بیمه:** ✅ **انجام شد** — `TenantInsurance` (entity/repo/service/API/UI) + جدول `tenant_insurances` + guard `assertActive()` + `CoverageRule` VO. `EntityInsurancePricing` فعلاً برای ویزیت آزاد/سهم ساده باقی ماند (فاز ۴ ادغام). API: `/api/v1/billing/tenant-insurances` (GET/POST/PATCH/DELETE). UI: صفحه‌ی «بیمه و قیمت‌گذاری».
2. **فاز ۲ — پوشش خدمت:** ✅ **انجام شد** — `ServiceItem.insuranceCovered` + `insurancePriceRials` + موجودیت `TenantServiceCoverage` (override per-service per-contract) + `coverageRuleForService()` (resolve با ارث از قرارداد) + API `…/service-coverage` (GET/PUT) + UI toggle «شامل بیمه» در صفحه‌ی خدمات.
3. **فاز ۳ — تعرفه:** ✅ **انجام شد** — موجودیت `Tariff` (service_item × سال شمسی) + `TariffService::resolvePrice()` با fallback به `ServiceItem.priceRials` + `currentJalaliYear()` (IntlDateFormatter، رقم لاتین) + API `…/tariffs` (GET) و `…/tariffs/{year}` (PUT) + UI «تعرفه‌های سالانه» (modal از ردیف خدمت).
4. **فاز ۴ — محاسبه + Invoice:** ✅ **انجام شد** — VO `Money`/`ShareBreakdown` + `BillingCalculator` (۶ تست واحد سبز، نمونه ۶۰۰k) + `Invoice`/`InvoiceItem` (aggregate، جداول `invoices`/`invoice_items`) + `InvoiceService::createFromSession` (resolve تعرفه‌ی Tariff + CoverageRule per-service + محاسبه) + API `/api/v1/billing/invoices` (POST/GET/finalize). `PatientService::calculateFinalPrice` فعلاً برای سازگاری باقی ماند (مصرف UI موجود).
5. **فاز ۵ — Claim + گزارش:** ✅ **انجام شد** — `Claim`/`ClaimItem` (جداول `claims`/`claim_items`) + state machine (`pending→submitted→approved/rejected→paid`) + `ClaimService::createFromInvoice` (تفکیک پایه/مکمل از InvoiceItemها) + transitions + API `/api/v1/billing/claims` (POST/GET + `{uuid}/{action}`) + گزارش `…/reports/insurance-debt`.
6. **فاز ۶ — Frontend + API بیمه:** ✅ **انجام شد** — صفحه‌ی «مطالبات بیمه» (`ClaimsPage`: لیست + فیلتر وضعیت + transitions submit/approve/reject/pay + گزارش بدهی) + route `/admin/claims` + آیتم sidebar (doctor/clinic) + interface `ClaimSubmitterInterface` با پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` (autowired؛ آماده‌ی جایگزینی با API بیمه‌ی ایران بدون تغییر `ClaimService`). UI صورتحساب در فرم مراجعه به فاز بعدی موکول شد (نیاز به بازطراحی مودال مراجعه — پرامپت `new-visit-modal-ux.md`).

> توجه: `EntityInsurancePricing` فعلی (همین session ساخته شد) در فاز ۱ به `TenantInsurance` تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.
