fix(billing): bill the insurance share on clinic appointments

The invoice and the encounter belong to the clinic, but the contract sits on
the doctor, so every coverage lookup asked the clinic, got nothing, and
resolved to zero percent. Three failures followed from that one wrong tenant:
the encounter charged the patient the full amount, the payment page showed a
balance the insurer owed, and — because no line carried an insurance share —
no claim was ever built, leaving the claims page empty.

Coverage now resolves through the same doctor-first-then-clinic rule the rest
of the insurance settings use, both when the encounter is created and when its
invoice lines are built.

Changing the insurance on an appointment also re-runs the encounter's shares.
Reception routinely confirms first and corrects the insurance afterwards, and
until now those shares stayed frozen at whatever the first calculation said.
Recorded payments are untouched; only the payable amount moves, so an
overpayment simply clamps the remainder at zero. The PATCH response also
returns the appointment's real venue now, matching GET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-18 17:37:44 +03:30
co-authored by Claude Opus 5
parent b24f45cc83
commit 4f76cff503
5 changed files with 254 additions and 9 deletions
+2
View File
@@ -460,6 +460,8 @@ Get appointment detail.
}
```
> ℹ️ تغییر بیمهٔ نوبت با `PATCH` سهم‌های مراجعهٔ همان نوبت را هم دوباره حساب می‌کند: پذیرش گاهی اول نوبت را قطعی می‌کند و بعد بیمه را اصلاح می‌کند، و بدون این، مراجعه روی محاسبهٔ اول می‌ماند و صفحهٔ پرداخت سهمِ بیمه را از بیمار می‌خواهد. پرداخت‌های ثبت‌شده دست نمی‌خورند؛ فقط مبلغ قابل‌پرداخت اصلاح می‌شود. پاسخ `PATCH` هم مثل `GET` نشانیِ واقعیِ نوبت را برمی‌گرداند.
> ️ `address` is the venue recorded on this appointment (`address_id`) — the doctor's own
> office or the clinic branch, whichever the booking was made at — and it is the only place
> `telephone` is returned. When that record carries no number, the clinic's own number takes
@@ -56,6 +56,7 @@ class AppointmentController extends BaseController
private readonly \App\Resource\Service\PublicResourceBookingService $publicResources,
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope,
private readonly \App\Patient\Service\PatientService $patients,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -1482,7 +1483,12 @@ class AppointmentController extends BaseController
if ($completed) {
}
return $this->success(['data' => $appointment->toArray()]);
// انتخاب بیمه می‌تواند بعد از ساخته‌شدنِ مراجعه عوض شود — پذیرش اول نوبت را
// قطعی می‌کند و بعد بیمه را اصلاح می‌کند. بدون این، سهم‌های مراجعه روی همان
// محاسبهٔ اول می‌مانند و صفحهٔ پرداخت سهمِ بیمه را از بیمار می‌خواهد.
$this->patients->resyncInsuranceShares($appointment);
return $this->success(['data' => $this->appointmentWithVenue($appointment)]);
}
/**
+32 -4
View File
@@ -25,6 +25,7 @@ class InvoiceService
private readonly InsuranceRepository $insuranceRepo,
private readonly EventDispatcherInterface $events,
private readonly ActorIdentityResolver $actorIdentity,
private readonly \App\Insurance\Service\InsuranceScopeResolver $insuranceScope,
) {}
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
@@ -107,10 +108,16 @@ class InvoiceService
$visitCategory = $session->getInsuranceServiceCategory() ?? ServiceCategory::Outpatient;
$invoice->setServiceCategory($session->getInsuranceServiceCategory());
// قرارداد بیمه جای دیگری غیر از محیطِ صورتحساب نشسته است: صورتحسابِ نوبتِ
// کلینیک به کلینیک تعلق دارد، ولی قرارداد معمولاً روی خودِ پزشک ثبت شده. با
// محیط صورتحساب، هر سهم بیمه صفر درمی‌آمد — بیمار کل مبلغ را بدهکار می‌شد و
// مطالبه‌ای هم برای بیمه ساخته نمی‌شد. {@see InsuranceScopeResolver}
[$coverageType, $coverageId] = $this->coverageScope($session, $entityType, $entityId);
$visitPrice = $session->getVisitPriceRials();
if ($visitPrice > 0) {
$baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId, $visitCategory);
$suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId, $visitCategory);
$baseRule = $this->tenantInsuranceService->coverageRule($coverageType, $coverageId, $baseId, $visitCategory);
$suppRule = $this->tenantInsuranceService->coverageRule($coverageType, $coverageId, $suppId, $visitCategory);
$breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null));
}
@@ -122,8 +129,8 @@ class InvoiceService
$unitPrice = $item->getPriceRials();
$total = new Money($unitPrice * $qty);
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseId, $item->getId());
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppId, $item->getId());
$baseRule = $this->tenantInsuranceService->coverageRuleForService($coverageType, $coverageId, $baseId, $item->getId());
$suppRule = $this->tenantInsuranceService->coverageRuleForService($coverageType, $coverageId, $suppId, $item->getId());
$breakdown = $this->calculator->calculateItem($total, $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, $qty, $breakdown, $item->getId()));
@@ -132,6 +139,27 @@ class InvoiceService
$invoice->recalculateTotals();
}
/**
* محیطی که قرارداد بیمهٔ این مراجعه در آن ثبت شده — اول پزشکِ نوبت، بعد کلینیکش.
*
* مراجعهٔ دستی نوبتی ندارد، پس پزشکی هم برای پرسیدن نیست و همان محیط صورتحساب
* می‌ماند؛ آنجا قرارداد در همان محیط ثبت می‌شود.
*
* @return array{0: string, 1: int}
*/
private function coverageScope(PatientSession $session, string $entityType, int $entityId): array
{
$appointment = $session->getAppointment();
if ($appointment === null) {
return [$entityType, $entityId];
}
return $this->insuranceScope->forContracts(
(int) $appointment->getDoctor()->getId(),
$appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null,
);
}
/** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */
public function finalize(Invoice $invoice): void
{
+72 -4
View File
@@ -165,6 +165,67 @@ class PatientService
->coveragePercent;
}
/**
* سهم‌های مراجعهٔ یک نوبت را دوباره حساب می‌کند — برای وقتی بیمهٔ نوبت *بعد از*
* ساخته‌شدنِ مراجعه عوض می‌شود.
*
* بدون این، سهم‌ها همان لحظهٔ ساخت منجمد می‌شدند: نوبتی که بعداً بیمه گرفت،
* مراجعه‌اش همچنان صددرصد به گردن بیمار بود و صفحهٔ پرداخت مانده‌ای نشان می‌داد
* که بیمه باید می‌پرداخت.
*
* پرداخت‌های ثبت‌شده دست‌نخورده می‌مانند؛ فقط مبلغِ قابل‌پرداخت اصلاح می‌شود، پس
* مانده خودبه‌خود درست می‌شود و اضافه‌پرداخت در صفر کلمپ می‌شود.
*/
public function resyncInsuranceShares(Appointment $appointment): ?PatientSession
{
[$entityType, $entityId] = $this->appointmentInsurance->tenantOf($appointment);
$session = $this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId);
if ($session === null) {
return null;
}
[$coverageType, $coverageId] = $this->appointmentInsurance->contractScopeOf($appointment);
$category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId());
$session->setBaseInsuranceDiscountPercent(
$this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceBaseId(), $category)
);
$session->setSupplementaryDiscountPercent(
$this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceSupplementaryId(), $category)
);
$shares = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
array_map(
static fn (SessionService $line): array => [
'item_id' => $line->getServiceItem()->getId(),
'price_rials' => $line->getLineTotalRials(),
],
$session->getServices()->toArray(),
),
$coverageType,
$coverageId,
$appointment->getInsuranceBaseId(),
$appointment->getInsuranceSupplementaryId(),
$category,
);
$session->applyShares(
$shares['gross_total_rials'],
$shares['base_insurance_rials'],
$shares['supplementary_insurance_rials'],
$shares['patient_share_rials'],
);
$this->sessionRepo->save($session);
return $session;
}
/**
* پرونده و مراجعهٔ خودکار برای یک نوبت قطعی‌شده.
*
@@ -246,15 +307,22 @@ class PatientService
// بیمهٔ انتخاب‌شده روی نوبت مبنای محاسبه است؛ نوبتِ بدون بیمه مثل قبل کاملاً
// سهم بیمار می‌ماند (coverageRule برای insuranceId=null، notCovered می‌دهد).
//
// درصد پوشش از همان محیطی خوانده می‌شود که خودِ قرارداد آنجاست — اول پزشک،
// بعد کلینیک. با محیطِ پرونده (کلینیک) قراردادِ ثبت‌شده روی پزشک دیده نمی‌شد و
// نتیجه‌اش صفر درصد پوشش بود: کل مبلغ به گردن بیمار می‌افتاد در حالی که
// نوبت بیمه داشت. {@see InsuranceScopeResolver}
[$coverageType, $coverageId] = $this->appointmentInsurance->contractScopeOf($appointment);
$category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId());
$session->setBaseInsuranceDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category)
$this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceBaseId(), $category)
);
$session->setSupplementaryDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceSupplementaryId(), $category)
$this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceSupplementaryId(), $category)
);
$shares = $this->calculateFinalPrice(
@@ -263,8 +331,8 @@ class PatientService
fn(SessionService $line) => ['item_id' => $line->getServiceItem()->getId(), 'price_rials' => $line->getLineTotalRials()],
$lines,
),
$entityType,
$entityId,
$coverageType,
$coverageId,
$appointment->getInsuranceBaseId(),
$appointment->getInsuranceSupplementaryId(),
$category,
@@ -0,0 +1,141 @@
<?php
namespace App\Tests\Billing;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Patient\Service\PatientService;
use App\Tests\ApiTestCase;
/**
* نوبتِ کلینیک، قراردادِ پزشک: سهم بیمه باید واقعاً کسر شود.
*
* صورتحساب و مراجعه به محیط کلینیک تعلق دارند، ولی قرارداد بیمه روی خودِ پزشک ثبت
* شده است. تا پیش از این، درصد پوشش از محیط کلینیک پرسیده می‌شد، صفر برمی‌گشت، و
* سه چیز پشت سر هم خراب می‌شد: مراجعه کل مبلغ را از بیمار می‌خواست، صفحهٔ پرداخت
* ماندهٔ نادرست نشان می‌داد، و چون سهم بیمه صفر بود هیچ مطالبه‌ای هم ساخته نمی‌شد.
*/
class ClinicAppointmentUsesDoctorContractTest extends ApiTestCase
{
private const VISIT_PRICE = 3_300_000;
/** @return array{0: User, 1: Doctor, 2: Clinic, 3: Insurance} */
private function scenario(int $coveragePercent): array
{
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر قرارداد شخصی');
$doctor->setMobileNumber($doctorUser->getMobileNumber());
$this->em->persist($doctor);
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک مطالبه');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$insurance = new Insurance('بیمه مطالبه ' . random_int(1000, 9999), InsuranceType::Basic);
$this->em->persist($insurance);
$this->em->flush();
// قرارداد روی خودِ پزشک — همان کاری که پنل کلینیک می‌کند.
$this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'insurance_id' => $insurance->getId(),
'coverage_percent' => $coveragePercent,
]);
self::assertSame(201, $this->responseCode());
return [$owner, $doctor, $clinic, $insurance];
}
private function appointment(Doctor $doctor, Clinic $clinic, Insurance $insurance): Appointment
{
$appointment = $this->newAppointment(
$doctor,
$this->createUser(['ROLE_USER']),
time() + 3600,
time() + 5400,
$clinic,
);
$appointment->setVisitPriceRials(self::VISIT_PRICE);
$appointment->setInsuranceBaseId($insurance->getId());
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
public function testTheInsuranceShareIsDeductedFromWhatThePatientOwes(): void
{
[, $doctor, $clinic, $insurance] = $this->scenario(30);
$appointment = $this->appointment($doctor, $clinic, $insurance);
$session = static::getContainer()->get(PatientService::class)
->autoCreateOnAppointmentConfirm($appointment);
self::assertNotNull($session);
self::assertSame(990_000, $session->getBaseInsuranceRials(), 'سهم بیمه ۳۰٪ ویزیت است');
self::assertSame(2_310_000, $session->getPatientShareRials());
self::assertSame(2_310_000, $session->getRemainingRials(), 'مانده نباید شامل سهم بیمه باشد');
}
public function testTheInvoiceCarriesTheInsuranceShareSoAClaimCanBeBuilt(): void
{
[, $doctor, $clinic, $insurance] = $this->scenario(30);
$appointment = $this->appointment($doctor, $clinic, $insurance);
$session = static::getContainer()->get(PatientService::class)
->autoCreateOnAppointmentConfirm($appointment);
$invoice = static::getContainer()->get(\App\Billing\Service\InvoiceService::class)
->createFromSession($session, 'clinic', (int) $clinic->getId());
$shares = array_map(
static fn ($item): int => $item->getBaseInsuranceRials(),
$invoice->getItems()->toArray(),
);
self::assertContains(990_000, $shares, 'خط ویزیت باید سهم بیمه داشته باشد');
// صورتحساب همان‌جا نهایی می‌شود و مطالبهٔ بیمه از روی همین سهم ساخته می‌شود؛
// با سهمِ صفر هیچ مطالبه‌ای ساخته نمی‌شد و صفحهٔ مطالبات خالی می‌ماند.
$claims = $this->em->getRepository(\App\Billing\Entity\Claim::class)
->findBy(['entityType' => 'clinic', 'entityId' => (int) $clinic->getId()]);
self::assertNotSame([], $claims, 'باید برای سهم بیمه مطالبه ساخته شود');
self::assertSame(990_000, $claims[0]->getTotalClaimedRials());
}
/** نوبتِ بدون قرارداد، مثل قبل کاملاً سهم بیمار می‌ماند. */
public function testAnAppointmentWithoutAnyContractStillBillsThePatientInFull(): void
{
[, $doctor, $clinic, ] = $this->scenario(30);
$other = new Insurance('بیمه بی‌قرارداد ' . random_int(1000, 9999), InsuranceType::Basic);
$this->em->persist($other);
$this->em->flush();
$appointment = $this->newAppointment(
$doctor,
$this->createUser(['ROLE_USER']),
time() + 7200,
time() + 9000,
$clinic,
);
$appointment->setVisitPriceRials(self::VISIT_PRICE);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
$session = static::getContainer()->get(PatientService::class)
->autoCreateOnAppointmentConfirm($appointment);
self::assertSame(0, $session->getBaseInsuranceRials());
self::assertSame(self::VISIT_PRICE, $session->getPatientShareRials());
}
}