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