DOMAIN_MODEL — bff-tenant-booking-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · API_CONTRACTS
Cross-cutting: Standards · NAMING · 06 Data Models
1. Posture: this BFF holds no domain state
The "domain" of this BFF is session and orchestration ergonomics, not booking. Source-of-truth for Reservation, Inventory, RatePlan, Folio, PaymentIntent, Theme, and Property lives in their respective services. The aggregates here exist only to:
- Mirror the user's in-flight booking funnel for refresh-resilience and back-button correctness.
- Hold a verified handoff token long enough to consume it once.
- Cache a composed bootstrap blob to keep first-paint fast.
- Produce telemetry envelopes.
No aggregate in this file is replicated to any client SQLite store; see SYNC_CONTRACT.
2. Aggregates
2.1 TenantBootstrap (cache aggregate)
Composed read-model that the SPA consumes on first load.
interface TenantBootstrap {
tenantId: TenantId;
tenantSlug: string;
brandName: string;
brandTagline: string | null;
theme: ThemeBundle; // tokens + RTL/LTR + asset URLs
flowConfig: FlowConfig; // step ordering, optional fields, payment-rails order
locales: LocaleEntry[]; // [{ tag, displayName, isRtl }]
currencies: CurrencyEntry[]; // [{ code, symbol, decimalPlaces, isPrimary }]
paymentMethods: PaymentMethodVM[];
policies: PolicyVM[]; // privacy, cancellation, T&C, accessibility
handoffPayload: HandoffPayload | null;
serverTime: string; // ISO 8601 for client clock skew check
csp: { nonce: string };
composedAt: string;
composedFrom: { themeVersion: string; tenantConfigVersion: string };
}
Invariants:
theme.locale⊆locales[].tagandtheme.currency∈currencies[].code.paymentMethods[]is non-empty whentenant.paymentEnabled = true; otherwise the booking flow is disabled and bootstrap returnsMELMASTOON.TENANT.PAYMENT_NOT_CONFIGURED.csp.nonceis freshly generated per response (never cached).composedFromversions are echoed by the SPA on subsequent calls so the BFF can detect skew and force re-bootstrap.
Cached in Memorystore under tenant-bootstrap:<tenantId>:<themeVersion>:<configVersion>:<localeBundleHash>. Invalidated by melmastoon.theme.published.v1 and melmastoon.tenant.config_updated.v1.
2.2 BookingDraft
Short-lived, mutable, server-side mirror of the user's in-flight booking. Identity prefix bdr_.
interface BookingDraft {
id: BookingDraftId; // bdr_<ulid>
tenantId: TenantId;
sessionId: string; // tnt-session cookie ULID
createdAt: string;
updatedAt: string;
expiresAt: string; // = max(createdAt + 30m, quote.expiresAt, hold.expiresAt)
flowState: BookingFlowState;
searchParams: SearchParams;
selectedRoom: SelectedRoomRef | null;
quote: QuoteRef | null;
hold: HoldRef | null;
guest: GuestProfileDraft | null;
paymentSelection: PaymentSelection | null;
paymentIntent: PaymentIntentRef | null;
loyaltyContext: LoyaltyContext | null; // Phase 2+
marketingAttribution: MarketingAttribution;
handoffArrivalId: HandoffArrivalId | null;
errorTrail: FlowError[]; // capped 10
clientHints: ClientHints; // device, viewport, currency intent
schemaVersion: 1;
}
type BookingFlowState =
| 'searching'
| 'selecting'
| 'quoting'
| 'holding'
| 'collecting_details'
| 'paying'
| 'awaiting_return'
| 'confirming'
| 'confirmed'
| 'failed'
| 'abandoned';
interface SearchParams {
propertyId: PropertyId | null; // null = full property catalog (rare on tenant booking)
checkIn: string; // YYYY-MM-DD
checkOut: string;
occupancy: { adults: number; children: number; rooms: number };
promoCode: string | null;
ratePlanHint: string | null;
displayCurrency: string;
locale: string;
}
interface SelectedRoomRef { roomTypeId: RoomTypeId; ratePlanId: string; }
interface QuoteRef { id: string; expiresAt: string; totalDisplay: MoneyDisplay; }
interface HoldRef { reservationId: ReservationId; expiresAt: string; }
interface PaymentSelection { method: 'card' | 'paypal' | 'mfs' | 'cash_on_arrival'; provider: string; }
interface PaymentIntentRef { intentId: PaymentIntentId; redirectUrl: string | null; status: 'created' | 'redirected' | 'returned' | 'captured' | 'failed'; }
Invariants:
flowStatetransitions follow the state machine in §3 below; an attempt at an illegal transition raisesMELMASTOON.BFF.TENANT.INVALID_FLOW_TRANSITION.expiresAt≤min(quote.expiresAt, hold.expiresAt). The draft is purged atexpiresAt.guest.email(when present) passes RFC 5322 syntactic validation; full validation lives inreservation-service.paymentIntent.status = 'returned'is required beforeflowStatecan advance toconfirming.errorTrailcapped at 10 entries (FIFO).- Mutations enforce optimistic concurrency via
updatedAtversion (compare-and-swap in Memorystore Lua script).
Lifecycle hooks:
- On
flowState = 'confirmed': emitmelmastoon.bff.tenant.booking.draft.converted.v1; mirror to Postgresbooking_draft_snapshots; remove hot Memorystore key. - On TTL expiry without conversion: emit
melmastoon.bff.tenant.booking.draft.abandoned.v1; mirror to snapshots; remove hot key.
2.3 BookingHandoffArrival
Verified inbound handoff token from bff-consumer-service. Identity prefix bha_.
interface BookingHandoffArrival {
id: HandoffArrivalId; // bha_<ulid>
tenantId: TenantId;
consumerSessionId: string; // gms_<ulid> from consumer BFF
receivedAt: string;
expiresAt: string; // 30 min after issuance per consumer mint
consumed: boolean;
consumedAt: string | null;
payload: HandoffPayload;
hmacSignatureFingerprint: string; // sha256 of signature, for forensic correlation
bookingDraftId: BookingDraftId | null; // wired after first /hold
}
interface HandoffPayload {
consumerSessionId: string;
tenantId: TenantId;
propertyId: PropertyId;
checkIn: string;
checkOut: string;
occupancy: { adults: number; children: number; rooms: number };
currency: string;
locale: string;
campaign?: { source: string; medium: string; campaign: string };
expiresAt: string;
}
Invariants:
consumed = trueis monotonic; second consume attempt returnsMELMASTOON.BFF.TENANT.HANDOFF_REPLAYED.tenantIdin payload must match the path's resolvedtenantId; mismatch returnsMELMASTOON.BFF.TENANT.HANDOFF_TENANT_MISMATCH.expiresAt< now →MELMASTOON.BFF.TENANT.HANDOFF_EXPIRED.
2.4 MarketingAttribution
Session-scoped attribution captured at first touch and propagated through the funnel.
interface MarketingAttribution {
source: string | null; // utm_source or handoff.campaign.source
medium: string | null;
campaign: string | null;
content: string | null;
term: string | null;
referrer: string | null;
firstTouchAt: string;
lastTouchAt: string;
}
Stored in Memorystore session blob; mirrored on every BookingDraftSnapshot.
2.5 LoyaltyContext (Phase 2+)
interface LoyaltyContext {
programId: string;
memberTier: 'silver' | 'gold' | 'platinum';
ratePlanModifier?: { kind: 'percent_off' | 'flat_off'; value: number };
pointsBalance: number | null;
validUntil: string;
}
Read from iam-service; injected as a hint into pricing calls. Never authoritative.
2.6 ConfirmationView (cache aggregate)
interface ConfirmationView {
reservationId: ReservationId;
tenantId: TenantId;
bookingDraftId: BookingDraftId | null;
reservation: ReservationSummaryVM;
folio: FolioSummaryVM;
guest: GuestSummaryVM;
property: PropertyAddressVM;
keyCredentialPlaceholder: KeyCredentialPlaceholderVM | null;
postStay: PostStayInfoVM;
support: { phone: string; email: string; whatsapp: string | null };
composedAt: string;
}
Cached for 5 min keyed by (tenantId, reservationId); regenerated on demand.
2.7 BookingDraftSnapshot
Cold mirror of an abandoned or converted draft for analytics + abandoned-cart. Identity prefix bds_. Lives in Postgres booking_draft_snapshots (see DATA_MODEL).
3. Booking flow state machine
handoff arrival
│
▼
┌──────────┐ ┌──────────────┐
start ─► │ searching│ ──── select room ───► │ selecting │
└──────────┘ └────┬─────────┘
│ POST /quote
▼
┌──────────┐
│ quoting │
└────┬─────┘
│ POST /hold
▼
┌──────────┐
│ holding │
└────┬─────┘
│ PATCH guest details
▼
┌──────────────────────────────┐
│ collecting_details │
└────────────────┬─────────────┘
│ POST payment-intent
▼
┌──────────┐ redirect leaves
│ paying │ ────────────────► provider
└────┬─────┘
│ provider returns
▼
┌────────────────┐
│awaiting_return │
└────┬───────────┘
│ /return validates
▼
┌────────────┐ reservation-service confirms
│ confirming │ ─────────────────────────────►
└────┬───────┘
▼
┌──────────┐
│confirmed │
└──────────┘
any state ── error / timeout ──► failed
any state ── TTL expiry ──────► abandoned
Allowed transitions (others rejected with MELMASTOON.BFF.TENANT.INVALID_FLOW_TRANSITION):
| From | To |
|---|---|
searching | selecting |
selecting | quoting, searching |
quoting | holding, selecting, failed |
holding | collecting_details, failed, abandoned |
collecting_details | paying, holding (back), failed, abandoned |
paying | awaiting_return, failed |
awaiting_return | confirming, failed |
confirming | confirmed, failed |
* (except confirmed) | abandoned (TTL) |
4. Domain events emitted
This BFF emits only telemetry events; see EVENT_SCHEMAS for full payloads. None of these are domain events. Subjects:
melmastoon.bff.tenant.bootstrap.served.v1melmastoon.bff.tenant.booking.draft.created.v1melmastoon.bff.tenant.booking.draft.abandoned.v1melmastoon.bff.tenant.booking.draft.converted.v1melmastoon.bff.tenant.payment_intent.created.v1melmastoon.bff.tenant.confirmation.viewed.v1melmastoon.bff.tenant.flow.step_completed.v1melmastoon.bff.tenant.flow.error_encountered.v1melmastoon.bff.tenant.handoff.consumed.v1melmastoon.bff.tenant.locale.changed.v1melmastoon.bff.tenant.currency.changed.v1
5. Value objects
type BookingDraftId = Branded<string, 'BookingDraftId'>;
type HandoffArrivalId = Branded<string, 'HandoffArrivalId'>;
type DraftSnapshotId = Branded<string, 'DraftSnapshotId'>;
interface MoneyDisplay {
currency: string; // ISO 4217
amountMinor: number; // micro-units cast down
formatted: string; // "AFN 12,500"
}
interface ThemeBundle {
locale: string;
isRtl: boolean;
currency: string;
paletteRef: string; // theme-config theme id
designTokensCssUrl: string;
logoUrl: string;
faviconUrl: string;
fontFamilies: string[];
brandColors: { primary: string; secondary: string; accent: string; surface: string; onSurface: string; };
}
interface FlowConfig {
steps: BookingFlowState[];
optionalGuestFields: string[]; // e.g., ['nationalIdNumber', 'arrivalTime']
paymentRailsOrder: ('card' | 'paypal' | 'mfs' | 'cash_on_arrival')[];
cashOnArrivalEnabled: boolean;
requireGuestSignIn: boolean; // Phase 2+
}
6. Domain errors
| Error | When | Code |
|---|---|---|
SlugUnknownError | tenant slug unresolvable | MELMASTOON.BFF.TENANT.SLUG_UNKNOWN |
TenantSuspendedError | tenant suspended | MELMASTOON.TENANT.SUSPENDED |
BootstrapVersionSkewError | client composedFrom older than acceptable | MELMASTOON.BFF.TENANT.BOOTSTRAP_STALE |
InvalidFlowTransitionError | illegal state transition | MELMASTOON.BFF.TENANT.INVALID_FLOW_TRANSITION |
BookingDraftNotFoundError | unknown or expired draft | MELMASTOON.BFF.TENANT.DRAFT_NOT_FOUND |
BookingDraftConflictError | optimistic concurrency mismatch | MELMASTOON.BFF.TENANT.DRAFT_CONFLICT |
HandoffExpiredError | handoff token past expiresAt | MELMASTOON.BFF.TENANT.HANDOFF_EXPIRED |
HandoffReplayedError | second consume of single-use token | MELMASTOON.BFF.TENANT.HANDOFF_REPLAYED |
HandoffTenantMismatchError | path tenant ≠ token tenant | MELMASTOON.BFF.TENANT.HANDOFF_TENANT_MISMATCH |
HandoffSignatureInvalidError | HMAC mismatch | MELMASTOON.BFF.TENANT.HANDOFF_SIGNATURE_INVALID |
PaymentReturnInvalidError | redirect-return state invalid | MELMASTOON.BFF.TENANT.PAYMENT_RETURN_INVALID |
These are added to ERROR_CODES under the BFF and BFF.TENANT rows in the same PR as this bundle.