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,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
use Modules\Payment\Events\PaymentFailed;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
/**
|
||||
* Confirms a Payment following an inbound webhook notification — never
|
||||
* trusts the webhook's own trade_status directly, re-verifies with the
|
||||
* gateway first (domain.md §6, bnf_event's client-driven-confirmation
|
||||
* fallback pattern).
|
||||
*
|
||||
* Idempotent: KBZ may redeliver the same notification (or this may run more
|
||||
* than once for the same transaction for other reasons), so a Payment only
|
||||
* ever transitions out of `pending` once — a redelivery after that is a
|
||||
* no-op that doesn't re-call the gateway or re-dispatch events.
|
||||
*/
|
||||
class ConfirmPaymentAction
|
||||
{
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentMethod $method, string $gatewayTransactionId): ?Payment
|
||||
{
|
||||
$payment = Payment::where('gateway', $method)
|
||||
->where('gateway_transaction_id', $gatewayTransactionId)
|
||||
->first();
|
||||
|
||||
if ($payment === null || $payment->status !== PaymentStatus::Pending) {
|
||||
return $payment;
|
||||
}
|
||||
|
||||
$verified = $this->paymentService->verify($method, $gatewayTransactionId);
|
||||
|
||||
if ($verified->status === PaymentStatus::Pending) {
|
||||
return $payment;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($payment, $verified) {
|
||||
$payment->update([
|
||||
'status' => $verified->status,
|
||||
'gateway_payload' => $verified->gatewayPayload,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
match ($verified->status) {
|
||||
PaymentStatus::Completed => PaymentCompleted::dispatch($payment),
|
||||
PaymentStatus::Failed => PaymentFailed::dispatch($payment),
|
||||
PaymentStatus::Pending => null,
|
||||
};
|
||||
|
||||
return $payment;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
/**
|
||||
* Starts a payment attempt for a booking — calls the resolved gateway via
|
||||
* PaymentService, then persists the attempt as a `payments` row regardless
|
||||
* of outcome (a failed precreate is still a recorded attempt, domain.md §6).
|
||||
*
|
||||
* Booking status only ever flips to `confirmed` once the gateway confirms
|
||||
* success via the webhook/verify path (T5.9/T5.10) — never here.
|
||||
*/
|
||||
class InitiatePaymentAction
|
||||
{
|
||||
private const CURRENCY = 'MMK';
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
|
||||
{
|
||||
if ($booking->status !== BookingStatus::PendingPayment) {
|
||||
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
));
|
||||
|
||||
return DB::transaction(fn () => Payment::create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'initiated_at' => now(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking can have more than one payment attempt (retry after
|
||||
* failure), so the merchant order id must be unique per attempt, not
|
||||
* just per booking — suffixed with the attempt number.
|
||||
*/
|
||||
private function merchantOrderId(Booking $booking): string
|
||||
{
|
||||
$attempt = $booking->payments()->count() + 1;
|
||||
|
||||
return "{$booking->booking_ref}-{$attempt}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds the booking id (encrypted, so the URL doesn't leak a raw
|
||||
* sequential id) as an optional path segment on the webhook URL —
|
||||
* mirrors bnf_event's `{encryptOrderId?}` on `paymentComplete`, giving
|
||||
* the webhook a direct way to locate the booking as a redundant check
|
||||
* alongside `merch_order_id` in the signed payload. Uses Laravel's
|
||||
* Crypt facade rather than porting bnf_event's hand-rolled openssl
|
||||
* helper (BNFEventEncryption) — same idea, standard implementation.
|
||||
*/
|
||||
private function notifyUrl(Booking $booking, PaymentMethod $method): string
|
||||
{
|
||||
return route('payment.webhooks.handle', [
|
||||
'method' => $method->value,
|
||||
'encryptBookingId' => Crypt::encryptString((string) $booking->id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Exceptions\RefundFailedException;
|
||||
use Modules\Payment\Exceptions\RefundNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
/**
|
||||
* Usable from both the API and Filament (T5.13) — resolves the gateway via
|
||||
* PaymentService/PaymentGatewayFactory, never a concrete gateway class.
|
||||
*
|
||||
* Unlike bnf_event (refund amount was effectively hardcoded to full-amount),
|
||||
* partial refunds are explicitly supported: `amount` is validated against
|
||||
* the Payment's remaining refundable balance, not just its original total
|
||||
* (domain.md §6).
|
||||
*/
|
||||
class RefundBookingAction
|
||||
{
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, string $amount, string $reason, ?int $requestedBy = null): Refund
|
||||
{
|
||||
if ($booking->status !== BookingStatus::Confirmed) {
|
||||
throw RefundNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
$payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
|
||||
|
||||
if ($payment === null) {
|
||||
throw RefundNotAllowedException::noCompletedPayment($booking);
|
||||
}
|
||||
|
||||
$this->assertWithinRefundableBalance($payment, $amount);
|
||||
|
||||
$result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason);
|
||||
|
||||
$refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) {
|
||||
$refund = Refund::create([
|
||||
'payment_id' => $payment->id,
|
||||
'status' => $result->status,
|
||||
'amount' => $amount,
|
||||
'reason' => $reason,
|
||||
'gateway_refund_id' => $result->gatewayRefundId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'requested_by' => $requestedBy,
|
||||
'requested_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
if ($result->status === RefundStatus::Completed) {
|
||||
RefundProcessed::dispatch($refund);
|
||||
}
|
||||
|
||||
return $refund;
|
||||
});
|
||||
|
||||
if ($result->status === RefundStatus::Failed) {
|
||||
throw RefundFailedException::fromResult($result);
|
||||
}
|
||||
|
||||
return $refund;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (bccomp($amount, $remaining, 2) === 1) {
|
||||
throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user