Payment Gateway System
Gateway-agnostic payment system enabling multi-provider support with unified DTOs and market-specific configuration.
Architecture Overview
Section titled “Architecture Overview”The payment system uses a modular architecture where:
- Gateway-agnostic DTOs standardize customer and payment data
- Gateway Factory selects the appropriate provider per market/payment method
- PaymentService orchestrates all payment operations
- Individual gateways transform DTOs to provider-specific formats
Controller/Livewire │ ▼┌──────────────────┐│ PaymentService │ ◄── Main orchestrator└───────┬──────────┘ │ ▼┌──────────────────┐ ┌─────────────────┐│ GatewayFactory │────►│ MarketConfig │└───────┬──────────┘ └─────────────────┘ │ ▼┌──────────────────┐│ PaymentGateway │ ◄── Stripe/Adyen/Redsys│ Interface │└──────────────────┘Gateway-Agnostic DTOs
Section titled “Gateway-Agnostic DTOs”CustomerData
Section titled “CustomerData”Standardizes customer information across all gateways.
Source: backend/app/Services/Payment/DTOs/CustomerData.php
Properties:
id- Internal client IDname- Customer’s full nameemail- Customer’s email addressphone- 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 formattoAdyenFormat()- Adyen shopper format with shopperReferencetoRedsysFormat()- Redsys DS_MERCHANT fields
AddressData
Section titled “AddressData”Standardizes billing addresses across gateways.
Source: backend/app/Services/Payment/DTOs/AddressData.php
Properties:
line1,line2- Street addresscity,state,postalCodecountry- ISO 3166-1 alpha-2 code (e.g., ‘ES’)
Methods:
isEmpty()- Check if address has any dataisValid()- Check if address has minimum required data (country)toStripeFormat()- Stripe address formattoAdyenFormat()- Adyen address withstreet,houseNumberOrName
Creating Customer Data from Client
Section titled “Creating Customer Data from Client”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();Supported Gateways
Section titled “Supported Gateways”| 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 URLs
Section titled “Redsys callback URLs”Redsys callback and browser return URLs are generated from the backend
API_URL config, not APP_URL.
APP_URL- backend/admin canonical originAPI_URL- public API origin exposed to external systems
This keeps Redsys notifications and browser returns on the public API host:
POST /api/webhooks/redsysGET /api/{market}/{lang}/checkout/payment/redsys/ok/{order?}GET /api/{market}/{lang}/checkout/payment/redsys/ko/{order?}
Payment Service
Section titled “Payment Service”The main orchestrator for all payment operations.
Source: backend/app/Services/Payment/PaymentService.php
Key Operations
Section titled “Key Operations”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,);Post-Payment Finalization
Section titled “Post-Payment Finalization”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:
- Creates
Passengerrecords from checkout session’straveler_data - Attaches passengers to booking (first passenger = lead)
- Attaches passengers to client
- Persists flight selection to
booking.flight_selection(enriched with city names and duration) for both economy and business - Creates
BookingUpsellrecords from session selections (hotels, activities, transfers, business class flights) - Stores
payment_method_codeandpayment_gateway_codeon the booking for auditability - 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
AwaitingBalance→FullyPaid),ShareTripWithPassengersJobis dispatched to email each passenger their trip-access magic link. Idempotency is guarded bybookings.trip_shared_at. See Trip Access Authentication.
- Flights selected (
- 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();Deposit/Balance Payments
Section titled “Deposit/Balance Payments”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
Calculation Logic
Section titled “Calculation Logic”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 − depositraw_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 fromflight_selection.fare_total_price(the pax-scaled sum of each leg’s raw per-personfare_price), with a fallback to the sessionflight_base_pricefor default economy sessions that carry no per-leg breakdown.fare_total_priceis 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_beforeis 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 viafare_total_price, and the business upgrade’s margin is captured bymargin% × 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 enrichedflight_selectioncolumn forraw_flight_costso 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
Configuration
Section titled “Configuration”Config: config/payment.php
'deposit' => [ 'balance_days_before' => 7, // Balance due 7 days before departure],Env var: PAYMENT_BALANCE_DAYS_BEFORE (default: 7)
Payment Types
Section titled “Payment Types”| 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.
Offline Payments
Section titled “Offline Payments”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
Why it is not just a status flip
Section titled “Why it is not just a status flip”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:
- Runs the shared post-payment finalization first, building the booking from its checkout data.
- Creates the
Paymentrow against theofflinegateway, with a syntheticgateway_payment_id(offline-{reference}-{random}) since no gateway order exists. - Delegates to
updatePaymentStatus($payment, Succeeded), so the booking transitions, superseded attempts are retired, the exchange-rate snapshot is captured,ShareTripWithPassengersJobis 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.
What the operator captures
Section titled “What the operator captures”| 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.
Payment progress counter
Section titled “Payment progress counter”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
Admin Payment Links
Section titled “Admin Payment Links”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 thesigned:relativemiddleware) 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_URLvsAPP_URL). - The requested payment type is clamped server-side to the booking’s state (
awaiting_balancealways 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.
Scheduled Balance Charges
Section titled “Scheduled Balance Charges”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
Gateway Dashboard URLs
Section titled “Gateway Dashboard URLs”Payments include links to view transactions in the gateway’s dashboard.
Source: backend/app/Models/Payment.php:250
Gateway Dashboard Links
Section titled “Gateway Dashboard Links”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}
Admin Panel Integration
Section titled “Admin Panel Integration”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
Gateway Interface
Section titled “Gateway Interface”All gateways implement PaymentGatewayInterface.
Source: backend/app/Contracts/Payment/PaymentGatewayInterface.php
Required Methods
Section titled “Required Methods”| 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 |
Result DTOs
Section titled “Result DTOs”| 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 |
Payment Configuration
Section titled “Payment Configuration”Environment Variables
Section titled “Environment Variables”# Stripe (via Laravel Cashier)STRIPE_KEY=pk_test_xxxSTRIPE_SECRET=sk_test_xxxSTRIPE_WEBHOOK_SECRET=whsec_xxx
# Payment settingsPAYMENT_DEFAULT_CURRENCY=EURPAYMENT_BALANCE_DAYS_BEFORE=7Database Tables
Section titled “Database Tables”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 configurationpayments- Payment transactions, including offline provenance (offline_method,offline_reference,receipt_path,confirmed_by_user_id)client_payment_methods- Saved payment methods per client
Testing
Section titled “Testing”Tests use dedicated factories for payment components.
Source: backend/tests/Feature/Services/ and backend/tests/Feature/Models/
# 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=AddressDataTestRelated
Section titled “Related”- Stripe Local Setup - Environment setup, webhooks, and test cards
- Checkout API - Frontend checkout flow
- Booking Model - Booking payment status
- Source:
backend/app/Services/Payment/