Skip to article

The cart is the hardest problem in travel

A flight, a hotel and a transfer in one cart looks like ecommerce. It is actually a distributed transaction across suppliers that never agreed to coordinate, with inventory that expires while the customer types a card number.

Every travel product eventually draws the same wireframe. A flight card, a hotel card, a transfer card, one total, one button. It looks like a shopping cart, and that resemblance is the trap.

A retail cart holds items that exist. The warehouse has the shoes. The price is the price. Between "add to cart" and "pay," almost nothing changes, and if it does, the store absorbs it silently because margins allow it.

A travel cart holds promises from three or four independent companies, none of which know the others exist. The airline's fare can reprice while the traveller reads the cancellation policy. The bedbank's hold expires on a timer the airline has never heard of. The activity supplier offers no hold at all. Then one button has to commit all of them, atomically, from the traveller's point of view, across systems that offer no atomicity whatsoever.

That is not ecommerce. That is a distributed-systems problem wearing an ecommerce costume.

A cart is a set of decaying options

The first mental shift: cart items are not items. They are options with expiry dates, and every supplier sets a different one.

A low-cost carrier fare is typically not held at all. The quote you show is a snapshot of a yield-management system that reprices continuously. Treat it as accurate for minutes, sometimes less on high-demand routes. A full-service carrier via NDC may give you a priced offer with an explicit validity window. A bedbank hold is often a real reservation with a countdown, twenty minutes being a common shape. A tours-and-activities supplier frequently offers no hold state at all: you find out the real price and availability at the moment you try to book.

ComponentTypical holdReprice behaviourRisk at commit
LCC fareNoneContinuous, can move in minutesHigh: price or seat gone
NDC offerExplicit offer expiryFixed until expiry, then re-shopMedium: bounded by offer TTL
Bedbank roomTimed hold, often ~20 minStable during holdLow during hold, high after
ExperienceNoneChecked at booking timeMedium: capacity-driven

So the cart's core data structure is not a list of line items. It is a set of decaying options, each with its own clock, and the cart's job is to keep the traveller inside the window where all of them are still simultaneously true.

T+0 T+10m T+20m T+30m FLIGHT · LCC FARE no hold · quote only HOTEL · BEDBANK timed hold · 20 min EXPERIENCE no hold · book to know reprice risk begins hold released price checked only at commit COMMIT BEFORE FIRST EXPIRY
Each cart item decays on its own clock. The commit window is their intersection.

Once you see the cart this way, the engineering agenda writes itself: track a TTL per item, surface the tightest one to the traveller, and revalidate everything the moment intent turns serious.

The reprice race

Between "add to cart" and "pay" there is always a gap, and in that gap prices move. So a serious cart revalidates every component immediately before commit: one final availability-and-price check per supplier, in parallel, with a short timeout.

Three outcomes come back, and each needs a decided policy rather than an improvised one.

Everything holds. Proceed. This is the common case and it should feel instant.

One leg repriced. Now you choose from exactly three moves. Surface the delta: show the traveller the new total with the changed component highlighted, and require an explicit re-accept. Re-shop: silently fetch alternatives for the changed component and offer a swap, useful when the original fare class simply sold out. Absorb: eat the difference when it is inside a policy threshold, because interrupting a high-intent checkout over a small delta costs more in abandonment than the delta itself.

The mistake is treating this as an error path. On volatile routes, repricing is a routine event, and the policy deserves configuration, not hardcoding:

{
  "revalidation": {
    "timeout_ms": 4000,
    "on_reprice": {
      "absorb_up_to": {
        "amount": 300,
        "currency": "INR",
        "pct_of_component": 2.0
      },
      "above_threshold": "surface_delta",
      "component_unavailable": "reshop_same_constraints"
    },
    "on_supplier_timeout": "treat_component_as_unverified"
  }
}

A component died. The fare class is gone or the room type sold out. Re-shop within the traveller's original constraints, and never let the rest of the cart expire while they consider the replacement. Refresh the surviving holds in the background.

One subtle race remains even after revalidation: the check and the booking are separate calls, and the price can move between them. Revalidation shrinks the window from minutes to seconds. Only the commit protocol closes it.

The commit is a multi-party saga

Here is where the ecommerce analogy fails completely. When the traveller pays, you must book with three or four suppliers, and there is no transaction coordinator on Earth that spans an airline's ticketing system and a bedbank's reservation database. Two-phase commit requires every participant to expose a prepare phase, hold locks through the coordinator's decision, and obey an unlock protocol. Airlines and bedbanks expose none of that. You get "book" and "cancel," with fees, deadlines and moods attached. So the only available correctness model is a saga: a sequence of local transactions, each with a compensating action if a later step fails.

Order of operations matters enormously:

  1. Authorise payment first. One authorisation for the full total. No supplier calls until the money is real. An auth is reversible; a booking may not be.
  2. Book the most volatile item first. Usually the flight. It is the component most likely to fail or reprice, and failing early is cheap: nothing to compensate yet. It is also often the hardest to reverse once ticketed, which is exactly why you want it committed while every other component is still safely inside a hold.
  3. Confirm the held items next. The bedbank hold converts to a booking. This should be near-deterministic inside the hold window, which is why it can safely wait its turn.
  4. Book the no-hold items. The experience, the transfer. Highest uncertainty per call, cheapest to compensate.
  5. Capture per confirmation. Convert slices of the auth to captures as each component confirms.

Every supplier attempt carries its own idempotency key, persisted before the call is made. Supplier APIs time out after the booking exists on their side more often than anyone admits. A retry without a key creates a duplicate PNR and a very unpleasant reconciliation ticket. A retry with a key returns the existing booking. And when a call times out, the honest state is unknown, not failed: query the supplier, or reconcile out of band, before you dare retry or compensate.

Then a later step fails, and the saga earns its keep. Suppose the flight ticketed and the hotel confirm fails hard: hold expired mid-flow, or the bedbank rejected the conversion.

ORCHESTRATOR PAYMENT AIRLINE BEDBANK auth full total · single auth auth ok ticket flight · idem key k1 ticketed · pnr capture flight portion confirm held room · idem key k2 confirm FAILED · hold lapsed COMPENSATION PATH void ticket inside void window · else refund path refund captured flight portion release remaining auth END STATE · order failed cleanly · traveller made whole · every step journalled
Commit saga: the hotel confirm fails after the flight ticketed, so the saga walks backwards.

The compensation path is where policy and engineering meet. Many tickets can be voided free within a window; after that, compensation means a refund flow with different economics. Sometimes the right compensation is not reversal at all but recovery: re-shop the hotel and offer the traveller a save, because they wanted the trip, not the refund. Either way the orchestrator journals every step, because a crash between "flight ticketed" and "hotel failed" must resume into compensation, not amnesia. Partial states are not exceptions to hide. They are states to design, as the order lifecycle essay argues from the servicing side.

The money has its own shape

Payment for a multi-supplier cart has a correct shape, and most first implementations get it wrong by charging per component.

One auth, staged captures. The traveller sees one payment. Underneath, authorise the full total once, then capture per supplier confirmation. If the saga aborts, the un-captured remainder releases automatically and only confirmed components were ever charged. Capture-per-confirmation also gives finance a clean join: every capture maps to exactly one supplier reference.

Refunds map back to components. When the hotel cancels three weeks later, the refund must trace to the hotel's captures, not to "the order." This is why the ledger lives at component level from day one: order totals are a view over component entries, never a field you overwrite. The payments and reconciliation essay follows that thread through settlement.

Currency is quieter and nastier. The airline prices in EUR, the bedbank in USD, the experience in THB, and the traveller pays in INR. Convert each component at a rate you record per component at commit time, because a refund a month later must reverse at the booked rate, not today's. Then rounding: each component rounds to the presentment currency's minor unit independently, and the sum of rounded components must equal the charged total exactly. Round components, compute the residual against the rounded total, and assign the remainder deterministically to a designated component. A cart that is one paisa off will fail reconciliation forever, and nobody enjoys debugging a one-paisa discrepancy across four supplier statements.

What the "just call two APIs" demo never shows

The demo works. Two API calls, a combined total, a happy path on stage. It dies in production the first week, and now the reasons are visible: the demo carried no TTLs, so the fare it displayed was stale before checkout. It never revalidated, so the first reprice became a support ticket. It booked sequentially with no compensation, so the first hotel failure stranded a ticketed flight on the company card. It charged per component, so the first partial failure double-charged a traveller. It ignored rounding, so finance found it before the customers did.

A real order abstraction owns all of it: per-item hold state and TTLs, a revalidation step with explicit reprice policy, a journalled saga with ordered booking and compensating actions, idempotency keys on every supplier attempt, single-auth staged-capture payment with component-level ledger entries, and per-component currency and rounding rules. That is the actual product behind the one button. The cart looks like the easiest screen in travel. It is the hardest, because it is the exact point where every supplier's clock, price and failure mode must agree for a few seconds, and someone has to be responsible for making them agree. That someone is the order layer.