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
@@ -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());
}
}