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,28 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\ClaimItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClaimItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClaimItem::class);
}
public function existsForInvoiceItem(int $invoiceItemId, int $insuranceKindClaimInsuranceId): bool
{
return $this->createQueryBuilder('ci')
->select('COUNT(ci.id)')
->join('ci.claim', 'c')
->where('ci.invoiceItemId = :iid')
->andWhere('c.insuranceId = :ins')
->setParameter('iid', $invoiceItemId)
->setParameter('ins', $insuranceKindClaimInsuranceId)
->getQuery()
->getSingleScalarResult() > 0;
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\Claim;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClaimRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Claim::class);
}
public function findByUuid(string $uuid): ?Claim
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return Claim[] */
public function findByTenant(string $entityType, int $entityId, ?string $status = null): array
{
$qb = $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('c.id', 'DESC');
if ($status !== null) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
/**
* گزارش بدهی بیمه: جمع claimed/approved/paid بر اساس بیمه برای tenant.
* @return array<int, array{insurance_id:int, claimed:int, approved:int, paid:int, debt:int}>
*/
public function debtReport(string $entityType, int $entityId): array
{
$rows = $this->createQueryBuilder('c')
->select('c.insuranceId AS insurance_id')
->addSelect('SUM(c.totalClaimedRials) AS claimed')
->addSelect('SUM(COALESCE(c.totalApprovedRials, 0)) AS approved')
->addSelect('SUM(COALESCE(c.totalPaidRials, 0)) AS paid')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->groupBy('c.insuranceId')
->getQuery()
->getArrayResult();
return array_map(function (array $r) {
$claimed = (int) $r['claimed'];
$paid = (int) $r['paid'];
return [
'insurance_id' => (int) $r['insurance_id'],
'claimed' => $claimed,
'approved' => (int) $r['approved'],
'paid' => $paid,
'debt' => max(0, $claimed - $paid),
];
}, $rows);
}
public function save(Claim $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\InvoiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InvoiceItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, InvoiceItem::class);
}
public function save(InvoiceItem $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\Invoice;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InvoiceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Invoice::class);
}
public function findByUuid(string $uuid): ?Invoice
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findBySession(int $patientSessionId): ?Invoice
{
return $this->findOneBy(['patientSessionId' => $patientSessionId]);
}
public function save(Invoice $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}