Files
clinicpro/docs/architecture/insurance-billing-system.md
T
hamedandClaude Opus 4.8 89191eee57 feat: insurance & medical billing system (6 phases)
Multi-tenant insurance contracts, service coverage, versioned tariffs,
invoice calculation, and insurance claims with debt reporting.

- TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling,
  versioning, soft-deactivate) + active guard
- ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides
- Tariff: versioned yearly tariffs with fallback to ServiceItem price
- Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested),
  Invoice/InvoiceItem aggregate, InvoiceService.createFromSession
- Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid),
  ClaimService, insurance-debt report
- ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready)
- Admin UI: insurance-pricing page, claims page, service tariff modal,
  service insurance toggle; routes + sidebar entries
- Architecture doc + billing/insurance/clinic-services API docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:05:24 +03:30

400 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سیستم صورتحساب و بیمه (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` تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.