Multi-tenant travel platform with an auditable wallet
A wallet-first travel commerce platform for flights, hotels and cars, where agent and corporate organisations share one prepaid balance and every debit has to be correct under concurrency, replay and rollback.
- Role
- Sole developer
- Period
- 2026
- Stack
- Node.js
- TypeScript
- Express.js
- PostgreSQL
- Drizzle
- React
- Vite
- Docker

Measured
234
Server tests
Across 27 suites. The financial set proves webhook replay cannot double-credit, and that two concurrent bookings against one unit of inventory yield exactly one booking.
Postgres RLS
Tenant isolation
rlsIsolation.test.ts connects as a dedicated non-superuser role and proves a forgotten filter still cannot return another tenant's rows.
Atomic
Wallet debits
An insufficient balance rolls the inventory decrement back in the same transaction, proved in the integration suite rather than asserted.
Problem
Travel agents and corporate travel desks do not buy the way a consumer does. They top a balance up once and draw against it all month, several people book on the same account, and an agent earns commission on what they sell. That turns a booking flow into a financial system: money moves before inventory is confirmed, two people can book the last seat at the same moment, and a payment provider will happily deliver the same webhook twice.
Approach
Financial correctness came first and was written as tests before it was written as features. The rules that matter are not visible in a screenshot, so they are pinned by an integration suite that provokes the failure rather than assuming it cannot happen.
Provider integration was designed as a seam from the start. The normalised flight, hotel and vehicle models mirror the shapes the real GDS APIs return, so wiring live Amadeus and Travelport is a change below the seam and nothing above it moves. The mock provider serves deterministic content over real inventory, which is what makes the concurrency tests meaningful.
- Webhook replay proved unable to double-credit or double-confirm
- Two concurrent bookings against one unit of inventory yield exactly one booking
- Commission rates snapshot at sale, so a later rate change cannot rewrite history
- Tenant isolation tested from a non-superuser connection, where RLS actually applies
Architecture
Folders are layers and a filename carries its role, so one resource reads as a matching set: adminRouter, adminController, adminService, adminValidators. Controllers stay thin, moving a request to a service and back with no business logic and no database access. Services own the business rules and every transaction boundary. Repositories hold Drizzle queries only, including the conditional updates that make an inventory decrement and a wallet debit atomic.
Tenant isolation is enforced twice. The application layer scopes every query, and a request-pinned connection sets the RLS session variables so Postgres refuses cross-tenant rows even if a filter is forgotten. A boot-time check verifies the policies are actually on rather than trusting that a migration ran.
- Append-only ledger with database constraints as the backstop, not the plan A
- Short-lived bearer token in memory, rotating refresh token in an httpOnly cookie, no credential in localStorage
- Paystack webhooks verified by HMAC-SHA512 before anything is trusted
- One Docker image for server and built client; stateless, pointed at managed Postgres
Outcome
234 server tests across 27 suites, with the financial and multitenancy behaviour proved rather than described: replay safety, booking atomicity under concurrency, commission immutability, and cross-tenant reads blocked at the database.
CI runs lint, typecheck, test and build against a real postgres:16 container on every push, and all four have to pass. The platform runs today as a single container on Railway.