diff --git a/src/Billing/Service/SessionBillingService.php b/src/Billing/Service/SessionBillingService.php index 05b9ad76..26de0735 100644 --- a/src/Billing/Service/SessionBillingService.php +++ b/src/Billing/Service/SessionBillingService.php @@ -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; } diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index c3f2f56a..56efc45c 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -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; } } diff --git a/tests/Billing/ConfirmedAppointmentAppearsInPaymentsTest.php b/tests/Billing/ConfirmedAppointmentAppearsInPaymentsTest.php new file mode 100644 index 00000000..95d587a5 --- /dev/null +++ b/tests/Billing/ConfirmedAppointmentAppearsInPaymentsTest.php @@ -0,0 +1,147 @@ +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> ردیف‌های فهرست پرداخت‌های همان پزشک */ + 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)); + } +} diff --git a/tests/Shared/ApiLeastPrivilegeTest.php b/tests/Shared/ApiLeastPrivilegeTest.php index 0e8ce826..5ff4d9ba 100644 --- a/tests/Shared/ApiLeastPrivilegeTest.php +++ b/tests/Shared/ApiLeastPrivilegeTest.php @@ -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' => 'شبای خودِ منشی',