Status Field Conventions
Post split-booking-status-axes (2026-04), every booking carries TWO orthogonal
status axes (lifecycle + payment). The payment_transaction.status is a third,
independent enum with its own conventions.
Canonical forms
| Field | Case | Spelling | Example values |
|---|---|---|---|
booking.lifecycle_status | lowercase | US | hold, confirmed, arrived, completed, canceled, no_show, expired |
booking.payment_status | lowercase | n/a | pending, paid, part_paid, failed, refunded |
payment_transaction.status | lowercase | US | pending, processing, completed, failed, canceled, refunded |
Note the US spelling canceled (one L) used system-wide. The UK spelling cancelled (two Ls) is considered a bug.
The two axes are independent. A booking can be lifecycleStatus='completed' + paymentStatus='pending' (service delivered, customer still owes — surfaced in the admin "Unpaid completed" filter). Pre-split this combined state was unrepresentable.
Why these conventions?
booking.lifecycle_statusis a Postgres enum (lifecycle_status_enum). The DB enforces the 7 lowercase canonical values.booking.payment_statusis a Postgres enum (payment_status_enum). The DB enforces the 5 lowercase canonical values. (Pre-split this was a free-form String column with UPPERCASE writes — the split normalised it to a strict enum.)payment_transaction.statusis its own domain enum (PAYMENT_TRANSACTION_STATUS) with values for the payment-processor lifecycle, distinct from booking's split axes.
Import canonical constants, don't hardcode strings
Backend (API, worker)
import {
LIFECYCLE_STATUS,
PAYMENT_STATUS,
PAYMENT_TRANSACTION_STATUS,
type LifecycleStatus,
type PaymentStatus,
type PaymentTransactionStatus,
} from '@booking/contracts';
// ✓ Good — two-axis assertions
if (booking.lifecycleStatus === LIFECYCLE_STATUS.CANCELED) { ... }
if (booking.paymentStatus === PAYMENT_STATUS.PAID) { ... }
tx.paymentTransaction.create({
data: { status: PAYMENT_TRANSACTION_STATUS.COMPLETED, ... },
});
// ✗ Bad — hardcoded literal, case-sensitive typo-prone
if (booking.lifecycleStatus === 'CANCELLED') { ... } // wrong case + wrong spelling
Frontend (web)
The web app has its own local constants file at apps/web/lib/constants/booking-status.ts. It re-exports LIFECYCLE_STATUS and PAYMENT_STATUS from @booking/contracts:
import { LIFECYCLE_STATUS } from '@/lib/constants/booking-status';
if (booking.lifecycleStatus === LIFECYCLE_STATUS.CANCELED) { ... }
For combined-state UI badges (e.g. "Deposit paid", "Unpaid completed"), use the derived DISPLAY_STATUS enum + deriveDisplayStatus(lifecycle, payment) helper from @booking/contracts. Don't try to recompute combined display states from raw axes in components.
Why does my comparison always return false?
Most common cause: comparing against a pre-split value that no longer exists. The pre-split single-axis enum had 'tentative', 'paid', 'part_paid', 'compensation_failed' — none of which are valid lifecycle values post-split. Use the two-axis equivalents:
| Pre-split (no longer valid) | Post-split equivalent |
|---|---|
lifecycleStatus === 'tentative' | lifecycleStatus === 'confirmed' && paymentStatus === 'pending' (PoA) |
lifecycleStatus === 'paid' | lifecycleStatus === 'confirmed' && paymentStatus === 'paid' |
lifecycleStatus === 'part_paid' | lifecycleStatus === 'confirmed' && paymentStatus === 'part_paid' |
lifecycleStatus === 'compensation_failed' | lifecycleStatus === 'canceled' && paymentStatus === 'failed' && compensationStatus === 'exhausted' |
Second most common cause: case mismatch. Check what the data source actually returns:
| Data source | What it returns |
|---|---|
Prisma booking.findMany | lowercase booking.lifecycleStatus + booking.paymentStatus |
GET /api/v1/customers/me/bookings | lowercase lifecycleStatus + paymentStatus |
GET /api/v1/admin/bookings/search | lowercase lifecycleStatus + paymentStatus |
Prisma paymentTransaction.findMany | lowercase status |
Then compare against the matching canonical constant — never a hardcoded string literal.
Historical context
Three status-related bugs were fixed in quick succession in 2026-04, all case-mismatch between writer and reader. The constants pattern above prevents this class of bug at the type level — typos and wrong-case literals become compile errors when the comparison target is typed as LifecycleStatus/PaymentStatus etc.
The split itself (Section 11 of the split-booking-status-axes change, 2026-04) made one more class of bug impossible: conflating lifecycle and payment axes. Pre-split, "did the customer pay?" required reading the lifecycle enum (where 'paid' was a value); post-split, the payment axis is its own column with its own enum. Cancellation can no longer accidentally clear payment state, and "completed + unpaid" becomes a representable state.