feat: port tauri create-service payment flow to admin session settlement
Port the tauri /files/create-service page (payment mode) to the admin SPA
and back it with real multi-part session settlement:
Backend:
- New SessionPayment entity (session_payments table): partial payments
per session with method (wallet/pos/cash/card), amount, paid_at, actor
- PatientSession: settlement discount (percent/fixed), discount_rials,
paid_at, payments relation; remaining debt derived from
final - discount - paid total
- POST /api/v1/session/{uuid}/payments: register a partial payment;
wallet method debits the patient wallet; zero remaining marks paid
- PATCH /api/v1/session/{uuid}: accepts discount_type/discount_value
(null removes) and paid_at, backward compatible
- New error codes: ERR_SESSION_PAYMENT_INVALID/_EXCEEDS,
ERR_SESSION_DISCOUNT_INVALID
- Migration + 14 functional tests (partial/full/wallet/exceed/discount)
Frontend (admin):
- SessionPaymentPage: two-step stepper (پرداخت ← جزییات) ported from
tauri AddService payment mode — service cost, settlement discount
input, Jalali payment date, wallet balance, 4-method payment accordion,
paid-list box, details summary
- SessionStepper + stepper/payment icons ported verbatim from tauri SVGs
- «تکمیل پرداخت» on SessionServiceCard now navigates to the payment page
(replaces the small settle modal on PatientDetailPage)
- Routes for patients/ and my-patients/ variants; vitest coverage
- docs/api/patient.md updated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تسویهی چندتکهی مراجعه: POST /session/{uuid}/payments پرداخت جزئی ثبت میکند
|
||||
* (روش wallet از کیف پول کسر میشود) و PATCH /session/{uuid} تخفیف تسویه
|
||||
* (percent/fixed/حذف) را اعمال میکند. صفر شدن مانده → is_paid و paid_at.
|
||||
*/
|
||||
class SessionPaymentTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record, $patient];
|
||||
}
|
||||
|
||||
private function sessionFor(PatientRecord $record, int $finalPriceRials): PatientSession
|
||||
{
|
||||
$session = new PatientSession($record);
|
||||
$session->setFinalPriceRials($finalPriceRials);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
// ── پرداخت جزئی ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testPartialPaymentReducesDebtButNotPaid(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 500_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 200_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertFalse($res['data']['is_paid']);
|
||||
self::assertSame(200_000, $res['data']['paid_total_rials']);
|
||||
self::assertSame(300_000, $res['data']['patient_debt_rials']);
|
||||
self::assertCount(1, $res['data']['payments']);
|
||||
self::assertSame('cash', $res['data']['payments'][0]['method']);
|
||||
self::assertSame($owner->getMobileNumber(), $res['data']['payments'][0]['created_by_name']);
|
||||
}
|
||||
|
||||
public function testFullSettlementViaMultiplePaymentsMarksPaid(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 500_000);
|
||||
|
||||
$this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 200_000,
|
||||
]);
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'card', 'amount_rials' => 300_000, 'paid_at' => 1_800_000_000,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
self::assertSame('card', $res['data']['payment_method']);
|
||||
self::assertSame(1_800_000_000, $res['data']['paid_at']);
|
||||
self::assertSame(0, $res['data']['patient_debt_rials']);
|
||||
self::assertCount(2, $res['data']['payments']);
|
||||
}
|
||||
|
||||
public function testWalletPartialPaymentDebitsWallet(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
|
||||
$this->em->flush();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'wallet', 'amount_rials' => 150_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(250_000, $res['data']['patient_debt_rials']);
|
||||
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(850_000, $wallet['data']['balance_rials']);
|
||||
$debit = $wallet['data']['recent_transactions'][0];
|
||||
self::assertSame('debit', $debit['type']);
|
||||
self::assertSame('session:' . $session->getUuid(), $debit['reference']);
|
||||
}
|
||||
|
||||
public function testWalletPaymentRejectedWhenInsufficient(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 100_000, 'credit', 100_000));
|
||||
$this->em->flush();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'wallet', 'amount_rials' => 200_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
|
||||
|
||||
// هیچ پرداختی ثبت نشد
|
||||
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
|
||||
self::assertSame(400_000, $sessions['data'][0]['patient_debt_rials']);
|
||||
self::assertCount(0, $sessions['data'][0]['payments']);
|
||||
}
|
||||
|
||||
public function testPaymentExceedingDebtRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 400_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_EXCEEDS', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testInvalidMethodRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'bitcoin', 'amount_rials' => 100_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_INVALID', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testZeroAmountRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 0,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_INVALID', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testSessionNotFoundReturns404(): void
|
||||
{
|
||||
[$owner] = $this->recordFor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/session/00000000-0000-0000-0000-000000000000/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 100_000,
|
||||
]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── تخفیف تسویه ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testPercentDiscountReducesDebt(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'percent', 'discount_value' => 25,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('percent', $res['data']['discount_type']);
|
||||
self::assertSame(100_000, $res['data']['discount_rials']);
|
||||
|
||||
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
|
||||
self::assertSame(300_000, $sessions['data'][0]['patient_debt_rials']);
|
||||
}
|
||||
|
||||
public function testFixedDiscountThenRemove(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'fixed', 'discount_value' => 150_000,
|
||||
]);
|
||||
self::assertSame(150_000, $res['data']['discount_rials']);
|
||||
|
||||
// حذف تخفیف
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => null,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNull($res['data']['discount_type']);
|
||||
self::assertSame(0, $res['data']['discount_rials']);
|
||||
}
|
||||
|
||||
public function testDiscountOverLimitsRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'percent', 'discount_value' => 150,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_DISCOUNT_INVALID', $res['errors'][0]['code']);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'fixed', 'discount_value' => 500_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_DISCOUNT_INVALID', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testFullPercentDiscountMarksPaid(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'percent', 'discount_value' => 100,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
self::assertNotNull($res['data']['paid_at']);
|
||||
}
|
||||
|
||||
public function testDiscountPlusPaymentSettles(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'fixed', 'discount_value' => 100_000,
|
||||
]);
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'pos', 'amount_rials' => 300_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
self::assertSame('pos', $res['data']['payment_method']);
|
||||
self::assertSame(0, $res['data']['patient_debt_rials']);
|
||||
}
|
||||
|
||||
public function testPaymentOnSettledSessionRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 200_000);
|
||||
|
||||
$this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 200_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// مراجعه تسویه شده — هر پرداخت بعدی از مانده (صفر) بیشتر است
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 1,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_EXCEEDS', $res['errors'][0]['code']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user