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:
hamed
2026-07-16 13:32:06 +03:30
co-authored by Claude Opus 4.8
parent 73ae4baa66
commit 27d088c6dd
18 changed files with 1425 additions and 55 deletions
+270
View File
@@ -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']);
}
}