Skip to content

Payment Gateway System

Gateway-agnostic payment system enabling multi-provider support with unified DTOs and market-specific configuration.

The payment system uses a modular architecture where:

  1. Gateway-agnostic DTOs standardize customer and payment data
  2. Gateway Factory selects the appropriate provider per market/payment method
  3. PaymentService orchestrates all payment operations
  4. Individual gateways transform DTOs to provider-specific formats
Controller/Livewire
┌──────────────────┐
│ PaymentService │ ◄── Main orchestrator
└───────┬──────────┘
┌──────────────────┐ ┌─────────────────┐
│ GatewayFactory │────►│ MarketConfig │
└───────┬──────────┘ └─────────────────┘
┌──────────────────┐
│ PaymentGateway │ ◄── Stripe/Adyen/Redsys
│ Interface │
└──────────────────┘

Standardizes customer information across all gateways.

Source: backend/app/Services/Payment/DTOs/CustomerData.php

Properties:

  • id - Internal client ID
  • name - Customer’s full name
  • email - Customer’s email address
  • phone - Customer’s phone number (optional)
  • billingAddress - AddressData object (optional)
  • preferredLocale - Locale code e.g., ‘es_ES’ (optional)
  • preferredCurrency - Currency code e.g., ‘EUR’ (optional)
  • metadata - Additional key-value pairs

Gateway transformations:

  • toStripeFormat() - Stripe customer creation format
  • toAdyenFormat() - Adyen shopper format with shopperReference
  • toRedsysFormat() - Redsys DS_MERCHANT fields

Standardizes billing addresses across gateways.

Source: backend/app/Services/Payment/DTOs/AddressData.php

Properties:

  • line1, line2 - Street address
  • city, state, postalCode
  • country - ISO 3166-1 alpha-2 code (e.g., ‘ES’)

Methods:

  • isEmpty() - Check if address has any data
  • isValid() - Check if address has minimum required data (country)
  • toStripeFormat() - Stripe address format
  • toAdyenFormat() - Adyen address with street, houseNumberOrName

The Client model provides a single source of truth for payment customer data:

Source: backend/app/Models/Client.php:156

// Get gateway-agnostic customer data
$customerData = $client->toPaymentCustomerData();
// With additional metadata
$customerData = $client->toPaymentCustomerData([
'booking_id' => $booking->id,
'source' => 'checkout',
]);
// Convert to specific gateway format
$stripeData = $customerData->toStripeFormat();
$adyenData = $customerData->toAdyenFormat();
$redsysData = $customerData->toRedsysFormat();
Gateway Status Payment Methods
Stripe Active Card, Apple Pay, Google Pay, SEPA Debit
Redsys Active Card (ES market, Redirection method)
Adyen Planned Card, Apple Pay, Google Pay, iDEAL, Klarna
Offline Not a provider Bank transfer, bank card charged outside the checkout

Gateway availability is configured per market in market_payment_methods table.

offline has no driver behind it: payments requires a gateway on every row, so money collected outside the checkout points at that catalog anchor. It is left inactive and never wired into market_payment_methods, so it cannot surface in the customer-facing payment step. See Offline Payments.

Redsys callback and browser return URLs are generated from the backend API_URL config, not APP_URL.

  • APP_URL - backend/admin canonical origin
  • API_URL - public API origin exposed to external systems

This keeps Redsys notifications and browser returns on the public API host:

  • POST /api/webhooks/redsys
  • GET /api/{market}/{lang}/checkout/payment/redsys/ok/{order?}
  • GET /api/{market}/{lang}/checkout/payment/redsys/ko/{order?}

The main orchestrator for all payment operations.

Source: backend/app/Services/Payment/PaymentService.php

Get payment methods for market:

$methods = $paymentService->getPaymentMethodsForMarket($market);
// Returns: Collection of {code, name, icon, gateway, display_order}

Create payment intent:

$result = $paymentService->createPaymentIntent(
booking: $booking,
client: $client,
market: $market,
paymentMethodCode: 'card',
depositOnly: true,
);
// Returns: {intent_result, payment_type, amount_cents}

Record payment after frontend confirmation:

$payment = $paymentService->recordPayment(
booking: $booking,
client: $client,
gatewayCode: 'stripe',
paymentMethodCode: 'card',
gatewayPaymentId: 'pi_xxx',
paymentType: PaymentType::Deposit,
amountInCents: 15000,
status: PaymentStatus::Succeeded,
);

Everything the customer chose at checkout lives in bookings.checkout_session_data until a payment succeeds. Turning that into a real booking is a single shared path, BookingFinalizationService::finalizeAfterPayment(Booking, PaymentMethodCode, PaymentGatewayCode), used by both channels:

  • the Redsys browser return / webhook, after the gateway authorizes the charge;
  • PaymentService::confirmOfflinePayment(), when an operator confirms a bank transfer days later.

It transitions PendingPayment → PaymentProcessing (the state machine has no direct edge from PendingPayment to the post-payment states), runs the finalization below, stores the method/gateway codes, clears checkout_session_data and locks attribution. It is idempotent: a booking that already has passengers is left untouched.

After successful payment, PaymentController creates/updates the Client from client_data (the booking contact person), then BookingFinalizationService completes the booking:

  1. Creates Passenger records from checkout session’s traveler_data
  2. Attaches passengers to booking (first passenger = lead)
  3. Attaches passengers to client
  4. Persists flight selection to booking.flight_selection (enriched with city names and duration) for both economy and business
  5. Creates BookingUpsell records from session selections (hotels, activities, transfers, business class flights)
  6. Stores payment_method_code and payment_gateway_code on the booking for auditability
  7. Routes booking status via PaymentService::updateBookingPaymentStatus():
    • Flights selected (ECONOMY / BUSINESS) → pending_flight_booking
    • Land-only booking (no flight selection) → pending_land_confirmation
    • On this first paid transition (and only this one — not on AwaitingBalanceFullyPaid), ShareTripWithPassengersJob is dispatched to email each passenger their trip-access magic link. Idempotency is guarded by bookings.trip_shared_at. See Trip Access Authentication.
  8. Clears the checkout session via CheckoutSessionService::clear() to prevent reuse

Source: backend/app/Services/Booking/BookingFinalizationService.php, backend/app/Http/Controllers/Api/PaymentController.php

// Called automatically in PaymentController::confirm() after successful payment
$this->bookingFinalizationService->finalizeFromCheckoutSession($booking, $session);
// Session cleared after all finalization steps complete
$this->checkoutSessionService->clear();

The system supports split payments where customers pay a deposit upfront and the balance before departure. The split is based on the cost structure of the trip, not a fixed percentage: the deposit secures everything at financial risk (the non-refundable provider flight cost plus our full booking margin), and the balance is the deferred land/supplier cost we settle closer to departure.

Source: backend/app/Services/Payment/PaymentCalculatorService.php

The deposit is computed by a single source of truth, PaymentCalculatorService::depositFromCheckout(), so the three deposit sites cannot drift:

deposit = raw_flight_cost + (margin% × total_price) (capped at total_price)
balance = total_price − deposit
  • raw_flight_cost — the raw provider (Aerticket) flight cost for the whole party across all legs (international + every domestic leg), for the selected cabin (economy OR business). Read from flight_selection.fare_total_price (the pax-scaled sum of each leg’s raw per-person fare_price), with a fallback to the session flight_base_price for default economy sessions that carry no per-leg breakdown. fare_total_price is raw of margin for both cabins — for business it is the raw business fare, not the marked-up delta.
  • margin% — the offer margin (default 20%, may vary per market/offer), applied to the whole product total.
  • total_price — the full product total (base + all upsells + insurance).
  • Safety cap: deposit cannot exceed total.
  • Deposit available: when departure_date - balance_days_before is in the future (otherwise full payment is required immediately).

Consequences of this rule:

  • The deposit now includes the full booking margin (not just the flight’s own share of margin), so deposits are materially larger than under the previous flight-only formula. The balance (total − deposit) is approximately the raw land/supplier cost.
  • The business-class extra (business_extra_price_per_person) is not added to the deposit separately: the raw business fare already arrives via fare_total_price, and the business upgrade’s margin is captured by margin% × total_price. This avoids double-counting the business margin.
  • It is a deliberately simple rule. It slightly over-collects vs. exact profit (transfers and insurance carry no margin, and marketing rounding adds noise), which is accepted because over-collecting into a deposit is on the safe side.

The three deposit sites all call depositFromCheckout():

  • PaymentController::createIntent() — the actual charge; reads the live checkout session.
  • CheckoutSessionResource — the checkout summary display; reads the live session.
  • BookingDetailsResource — the trip/quote page. Only quote bookings (no stored deposit yet) derive it, from the persisted checkout snapshot; it prefers the booking’s enriched flight_selection column for raw_flight_cost so a stale snapshot cannot under-collect on a business quote.

Once a payment is initiated, deposit_amount is stored on the booking and every display surface reads the stored value — only pre-payment quotes derive it.

Security: CheckoutSessionResource strips legs and fare_total_price (the raw provider cost) from the flight_selection it returns to the client, so the raw Aerticket cost / flight margin is never exposed in the checkout API response. The persisted booking column keeps these fields for finalization.

Source: backend/app/Services/Payment/PaymentCalculatorService.php, backend/app/Http/Resources/CheckoutSessionResource.php, backend/app/Http/Resources/BookingDetailsResource.php, backend/app/Http/Controllers/Api/PaymentController.php

Config: config/payment.php

'deposit' => [
'balance_days_before' => 7, // Balance due 7 days before departure
],

Env var: PAYMENT_BALANCE_DAYS_BEFORE (default: 7)

Type When Used
deposit Upfront portion (raw flight cost + full booking margin)
balance Deferred portion (raw land/supplier cost), charged before departure
full When deposit not available (departure within balance_days_before)

Outstanding Balance — Single Source of Truth

Section titled “Outstanding Balance — Single Source of Truth”

What the client still owes is always Booking::outstandingBalance() (max(0, total_amount − Σ succeeded payments), queried fresh). Payment links, Redsys initiation, the admin UI (the balance shown and emailed) and the fully-paid transition all derive from it, so the displayed, emailed and charged amounts can never diverge — deriving from deposit_amount instead would drift whenever a refund, extra payment or manual adjustment moved the paid total off the deposit.

Fully-paid invariant: PaymentService::updateBookingPaymentStatus() only transitions AwaitingBalance → FullyPaid when the outstanding balance is zero (≤ €0.01). A partial success — e.g. a stale superseded attempt succeeding late with a smaller amount — leaves the booking awaiting the remainder and logs a warning.

Superseded attempts: When a payment succeeds, or a new Redsys redirect is generated for the same type, older pending attempts of that type on the booking are closed as Cancelled with failure_code = superseded (Payment::cancelSupersededPendingAttempts()), including any scheduled auto-charge of the same type — preventing double charges and keeping the payments table readable.

Clients who cannot pay by card wire the money instead, or are charged on a card outside the online checkout. Operators record those payments from the booking they are working on, through the Confirm Payment header action on ViewBooking.

Source: backend/app/Services/Payment/PaymentService.php (confirmOfflinePayment()), backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php

A booking whose card payment failed never reached the Redsys return, so it has no passengers, no flight selection, no upsells, no insurance contract — and updateBookingPaymentStatus() refuses to advance a booking with no passengers. Recording the payment alone would leave the booking dead. confirmOfflinePayment() therefore:

  1. Runs the shared post-payment finalization first, building the booking from its checkout data.
  2. Creates the Payment row against the offline gateway, with a synthetic gateway_payment_id (offline-{reference}-{random}) since no gateway order exists.
  3. Delegates to updatePaymentStatus($payment, Succeeded), so the booking transitions, superseded attempts are retired, the exchange-rate snapshot is captured, ShareTripWithPassengersJob is dispatched and the customer receives the payment-confirmation email — exactly as after an online card payment.

If the booking cannot be rebuilt (no checkout data left, allotment exhausted, invalid traveler data), the failure is reported and the payment is still recorded as succeeded without advancing the booking — the money is real and must be visible; the booking waits for manual recovery. This mirrors what the Redsys return does when finalization fails.

Field Behaviour
Instalment Derived from what is already collected. Only when nothing is collected and the plan has two instalments does the modal ask: first payment or the whole booking, with both amounts on the buttons.
Amount, method, value date, reference Prefilled (outstanding amount, bank transfer, today) behind an Adjust disclosure.
Offline method bank_transfer or card_offline only — Volare takes no cash, and a free-form bucket produces rows treasury cannot classify.
Receipt Required. PDF/JPG/PNG up to 50 MB, stored privately on config('filesystems.default') (S3 in production) and served through a signed URL.

Provenance is persisted in real columns — offline_method, offline_reference, receipt_path, confirmed_by_user_id — not in metadata, so treasury can filter and report on them. The value date is stored in charged_at and displayed as a date only (no hour, no timezone shifting) for offline payments.

BookingPaymentProgress derives how the money stands as plan instalments: the denominator is the payment plan (deposit_amount !== null ? 2 : 1), never the number of payment rows, so failed and superseded attempts cannot inflate it. The numerator counts instalments covered by succeeded payments of any channel — a confirmed bank transfer counts exactly like a Redsys card payment — and reads complete whenever nothing is outstanding.

The same counter drives the Confirm Payment (0/2) header action (static Payment confirmed (n/n) once everything is collected) and the segmented bar in the Booking Summary and Payments section headers.

Source: backend/app/Services/Payment/BookingPaymentProgress.php, backend/app/Filament/Support/PaymentProgressBar.php

Agents generate customer-facing payment links for an existing booking from the Arkana booking view (with an optional email of the balance request via BalancePaymentRequestNotification).

Source: backend/app/Services/Payment/PaymentLinkService.php, backend/app/Services/Payment/BookingPaymentInitiationService.php, backend/app/Http/Controllers/BookingPaymentRedirectController.php

  • Stripe bookings get a hosted Checkout URL (Cashier checkoutCharge).
  • Redsys bookings (no hosted URL) get a link to our own public redirect route, GET /api/pay/{booking:booking_reference} (booking.pay), which builds the auto-submitting Redsys gateway form when the customer opens it.

Security & concurrency:

  • The link carries a relative URL signature (PublicApiUrl::signedRoute(), validated with the signed:relative middleware) because opening it mutates payment state — forged or guessed booking references cannot create gateway orders. The signature is computed over the relative URL so it stays valid regardless of the host anchoring the link (API_URL vs APP_URL).
  • The requested payment type is clamped server-side to the booking’s state (awaiting_balance always charges the balance; initial-payment bookings never accept a balance type), so a stale or tampered link can never charge the wrong amount.
  • Redsys initiation runs in a transaction under a per-booking row lock: the amount calculation, sibling supersede and pending-payment pre-creation are serialized, so two concurrent link openings cannot each compute the full remainder and create two payable orders.
  • Fully paid or non-payable bookings get a 410 invalid-link page and no payment record.

ProcessScheduledBalancePaymentsJob (hourly) charges due balance payments off-session with retry logic (payment.balance_retry.max_retries). Hardened against double charges: the job takes a WithoutOverlapping lock so two workers can never iterate the same due payments, rechecks each payment’s current status immediately before charging (a manual link payment may have superseded it since the query), and processes the backlog with chunkById.

Source: backend/app/Jobs/ProcessScheduledBalancePaymentsJob.php

Payments include links to view transactions in the gateway’s dashboard.

Source: backend/app/Models/Payment.php:250

Stripe:

  • Test mode: https://dashboard.stripe.com/test/payments/{id}
  • Live mode: https://dashboard.stripe.com/payments/{id}

Adyen:

  • https://ca-{environment}.adyen.com/ca/ca/accounts/showTx.shtml?pspReference={id}

The ViewPayment page includes a “View in {Gateway}” action button, and a “Download Receipt” action for payments confirmed offline. There is no “mark as paid” action there: offline payments are confirmed from the booking, through the single service path described in Offline Payments.

The booking view page (ViewBooking) includes a Payments section showing deposit tracking, total paid vs remaining, and a table of all payment records with gateway dashboard links. Total paid is derived from succeeded payments only, using the eager-loaded collection. The page also renders the full cost breakdown (P&L, payment schedule, pricing view, direct supplier costs), the payment-link generation actions and the Confirm Payment action.

Source: backend/app/Filament/Resources/Payments/Pages/ViewPayment.php, backend/app/Filament/Resources/Bookings/Schemas/BookingPaymentSection.php

All gateways implement PaymentGatewayInterface.

Source: backend/app/Contracts/Payment/PaymentGatewayInterface.php

Method Purpose
createCustomer() Create customer in gateway
createSetupIntent() Save payment method for future use
createPaymentIntent() Create one-time or initial charge
confirmPayment() Confirm after 3DS authentication
chargeOffSession() Charge saved method (scheduled balance)
refund() Process refund
handleWebhook() Process gateway webhooks
supportsPaymentMethod() Check method support
getGatewayCode() Return gateway identifier
DTO Purpose
PaymentIntentResult Payment intent creation result with client_secret
PaymentResult Charge/confirm result with success/failure info
SetupIntentResult Setup intent for saving payment methods
RefundResult Refund transaction result
WebhookResult Webhook processing result
Terminal window
# Stripe (via Laravel Cashier)
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Payment settings
PAYMENT_DEFAULT_CURRENCY=EUR
PAYMENT_BALANCE_DAYS_BEFORE=7
  • payment_gateways - Gateway definitions (stripe, adyen, redsys, offline)
  • payment_methods - Method types (card, apple_pay, sepa_debit, bank_transfer, card_offline)
  • market_payment_methods - Market-specific gateway/method configuration
  • payments - Payment transactions, including offline provenance (offline_method, offline_reference, receipt_path, confirmed_by_user_id)
  • client_payment_methods - Saved payment methods per client

Tests use dedicated factories for payment components.

Source: backend/tests/Feature/Services/ and backend/tests/Feature/Models/

Terminal window
# Run payment tests
./vendor/bin/sail artisan test --filter=Payment
# Test DTO transformations
./vendor/bin/sail artisan test --filter=CustomerDataTest
./vendor/bin/sail artisan test --filter=AddressDataTest