Skip to content

thinkgrid-labs/lulan

Repository files navigation

Lulan — Open-source Headless Reservation Infrastructure for Modern Transit.

CI License: AGPL-3.0 SDKs: MIT Rust

Lulan is an open-source, API-first reservation platform for airlines, buses, ferries, rail, and any operator that sells capacity instead of products. It runs in two modes behind one API: a complete standalone booking engine — inventory, pricing, payments, QR ticketing, offline validation, end to end — or an orchestration layer that owns the customer-facing reservation experience and synchronizes confirmed bookings into the operational systems you already run (airline PSS, ferry manifest backend, bus dispatch) through sync connectors.

Built in Rust: segment-aware seat inventory, race-free reservations under high concurrency, event-sourced order lifecycle, a sandboxed WebAssembly pricing engine, and cryptographically signed QR tickets that validate fully offline.

Think of commerce tools, but for seats, cabins, vehicle slots, and cargo holds" — inventory that exists in space and time, not on a shelf.


Table of Contents


Why an open-source transit reservation system?

Most transportation operators still run on legacy reservation software: expensive licenses, proprietary lock-in, monolithic deployments, and booking conflicts the moment demand spikes. General-purpose e-commerce platforms don't help — they assume inventory is static. Transit inventory isn't:

A ─── B ─── C ─── D        Seat 12A on one departure:

A → B   Reserved           the same physical seat is sold inventory
B → C   Available          on one journey segment and open capacity
C → D   Reserved           on the next.

Selling a seat means atomically claiming a span of segments on a specific departure, while hundreds of other buyers race you for the same span. Lulan is a reservation engine built for exactly that problem — and it is verified, not just claimed: the invariant harness fires 10,000 simultaneous contenders at 52 seats and records zero double-sells, including with Redis killed mid-run.

Key features

Capability What it means
Segment-aware inventory Seats, cabins, vehicle decks, and cargo pools tracked per journey segment with bitmask occupancy — the PRD's "12A is free B→C but taken A→B" answered in one query.
Race-free booking at scale Redis-backed soft holds + guarded PostgreSQL claims. Zero double-sells at 10k concurrent contenders (chaos-tested).
Event-sourced orders Every order is an append-only event stream (order_created → … → passenger_boarded) with a transactional outbox; replaying events reproduces the read model exactly.
Multi-passenger itineraries One order, N passengers; per-passenger seats and fares, including regulated concession fares (child, senior, disability) that many markets mandate by law.
Pluggable pricing (WASM) Deterministic integer-only fare rules, plus operator-supplied pricing modules run in a fuel-metered, memory-capped WebAssembly sandbox with no host imports — measured at p95 ≈ 443 µs per quote. Native and WASM engines are property-tested to be bit-identical.
Signed quotes Short-TTL HMAC quote tokens: the price shown is the price charged, tamper-proof.
Offline ticket validation Ed25519-signed CBOR QR tickets (~245 bytes). The MIT lulan-validate crate verifies them with no server, no clock assumptions, and compiles to WebAssembly for browser and mobile boarding apps.
Offline boarding sync Gate and crew devices journal scans locally and sync idempotent batches when connectivity returns; duplicate and cloned-QR scans are detected and flagged.
Headless & self-hostable JSON REST APIs only — bring your own storefront, kiosk, or POS. One Docker image, PostgreSQL + Redis, no per-booking fees.

How it works

A booking flows through seven stages — two optional, all independently verifiable:

  1. Search & availabilityGET /v1/trips/search returns candidate trips per leg (one-way or round-trip), each with operator, service number, vehicle, schedule, and span-aware seat/pool availability. GET /v1/trips/{id}/availability drills into the seat map, including which seats other sessions currently hold.
  2. Hold (optional)POST /v1/holds soft-holds the selected seats across every leg as one itinerary hold with a countdown (expires_at, operator-configurable). Expired holds auto-release with zero cleanup; buying with an expired hold is a deterministic 409. Holds never gate the sale — claims at order time are the source of truth.
  3. Add-ons (optional)GET /v1/ancillaries lists everything the operator sells alongside the fare (baggage, meals, insurance, priority boarding), per-passenger or per-order, tied to one leg or the whole itinerary.
  4. QuotePOST /v1/quotes prices the itinerary + add-ons (per passenger type, occupancy, peak day, round-trip and promo discounts) and returns a signed, short-lived quote token locking the full total.
  5. OrderPOST /v1/orders atomically claims every item on every leg for N passengers; a conflict on any leg rolls back everything. One order, one payment — fares and add-ons together.
  6. Pay & ticket — a payment-provider webhook captures payment and auto-issues one Ed25519-signed QR ticket per passenger per leg.
  7. Board — even offline — gate devices verify tickets locally against cached public keys (GET /v1/ticket-keys), then sync their scan journal (POST /v1/scans); the order aggregates to Boarded when the last passenger scans in.

Quick start

Prerequisites: Rust (edition 2024), Docker, just.

git clone https://github.com/thinkgrid-labs/lulan.git
cd lulan
just up      # PostgreSQL 16 + Redis 7 (Docker)
just serve   # migrate, seed a sample multi-stop network, serve on :8080

Book a ticket end to end:

# 1. Find a departure (sample dataset: a 4-stop ferry line, BTG → … → CEB)
curl "localhost:8080/v1/trips/search?origin=BTG&destination=CEB&departure_date=$(date +%F)"

# 2. Quote a senior + child itinerary (concession fares applied automatically)
curl -X POST localhost:8080/v1/quotes -H 'content-type: application/json' -d '{
  "trip_id": "<trip>",
  "items": [
    {"unit_code": "12C", "origin": "BTG", "destination": "CEB", "passenger_type": "senior"},
    {"unit_code": "12D", "origin": "BTG", "destination": "CEB", "passenger_type": "child"}
  ]}'

# 3. Order with the quote token, pay (fake provider), fetch signed QR tickets
curl -X POST localhost:8080/v1/orders ...
curl -X POST localhost:8080/v1/orders/<id>/payment
curl localhost:8080/v1/orders/<id>/tickets

Run the test suite and the concurrency harness:

just check                 # fmt + clippy + full test suite
just loadgen 10000 0.5     # 10k contenders, 50% via holds — expect 0 double-sells
just loadgen-paced 200 30  # open-loop 200 req/s — honest seat-lock latencies

API overview

Endpoint Purpose
GET /v1/trips/search One-way / round-trip search: candidate trips per leg with schedule + availability
GET /v1/trips/{id}/availability Per-seat / per-pool availability for a journey span
POST /v1/holds Soft-hold a one-way or round-trip seat selection as one itinerary hold (TTL, auto-release)
GET /v1/ancillaries Add-on catalog: baggage, meals, insurance — whatever the operator sells
POST /v1/quotes Itemised quote for fares + add-ons, locked by a signed token
POST /v1/orders Atomic multi-passenger booking (live-priced or quote-token)
POST /v1/orders/{id}/payment Create payment intent (provider port)
POST /v1/payments/fake/webhook Idempotent capture webhook → auto-issues tickets
GET /v1/orders/{id}/tickets Ed25519-signed QR ticket tokens
GET /v1/ticket-keys Public keys for offline validators
POST /v1/scans Batched, idempotent boarding-scan sync (validator key)
GET /v1/customers/me/orders Authenticated customer's bookings (IdP JWT)
POST /v1/webhooks Register HMAC-signed webhook endpoints (admin key)
GET /metrics Prometheus metrics

The full surface is documented in the OpenAPI spec — committed at crates/lulan-api/openapi.json and served live at GET /openapi.json. A typed TypeScript client ships as @lulan/storefront-sdk.

Bring your own providers

Lulan never owns accounts and never touches card data — identity and payments are ports: the core defines the contract, an adapter plugs your provider in. (Reservation Sync Connectors, planned, follow the same pattern for operational systems.)

Identity provider — customers and staff

The core verifies a bearer JWT from your IdP and keeps only an (issuer, subject) reference. No passwords, resets, sessions, or MFA in Lulan — ever. One port serves both principals: a plain verified JWT is a customer; a JWT that matches an enrolled staff row gains an operator role (admin / ops / support).

# Ships today: HS256 shared-secret JWT (first-party storefront backends)
LULAN_IDP_ISSUER=https://auth.example.com
LULAN_IDP_HS256_SECRET=...            # your IdP's signing secret
LULAN_BOOTSTRAP_ADMIN_STAFF='https://auth.example.com|user-id-of-admin'
Use case How it maps
Ferry line with a Next.js storefront using Supabase/Firebase auth Storefront session JWT goes straight to Lulan as the customer token — bookings attach to the customer, GET /v1/customers/me/orders lists them
Bus company on Auth0 / Clerk / Keycloak Same trait, JWKS (RS256) adapter — planned; one adapter covers all JWKS-publishing IdPs
Walk-up / kiosk sales, no accounts at all Skip the IdP entirely: guest checkout is first-class — guest_contact + an HMAC retrieval token (magic link) per order
Back-office staff signing into the admin app Same IdP login; an admin enrols their identity via POST /v1/admin/staff with a role — every action they take is audited by name

Payment provider — charge and refund

The engine reconciles provider webhooks against the order state machine; the provider port is two calls:

pub trait PaymentProvider {
    /// Charge amount_minor for an order → provider intent (e.g. pi_…).
    async fn create_intent(&self, order_id, amount_minor, currency) -> PaymentIntent;
    /// Full refund of a captured intent (admin refunds, trip cancellation).
    async fn refund(&self, payment_intent_id, amount_minor) -> Result<()>;
}

Ships today: FakeProvider (drives dev, tests, and every demo — including the async webhook flow, exactly like a real provider). Real adapters are adapter-sized work, not engine work:

Use case How it maps
Global card payments (Stripe) create_intent → PaymentIntent; Stripe's payment_intent.succeeded webhook hits Lulan's capture endpoint → order Paid → tickets auto-issue
PH market e-wallets — GCash/Maya via Xendit or PayMongo Same flow; the intent id is the provider's charge/checkout id
Cash at a counter / agent network A trusted integration API key confirms payment through the same webhook path — the state machine doesn't care who captured
Trip cancelled by ops, or support refunds a booking Lulan calls refund() before releasing seats and voiding tickets — money moves first, inventory second

Payment capture is idempotent (duplicate and out-of-order webhooks are acknowledged, never re-applied), and Idempotency-Key on order creation makes client retries double-booking-proof end to end.

Architecture

        Your storefront / kiosk / POS / boarding app
                          │
              JSON REST  ·  @lulan/storefront-sdk (TS)
                          │
                ┌─────────▼──────────┐
                │     lulan-api      │  Axum · HTTP/2
                ├────────────────────┤
                │    lulan-engine    │  inventory · orders · tickets
                │   lulan-pricing    │  native + WASM sandbox (wasmtime)
                └─┬───────┬────────┬─┘
                  │       │        │
            PostgreSQL  Redis   *.wasm pricing modules
            (truth +   (holds,  (operator-supplied,
             events)    cache)   no host imports)

   Offline edge:  lulan-validate (MIT, wasm32) verifies tickets
                  with zero server dependency.

Benchmarks

Real numbers, adversarial shapes, published in docs/benchmarks.md:

  • 0 double-sells across 10,000 simultaneous contenders on one 52-seat vehicle — repeated with Redis killed mid-run (chaos test).
  • WASM pricing: p50 347 µs / p95 443 µs per quote including per-call instantiation (PRD target < 5 ms), enforced by a CI assertion.
  • Ticket QR payload: ~245 bytes signed (target < 400 bytes for low-error-correction QR).

Project status & roadmap

⚠️ Pre-1.0, active development. APIs may change until the first stable release. Verified working today — the checked items below are implemented and covered by the test suite.

  • Segment-aware inventory engine (seats, pools, span claims)
  • Soft holds + race-free claims (0 double-sells @ 10k contenders)
  • Event-sourced order lifecycle with payment-provider port
  • Pricing engine — native + sandboxed WASM modules, signed quotes
  • Multi-passenger orders with passenger-type fares
  • Ed25519 QR ticketing + offline validation (lulan-validate)
  • Offline boarding-scan sync with replay/clone detection
  • Webhooks: HMAC-signed deliveries with durable retries
  • Authentication: API keys + roles, identity-provider port, guest checkout with retrieval tokens
  • Idempotent booking retries + per-caller rate limiting
  • OpenAPI spec (served at /openapi.json) + TypeScript SDK (@lulan/storefront-sdk)
  • Prometheus /metrics (OTLP traces planned)
  • Itineraries: one-way, round-trip & multi-city (one atomic order across legs, round-trip fares)
  • Itinerary holds: one hold across all legs, TTL auto-release, live held-seat map
  • Ancillaries: operator add-on catalog (baggage, meals, insurance) priced into quotes & orders
  • Production deploys: Compose (external or bundled databases, auto-TLS) + Helm chart
  • GTFS importer — bring your existing schedule feed
  • Open-loop benchmark mode (published seat-lock latencies vs the <20 ms target)
  • Admin operations API: staff RBAC (IdP-backed), network & schedule management, fare publishing with rollback, manifests, refunds — with @lulan/admin-sdk
  • Reference Next.js storefront + React Native boarding-crew app
  • @lulan/validate npm package (WASM build of the validator)
  • Reservation Sync Connectors — first-class orchestrated mode (PSS / manifest / dispatch sync, external-ref mapping)

Use cases

Lulan models any business that reserves capacity over space and time: regional and low-cost airlines, intercity and commuter bus lines, ferries and RoRo vessels, rail and metro networks, shuttle and van fleets, cargo and parcel space and vehicle-deck slots

Standalone mode fits operators without a sophisticated backend — provincial bus lines, ferry and tourism operators, shuttles, charters, small regional airlines: Lulan is the whole system, from search to boarding. Orchestrated mode fits enterprises with existing operational platforms: Lulan owns discovery → pricing → cart → payment → confirmed reservation, then a Reservation Sync Connector pushes it into the PSS / manifest system / dispatch backend (planned; today's HMAC-signed webhooks already enable the same integration DIY). Same API and domain model either way — only the connector changes.

Contributing

Lulan is developed in the open and welcomes issues, design discussions, and pull requests. Contribution guidelines and architecture decision records will be published as the project approaches its first release.

License

Package License Why
lulan-engine, lulan-api, lulan-pricing (host) AGPL-3.0 The core stays open — improvements to hosted deployments flow back.
lulan-validate (offline ticket verification) MIT Embed it in proprietary boarding and kiosk apps freely.
lulan-pricing-guest (reference WASM pricing module) MIT Copy it as the starting point for your own fare engine.
SDKs, UI components, examples MIT Build commercial storefronts without restriction.

Keywords: open-source reservation system · headless booking engine · reservation orchestration platform · airline reservation system · bus booking system · ferry reservation software · rail ticketing · seat reservation API · segment inventory · Rust booking engine · offline ticket validation · QR ticketing · WebAssembly pricing

Lulan aims to be the open-source foundation for capacity reservation worldwide — bringing modern developer tooling to an industry still dominated by legacy software. The name comes from the Filipino word for "to board, to load." Transportation is only the beginning.

About

Open-source Headless Reservation Infrastructure for Modern Transit

Topics

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages