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,147 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\ValueObject\CoverageRule;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
class TenantInsuranceService
{
public function __construct(
private readonly TenantInsuranceRepository $repo,
private readonly InsuranceRepository $insuranceRepo,
private readonly TenantServiceCoverageRepository $coverageRepo,
) {}
/**
* فعال‌سازی یا به‌روزرسانی قرارداد بیمه برای یک tenant.
* اگر قرارداد فعالی موجود باشد، همان ویرایش می‌شود؛ در غیر این صورت نسخه‌ی جدید ساخته می‌شود.
*/
public function activate(
string $entityType,
int $entityId,
int $insuranceId,
float $coveragePercent,
int $franchiseRials = 0,
?int $annualCeilingRials = null,
): TenantInsurance {
if ($this->insuranceRepo->find($insuranceId) === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمه یافت نشد', 404);
}
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
$version = $this->repo->latestVersion($entityType, $entityId, $insuranceId) + 1;
$contract = new TenantInsurance($entityType, $entityId, $insuranceId, $version);
}
$contract->setCoveragePercent($coveragePercent)
->setFranchiseRials($franchiseRials)
->setAnnualCeilingRials($annualCeilingRials)
->setActive(true);
$this->repo->save($contract);
return $contract;
}
public function deactivate(TenantInsurance $contract): void
{
$contract->setActive(false)->setEffectiveTo(time());
$this->repo->save($contract);
}
/**
* بررسی فعال‌بودن یک بیمه برای tenant. در پذیرش/صورتحساب استفاده می‌شود.
*/
public function assertActive(string $entityType, int $entityId, int $insuranceId): TenantInsurance
{
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این بیمه برای این کلینیک/پزشک فعال نیست', 422);
}
return $contract;
}
/**
* قانون پوشش یک بیمه برای tenant جاری (برای BillingCalculator).
* اگر قرارداد فعالی نباشد، notCovered برمی‌گردد.
*/
public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule
{
if ($insuranceId === null) {
return CoverageRule::notCovered();
}
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
return CoverageRule::notCovered();
}
return new CoverageRule(
coveragePercent: $contract->getCoveragePercent(),
franchiseRials: $contract->getFranchiseRials(),
ceilingRials: $contract->getAnnualCeilingRials(),
covered: true,
);
}
/**
* قانون پوشش یک خدمت خاص تحت بیمه‌ی tenant.
* اگر override خدمت موجود باشد اعمال می‌شود؛ فیلدهای null از قرارداد ارث می‌برند.
*/
public function coverageRuleForService(string $entityType, int $entityId, ?int $insuranceId, int $serviceItemId): CoverageRule
{
if ($insuranceId === null) {
return CoverageRule::notCovered();
}
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
return CoverageRule::notCovered();
}
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId);
if ($override !== null && !$override->isCovered()) {
return CoverageRule::notCovered();
}
return new CoverageRule(
coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(),
franchiseRials: $override?->getFranchiseRials() ?? $contract->getFranchiseRials(),
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
);
}
/** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */
public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array
{
$override = $this->coverageRepo->findOneFor($tenantInsuranceId, $serviceItemId);
return $override?->toArray();
}
public function setServiceCoverage(
TenantInsurance $contract,
int $serviceItemId,
bool $covered,
?float $coveragePercent,
?int $franchiseRials,
?int $ceilingRials,
): void {
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId)
?? new \App\Insurance\Entity\TenantServiceCoverage($contract->getId(), $serviceItemId);
$override->setCovered($covered)
->setCoveragePercent($coveragePercent)
->setFranchiseRials($franchiseRials)
->setCeilingRials($ceilingRials);
$this->coverageRepo->save($override);
}
}