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
@@ -9,8 +9,10 @@ use Modules\Payment\Contracts\PaymentGatewayInterface;
use Modules\Payment\Data\PaymentRequestData;
use Modules\Payment\Data\PaymentResultData;
use Modules\Payment\Data\RefundResultData;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Enums\RefundStatus;
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
use Modules\Payment\Support\KbzSignature;
/**
@@ -157,6 +159,46 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
);
}
/**
* bnf_event's equivalent (`OrderController::paymentComplete` /
* `KBZMiniApp::save`) trusted the raw `trade_status` from the POST body
* and only re-verified via `queryorder` afterward it never checked
* `sign` on the inbound payload at all. This closes that gap: KBZ signs
* webhook notifications with the same scheme as our outbound calls
* (confirmed against KBZ's "6 Callback Interface" spec), so the
* signature is checked first, before any of the payload is trusted.
*
* @param array<string, mixed> $payload
*/
public function handleWebhook(array $payload): PaymentResultData
{
/** @var array<string, mixed> $notification */
$notification = (array) ($payload['Request'] ?? []);
if (! $this->hasValidSignature($notification)) {
throw InvalidWebhookSignatureException::forGateway(PaymentMethod::KbzMiniApp);
}
return new PaymentResultData(
status: $this->mapTradeStatus($notification['trade_status'] ?? null),
gatewayTransactionId: $notification['merch_order_id'] ?? null,
gatewayPayload: $notification,
message: $notification['trade_status'] ?? null,
);
}
/**
* @param array<string, mixed> $notification
*/
private function hasValidSignature(array $notification): bool
{
if (! isset($notification['sign']) || ! is_string($notification['sign']) || $this->merchantKey === '') {
return false;
}
return hash_equals(KbzSignature::sign($notification, $this->merchantKey), strtoupper($notification['sign']));
}
/**
* @return array<string, mixed>
*/