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>
This commit is contained in:
hamed
2026-06-23 15:05:24 +03:30
co-authored by Claude Opus 4.8
parent 5b1dfe9b40
commit 89191eee57
54 changed files with 4233 additions and 10 deletions
@@ -0,0 +1,66 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EntityInsurancePricingRepository::class)]
#[ORM\Table(name: 'entity_insurance_pricing')]
#[ORM\UniqueConstraint(name: 'uniq_entity_insurance', columns: ['entity_type', 'entity_id', 'insurance_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_entity_pricing_owner')]
class EntityInsurancePricing
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'insurance_id', type: 'integer', nullable: true)]
private ?int $insuranceId = null;
#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
private int $patientShareRials = 0;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, ?int $insuranceId, int $patientShareRials = 0)
{
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->insuranceId = $insuranceId;
$this->patientShareRials = $patientShareRials;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getInsuranceId(): ?int { return $this->insuranceId; }
public function getPatientShareRials(): int { return $this->patientShareRials; }
public function isFreeVisit(): bool { return $this->insuranceId === null; }
public function setPatientShareRials(int $v): self { $this->patientShareRials = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'insurance_id' => $this->insuranceId,
'patient_share_rials' => $this->patientShareRials,
];
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Repository\TenantInsuranceRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TenantInsuranceRepository::class)]
#[ORM\Table(name: 'tenant_insurances')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_insurance_version', columns: ['entity_type', 'entity_id', 'insurance_id', 'version'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'is_active'], name: 'idx_tenant_insurance_active')]
class TenantInsurance
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'insurance_id', type: 'integer')]
private int $insuranceId;
#[ORM\Column(type: 'integer')]
private int $version = 1;
#[ORM\Column(name: 'is_active', type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
private string $coveragePercent = '0.00';
#[ORM\Column(name: 'franchise_rials', type: 'integer')]
private int $franchiseRials = 0;
#[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)]
private ?int $annualCeilingRials = null;
#[ORM\Column(name: 'effective_from', type: 'integer')]
private int $effectiveFrom;
#[ORM\Column(name: 'effective_to', type: 'integer', nullable: true)]
private ?int $effectiveTo = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, int $insuranceId, int $version = 1)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->insuranceId = $insuranceId;
$this->version = $version;
$this->effectiveFrom = time();
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getInsuranceId(): int { return $this->insuranceId; }
public function getVersion(): int { return $this->version; }
public function isActive(): bool { return $this->isActive; }
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
public function getFranchiseRials(): int { return $this->franchiseRials; }
public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; }
public function getEffectiveFrom(): int { return $this->effectiveFrom; }
public function getEffectiveTo(): ?int { return $this->effectiveTo; }
public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; }
public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; }
public function setEffectiveTo(?int $v): self { $this->effectiveTo = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'insurance_id' => $this->insuranceId,
'version' => $this->version,
'is_active' => $this->isActive,
'coverage_percent' => (float) $this->coveragePercent,
'franchise_rials' => $this->franchiseRials,
'annual_ceiling_rials' => $this->annualCeilingRials,
'effective_from' => $this->effectiveFrom,
'effective_to' => $this->effectiveTo,
];
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TenantServiceCoverageRepository::class)]
#[ORM\Table(name: 'tenant_service_coverage')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_service_coverage', columns: ['tenant_insurance_id', 'service_item_id'])]
#[ORM\Index(columns: ['service_item_id'], name: 'idx_tsc_service')]
class TenantServiceCoverage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'tenant_insurance_id', type: 'integer')]
private int $tenantInsuranceId;
#[ORM\Column(name: 'service_item_id', type: 'integer')]
private int $serviceItemId;
#[ORM\Column(type: 'boolean')]
private bool $covered = true;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)]
private ?string $coveragePercent = null;
#[ORM\Column(name: 'franchise_rials', type: 'integer', nullable: true)]
private ?int $franchiseRials = null;
#[ORM\Column(name: 'ceiling_rials', type: 'integer', nullable: true)]
private ?int $ceilingRials = null;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(int $tenantInsuranceId, int $serviceItemId)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->tenantInsuranceId = $tenantInsuranceId;
$this->serviceItemId = $serviceItemId;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getTenantInsuranceId(): int { return $this->tenantInsuranceId; }
public function getServiceItemId(): int { return $this->serviceItemId; }
public function isCovered(): bool { return $this->covered; }
public function getCoveragePercent(): ?float { return $this->coveragePercent !== null ? (float) $this->coveragePercent : null; }
public function getFranchiseRials(): ?int { return $this->franchiseRials; }
public function getCeilingRials(): ?int { return $this->ceilingRials; }
public function setCovered(bool $v): self { $this->covered = $v; $this->updatedAt = time(); return $this; }
public function setCoveragePercent(?float $v): self { $this->coveragePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; }
public function setFranchiseRials(?int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
public function setCeilingRials(?int $v): self { $this->ceilingRials = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'tenant_insurance_id' => $this->tenantInsuranceId,
'service_item_id' => $this->serviceItemId,
'covered' => $this->covered,
'coverage_percent' => $this->getCoveragePercent(),
'franchise_rials' => $this->franchiseRials,
'ceiling_rials' => $this->ceilingRials,
];
}
}