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

22 KiB
Raw Blame History

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

// 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)

// 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 تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.