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