feat(billing): ensure finalized invoice for confirmed appointments with payments

This commit is contained in:
hamed
2026-08-10 11:10:37 +03:30
parent 505ab412a3
commit 8a18457751
4 changed files with 170 additions and 1 deletions
+11 -1
View File
@@ -43,10 +43,20 @@ class SessionBillingService
}
if ($synced !== null) {
// صورتحسابِ پیش‌نویسِ مراجعه‌ای که پول گرفته، در فهرست پرداخت‌ها دیده نمی‌شود؛
// آن فهرست فقط finalized/paid را می‌آورد.
if ($synced->getStatus() === Invoice::STATUS_DRAFT && $session->getPaidTotalRials() > 0) {
$this->invoiceService->finalize($synced);
}
return $synced;
}
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
// مراجعهٔ بدون بیمه و بدون پرداخت سندی لازم ندارد. ولی به‌محض ثبت پول، باید
// صورتحساب داشته باشد: فهرست «پرداخت‌ها» صورتحساب‌محور است و بدون آن، پولِ
// ثبت‌شده هیچ‌جا دیده نمی‌شد.
$uninsured = $session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null;
if ($uninsured && $session->getPaidTotalRials() <= 0) {
return null;
}
+10
View File
@@ -715,6 +715,16 @@ class PatientService
$this->logSessionChange($session, 'payment', \App\Patient\Entity\SessionAuditLog::OP_CREATE, null, (string) $amountRials, $actor, 'ثبت پرداخت');
// پولی که ثبت شد باید در فهرست پرداخت‌ها دیده شود، و آن فهرست صورتحساب‌محور
// است. تنها جای این تضمین همین‌جاست تا هر سه مسیرِ ثبت پرداخت — قطعی‌کردن
// نوبت، صفحهٔ پرداخت مراجعه، و ثبت دستی — یک رفتار داشته باشند.
$recordOwner = $session->getRecord();
$this->sessionBilling->ensureFinalizedInvoice(
$session,
$recordOwner->getEntityType(),
$recordOwner->getEntityId(),
);
return $payment;
}
}
@@ -0,0 +1,147 @@
<?php
namespace App\Tests\Billing;
use App\Appointment\Entity\Appointment;
use App\Billing\Entity\Invoice;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* نوبتی که از پنل قطعی و پولش ثبت می‌شود باید در «پرداخت‌ها» دیده شود.
*
* فهرست پرداخت‌ها صورتحساب‌محور است و صورتحساب فقط برای مراجعهٔ بیمه‌دار ساخته
* می‌شد؛ نتیجه‌اش این بود که پرداختِ نوبتِ بدون بیمه هیچ‌جا دیده نمی‌شد.
*/
class ConfirmedAppointmentAppearsInPaymentsTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, 'دکتر تست');
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeAppointment(Doctor $doctor, int $visitPriceRials = 5_000_000): Appointment
{
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$appointment->setVisitPriceRials($visitPriceRials);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
/** @return array<int, array<string, mixed>> ردیف‌های فهرست پرداخت‌های همان پزشک */
private function payments(Doctor $doctor): array
{
$body = $this->authJson('GET', '/api/v1/my/billing/payments?limit=100', $doctor->getUser());
self::assertSame(200, $this->responseCode());
return $body['data'] ?? [];
}
public function testPartialPaymentOnConfirmShowsUpInThePaymentsList(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
self::assertSame([], $this->payments($doctor));
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'cash', 'amount_rials' => 1_000_000]],
]);
self::assertSame(200, $this->responseCode());
$rows = $this->payments($doctor);
self::assertCount(1, $rows);
self::assertSame(5_000_000, $rows[0]['amount_rials']);
self::assertSame(1_000_000, $rows[0]['paid_rials']);
self::assertSame('partial', $rows[0]['status']);
}
public function testFullPaymentOnConfirmIsListedAsPaid(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'pos', 'amount_rials' => 5_000_000]],
]);
$rows = $this->payments($doctor);
self::assertCount(1, $rows);
self::assertSame('paid', $rows[0]['status']);
}
/** صورتحساب باید نهایی باشد، وگرنه فهرست پرداخت‌ها آن را فیلتر می‌کند. */
public function testTheGeneratedInvoiceIsFinalized(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'cash', 'amount_rials' => 2_000_000]],
]);
$this->em->clear();
$invoices = $this->em->getRepository(Invoice::class)->findBy(['entityId' => $doctor->getId(), 'entityType' => 'doctor']);
self::assertCount(1, $invoices);
self::assertSame(Invoice::STATUS_FINALIZED, $invoices[0]->getStatus());
self::assertSame($res['data']['session']['uuid'] !== null, true);
}
/** مرزی: قطعی‌کردن بدون پرداخت سندی نمی‌سازد — همان رفتار قبلی. */
public function testConfirmWithoutPaymentCreatesNoInvoice(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
self::assertSame([], $this->payments($doctor));
}
/** خطا: پرداخت بیشتر از مبلغ نوبت رد می‌شود و صورتحسابی هم نمی‌ماند. */
public function testOverpaymentIsRejectedAndLeavesNoInvoice(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'cash', 'amount_rials' => 9_000_000]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame([], $this->payments($doctor));
}
/** پرداخت‌های یک پزشک نباید در فهرست پزشک دیگر بیاید. */
public function testPaymentsStayInsideTheirTenant(): void
{
$doctor = $this->makeDoctor();
$other = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'cash', 'amount_rials' => 1_000_000]],
]);
self::assertCount(1, $this->payments($doctor));
self::assertSame([], $this->payments($other));
}
}
+2
View File
@@ -67,6 +67,7 @@ class ApiLeastPrivilegeTest extends ApiTestCase
'app_settlement_settlement_balance' => 'کیف پول خودِ کاربر',
'app_settlement_settlement_transactions' => 'تراکنش‌های کیف پول خودِ کاربر',
'app_settlement_settlement_listmine' => 'تسویه‌های خودِ کاربر',
'app_userprofile_tourprogress_seen' => 'راهنماهای دیده‌شدهٔ خودِ کاربر',
'app_dashboard_dashboard_secretary' => 'محیط و مجوزهای خودِ منشی — ورودی رندر پنل',
// رفتار مستند: بدون مجوز فقط قابلیت‌های پلن می‌آید، نه وضعیت/تاریخ اشتراک.
// تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly
@@ -127,6 +128,7 @@ class ApiLeastPrivilegeTest extends ApiTestCase
'app_userprofile_userprofile_create' => 'پروفایل خودِ کاربر',
'app_userprofile_userprofile_update' => 'پروفایل خودِ کاربر',
'app_userprofile_userprofile_uploadavatar' => 'آواتار خودِ کاربر',
'app_userprofile_tourprogress_markseen' => 'ثبت دیده‌شدن راهنما برای خودِ کاربر',
'app_settlement_settlement_request' => 'تسویهٔ کیف‌پول خودِ کاربر',
'app_secretary_secretary_addiban' => 'شبای خودِ منشی',
'app_secretary_secretary_removeiban' => 'شبای خودِ منشی',