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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user