Skip to main content

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.localelocales[].tag and theme.currencycurrencies[].code.
  • paymentMethods[] is non-empty when tenant.paymentEnabled = true; otherwise the booking flow is disabled and bootstrap returns MELMASTOON.TENANT.PAYMENT_NOT_CONFIGURED.
  • csp.nonce is freshly generated per response (never cached).
  • composedFrom versions 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:

  • flowState transitions follow the state machine in §3 below; an attempt at an illegal transition raises MELMASTOON.BFF.TENANT.INVALID_FLOW_TRANSITION.
  • expiresAtmin(quote.expiresAt, hold.expiresAt). The draft is purged at expiresAt.
  • guest.email (when present) passes RFC 5322 syntactic validation; full validation lives in reservation-service.
  • paymentIntent.status = 'returned' is required before flowState can advance to confirming.
  • errorTrail capped at 10 entries (FIFO).
  • Mutations enforce optimistic concurrency via updatedAt version (compare-and-swap in Memorystore Lua script).

Lifecycle hooks:

  • On flowState = 'confirmed': emit melmastoon.bff.tenant.booking.draft.converted.v1; mirror to Postgres booking_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 = true is monotonic; second consume attempt returns MELMASTOON.BFF.TENANT.HANDOFF_REPLAYED.
  • tenantId in payload must match the path's resolved tenantId; mismatch returns MELMASTOON.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):

FromTo
searchingselecting
selectingquoting, searching
quotingholding, selecting, failed
holdingcollecting_details, failed, abandoned
collecting_detailspaying, holding (back), failed, abandoned
payingawaiting_return, failed
awaiting_returnconfirming, failed
confirmingconfirmed, 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.v1
  • melmastoon.bff.tenant.booking.draft.created.v1
  • melmastoon.bff.tenant.booking.draft.abandoned.v1
  • melmastoon.bff.tenant.booking.draft.converted.v1
  • melmastoon.bff.tenant.payment_intent.created.v1
  • melmastoon.bff.tenant.confirmation.viewed.v1
  • melmastoon.bff.tenant.flow.step_completed.v1
  • melmastoon.bff.tenant.flow.error_encountered.v1
  • melmastoon.bff.tenant.handoff.consumed.v1
  • melmastoon.bff.tenant.locale.changed.v1
  • melmastoon.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

ErrorWhenCode
SlugUnknownErrortenant slug unresolvableMELMASTOON.BFF.TENANT.SLUG_UNKNOWN
TenantSuspendedErrortenant suspendedMELMASTOON.TENANT.SUSPENDED
BootstrapVersionSkewErrorclient composedFrom older than acceptableMELMASTOON.BFF.TENANT.BOOTSTRAP_STALE
InvalidFlowTransitionErrorillegal state transitionMELMASTOON.BFF.TENANT.INVALID_FLOW_TRANSITION
BookingDraftNotFoundErrorunknown or expired draftMELMASTOON.BFF.TENANT.DRAFT_NOT_FOUND
BookingDraftConflictErroroptimistic concurrency mismatchMELMASTOON.BFF.TENANT.DRAFT_CONFLICT
HandoffExpiredErrorhandoff token past expiresAtMELMASTOON.BFF.TENANT.HANDOFF_EXPIRED
HandoffReplayedErrorsecond consume of single-use tokenMELMASTOON.BFF.TENANT.HANDOFF_REPLAYED
HandoffTenantMismatchErrorpath tenant ≠ token tenantMELMASTOON.BFF.TENANT.HANDOFF_TENANT_MISMATCH
HandoffSignatureInvalidErrorHMAC mismatchMELMASTOON.BFF.TENANT.HANDOFF_SIGNATURE_INVALID
PaymentReturnInvalidErrorredirect-return state invalidMELMASTOON.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.