136 lines
5.4 KiB
PHP
136 lines
5.4 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\Enums\PaymentStatus;
|
|
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.
|
|
*
|
|
* Idempotent per booking: KBZ's precreate rejects a second call tied to an
|
|
* order that's still in flight, so a repeat call (double-tap on "Pay", the
|
|
* customer re-opening the payment screen) must not blindly precreate again.
|
|
* If the latest attempt is still `pending`, it's re-verified against the
|
|
* gateway (via ConfirmPaymentAction, the same logic the webhook path uses)
|
|
* and reused instead of starting a new one.
|
|
*/
|
|
class InitiatePaymentAction
|
|
{
|
|
private const CURRENCY = 'MMK';
|
|
|
|
public function __construct(
|
|
private PaymentService $paymentService,
|
|
private ConfirmPaymentAction $confirmPayment,
|
|
) {}
|
|
|
|
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
|
|
{
|
|
if ($booking->status !== BookingStatus::PendingPayment) {
|
|
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
|
}
|
|
|
|
if ($booking->is_return_leg) {
|
|
throw PaymentInitiationNotAllowedException::isReturnLeg($booking);
|
|
}
|
|
|
|
return DB::transaction(function () use ($booking, $method) {
|
|
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
|
|
|
|
$latest = $booking->payments()->latest('id')->first();
|
|
|
|
if ($latest !== null) {
|
|
// No-op for an already-terminal payment (ConfirmPaymentAction
|
|
// only re-verifies `pending` ones), so this is cheap even for
|
|
// a Failed/Completed latest attempt — and it guards against a
|
|
// narrow race where a webhook already completed the payment
|
|
// but the queued booking-status listener hasn't run yet.
|
|
$reverified = $this->confirmPayment->handle($latest->gateway, $latest->gateway_transaction_id);
|
|
|
|
if ($reverified !== null && $reverified->status !== PaymentStatus::Failed) {
|
|
return $reverified;
|
|
}
|
|
}
|
|
|
|
$merchantOrderId = $this->merchantOrderId($booking);
|
|
$amount = $this->amount($booking);
|
|
|
|
$result = $this->paymentService->initiate(new PaymentRequestData(
|
|
bookingId: $booking->id,
|
|
merchantOrderId: $merchantOrderId,
|
|
amount: $amount,
|
|
currency: self::CURRENCY,
|
|
method: $method,
|
|
notifyUrl: $this->notifyUrl($booking, $method),
|
|
));
|
|
|
|
return Payment::create([
|
|
'booking_id' => $booking->id,
|
|
'gateway' => $method,
|
|
'status' => $result->status,
|
|
'amount' => $amount,
|
|
'currency' => self::CURRENCY,
|
|
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
|
'gateway_payload' => $result->gatewayPayload,
|
|
'initiated_at' => now(),
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A round trip's payment is combined on the outbound leg — covers both
|
|
* legs' price, since the return leg never gets its own Payment
|
|
* (domain.md §2b). A plain one-way booking just pays its own price.
|
|
*/
|
|
private function amount(Booking $booking): string
|
|
{
|
|
if ($booking->linked_booking_id === null) {
|
|
return (string) $booking->price;
|
|
}
|
|
|
|
return bcadd((string) $booking->price, (string) $booking->linkedBooking->price, 2);
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
]);
|
|
}
|
|
}
|