Complete Payment module: initiate/webhook/confirm/refund actions, Filament resources (T5.8-T5.13)

- InitiatePaymentAction + POST /api/v1/payments/{booking}/initiate
- Generic KBZ webhook (POST /api/v1/webhooks/{method}/{encryptBookingId?}),
  signature verification per KBZ's real callback spec, PaymentGatewayInterface::handleWebhook()
- ConfirmPaymentAction: idempotent confirmation, PaymentCompleted/PaymentFailed events,
  MarkBookingPaid listener
- RefundBookingAction + POST /api/v1/bookings/{booking}/refund: partial refunds validated
  against remaining balance, RefundProcessed event, MarkBookingRefunded listener
- CancelBookingAction now refunds confirmed bookings instead of rejecting; BookingPolicy::cancel
  requires process_refunds for confirmed bookings
- PaymentPlugin + PaymentResource/RefundResource Filament admin UI (read-only payments,
  refund list + Process action)
- Booking detail page now shows related payments
- Fix CACHE_STORE mismatch (database -> redis) so tagged route caching works
- CLAUDE.md: never run migrate:fresh/migrate:refresh/db:wipe on dev without being asked
This commit is contained in:
Nyan Lin Paing
2026-08-09 16:20:21 +07:00
parent 4737838021
commit d19a14a45e
55 changed files with 2547 additions and 29 deletions
@@ -0,0 +1,26 @@
<?php
namespace Modules\Payment\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Payment\Enums\PaymentMethod;
use RuntimeException;
/**
* Thrown by a gateway's handleWebhook() when the inbound payload's signature
* doesn't check out never let an unverified webhook be treated as genuine
* (domain.md §6).
*/
class InvalidWebhookSignatureException extends RuntimeException
{
public static function forGateway(PaymentMethod $method): self
{
return new self("Webhook signature verification failed for gateway [{$method->value}].");
}
public function render(Request $request): ?JsonResponse
{
return response()->json(['message' => $this->getMessage()], 400);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Payment\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Booking\Models\Booking;
use RuntimeException;
class PaymentInitiationNotAllowedException extends RuntimeException
{
public static function notPendingPayment(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] cannot have a payment initiated because its status is [{$booking->status->value}], not pending_payment."
);
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['message' => $this->getMessage()], 422);
}
return null;
}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Payment\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Payment\Data\RefundResultData;
use RuntimeException;
/**
* Thrown by RefundBookingAction after a failed refund attempt is already
* persisted (refunds.status = failed) the booking is deliberately left
* untouched, and the gateway's own message is surfaced to the caller
* (domain.md §6).
*/
class RefundFailedException extends RuntimeException
{
public static function fromResult(RefundResultData $result): self
{
return new self($result->message ?? 'Refund failed.');
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['message' => $this->getMessage()], 422);
}
return null;
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Payment\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Booking\Models\Booking;
use Modules\Payment\Models\Payment;
use RuntimeException;
class RefundNotAllowedException extends RuntimeException
{
public static function notConfirmed(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] cannot be refunded because its status is [{$booking->status->value}], not confirmed."
);
}
public static function noCompletedPayment(Booking $booking): self
{
return new self("Booking [{$booking->booking_ref}] has no completed payment to refund.");
}
public static function exceedsRefundableBalance(Payment $payment, string $amount, string $remaining): self
{
return new self(
"Refund amount [{$amount}] exceeds the remaining refundable balance [{$remaining}] on payment [{$payment->id}]."
);
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['message' => $this->getMessage()], 422);
}
return null;
}
}