Files
famous-ly4-ev/app-modules/payment/src/Actions/InitiatePaymentAction.php
T
Nyan Lin Paing d19a14a45e 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
2026-08-09 16:20:21 +07:00

89 lines
3.2 KiB
PHP

<?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),
]);
}
}