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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user