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