Add refund action to booking list/detail with full-refund toggle

- New RefundBookingTableAction on the booking list row and detail page,
  refunding a Confirmed booking directly via RefundBookingAction — no need
  to hunt up its Payment on the Refunds resource first.
- Payment::refundableBalance() extracted from RefundBookingAction's private
  balance check so both refund forms can display and cap against it.
- RefundBookingAction::resolveRefundablePayment() made public for the same
  reason (round-trip leg resolution reused by the UI).
- Both refund forms (ProcessRefundAction and the new booking action) gain a
  "Full refund" toggle, on by default, which refunds the payment's whole
  remaining balance without requiring a manually typed amount. Turning it
  off reveals an amount field capped at the refundable balance.
This commit is contained in:
Nyan Lin Paing
2026-08-30 14:46:26 +07:00
parent a905320d50
commit 231f5679ef
9 changed files with 368 additions and 15 deletions
@@ -35,14 +35,7 @@ class RefundBookingAction
throw RefundNotAllowedException::notConfirmed($booking);
}
// Round trip: payment is combined on the outbound leg, so a return
// leg has no Payment of its own — refund against its linked leg's
// Payment instead (domain.md §2b). The Confirmed check above still
// applies to $booking itself, not the payment holder, so each leg
// remains independently cancellable/refundable.
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
$payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
$payment = $this->resolveRefundablePayment($booking);
if ($payment === null) {
throw RefundNotAllowedException::noCompletedPayment($booking);
@@ -80,10 +73,27 @@ class RefundBookingAction
return $refund;
}
/**
* The Completed Payment a refund against $booking would apply to.
* Public so the Filament refund forms can look up the same Payment to
* surface its refundable balance before staff submit an amount.
*
* Round trip: payment is combined on the outbound leg, so a return leg
* has no Payment of its own resolve against its linked leg's Payment
* instead (domain.md §2b). The Confirmed check in handle() still applies
* to $booking itself, not the payment holder, so each leg remains
* independently cancellable/refundable.
*/
public function resolveRefundablePayment(Booking $booking): ?Payment
{
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
return $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
}
private function assertWithinRefundableBalance(Payment $payment, string $amount): void
{
$alreadyRefunded = (string) $payment->refunds()->where('status', RefundStatus::Completed->value)->sum('amount');
$remaining = bcsub((string) $payment->amount, $alreadyRefunded, 2);
$remaining = $payment->refundableBalance();
if (bccomp($amount, $remaining, 2) === 1) {
throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining);