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
+109
View File
@@ -10,12 +10,18 @@ use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionPayment;
use App\Patient\Entity\SessionService;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Repository\SessionPaymentRepository;
use App\Patient\Repository\SessionServiceRepository;
use App\Settlement\Service\WalletService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Staff\Repository\ClinicStaffRepository;
use App\Subscription\Service\SubscriptionService;
@@ -25,6 +31,7 @@ class PatientService
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly SessionServiceRepository $sessionServiceRepo,
private readonly SessionPaymentRepository $sessionPaymentRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserRepository $userRepo,
@@ -33,6 +40,7 @@ class PatientService
private readonly ClinicRepository $clinicRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly BillingCalculator $billingCalculator,
private readonly WalletService $walletService,
) {}
/**
@@ -178,4 +186,105 @@ class PatientService
return $session;
}
/**
* اعمال/حذف تخفیف تسویه روی مراجعه.
* type=null → حذف تخفیف. percent باید 0..100 و fixed حداکثر برابر مبلغ نهایی باشد.
* تخفیف نمی‌تواند از مانده‌ی قابل‌تخفیف (مبلغ نهایی منهای پرداخت‌های ثبت‌شده) بیشتر شود.
*/
public function applyDiscount(PatientSession $session, ?string $type, int $value): PatientSession
{
if ($type === null) {
$session->setDiscount(null, 0, 0);
$this->sessionRepo->save($session);
return $session;
}
if (!in_array($type, ['percent', 'fixed'], true) || $value < 0) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_type');
}
$final = $session->getFinalPriceRials();
if ($type === 'percent') {
if ($value > 100) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$rials = (int) round($final * $value / 100);
} else {
if ($value > $final) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$rials = $value;
}
// تخفیف نباید از آنچه هنوز پرداخت نشده بیشتر باشد (پرداخت‌ها برگشت‌ناپذیرند)
if ($rials > $final - $session->getPaidTotalRials()) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$session->setDiscount($type, $value, $rials);
if ($session->getRemainingRials() === 0 && $session->getPaymentMethod() === 'pending') {
// تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویه‌شده تلقی می‌شود
$session->setPaymentMethod('cash');
$session->setPaidAt(time());
}
$this->sessionRepo->save($session);
return $session;
}
/**
* ثبت یک پرداخت جزئی روی مراجعه. روش wallet همان مبلغ را از کیف پول بیمار
* کسر می‌کند (موجودی ناکافی → ۴۲۲). وقتی مانده صفر شود، payment_method و
* paid_at مراجعه ست می‌شوند تا is_paid برای مصرف‌کننده‌های فعلی درست بماند.
*/
public function addSessionPayment(
PatientSession $session,
string $method,
int $amountRials,
?int $paidAt = null,
?User $actor = null,
): SessionPayment {
if (!in_array($method, SessionPayment::METHODS, true)) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'method');
}
if ($amountRials <= 0) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'amount_rials');
}
$remaining = $session->getRemainingRials();
if ($amountRials > $remaining) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials');
}
if ($method === 'wallet') {
$names = array_values(array_filter(array_map(
fn(SessionService $s) => $s->toArray()['service_name'] ?? null,
$session->getServices()->toArray(),
)));
$label = $names !== [] ? implode('، ', $names) : 'ویزیت';
$this->walletService->withdraw(
$session->getRecord()->getUser(),
$amountRials,
$actor,
'پرداخت سرویس: ' . $label,
'wallet',
'session:' . $session->getUuid(),
);
}
$payment = new SessionPayment($session, $method, $amountRials, $paidAt);
$payment->setCreatedBy($actor)
->setCreatedByName($this->walletService->resolveActorName($actor));
$this->sessionPaymentRepo->save($payment);
$session->addPayment($payment);
if ($session->getRemainingRials() === 0) {
$session->setPaymentMethod($method);
$session->setPaidAt($paidAt ?? time());
}
$this->sessionRepo->save($session);
return $payment;
}
}