← Back
Home › Systems › Finance, HR and back office

corporate-travel

Lets employees book flights, hotels and trains from live supplier inventory while enforcing company policy and approvals.

Navan, SAP Concur Travel, TravelPerk

Open the corporate-travel pagecorporate-travel Hdr histogramdata-structures: Log-linear bucketed histograms with bounded relative error for latency percentilesDeterministic simulationtestingdistributed: Run the whole system in one thread under a seeded scheduler with simulated network, disk and clock, so every failure replays from its seed.›Primary-backup replicationdistributed: One node orders all writes and ships them to standbys; the acknowledgement rule (none, one, all) sets what a crash loses, and an external arbiter decides who takes over.›Saga patterndistributed: Split a cross-service operation into local transactions, each paired with an undo; on failure run the undos in reverse, or retry forward.›Authorization holdsfinance: Pending vs posted balances: auth holds, incremental auths, partial captures and hold expiryCurrency cross ratetriangulationfinance: Deriving cross rates through a base currency with correct precision, inversion and spreadsDouble-entry ledgerfinance: Append-only journal of transactions whose postings sum to zero per currency; balances are derived, so money can only move, never appear or vanish.›Idempotency keysfinance: Client-chosen unique key per logical operation, stored with a request fingerprint and the final response, so retries replay the first result instead of acting twice.›Money representationfinance: Store money as integer minor units with an explicit currency exponent, compute in wider integers, and round once with a named rounding mode.›Payment state machinefinance: Persisted state machine for a payment lifecycle (authorize, capture, void, refund) that treats timeouts as unknown, retries with the same key and applies webhooks idempotently.›Reconciliationfinance: Match internal ledger records against bank and processor statements by reference, then amount and date window; every unmatched or mismatched item becomes an exception.›Geodesic distance (haversine,Vincenty, Karney)geometry: Shortest-path distance between two lat/lon points on the Earth: haversine on a sphere (about 0.5% error), Vincenty or Karney on the WGS84 ellipsoid (sub-millimetre).›Geospatial cell index (geohash,S2, H3)geometry: Map a lat/lon to a 64-bit cell id on a hierarchical grid, so "near" becomes an integer range scan in any ordinary sorted index.›Point in polygongeometry: Ray-casting/winding tests and indexed polygon lookup for reverse geocodingProcess supervisionhardware-os: A parent process starts services, reaps their exits and restarts them under a delay and burst-limit policy, then stops them with SIGTERM before SIGKILL.›Learning to rankmachine-learning: Train a scorer on query-grouped data so that the order it induces maximizes NDCG; LambdaMART (boosted trees with NDCG-weighted pair gradients) is the production default.›Mobile push deliverynetworking: Sending notifications through APNs and FCM: HTTP/2 requests, token hygiene, collapse keys, TTL and priority, and the retry rules that keep you un-throttled.›Webhook deliverynetworking: Reliable server-to-server HTTP callbacks: signed POSTs, 2xx-means-done, retries over days with jitter, endpoint disabling, SSRF guards and idempotent receivers.›Coverage-guided fuzzingsecurity: Mutate inputs, run an instrumented target, and keep any input that reaches a new code edge; feedback turns blind guessing into incremental search.›Dual control approvalsecurity: Four-eyes / maker-checker approval with quorum policies for sensitive operationsMutual TLSsecurity: TLS where the client also proves a certificate key, so every connection carries a verified, rotatable machine identity.›Policy engines (PDP/PEP, Cedar,Rego, ReBAC)security: Decide allow/deny for (principal, action, resource, context) in a separate engine with default deny, forbid-wins and an auditable list of determining policies.›Samlsecurity: SAML 2.0 assertions, XML signatures, SP/IdP flows and canonicalization pitfallsPoint-in-time recoverystorage: Periodic base backups plus a continuously archived change log let you rebuild the state as of any instant, then branch from there.›XML Streaming Parserstorage: Tokenize XML into start, text and end events in constant memory, so a few fields can be extracted from huge, broken or hostile documents.›Calendar date arithmeticsystems: Civil dates, time zones (tzdb), DST gaps, month-end anchoring and duration mathCircuit breakersystems: Stop calling a failing dependency for a while, probe it with trial calls, and resume on recovery: fail fast instead of piling up timeouts.›Graceful drainingsystems: On a termination signal, stop taking new work, finish in-flight work until a deadline, tell clients to reconnect elsewhere, then exit; clients resume from a token.›Hedged requestssystems: Sending a backup request after a latency percentile and taking the first answer to cut tail latencyStale while revalidatesystems: Serving cached data past TTL while refreshing in the background, with request coalescingToken bucketsystems: A bucket of up to b tokens refilled at rate r; requests spend tokens, enforcing an average rate while allowing bursts of b.›Cooperative schedulingconcurrency: Green threads/coroutines yielding at await points on a user-space schedulerFault injectiondistributed: Inject latency, errors, kills and partitions via proxies, netem or hooksRecord replaydistributed: Record every nondeterministic input (time, randomness, I/O, model and tool outputs, per-tick player input) and re-execute deterministically for debugging, replays and state rebuildHealth checkingsystems: Decide per backend whether to route to it using probes and real-traffic errors, with consecutive-count hysteresis, capped ejection and a panic fallback.›Transactional outboxdistributed: Write the event as a row in the same local transaction as the state change; a relay publishes it later at-least-once, so state and events never diverge.›Iso 8583 messagesfinance: ISO 8583 card messages: MTIs, bitmaps, data elements and their encodingsCron schedulingsystems: Cron expression parsing and next-fire-time computation, with catch-up and time-zone rulesLeasesdistributed: A lock that expires on its own: bounded-time ownership that survives crashes and partitions, given bounded clock drift and fencing tokens on writes.›Cryptographic hashes (SHA-2,BLAKE3)security: Collision-resistant fingerprints of data; SHA-256 for interoperability, BLAKE3 for speed, HMAC/HKDF for keyed uses.›Largest remainder roundingfinance: Splitting an amount into parts that sum exactly (largest remainder / Hamilton) for allocations, splits and prorationsEvent sourcingfinance: Persist state changes as an append-only stream of immutable events per entity; current state is a fold over them, guarded by expected-version appends.›Hierarchical state machinesystems: Nest states so a substate inherits its parent's reactions and only overrides what differs, with ordered entry and exit actions along the path.›+ 29 moreTransparent interceptionnetworking: Redirect traffic to a local proxy (iptables TPROXY/REDIRECT, eBPF) without app changesSyscall interceptionsecurity: Intercept syscalls via ptrace, seccomp user notify or a user-space kernelEvent deduplication windowdistributed: Keep the ids of recently accepted events for a time window and drop repeats; memory stays bounded by rotating time buckets instead of per-entry timers.›Change data capturestorage: Decode the database's own commit log into row-change events in commit order, with a resume position, so any consumer can mirror the data exactly.›Fencing tokensdistributed: A number that grows with every lock grant and is checked by the protected resource, so a paused ex-holder's late write is rejected.›Optimistic concurrency controlstorage: Run transactions without locks, record what they read, and validate at commit that nothing changed; fastest when conflicts are rare.›+ 13 moreEbpfhardware-os: Verified in-kernel programs on hooks (kprobes, XDP, tc) with mapsPtrace process controlhardware-os: Ptrace: attach, read and write registers and memory, single-step, intercept signalsSeccomp bpfsecurity: Syscall filtering with seccomp BPF programsConsumer offsetsdistributed: Each consumer group stores one integer per partition, the next position to read, so a restarted consumer resumes where it left off with at-least-once delivery.›Incremental sync cursorsdistributed: The client keeps one opaque token; each pull returns only the changes after it plus the next token, so sync cost scales with what changed, not with the dataset.›Message sequence numbersdistributed: A per-stream counter on every message lets the receiver detect loss, duplicates and reordering, ack cheaply and resume after reconnect with "send from N".›+ 6 moreSegmented logdistributed: Split an append-only log into files named by their first offset, write only the newest, and find any record by binary search plus a sparse index.›Cursor paginationnetworking: Keyset/opaque-cursor pagination for stable incremental reads from APIs and tables+ 1 more

How it is built

Architecture overview

A corporate travel platform lets employees book flights, hotels and trains from live supplier inventory while the employer's rules decide what is allowed, who must approve, and which cost centre pays. It is a thin, fast layer in front of slow, unreliable suppliers (airline and hotel systems that speak XML or JSON over the internet), plus a slow, careful back office that turns each booking into money: charges, invoices, refunds and statements. The idea that makes it work is to keep these two speeds apart. Search is read-only, cached and hedged, so a slow supplier only costs a slower answer. Booking is a persisted saga with idempotency keys, a payment state machine and a double-entry ledger, so a timeout never becomes a double charge or a ticket nobody paid for. The company's policy sits between them as one engine, asked the same question at search time (is this offer in policy?) and at booking time (does this need approval?).

Parts:

 employee / agent ──► web + app ──► SAML single sign-on ──► traveller profile, cost centre, manager
                          │
                search ───┤                                   ┌──────────────┐
                          ▼                                   │ policy engine│◄── rules per company
   ┌─────────── supplier gateway ───────────┐   offers + flags │ (PDP)        │
   │ cache (SWR) ─► fan-out, hedge, breaker ├────────────────► └──────┬───────┘
   │ airline XML/JSON · hotel feeds · rail  │                         │ decision + reasons
   └───────────────────┬────────────────────┘                         ▼
                       │ normalised offers            ranking ─► results page
                       │                                                │ select
   book ──► approval (if out of policy) ──► booking saga ──► supplier order
                                                │  ▲                │
                                  payment state machine           ticket / voucher
                                                │                    │
                                  double-entry ledger ◄──────────────┘
                                                │
                       reconciliation ◄── airline settlement, card and bank statements
   events ──► webhooks to customer systems · push to travellers

How the parts fit together

Supplier gateway

Every search fans out to several sources and waits for the slowest one unless told otherwise. Four blocks keep that survivable.

Stale-while-revalidate (no page yet) serves a cached result immediately and refreshes it in the background; HTTP standardised the idea as the stale-while-revalidate and stale-if-error cache-control extensions (RFC 5861). It suits browsing (price calendars, hotel lists), where a price a few minutes old still helps choosing, but never the moment of purchase, which re-prices live: Duffel's flight API says offers go stale quickly and that the client should fetch the single offer again before asking for passenger and payment details.

Hedged requests (no page yet) cut the slow tail: if a supplier call has not answered after roughly the 95th-percentile latency for its class, send a second copy to another replica or route and take the first answer. Dean and Barroso report that this adds about 5 % load for a large cut in tail latency. Hedge only reads. A booking call is never hedged unless it carries an idempotency key.

Circuit breaker stops calling a supplier that is failing, so searches return the other suppliers' results in time rather than all waiting for one timeout. Token bucket enforces the supplier's own rate limits and each customer's share of them, so one company's bulk search cannot starve the rest.

XML Streaming Parser matters because the airline side speaks XML: IATA's NDC standard is an XML-based data transmission standard for airline offers and orders, and the OpenTravel Alliance's message standards for air, hospitality and car rental come from the same XML world (the industry is now moving parts of them to JSON). A streaming parser pulls out the fields needed per offer without building a tree of a large response, and rejects DOCTYPE declarations from untrusted senders.

The gateway's contract: normalised offers (one shape for all suppliers, integer minor units with an ISO 4217 currency, times with their zone) plus an expires_at; a supplier failure becomes a "source missing" marker, never an error page.

Pricing and dates

Money representation fixes the shape of every amount: integer minor units and a currency code. Different suppliers quote in different currencies, and a company wants one reporting currency. currency-cross-rate-triangulation (no page yet) converts A to B through a base currency; the European Central Bank publishes reference rates as units of each currency per one euro, usually updated around 16:00 CET on working days, so the rate from A to B is the euro-to-B rate divided by the euro-to-A rate. Convert once, with a named rounding mode, and store the original amount, the converted amount and the rate, because the supplier invoice is in the original currency.

calendar-date-arithmetic (no page yet) is where many travel bugs live. A hotel night is a difference of local calendar dates, a flight is an instant with two zones (it can land "yesterday" in local time), and an advance-purchase rule such as "book at least 14 days ahead" must count days in the traveller's zone. The IANA time zone database is the source for offsets and daylight-saving rules; store instants in UTC plus the zone name, never a bare offset.

Ranking and geography

A short list that puts the best offer first matters more than a long list, so Learning to rank scores each offer with a model trained on what travellers in that company picked (price, duration, stops, cabin, in-policy flag, distance to the meeting). The in-policy flag is both a feature and a visible badge or filter; a ranker that ignores policy shows results nobody may book.

Geodesic distance gives "4.2 km from the office" on a sphere or ellipsoid, and Geospatial cell index makes "hotels within 3 km of this address" an integer range scan in the ordinary database. Duty of care needs the opposite question: which of our travellers are inside this alert zone right now? point-in-polygon (no page yet) tests each traveller's last known position (a booked hotel, a flight leg) against a polygon, with cell ids used first to discard the many that are far away.

Policy and approval

Policy engines answer one question in one place: may this traveller book this offer in this context? The input is the traveller's grade, cost centre and country, the trip purpose, the offer's price, cabin and dates; the output is allow, deny or "allow with approval", plus the rules that decided, so the page can say "over the 180 EUR nightly cap in Zurich". Default deny and a logged decision make this auditable. The same engine runs at search time (to label results) and at checkout (to enforce), so the two cannot drift apart. Hand-coded if cabin == business checks are the alternative; they work until the second customer has a different rule.

dual-control-approval (no page yet) is the approval half. Perk (formerly TravelPerk) documents the shape: a travel approval process is either "line manager" based or approver based, and each approver has a condition, one of always, out_of_policy, or out_of_policy_and_notify_in_policy. The contract: the policy decision produces who must approve, the approval service creates a pending request that holds the offer (see below), and the traveller can never approve their own trip. Approver data (manager, delegate, cost centre owner) arrives from the customer's identity provider through provisioning (SCIM is the standard; Perk documents it for Okta and Microsoft Entra ID).

saml (no page yet) is how the employee reaches all of this without a new password: the company's identity provider sends a signed assertion with a validity window (NotBefore and NotOnOrAfter in the SAML 2.0 core specification), and the platform maps it to a traveller profile.

Booking

A booking changes several systems that share no transaction: the airline order, a card hold or charge, the hotel, the ledger. Saga pattern gives each step an undo (cancel the order, void the hold, release the room) and runs the undos in reverse if a later step fails; a two-phase commit is impossible because the suppliers will not join one. Payment state machine models the money side as persisted states (authorised, captured, voided, refunded) in which a timeout means "unknown", not "failed". Idempotency keys make every retry safe: the saga step id is the key sent to the supplier and to the payment provider, so the same retry returns the first result.

authorization-holds (no page yet) is the travel-specific twist. Travel is paid later than it is chosen: an unapproved trip is held, an airline fare is guaranteed only for a short time, and a hotel often authorises the card at booking and captures at check-out (Stripe's documentation describes exactly this hotel pattern, and notes that card authorisations typically last about 7 days for online payments and then expire and release the funds). The platform therefore stores each hold with its expiry, re-authorises before it lapses, and captures only when the supplier confirms. Duffel's order cancellation shows the other side: cancelling is a two-step flow, first a quote that says how much is refunded and to what (original payment or airline credit), then a confirmation.

Accounting

Double-entry ledger records each booking as postings that sum to zero: traveller's cost centre, supplier payable, service fee, tax. A cancel or exchange is a reversing entry, never an edit, which makes the invoice defensible: Perk, for example, documents that it issues invoices as merchant of record to an invoice profile (the legal entity a trip is invoiced to), and sends an invoice.issued event when a billing period closes.

Reconciliation compares the ledger with what others say happened. For airline tickets the reference is the industry settlement system: IATA's Billing and Settlement Plan gives each accredited agent one sales report and one remittance per period instead of a payment to every airline. Card statements and bank files form the second and third side. Every unmatched item becomes an exception someone owns.

Notifications

Webhook delivery sends events to customer systems (expense tools, HR systems). Documented platforms agree on the contract: HTTPS POST, 2xx means received, signed payloads (Perk uses an HMAC-SHA256 header), retries with exponential backoff (Duffel for 72 hours), and at-least-once delivery in no guaranteed order, so receivers deduplicate on the event id. Mobile push delivery reaches the traveller on the road (gate change, cancellation) through APNs and FCM; collapse keys let a rebooking replace the old alert.

Operations

Primary-backup replication. The booking database holds saga state, stored holds with their expiries, supplier order ids and the ledger. Losing a step of a booking saga would leave a supplier order or a card hold that nobody knows about, so the database has a backup that holds each saga step before the call to the supplier is made or acknowledged (Primary-backup replication). After a failover the saga runner resumes from the last stored step and the step's idempotency key makes the repeated supplier call return the first result.

Point-in-time recovery. A faulty migration, a wrong bulk policy change or a mistaken invoice run can corrupt bookings and ledger entries after the fact. Point-in-time recovery restores the booking and accounting tables to the moment before from base backups plus the log. Because suppliers hold the real orders, the restored state is then reconciled against supplier orders and card statements (Reconciliation) before the month close runs.

Process supervision. Supplier adapters (one per airline, hotel or rail source), the saga runner, the hold-renewal job, the webhook senders and the month-close jobs run as supervised processes (Process supervision). A crashing adapter for one supplier is restarted and its circuit breaker keeps the other suppliers' results flowing; a restarted saga runner picks up unfinished sagas from the stored step.

Graceful draining. A booking in progress is a chain of slow remote calls (supplier order, card network). On deploy a node stops starting new sagas, lets running ones reach a stored step boundary or finish, and hands any saga it cannot finish to another node, so a deploy never leaves an order without a ledger entry (Graceful draining). Search nodes drain their in-flight supplier fan-outs and let the cache serve in the meantime.

Mutual TLS. The internal services (search, policy, booking, accounting, notification senders) authenticate each other with certificates, as do the private links to suppliers and the card or payment provider that offer them (Mutual TLS). Booking accepts a "place hold" or "capture" call only from the saga runner's identity, so a compromised search node cannot move money.

Latency percentiles. Travel is dominated by remote latency, so tails matter. hdr-histogram (no page yet) records per supplier the time of search responses (which decides when a hedged request fires and when the circuit breaker opens), the time to first results page, and the booking saga's step times (hold, supplier order, capture). Slow suppliers then show up as their own percentile curve instead of dragging a shared average.

Deterministic simulation testing. A saga across a fake supplier, a fake card network and the ledger has failure orderings no human lists: timeout after the supplier committed, hold expiring during approval, a retry crossing an airline-initiated change. Deterministic simulation testing runs these with a seeded clock and injected supplier failures, and checks that every booking ends confirmed with a ledger entry or fully undone with every hold released.

Coverage-guided fuzzing. Coverage-guided fuzzing targets the parsers that take supplier bytes: the streaming parsers that turn airline, hotel and rail responses into offers, the bank and card statement files read by reconciliation, and the inbound supplier event webhooks.

Data and control flow

One search and booking:

  1. The employee signs in through SAML and the platform loads the traveller profile, cost centre and policy set.
  2. The gateway answers cache hits at once and refreshes them in the background; misses fan out to suppliers through token buckets and circuit breakers, with hedged retries on slow reads.
  3. Streaming parsers turn each response into normalised offers; money is converted into the reporting currency at one stored rate.
  4. The policy engine labels every offer (in policy, over cap, needs approval) with reasons; the ranker orders them.
  5. The employee selects an offer. The gateway re-prices it live; if the price moved, the page says so before anything is charged.
  6. If the policy says approval is needed, a pending request is created with an expiring hold; the approver sees the offer, the reason and the cost; the traveller cannot approve their own trip.
  7. The saga starts with one idempotency key per step: place a hold, create the supplier order, capture, write the ledger entries, issue the document. A failure at step k runs the undos for steps k-1 to 1.
  8. Events go out: webhook to customer systems, push to the traveller, itinerary by email.

Time goes into step 2 (supplier latency) and step 7 (supplier order and card network, each a remote call).

One airline-initiated change:

  1. The airline changes a schedule; the supplier sends an event (Duffel has an order.airline_initiated_change_detected event) or the platform polls.
  2. The event handler deduplicates on the event id and loads the booking.
  3. The change is applied to the order through the state machine, forward transitions only.
  4. Push notification to the traveller and webhook to the customer; if the new times break policy or a connection, a new approval or a change flow starts.

One month close: close the period and write one invoice per invoice profile from ledger postings; pull settlement files, card statements and bank files; match by reference, then by amount and date window; review the exceptions before the books lock; then send invoice.issued.

Scaling

In the order they bite:

  1. Supplier latency and rate limits. A search is as slow as its slowest source. Levers: cache with stale-while-revalidate for browsing, hedged requests for the tail, per-supplier token buckets, tight timeouts with partial results, circuit breakers.
  2. Fan-out cost. Each search triggers many billed or rate-limited supplier calls. Levers: query only the suppliers that can win, coalesce identical in-flight searches, cache by normalised query.
  3. Parsing and ranking CPU. Large XML answers dominate. Levers: streaming parse, a cheap first-pass score and a heavier ranker on the top few hundred.
  4. Booking correctness under concurrency. Row-level concurrency control per booking, partitioned by company.
  5. Month-close volume. Hash joins on references and daily incremental matching instead of one monthly batch.

Failure modes

FailureSymptomCauseFix
Stale price at purchasetraveller sees a price, checkout fails or shows a higher onecache or offer lifetime shorter than the user's think timelive re-price before payment; show expires_at; keep cache for browsing only
Double ticket or double chargetwo orders for one tripretry after a timeout without a keyidempotency key per step; status query before retry; unknown state until resolved
Paid but not ticketedcard captured, no itinerarysaga stopped between capture and order, or the order call timed outpayment state machine with "unknown", resume the saga, void or refund automatically
Hold expires firstapproved trip cannot be booked, card authorisation lapsedapproval slower than the hold windowstore hold expiry, re-authorise or re-quote on approval, escalate before expiry

| Duplicate or out-of-order webhook | customer system books an expense twice | at-least-once delivery without receiver dedupe | event id as key; process forward transitions only | | Policy drift | search shows "in policy", checkout asks for approval | two code paths evaluate rules | one engine, one call shape, same inputs | | SAML failure | everyone locked out after a certificate change | clock skew outside the assertion window, rotated signing certificate | allow small skew, accept both certificates during rotation |

Real implementations

namewhat it isrepolicencenotes
Duffel APIflight and stays booking API with offers, orders, webhooks, cancellation quotesduffelhq/duffel-api-javascript (client only)MITservice is hosted and closed; docs are public and describe the offer to order flow
Amadeus for DevelopersGDS self-service APIs (flight offers search, price, create orders)amadeus4dev/amadeus-python (SDK)MITservice is hosted and closed; see developers.amadeus.com
Perk (formerly TravelPerk)corporate travel platform; public API for trips, invoices, cost centres, SCIM users, webhooks——closed source; developers.perk.com documents approval processes and webhook behaviour
SAP Concurtravel and expense platform with itinerary, travel profile, exchange rate and hotel direct-connect APIs——closed source; developer.concur.com
Open Policy Agentgeneral policy engine usable for the in-policy decisionopen-policy-agent/opaApache-2.0not travel specific; see Policy engines
Cedarpolicy language and engine with default denycedar-policy/cedarApache-2.0same role as OPA

The alternative: a managed travel desk that books by hand through a GDS, with the platform only a workflow around email. No supplier gateway is needed, but policy, approval and reporting then depend on people.

Sources

Similar systems

Share the most building blocks with this one.