AerTicket Integration
Interface to AerTicket API for flight search, verification, booking, ticket issuance, void, ancillaries, retrieval, and cancellation. Supports both UAT and Production environments with automatic authentication, retry logic, and comprehensive error handling.
Quick Start
Section titled “Quick Start”Installation
Section titled “Installation”Environment Variables (.env)
Section titled “Environment Variables (.env)”# Environment: uat (development) or productionAERTICKET_ENVIRONMENT=uat
# API credentials provided by AERTICKETAERTICKET_LOGIN=your_api_loginAERTICKET_PASSWORD=your_api_password
# Optional timeout/retry overrides# AERTICKET_UAT_TIMEOUT=30# AERTICKET_UAT_RETRY_ATTEMPTS=3Test Connection
Section titled “Test Connection”./vendor/bin/sail artisan aerticket:test-connectionBasic Flight Search
Section titled “Basic Flight Search”use App\Services\AerticketCabinetService;use App\Services\Flights\Aerticket\AerticketSearchService;
$cabinet = app(AerticketCabinetService::class);$search = new AerticketSearchService($cabinet);
$results = $search->search([ 'origin' => 'BCN', 'destination' => 'MAD', 'departure_date' => '2025-12-15', 'return_date' => '2025-12-20', 'adults' => 2, 'cabin_class' => 'economy',]);
foreach ($results->getFares() as $fare) { echo "Price: {$fare->getTotalPrice()} {$fare->getCurrency()}\n";}Multi-City Flight Search
Section titled “Multi-City Flight Search”use App\Services\Flights\Aerticket\AerticketSearchService;use App\Services\Flights\Aerticket\DTOs\Request\PassengerType;use App\Services\Flights\Aerticket\DTOs\Request\SearchOptions;use App\Services\Flights\Aerticket\DTOs\Request\SearchRequest;
// Define multi-city segments (2-6 segments allowed)$segments = [ ['departure' => 'BCN', 'destination' => 'BKK', 'date' => '2025-12-20'], ['departure' => 'BKK', 'destination' => 'CNX', 'date' => '2025-12-25'], ['departure' => 'CNX', 'destination' => 'BCN', 'date' => '2026-01-05'],];
// Build passenger types$passengers = [ PassengerType::adult(2), PassengerType::child(1),];
// Create multi-city search request$searchRequest = SearchRequest::multiCity( segments: $segments, passengerTypeList: $passengers, searchOptions: new SearchOptions(cabinClassList: ['ECONOMY']));
// Execute search$searchService = new AerticketSearchService();$response = $searchService->search($searchRequest);Service Components
Section titled “Service Components”| Service | Purpose |
|---|---|
AerticketCabinetService |
HTTP client, authentication, environment switching |
AerticketSearchService |
Flight search |
AerticketSearchUpsellService |
Fare upgrade search |
AerticketVerifyService |
Fare verification |
AerticketBookService |
Booking creation |
CheckoutFlightBookingService |
Per-leg checkout flight booking (international and domestic, economy and business) |
AerticketTicketIssueService |
Ticket issuance |
AerticketFastlaneTicketingService |
Fast-track ticket issuance |
AerticketRePriceService |
Booking re-pricing |
AerticketVoidService |
Booking void |
AerticketAncillaryService |
Ancillary services (baggage, meals) |
AerticketFareRulesService |
Fare rules and restrictions |
AerticketRetrieveService |
PNR retrieval |
AerticketCancelService |
Booking cancellation |
FlightRouteValidationService |
Validate flight route availability for supplier tours |
Low-Volume Endpoints and Daily Budgets
Section titled “Low-Volume Endpoints and Daily Budgets”AerticketSearchUpsellService (/search-upsell) and AerticketAncillaryService::availableFareAncillaries() (/available-fare-ancillaries) are contractually capped by Aerticket at 100 calls per day each. Automated cache population, checkout display, and booking now stay within the single /search response and do not consume these low-volume endpoints; they are reserved for explicit/manual tooling.
| Bucket | Config key | Env var | Default |
|---|---|---|---|
search_upsell |
aerticket.daily_budgets.search_upsell |
AERTICKET_SEARCH_UPSELL_DAILY_LIMIT |
80 |
available_fare_ancillaries |
aerticket.daily_budgets.available_fare_ancillaries |
AERTICKET_ANCILLARIES_DAILY_LIMIT |
80 |
Counters are keyed aerticket:daily_budget:<bucket>:<YYYY-MM-DD> and reset at local midnight via RateLimiter decay. Setting a limit to 0 disables the check for that bucket.
When a budget is exhausted, AerticketDailyBudgetExceededException is thrown. Manual callers are expected to catch it and degrade gracefully rather than propagate it.
Source: backend/config/aerticket.php, backend/app/Exceptions/Services/Flights/Aerticket/Exceptions/AerticketDailyBudgetExceededException.php
Related: Dynamic Flight Cache — Baggage Resolution
Content Source Gating (GDS vs nonGDS)
Section titled “Content Source Gating (GDS vs nonGDS)”Every fare returned by /search carries a top-level contentSource field with one of two values:
GDS— sourced through Aerticket’s GDS pipeline (Amadeus / Sabre / Travelport). Ancillaries and seats are exposed via API.nonGDS— sourced through Aerticket’s NDC + LCC pipelines. The supplier does not expose ancillaries or seats via API; calls to/available-fare-ancillariesalways returnMethod not supported by supplier.
FareResult::$contentSource parses this field ('GDS' | 'nonGDS', defaulting to 'GDS' for missing/unknown values so legacy fixtures keep ancillary support). Two call sites consume it:
AerticketAncillaryService::availableFareAncillaries(string $fareId, array $itineraryIdList, string $contentSource = 'GDS')— when$contentSource !== 'GDS', returns an empty success response without issuing the HTTP request. The skip is logged asSkipping available-fare-ancillaries for nonGDS fare.BaggageResolutionService::resolveBaggageForFare()— short-circuits the ancillary tier for nonGDS fares before consuming the daily budget, so nonGDS traffic doesn’t penalise the GDS budget counter. STRICT policy returnsnull(discard); UPSELLABLE / OFF returnsResolvedBaggage::policyOff(...)so the offer stays usable at its native price.
fareSource (FSC_IATA / FSC_NEGO / FSC_CONSO / FSC_WEB) is the commercial channel and is orthogonal to GDS/nonGDS — the same airline can appear under both. Always gate on contentSource, never on fareSource.
Source: backend/app/Services/Flights/Aerticket/DTOs/Response/FareResult.php, backend/app/Services/Flights/Aerticket/AerticketAncillaryService.php, backend/app/Services/Flights/Baggage/BaggageResolutionService.php
Authentication Header Sanitization
Section titled “Authentication Header Sanitization”AerticketAuthenticationService::getSanitizedHeaders() powers the auth_headers field in API request/response logs. The Aerticket login is the account number (e.g. 082526 for the GDS account, 082836 for the NDC + LCC account) — it’s used for L2B accounting and request routing, not as a credential. It is logged in clear so operators can tell which account answered each request. Only the password is masked.
// Example output['login' => '082526', 'password' => 'Ip********2j']The same policy is applied to Telescope: TelescopeServiceProvider::hideSensitiveRequestDetails() keeps password in the hidden-headers list but does not hide login.
Source: backend/app/Services/AerticketAuthenticationService.php, backend/app/Providers/TelescopeServiceProvider.php
Configuration
Section titled “Configuration”Environment Variables
Section titled “Environment Variables”# Environment: "uat" or "production" (determines base URLs automatically)AERTICKET_ENVIRONMENT=uat
# Credentials (shared across environments)AERTICKET_LOGIN=your_api_loginAERTICKET_PASSWORD=your_api_password
# Optional: Override default timeout settings (seconds)# AERTICKET_UAT_TIMEOUT=30# AERTICKET_PROD_TIMEOUT=30
# Optional: Configure retry settings# AERTICKET_UAT_RETRY_ATTEMPTS=3# AERTICKET_UAT_RETRY_DELAY=100
# Optional: Logging configuration# AERTICKET_LOGGING_ENABLED=true# Use `stderr` (or the app's `stack`) so Aerticket logs reach Loki; the# .env.example default is `stderr` (config/aerticket.php falls back to `single`# only when the var is unset).# AERTICKET_LOG_CHANNEL=stderrBase URLs are configured automatically per environment in config/aerticket.php:
- UAT:
https://apihub-uat.aerticket-it.de/cabinet/and/api/v1/ - Production:
https://apihub.aerticket-it.de/cabinet/and/api/v1/
Cabinet Web Tool Access
Section titled “Cabinet Web Tool Access”The admin panel links directly to Aerticket’s official cabinet web tool as a backup channel for flight validation. AerticketCabinetService::findPnrsUrl(?string $pnrLocator) builds an environment-aware URL to the cabinet’s find-pnrs page; passing a PNR locator pre-fills and executes the search there. The link appears as a global “Aerticket Cabinet” navigation item (Flights group) and as a per-leg “Cabinet” action on the booking view, which deep-links the leg’s PNR. Manually entered flights are excluded since their reference is not an Aerticket locator.
Staging Environment Safeguards
Section titled “Staging Environment Safeguards”To prevent accidental low-cost carrier bookings in staging/UAT:
- Flydubai flights (airline code: FZ) are blocked
- Flights requiring instant purchase are blocked
These guards are gated by EnvironmentGuard::isStagingOrUat() (the app environment, not the AERTICKET_ENVIRONMENT config value) and are enforced only in the manual BookFlight Filament page (app/Filament/Pages/BookFlight.php:383,412) — the Flydubai check runs before verify, the instant-purchase check after. They are not applied globally across the automated checkout booking path.
Pricing & AERTiCKET Billing
Section titled “Pricing & AERTiCKET Billing”How AERTiCKET prices flights, what they invoice us, and the one rule every price parser must follow. Read this before touching any Aerticket price calculation — a subtle misreading of these fields once caused a ticket-fee double-counting bug.
The net-price rule (read this first)
Section titled “The net-price rule (read this first)”For each passenger, AERTiCKET reports the price we pay in a priceList:
| Field | Meaning |
|---|---|
AGENCY_PURCHASE_PRICE |
The net fare we pay AERTiCKET. Already includes the ticketing/service fee. |
TOTAL_TAX |
Airline taxes and surcharges. |
BASE_FARE |
Raw fare excluding the ticketing fee. Informational; used only as a fallback. |
Separately, each passenger may carry a surchargeInfoList (TICKET_FEE_INFORMATION, SERVICE_FEE, BOOKING_FEE). This is for information only. AERTiCKET’s own API documentation states, verbatim:
surchargeInfoList— Use this parameter for information only! This is AERTiCKET ticketing fee, which is already included in theagencyPurchasePricefrompriceList.
The net total is therefore always:
total = Σ passengers (AGENCY_PURCHASE_PRICE + TOTAL_TAX) × countDo:
- Sum only
AGENCY_PURCHASE_PRICE+TOTAL_TAX, per passenger, multiplied by passenger count. - Trust
AGENCY_PURCHASE_PRICEas the net price — it already contains the ticketing fee.
Don’t:
- Never add
surchargeInfoListon top ofAGENCY_PURCHASE_PRICE. The fee is already inside it; adding it double-counts. - Never multiply by number of flights/segments — prices are per passenger, not per leg (see below).
The one exception: if a response omits AGENCY_PURCHASE_PRICE and carries only BASE_FARE, the raw fare genuinely excludes the fee, so the fallback is BASE_FARE + TOTAL_TAX + surchargeInfoList. This is the only place surcharges are legitimately added (VerifiedFare::fromArray()).
Worked example
Section titled “Worked example”Royal Jordanian round trip MAD→AMM→MAD, 4 adults. AERTiCKET reports per adult:
AGENCY_PURCHASE_PRICE= 156.00 (= 149.00 fare + 7.00 ticketing fee, folded in)TOTAL_TAX= 253.39surchargeInfoList.TICKET_FEE_INFORMATION= 7.00 (reference only — the same €7 already inside 156.00)
Correct total: (156.00 + 253.39) × 4 = 1,637.56 — matches the binding invoice Total factura.
Double-counting the fee gives (156.00 + 253.39 + 7.00) × 4 = 1,665.56 — €28.00 too high (€7 × 4 passengers).
Fees, legs, and invoices
Section titled “Fees, legs, and invoices”- One invoice per PNR per leg. Each
FlightBookingrow (leg_index) is one PNR and one AERTiCKET invoice. A round trip is a single leg / PNR / invoice; a domestic connection is a separate leg with its own PNR, invoice, and fee. - The ticketing fee is per ticket, not per flight. A round trip is one ticket per passenger → one fee per passenger. The outbound and return directions do not each carry a fee. 4 passengers → 4 tickets → 4 × €7 = €28.
- Prices scale per passenger, never per segment. One search returns the whole journey bundled and priced per passenger type; multiply by passenger count, not by number of flights.
- The confirmed invoice is binding. AERTiCKET’s terms make the confirmed
Total facturathe authoritative price; our storedtotal_amountmust always reconcile to it.
Response shapes
Section titled “Response shapes”The same net-price rule applies across the three response shapes the parsers handle:
| Shape | Endpoint(s) | Structure |
|---|---|---|
passengerList |
retrieve / reprice | one entry per passenger, no count |
passengerTypeFareList |
search / verify / booking / subfare | one entry per passenger type, multiply by count |
legacy priceList / totalPrice |
old fixtures | flat fallback |
Where the rule lives (all parsers must agree)
Section titled “Where the rule lives (all parsers must agree)”The net-price rule is currently implemented in seven places. Any new or changed Aerticket price calculation MUST follow it, and you should check the others for consistency:
| Parser | Path |
|---|---|
FareResult::fromArray() |
search |
VerifiedFare::fromArray() |
verify (holds the only legitimate BASE_FARE surcharge fallback) |
RePriceResponse::getTotalPrice() |
reprice within tolerance |
RePriceResponse::calculateSubFarePrice() |
reprice subfare (outside tolerance) |
CheckoutFlightBookingService::extractPriceData() |
checkout booking creation (domestic + international) |
AerticketCreateBookingJob |
booking creation |
BookFlight (Filament) |
manual admin booking |
Repricing persists money
Section titled “Repricing persists money”Repricing at ticket-issue time (AerticketIssueTicketJob → RePriceResponse) recomputes and persists the price to flight_bookings:
previous_amount— the quoted price before repricingtotal_amount— the recomputed net price (what a parsing bug corrupts)reprice_difference—total_amount − previous_amountrepriced_at— timestamp of the reprice
Because these are persisted, a price-parsing bug corrupts stored financial data, not just a display — and fixing the code does not retroactively correct already-written rows. Those require a separate, deliberate data correction.
Reconciling against the AERTiCKET invoice
Section titled “Reconciling against the AERTiCKET invoice”To check a stored cost against AERTiCKET’s invoice, open the PNR in the cabinet web tool (see Cabinet Web Tool Access) and confirm that (AGENCY_PURCHASE_PRICE + TOTAL_TAX) × pax equals the invoice Total factura. If it is higher by exactly fee × pax, the ticketing fee was double-counted.
Source: backend/app/Services/Flights/Aerticket/DTOs/Response/{FareResult,VerifiedFare,RePriceResponse}.php, backend/app/Services/Checkout/CheckoutFlightBookingService.php
Key Operations
Section titled “Key Operations”Verify Fare
Section titled “Verify Fare”use App\Services\Flights\Aerticket\AerticketVerifyService;
$verify = new AerticketVerifyService($cabinet);
$response = $verify->verify([ 'fare_id' => $fare->getId(), 'session_id' => $searchResponse->getSessionId(),]);
if ($response->isAvailable()) { $updatedPrice = $response->getPrice();}Create Booking (Async)
Section titled “Create Booking (Async)”use App\Jobs\AerticketCreateBookingJob;
AerticketCreateBookingJob::dispatch( fareId: 'fare-123', instantTicketOrder: true, userId: auth()->id())->onQueue('aerticket-bookings');Checkout Flight Booking (Per-Leg)
Section titled “Checkout Flight Booking (Per-Leg)”After successful checkout payment, bookings with flights move to pending_flight_booking. Each flight leg is booked independently with its own FlightBooking record and PNR.
Triggers:
- Automatically, on confirmed payment. Reserving costs nothing at the supplier — only issuing a ticket does — but it holds the fare, and fares have been seen to move hundreds of euros in the hour it took an operator to press the button. So
PaymentServicereserves as soon as the money is confirmed, and the booking passes straight throughpending_flight_bookingtoflight_booking_in_progress. - Filament booking actions on the booking detail page (
Book All Flights/Book Flightper-leg /Retry), unchanged. They remain the way in when the automatic path is skipped or fails.
Both go through FlightBookingDispatchService::dispatchPendingLegs(), so when to reserve is the only thing that differs between them. Two properties of that dispatch are worth knowing:
- Jobs are queued
afterCommit. The payment router runs inside a transaction and the queue connections useafter_commit=false, so a worker starting early would reserve against a payment that had not landed yet — or one that then rolled back. - A leg that already holds a
FlightBookingis never re-sent, and a booking whose every leg is reserved keeps its current status. Moving it toflight_booking_in_progresswith nothing queued would show neitherBook FlightnorRetryand lock the operator out.
Only a Redsys payment reserves on its own. This is an allow-list, not “anything that is not offline”: a gateway enabled later must be considered on its own merits before it starts committing us to suppliers, so silence means “not yet” rather than “go ahead”. An offline confirmation (bank transfer, or a card an operator charged by hand) never triggers it: that is a recovery flow with somebody already inside it — often building the booking from a checkout that never completed — so the reservation stays their call and the booking waits at pending_flight_booking. A payment the router cannot identify is treated the same way, since reserving commits us to a supplier and must not run on a guess.
Issuing the ticket is never automatic.
Once a leg holds a PNR, a separate Issue Flight / Issue All Flights action (#2206) sends it to Aerticket for ticket issuance (app/Filament/Resources/Bookings/Pages/ViewBooking.php). It is guarded by FlightBooking::canIssueTicket() (app/Models/FlightBooking.php): only non-manual bookings that hold a PNR and are not already issued, ticketing, voided, or cancelled are issuable.
Process (per leg):
- The trigger transitions the booking to
flight_booking_in_progress - One
CreateCheckoutFlightBookingJobdispatched per unbooked leg toaerticket-bookingsqueue - Each job calls
CheckoutFlightBookingService::bookLegForCheckout($booking, $legIndex):- International legs:
bookInternationalLeg()re-searches round-trip viaEconomyFlightSearchService::searchInternationalOnly()(economy) orBusinessFlightSearchService::searchBusinessFlightsRaw()(business), matches by flight numbers + price - Domestic legs:
bookDomesticLeg()searches one-way viaAerticketSearchService, matches by flight numbers + price
- International legs:
- Verifies fare availability via
AerticketVerifyService - Books via
AerticketBookServicewithinstantTicketOrder=false - Creates
FlightBookingrecord withsource=CHECKOUT,booking_id,leg_index, andflight_type - Dispatches
AerticketRetrieveBookingJobfor PNR details - All-legs-booked check: Only transitions to
flights_confirmedwhen ALL legs haveFlightBookingrecords
Business class specifics:
- Business fares are bundled (1 fare = both legs, itinerary index 1 only), unlike economy mix-and-match
BookingUpsellwith typeflight_upgradecontinues to be created for financial tracking
Retry logic:
- 3 attempts, 2 max exceptions
- 120s backoff between attempts
- 420s timeout per attempt
ShouldBeUniquewithuniqueId = "checkout-flight:{bookingId}:{legIndex}"
Idempotency: a cache lock on checkout-flight-booking:{bookingId}:{legIndex}, held across the booking call, with a per-leg FlightBooking::where(booking_id, leg_index)->exists() check inside it. The check on its own does not cover concurrency: its row lock is released when its transaction commits, which happens before the supplier call, so two concurrent deliveries of the job would both find no booking and both create a PNR. See Queue System for the jobs that run longer than retry_after and how each is guarded.
Error handling:
- Failures produce structured error context via
CheckoutFlightBookingException::$context, which is stored inbooking_status_transitions.metadata(includingleg_index) and displayed in the admin status timeline - Context is automatically extracted from Aerticket exception types (price change, fare expired, timeout, validation, verify error, booking error) – see Checkout API - Structured error context for details
- API error responses include
provider_errorsin context for debugging with Aerticket support - Admin users receive Filament database notifications on success/failure (per-leg and all-legs-complete)
- Failed bookings can be retried from the booking detail page (top-level or per-leg)
Limitations:
- No instant ticketing (manual ticketing required)
Source: backend/app/Services/Checkout/CheckoutFlightBookingService.php
Related: Checkout API - Admin-Driven Flight Booking
Issue Ticket
Section titled “Issue Ticket”use App\Jobs\AerticketIssueTicketJob;
AerticketIssueTicketJob::dispatch( bookingReference: 'ABC123', bookingId: $flightBooking->id, userId: auth()->id())->onQueue('aerticket-tickets');Fastlane Ticketing
Section titled “Fastlane Ticketing”Faster alternative to regular ticket issuance. Booking must be at least 5 minutes old.
use App\Jobs\AerticketFastlaneTicketingJob;
AerticketFastlaneTicketingJob::dispatch( bookingReference: 'ABC123', bookingId: $flightBooking->id, userId: auth()->id())->onQueue('aerticket-fastlane');Re-Price Booking
Section titled “Re-Price Booking”use App\Services\Flights\Aerticket\AerticketRePriceService;use App\Services\Flights\Aerticket\DTOs\Request\PriceRange;
$rePriceService = new AerticketRePriceService($cabinet);
// Re-pricing with price tolerance (±5 EUR)$priceRange = new PriceRange(min: 5.0, max: 5.0);$response = $rePriceService->rePrice('ABC123', $priceRange);
if ($response->hasNewFares()) { // Price outside tolerance - requires approval $subFareToken = $response->getSubFareToken();}View Ancillaries
Section titled “View Ancillaries”use App\Services\Flights\Aerticket\AerticketAncillaryService;
$ancillaryService = new AerticketAncillaryService($cabinet);
// For bookings (after booking)$response = $ancillaryService->availableBookingAncillaries( pnrLocator: 'ABC123', ancillaryTypes: ['BAGGAGE', 'MEAL']);
foreach ($response->getAncillariesByType() as $type => $ancillaries) { foreach ($ancillaries as $ancillary) { echo "{$ancillary->name}: {$ancillary->getFormattedPrice()}\n"; }}Cancel Booking
Section titled “Cancel Booking”use App\Services\Flights\Aerticket\AerticketCancelService;
$cancel = new AerticketCancelService($cabinet);
// Normal cancellation$result = $cancel->cancelBooking('ABC123');
// Force cancellation (even if tickets issued)$result = $cancel->cancelBooking('ABC123', forceCancellation: true);Validate Flight Route
Section titled “Validate Flight Route”Performs a test multi-city search to verify that a supplier tour’s international flight legs have actual flight availability. Used in the admin panel when creating or editing supplier tours.
How it works:
- Extracts international legs from the ProductTemplate itinerary via
FlightRouteConfigGenerator - Builds a multi-city
SearchRequestwith 2 adults, sample dates ~60 days out, origin MAD - Calls
AerticketSearchService::search() - Returns structured result with success/failure, route summary, fare count, and cheapest fare
Error types: no_legs, airport_resolution, no_results, timeout, validation, api_error
Admin integration:
- Create wizard: Checkbox in Step 1 triggers validation on Next (blocks progression on failure)
- Edit/View pages: Header button with confirmation modal
Source: backend/app/Services/Flights/FlightRouteValidationService.php
Related: Supplier Tours
Protecting the Reserved Fare
Section titled “Protecting the Reserved Fare”A reservation holds a price, and the airline holds it for less time than anyone assumed. Every
pnrRuleSet — on the booking response and on every retrieve — carries two deadlines, and
they expire different things:
| Field | Stored as | What runs out | Observed |
|---|---|---|---|
fareTicketTimeLimit |
flight_bookings.fare_expires_at |
The price we reserved at | Midnight of the booking day |
ticketTimeLimit |
flight_bookings.reservation_expires_at |
The seat — the airline cancels the PNR for want of a ticket | 3 to 21 days, airline-dependent |
automatedTicketTimeLimitCancellation.text |
flight_bookings.reservation_expiry_note |
The airline’s own wording for that cancellation | e.g. ET CANCELLATION DUE TO NO TICKET |
Losing the first costs money; losing the second costs the seat.
Aerticket sends local wall-clock times plus a timezone (Europe/Madrid). They are stored in
UTC — reading them as UTC would place a 23:59 Madrid deadline two hours after the price was
already gone. Both are refreshed on every retrieve, because an airline can move a ticketing
time limit after booking. A PNR that publishes no limit leaves the columns null, which reads
as unknown, never as expired.
The sweep. flights:protect-expiring-fares runs hourly (routes/console.php) and queues
ProtectExpiringFareJob for every unissued reservation whose fare dies within 12 hours.
Twelve rather than the one hour originally proposed: the fare typically dies at midnight, and
an approval request raised at 23:00 reaches nobody until the price is already lost. Pass
--hours= to change the window or --dry-run to see what would be queued.
The three outcomes, all recorded and all visible to operations:
- Within the ±5 EUR tolerance (
SettingsService::getRepricingTolerance()) — the new price is taken,repriced_atstamped, and a notification sent. Announced even when small: re-pricing without a cap is only acceptable while every instance is visible to somebody. - Outside tolerance — the booking enters the same approval flow issuance uses: the same
pending_reprice_approvalstatus andreprice_*columns, so the approval action already in Arkana picks it up with no second mechanism beside it. The price is not taken; a person decides. - The fare is gone — reported at once with the airline’s own wording rather than after
three futile retries, since a fare the airline no longer recognises will not recognise it
later either. The reservation is deliberately left untouched. Cancelling it to re-enter
the market risks the seat, and a seat lost in high season cannot be bought back at any
price, where a fare increase can. The moment is stamped on the reservation
(
fare_lost_at,fare_lost_reason), because a bell notification is read once and gone.
Those bookings are listed under the Fare expired, needs attention filter on the Flight
Bookings table, which has two ways in: the fare we established was gone (fare_lost_at), and
the deadline that passed with no re-price (fare_expires_at). They do not always arrive
together — a PNR the airline has already cancelled reports its fare gone while its own clock
still runs — and a filter watching only the clock would leave those invisible until it ran out.
When the Airline Drops the Reservation
Section titled “When the Airline Drops the Reservation”Past ticketTimeLimit an airline cancels an unissued PNR itself, and a retrieve is how we find
out. This is the one case where acting carries no risk, and it is the exception to
everything above: the seat is already gone, so re-booking cannot lose anything and the sooner
it runs the closer the market is to the price the customer was quoted.
AerticketRetrieveBookingJob queues RebookCancelledReservationJob, which marks the dead
reservation, frees its leg, and books a replacement through the same
FlightBookingDispatchService the operator’s button uses — inheriting the search filters, the
same-flight requirement and the re-pricing tolerance, with a price beyond it still stopping for
human approval.
It fires only for cancellations the airline made. The two are told apart by our own record:
cancelling from Arkana writes booking_status = 'cancelled' before calling Aerticket, so a PNR
the supplier reports as gone while we still consider it live can only have been dropped on
their side. Re-buying a seat an operator deliberately released would be a booking nobody asked
for, and the supplier reports both cancellations identically.
Two consequences worth knowing:
flights_confirmed → flight_booking_in_progressis a permitted transition, because a trip whose airline dropped a leg genuinely is back to booking flights. Without the way back it would sit inflights_confirmedholding a flight that no longer exists.- A cancelled reservation no longer counts as a held leg, which is what lets the dispatcher see the leg as open again.
A booking with no passengers attached is refused by the status machine rather than re-booked — loudly, which is the right outcome for a trip missing the data the supplier needs.
Source: app/Jobs/RebookCancelledReservationJob.php,
FlightBooking::wasCancelledByTheAirline()
Re-pricing itself lives in FareRepricingService, shared with AerticketIssueTicketJob, so
“the price moved” cannot come to mean two different things depending on which path asked.
Source: app/Console/Commands/ProtectExpiringFaresCommand.php,
app/Jobs/ProtectExpiringFareJob.php,
app/Services/Flights/Aerticket/DTOs/Response/TicketingDeadlines.php,
app/Services/Flights/Aerticket/FareRepricingService.php
Ticketing Observability
Section titled “Ticketing Observability”Ticketing is now observed end-to-end rather than assumed (#2199). Aerticket’s ticket/fastlane endpoints return a PNR locator but no reliable “ticketed” status in the immediate response, so the issue, fastlane, and create-booking jobs no longer mark ticket_status = 'issued' directly.
- Interim
ticketingstate.AerticketIssueTicketJob,AerticketFastlaneTicketingJob, andAerticketCreateBookingJob(instant-ticket path) set the booking to an interimticket_status = 'ticketing'after a successful call. The full set of states ispending | not_issued | pending_reprice_approval | ticketing | issued | voided | failed(app/Models/FlightBooking.php). - Retrieve is the authoritative promoter. A confirming
AerticketRetrieveBookingJobreconciles against RetrievePlus and is the only placeticketing → issuedhappens: a TICKETED PNR promotes toissued; a locally-issuedbooking that is not actually TICKETED is corrected back toticketing. ISSUEDpnrStatus recognized. RetrievePlus may returnISSUED(notTICKETED) for a fully ticketed PNR;RetrievedBooking::isTicketed()treats both as ticketed (STATUS_TICKETED,STATUS_ISSUED).- Structured per-stage logging.
AerticketLogContext::for($flightBooking, $stage, $extra)builds a stable, greppable log context (stage, PNR locator, booking ids, ticket/pnr status) with the request’sapihubflowidmerged in (app/Support/Aerticket/AerticketLogContext.php). Logs ship viaLOG_STACK=stderr,nightwatchso they reach both Loki and Nightwatch. - “Flights Logs” Filament resource. A read-only Filament resource (
app/Filament/Resources/FlightLogs/) surfaces the captured ticketing logs in the admin panel.
Passenger Validation
Section titled “Passenger Validation”Name Validation
Section titled “Name Validation”Per AerTicket API v3.17 specification:
- ASCII letters and spaces only (
/^[a-zA-Z\s]+$/) – no accents, numbers, or symbols - No
+character (causes booking failures) - Last name minimum 2 characters
- Each name maximum 57 characters
- Combined firstName + lastName: 2-57 characters total (
PassengerNameLengthrule) - Valid titles: MR, MRS, MS, CHD, INF, DR MR, etc.
These rules are enforced in both the Filament admin (camelCase fields: firstName/lastName) and the checkout API (snake_case fields: first_name/last_name). The PassengerNameLength rule auto-detects the naming convention.
Usage: See PassengerValidationRules::firstName() and ::lastName() for the rule arrays.
Source: backend/app/Rules/PassengerValidationRules.php, backend/app/Rules/PassengerNameLength.php
Age Category Validation
Section titled “Age Category Validation”- Infant (INF): 0-23 months
- Child (CHD): 2-15 years
- Adult (ADT): 16+ years
use App\Enums\AgeCategory;
$ageCategory = AgeCategory::fromDateOfBirth($dateOfBirth);
if (!$ageCategory->isValidForDateOfBirth($dateOfBirth)) { $errorMessage = $ageCategory->getValidationErrorMessage($dateOfBirth);}Error Handling
Section titled “Error Handling”Exception Types
Section titled “Exception Types”| Exception | Description |
|---|---|
AerticketValidationException |
Invalid input |
AerticketTimeoutException |
Request timeout |
AerticketSearchException |
Search error |
AerticketVerifyException |
Fare verification error |
AerticketBookingException |
Booking error |
AerticketTicketIssueException |
Ticket issuance error |
AerticketRePricingException |
Re-pricing error |
AerticketFareExpiredException |
Fare expired (410) |
AerticketPriceChangeException |
Price changed |
AerticketDailyBudgetExceededException |
Internal per-day call budget exhausted for search_upsell or available_fare_ancillaries. Caller degrades gracefully. |
Laravel Context Enrichment (Nightwatch)
Section titled “Laravel Context Enrichment (Nightwatch)”All Aerticket API requests and exceptions automatically add context data to Laravel’s Context facade, which is included in Nightwatch error reports for debugging.
Transport-level context (added by AerticketCabinetService):
aerticket_endpoint- API operation (e.g., “verify-fare”, “search”, “create-booking”)aerticket_environment- “uat” or “production”aerticket_timeout- Configured timeout in secondsaerticket_apihubflowid- Aerticket’s internal tracking ID from response header
Domain-level context (added by exception classes):
aerticket_fare_id- Fare ID (on booking/verify exceptions)aerticket_booking_reference- PNR locator (on retrieve/cancel/ticket exceptions)aerticket_error_details- Error details array when available
This context is automatically captured in Nightwatch reports when exceptions occur, making it easier to debug issues with AerTicket support.
Source: backend/app/Services/AerticketCabinetService.php, backend/app/Exceptions/Aerticket/*.php
Example Error Handling
Section titled “Example Error Handling”try { $results = $search->search($data);} catch (AerticketValidationException $e) { return response()->json(['error' => $e->getMessage()], 422);} catch (AerticketTimeoutException $e) { return response()->json(['error' => 'Request timed out'], 504);} catch (AerticketSearchException $e) { Log::error('Search failed', ['error' => $e->getMessage()]); return response()->json(['error' => 'Search failed'], 500);}Local fixture replay
Section titled “Local fixture replay”AerTicket /search calls take 5–30s and consume daily API budget, which makes iterating on post-search ranking, signature-match, and cache-update logic painful. The fixture system captures real responses to JSON files on disk and replays them in ~30ms on subsequent calls. The replayed Illuminate\Http\Client\Response is byte-equivalent to a live one — same status, same headers, same body — so callers can’t tell the difference.
Toggle the system with AERTICKET_FIXTURES_MODE in .env. Captures land in storage/app/aerticket-fixtures/ and are gitignored (PNRs, fareIds, and apihubflowids are PII).
| Mode | Behaviour |
|---|---|
off (default) |
Production. Always hit AerTicket. Never read or write fixtures. |
replay |
Read-only. Hit on the captured fixture, fail loudly on miss. Best for deterministic tests and “find every code path that still tries to reach AerTicket” debug sessions. |
replay_or_record |
Replay if a fixture exists; otherwise hit live and save the response for next time. First run = slow, every run after = ~30ms. Best for the dev loop. |
record |
Always hit live, always overwrite the fixture. Refresh stale captures without manually purging. |
Successful responses (2xx) are persisted; transient 5xx errors don’t poison the cache.
Capture a flight
Section titled “Capture a flight”The capture command is flight-centric — it operates on DynamicFlightCache rows (the same unit the admin “Re-cache” button works on):
# A specific cache rowsail artisan aerticket:fixtures:capture --flight=1629
# Every row of a route on a datesail artisan aerticket:fixtures:capture --route=24 --date=2026-09-15
# Every PENDING rowsail artisan aerticket:fixtures:capture --pending
# Skip the reset confirmation promptsail artisan aerticket:fixtures:capture --flight=1629 --forceInternally the command:
- Resolves the entry/entries from the flag.
- Resets non-
PENDINGrows toPENDINGviaDynamicFlightCachePopulatorService::resetEntryForRecache(with a confirmation prompt; same operation the admin Re-cache button performs — drops sibling fare positions 2..N, repoints orphanedoffer_flightsto position 1, clears segments). This avoids the unique-constraint collision on(flight_cache_id, leg_sequence, itinerary_index, segment_number). - Forces
aerticket.fixtures_mode = recordfor the run. - Calls
DynamicFlightCachePopulatorService::executeSearches($entries)— same path the populator job uses. Automated baggage resolution stays inside the captured/searchresponse.
A typical economy round-trip capture produces:
- 1 ×
search-{hash12}.json— the main flight response (~5–6 MB).
Inspect what’s captured
Section titled “Inspect what’s captured”sail artisan aerticket:fixtures:list+----------------------------+----------------------------------------------+----------+---------------------------+| Endpoint | File | Size | Captured at |+----------------------------+----------------------------------------------+----------+---------------------------+| search | search-562f0a4a66fd.json | 5.6 MB | 2026-04-29T09:10:03+00:00 || search-upsell | search-upsell-187482a3f255.json | 331.6 KB | 2026-04-29T09:10:18+00:00 || available-fare-ancillaries | available-fare-ancillaries-d454529a5a45.json | 1.8 KB | 2026-04-29T09:10:33+00:00 |+----------------------------+----------------------------------------------+----------+---------------------------+13 fixture(s) at /var/www/html/storage/app/aerticket-fixturesMode: replay_or_recordRefresh or wipe
Section titled “Refresh or wipe”# Refresh a single flight against live AerTicket (overwrites the existing fixture)AERTICKET_FIXTURES_MODE=record sail artisan aerticket:fixtures:capture --flight=1629 --force
# Wipe everything — confirms first unless --forcesail artisan aerticket:fixtures:purgeVerifying replay
Section titled “Verifying replay”Three ways to confirm a request is hitting the fixture instead of AerTicket:
-
Tail the log
Terminal window sail artisan pail --filter=AerticketReplay logs:
[INFO] Aerticket fixture replay hit endpoint=search hash=562f0a4a66fd mode=replay_or_recordLive calls log
Making API requestfollowed seconds later byAPI response received. -
Telescope HTTP Client tab at
localhost/telescope/client-requests. Replay shows zero new outbound requests toapihub.aerticket-it.de. -
Stopwatch. Live re-cache: 30s+. Pure replay: 3–8s (the time is now DB writes, not HTTP). The browser’s network tab shows the same.
Recipe: dev loop on flight-pipeline code
Section titled “Recipe: dev loop on flight-pipeline code”AERTICKET_FIXTURES_MODE=replay_or_record
# Capture once (slow)sail artisan aerticket:fixtures:capture --flight=1629
# Iterate on FlightRankingPolicy / EconomyFlightSearchService / cache-update etc.# Every reload of /es/checkout/<offer> hitting that flight returns from disk in ~30ms.
# When AerTicket data drifts and you need fresh prices:sail artisan aerticket:fixtures:capture --flight=1629 --force
# Find code paths that still try to talk to AerTicket — switch to strict replay:# .envAERTICKET_FIXTURES_MODE=replay# A miss now throws, pinpointing the offending call site.How requests are matched
Section titled “How requests are matched”AerticketFixtureStore::hashKey($endpoint, $data) computes a SHA-256 of endpoint + sorted-keys-JSON(normalized payload). The normalization:
- Drops
apihubflowid,internalRequestId,requestId,requestSentAt. - Sorts
segmentListbydepartureDateso equivalent round-trip requests with different leg order share a hash.
Requests that don’t match any captured fixture either fail (replay) or fall through to live + record (replay_or_record).
Configuration knobs
Section titled “Configuration knobs”config/aerticket.php:
'fixtures_mode' => env('AERTICKET_FIXTURES_MODE', 'off'),'fixtures_path' => env('AERTICKET_FIXTURES_PATH', storage_path('app/aerticket-fixtures')),Keep AERTICKET_FIXTURES_MODE=off in production. The default is off precisely so a leaked dev .env can’t accidentally serve cached responses to real customers.
Testing
Section titled “Testing”# All AerTicket tests./vendor/bin/sail artisan test --filter=Aerticket
# Connection test./vendor/bin/sail artisan aerticket:test-connection
# Re-pricing tests./vendor/bin/sail artisan test --filter=RePrice
# Ancillary tests./vendor/bin/sail artisan test --filter=AncillaryTroubleshooting
Section titled “Troubleshooting”Authentication Failure (401)
Section titled “Authentication Failure (401)”php artisan tinker>>> config('aerticket.credentials.login')Connection Timeout
Section titled “Connection Timeout”Increase the relevant timeout in .env (there is no single AERTICKET_TIMEOUT; timeouts are per environment and per operation):
# Per environment (config/aerticket.php)AERTICKET_UAT_TIMEOUT=60AERTICKET_PROD_TIMEOUT=60
# Search operations override the environment timeoutAERTICKET_SEARCH_TIMEOUT=60Missing apihubflowid
Section titled “Missing apihubflowid”Check logs:
./vendor/bin/sail artisan pail --filter="apihubflowid"The apihubflowid is required when contacting AerTicket support.
Related Documentation
Section titled “Related Documentation”- Async Booking Workflow - Queue-based booking
- Flight Search UI - FilamentPHP interface
- Airport Search - Airport lookup service
- Queue System - Queue infrastructure