DOMAIN_MODEL — reservation-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS
Strategic anchors: 02 Enterprise Architecture §3 · 04 Event-Driven Architecture §3 · standards/NAMING · standards/ERROR_CODES
The domain model is pure TypeScript. No NestJS decorators, no Drizzle imports, no pg, no fetch, no process.env. The CI dependency-graph guard fails any PR that introduces such imports under src/domain/. The model below is the source of truth for invariants, value objects, and state machine semantics; the SQL projection and the wire DTOs are downstream of this.
1. Value objects
import { Branded } from '@ghasi/domain-primitives';
// IDs
export type TenantId = Branded<string, 'TenantId'>; // tnt_…
export type PropertyId = Branded<string, 'PropertyId'>; // ppt_…
export type RoomId = Branded<string, 'RoomId'>; // rmu_…
export type RoomTypeId = Branded<string, 'RoomTypeId'>; // rmt_…
export type RatePlanId = Branded<string, 'RatePlanId'>; // rate_…
export type ReservationId = Branded<string, 'ReservationId'>; // rsv_…
export type BookingId = Branded<string, 'BookingId'>; // bkg_… (transient pre-confirm)
export type GuestId = Branded<string, 'GuestId'>; // gst_…
export type FolioId = Branded<string, 'FolioId'>; // fol_… (reference)
export type PaymentIntentId = Branded<string, 'PaymentIntentId'>;// pyi_… (reference)
export type KeyCredentialId = Branded<string, 'KeyCredentialId'>;// key_… (reference)
export type AllocationId = Branded<string, 'AllocationId'>; // inv_… (reference)
export type ChannelPartnerId = Branded<string, 'ChannelPartnerId'>; // for OTA/meta
// Money — stored as bigint micro-units; Money is a value object
export interface Money {
amountMicro: bigint; // e.g. 12_500_000n = 12.50
currency: CurrencyCode; // ISO 4217
}
export type CurrencyCode = 'USD' | 'EUR' | 'IRR' | 'AFN' | 'TJS' | 'PKR' | 'GBP' | 'AED';
// Date / time
export interface DateRange {
start: ISODate; // inclusive, local property date
end: ISODate; // exclusive, local property date
}
export interface StayWindow extends DateRange {
nights: number; // computed; invariant: end > start && nights >= 1
}
// FX snapshot — locked at confirm time
export interface FXSnapshot {
base: CurrencyCode; // rate plan currency
quote: CurrencyCode; // tenant billing currency
rate: number; // 1 base = rate * quote
source: 'tenant_pinned' | 'pricing_service' | 'manual_override';
capturedAt: ISODate;
}
// Channel attribution
export type ReservationChannel =
| 'direct' // tenant booking site (web or mobile)
| 'meta' // consumer meta layer
| 'walk_in' // staff at front desk (Electron)
| 'phone_by_staff' // staff via phone (Electron, deferred KYC)
| 'ota'; // Phase 3+: Booking.com / Expedia / Hotelbeds
export interface ChannelAttribution {
channel: ReservationChannel;
partnerId?: ChannelPartnerId; // for `ota` and some `meta` partners
campaignRef?: string; // free-form tag, max 64
capturedAt: ISODate;
}
// Special-request taxonomy
export type SpecialRequestTag =
| 'halal' | 'vegetarian' | 'vegan' | 'gluten_free'
| 'baby_cot' | 'extra_bed' | 'high_floor' | 'low_floor'
| 'late_check_in' | 'early_check_in' | 'late_check_out'
| 'quiet_room' | 'connecting_rooms' | 'accessible'
| 'airport_pickup' | 'pet_friendly' | 'smoking' | 'non_smoking'
| 'other';
All value objects are constructed via factory functions that throw InvalidValueError on validation failure. Money arithmetic is closed over the same currency only; cross-currency math goes through FXSnapshot.
2. Aggregates and entities
2.1 Reservation (aggregate root)
export interface Reservation {
id: ReservationId;
tenantId: TenantId;
propertyId: PropertyId;
status: ReservationStatus; // §3 state machine
reservationCode: string; // human-readable, e.g. 'GM-9F4K2C' — unique per tenant
channel: ChannelAttribution;
bookingId?: BookingId; // present while in funnel
primaryGuest: Guest; // value object below; composed by reference to gst_
additionalGuests: AdditionalGuest[];
stayWindow: StayWindow;
items: ReservationItem[]; // 1..N rooms
ratePlan: { id: RatePlanId; snapshotName: string }; // name pinned for audit
fxSnapshot?: FXSnapshot; // present from confirm onward
totals: ReservationTotals; // computed; rebuilt on every modification
payment: ReservationPaymentSummary;
specialRequests: SpecialRequest[];
modifications: ReservationModification[]; // append-only audit
hold?: ReservationHold; // present only while held
// Saga tracking — present only while a saga step is mid-flight
pendingSagaStep?: PendingSagaStep;
// OCC + audit
version: number; // optimistic concurrency control
createdAt: ISODate;
createdBy: ActorRef;
updatedAt: ISODate;
updatedBy: ActorRef;
// Lock interaction
keyCredentialIds: KeyCredentialId[]; // populated as lock issues
requiresManualKey: boolean; // true on lock-vendor failure
// Folio reference (owned by billing-service)
folioId?: FolioId;
}
export interface ReservationTotals {
subtotal: Money; // sum of item nightly rates
discountTotal: Money;
taxTotal: Money;
grandTotal: Money;
inTenantCurrency?: Money; // present when fxSnapshot != null
}
export interface ReservationPaymentSummary {
method: 'paypal' | 'card' | 'cash_on_arrival' | 'mfs' | 'mixed';
status: 'none' | 'pending_intent' | 'pending_capture' | 'pending_cash'
| 'captured' | 'partially_refunded' | 'refunded' | 'failed';
paymentIntentIds: PaymentIntentId[];
totalCapturedMicro: bigint;
totalRefundedMicro: bigint;
}
export type ActorRef =
| { type: 'guest'; guestId: GuestId }
| { type: 'staff'; userId: string; staffId?: string }
| { type: 'system'; component: 'hold-expiry' | 'saga' | 'compensation' }
| { type: 'partner'; partnerId: ChannelPartnerId };
export interface PendingSagaStep {
step: 'await_inventory' | 'await_payment' | 'await_lock' | 'await_refund' | 'await_release';
startedAt: ISODate;
attempts: number;
expectedEventTypes: string[];
deadline: ISODate;
}
2.2 ReservationItem
export interface ReservationItem {
itemId: string; // ULID, scoped within reservation
roomTypeId: RoomTypeId;
roomId?: RoomId; // null until inventory assigns a specific room
stayWindow: StayWindow; // typically equal to reservation; can differ for split nights in modify flows
occupants: { adults: number; children: number; infants: number };
nightlyBreakdown: NightlyRate[]; // one entry per night
itemSubtotal: Money;
allocationId?: AllocationId; // set when inventory.allocation.committed.v1 lands
notes?: string; // free text, <= 500 chars
}
export interface NightlyRate {
date: ISODate; // local property date
rate: Money; // pre-tax, post-discount
rateCode: string; // e.g. 'BAR', 'NREF', 'CORP-ACME'
taxMicro: bigint; // computed by pricing-service
}
2.3 Guest and AdditionalGuest
export interface Guest {
id: GuestId;
fullName: GuestName; // value object with transliterations
email?: string;
phoneE164?: string;
locale: BCP47; // e.g. 'ps-AF', 'fa-IR', 'en-US'
preferences: SpecialRequestTag[]; // sticky preferences across stays
documentRef?: { type: 'passport' | 'national_id' | 'driving_license'; numberLast4: string; issuer?: string };
loyaltyId?: string;
}
export interface AdditionalGuest {
occupantId: string; // ULID
fullName: GuestName;
ageBand: 'adult' | 'child' | 'infant';
documentRef?: Guest['documentRef'];
isChildWithoutId?: boolean; // edge: child guest with no ID
}
export interface GuestName {
given: string; // primary script
family: string;
scriptHint: 'latin' | 'arabic' | 'persian' | 'pashto' | 'cyrillic' | 'other';
latinTranslit?: { given: string; family: string }; // produced by AI helper, see AI_INTEGRATION.md
}
2.4 SpecialRequest
export interface SpecialRequest {
id: string; // ULID
tags: SpecialRequestTag[]; // structured for routing
freeText?: string; // <= 1000 chars; localized
locale: BCP47;
source: 'guest' | 'staff' | 'ai_parser';
createdAt: ISODate;
fulfilled: boolean;
fulfilledAt?: ISODate;
fulfilledBy?: ActorRef;
}
2.5 ReservationModification (append-only audit)
export interface ReservationModification {
id: string; // ULID
type: ModificationType;
occurredAt: ISODate;
actor: ActorRef;
before: unknown; // snapshot of mutated subset, JSON
after: unknown; // snapshot of mutated subset, JSON
causationEventId?: string; // when triggered by a consumed event
reason?: string; // free text, <= 500 chars
policyAppliedRef?: string; // e.g. 'tenant.cancellation_policy@v3'
}
export type ModificationType =
| 'date_change' | 'room_change' | 'room_added' | 'room_removed'
| 'guest_count_change' | 'rate_change' | 'guest_profile_update'
| 'special_request_added' | 'channel_attribution_corrected'
| 'manual_state_override' /* compliance-only, audit-flagged */;
2.6 ReservationHold
export interface ReservationHold {
id: string; // ULID
placedAt: ISODate;
expiresAt: ISODate; // placedAt + ttlSeconds
ttlSeconds: number; // default 600, tenant-overridable 120..1800
inventoryAllocationIds: AllocationId[];
paymentIntentId?: PaymentIntentId; // set once payment intent is created
}
3. Reservation state machine
States and only these states are legal:
┌─────────────┐
│ quoted │ ← POST /quotes (no allocation, no hold)
└──────┬──────┘
│ HoldReservation (reserve inventory)
▼
┌──────────────────┐ ┌─────────────┐ TTL elapsed ┌──────────────────┐
│ expired_hold │◀─┤ held │───────────────▶│ expired_hold │
└──────────────────┘ └─────┬───────┘ └──────────────────┘
│ payment.captured.v1 OR cash_on_arrival selected
▼
┌─────────────┐ CancelReservation (policy-allowed)
│ confirmed │──────────────────▶ ┌─────────────┐
└─────┬───────┘ │ cancelled │
│ check-in started └─────────────┘
▼
┌──────────────────┐
│ check_in_started │ (transient; key issuance + folio open)
└─────┬────────────┘
│ checked_in.v1
▼
┌─────────────┐
│ checked_in │ ← guest in-house (alias `in_house`)
└─────┬───────┘
│ check-out started
▼
┌──────────────────┐
│ checkout_started │ (transient)
└─────┬────────────┘
│ checked_out.v1
▼
┌─────────────┐
│ checked_out │ (terminal, billing closes folio)
└─────────────┘
Side branches from `confirmed`:
confirmed ──no_show──▶ no_show (after grace; policy applied)
confirmed ──date/room change──▶ confirmed (sub-saga; modifications recorded)
Side branches from `checked_in`:
checked_in ──early_checkout──▶ checked_out (early_checkout=true on event)
checked_in ──overstayed──▶ checked_in (event only; state unchanged; alert + folio adjustment)
Aliases: in_house is the operator-facing display label for checked_in. The persisted state value is always checked_in.
3.1 Allowed transitions table
| From | To | Command / event | Guards |
|---|---|---|---|
quoted | held | HoldReservation | quote not expired; inventory commit succeeds |
quoted | cancelled | CancelReservation (auto, before hold) | always allowed |
held | confirmed | payment.transaction.captured.v1 OR cash_on_arrival && ConfirmReservation | hold not expired; payment matches expected total; OCC |
held | expired_hold | hold-expiry sweeper | TTL elapsed without confirm |
held | cancelled | CancelReservation OR inventory.allocation.failed.v1 OR payment.transaction.failed.v1 | always allowed |
confirmed | check_in_started | StartCheckIn | now ≥ stay.start − earlyWindow (or override); no pendingSagaStep |
check_in_started | checked_in | CompleteCheckIn (after lock-issued.v1 OR manual-key flag) | folio opened |
confirmed | cancelled | CancelReservation | policy-allowed; OCC |
confirmed | no_show | RecordNoShow (auto or manual) | now ≥ stay.start + noShowGrace |
confirmed | confirmed | ModifyReservation (date_change, room_change, ...) | sub-saga gates met |
checked_in | checkout_started | StartCheckOut | folio not in dispute |
checkout_started | checked_out | CompleteCheckOut | key revoked or revoke-queued; folio closed |
checked_in | checked_out (early) | RecordEarlyCheckout | refund policy applied |
terminal (cancelled, no_show, checked_out, expired_hold) | (none) | — | reservations are immutable post-terminal except for compliance overrides |
Any transition not in the table raises MELMASTOON.RESERVATION.ILLEGAL_TRANSITION.
4. Invariants (enforced in domain code)
| # | Invariant | Error code |
|---|---|---|
| I1 | Reservation.tenantId matches every nested aggregate's tenant context | MELMASTOON.RESERVATION.CROSS_TENANT_REFERENCE |
| I2 | A confirmed reservation has either payment.totalCapturedMicro >= grandTotal.amountMicro OR payment.method == 'cash_on_arrival' && payment.status == 'pending_cash' | MELMASTOON.RESERVATION.CONFIRM_WITHOUT_PAYMENT |
| I3 | No two ReservationItems within the same property hold the same roomId for overlapping stayWindows | MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED |
| I4 | stayWindow.end > stayWindow.start and nights == diffDays(end, start) | MELMASTOON.RESERVATION.INVALID_STAY_WINDOW |
| I5 | items.length >= 1 for any reservation past quoted | MELMASTOON.RESERVATION.NO_ROOMS |
| I6 | pendingSagaStep == null is required before any operator-initiated modification | MELMASTOON.RESERVATION.SAGA_IN_FLIGHT |
| I7 | version strictly increases on every save; mismatch raises stale-version error | MELMASTOON.RESERVATION.STALE_VERSION |
| I8 | fxSnapshot is set on or before transition to confirmed and is immutable afterward | MELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKED |
| I9 | additionalGuests.length + 1 (primary) matches sum of item occupants per stay night within ±1 (housekeeping tolerance) | MELMASTOON.RESERVATION.OCCUPANT_COUNT_MISMATCH |
| I10 | Children flagged isChildWithoutId cannot be the primary guest | MELMASTOON.RESERVATION.PRIMARY_GUEST_REQUIRES_ID |
| I11 | Modifications on a checked_in reservation cannot mutate stayWindow.start to a past date | MELMASTOON.RESERVATION.RETROACTIVE_DATE_CHANGE |
| I12 | Cancellation policy version on the modification audit row must equal the policy version active at confirm-time or an explicit policyOverride actor of role gm/owner | MELMASTOON.RESERVATION.POLICY_VERSION_MISMATCH |
| I13 | reservationCode is unique per tenant | MELMASTOON.RESERVATION.CODE_COLLISION |
| I14 | Holds cannot exceed tenant.settings.maxConcurrentHoldsPerProperty (default 200) | MELMASTOON.RESERVATION.HOLD_LIMIT_EXCEEDED |
5. Domain events (names; payloads in EVENT_SCHEMAS)
| Event | Emitted on |
|---|---|
ReservationQuoteRequested | RequestQuote use case |
ReservationQuoteCreated | quote computed and pinned |
ReservationHeld | hold placed, awaiting payment |
ReservationHoldExpired | sweeper fires |
ReservationConfirmed | held → confirmed |
ReservationCancelled | any → cancelled |
ReservationModified | modification recorded |
ReservationDatesChanged | sub-type with explicit re-allocation payload |
ReservationCheckInStarted | check-in flow begun |
ReservationCheckedIn | check-in complete |
ReservationCheckoutStarted | check-out flow begun |
ReservationCheckedOut | check-out complete |
ReservationNoShow | no-show declared |
ReservationEarlyCheckout | early checkout recorded |
ReservationOverstayed | overstay detected (event only; no state change) |
SpecialRequestAdded | request appended |
All events are wrapped in the canonical envelope from 04 §4. The causationId is the event that triggered the transition (e.g., payment.transaction.captured.v1 for ReservationConfirmed).
6. Domain errors (catalog excerpt)
| Code | Meaning |
|---|---|
MELMASTOON.RESERVATION.CROSS_TENANT_REFERENCE | aggregate referenced another tenant's id |
MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED | item conflict within tenant; mirror of inventory guard |
MELMASTOON.RESERVATION.CONFIRM_WITHOUT_PAYMENT | I2 violation |
MELMASTOON.RESERVATION.ILLEGAL_TRANSITION | state-machine violation |
MELMASTOON.RESERVATION.STALE_VERSION | OCC mismatch |
MELMASTOON.RESERVATION.HOLD_EXPIRED | command issued against expired hold |
MELMASTOON.RESERVATION.HOLD_LIMIT_EXCEEDED | tenant limit |
MELMASTOON.RESERVATION.SAGA_IN_FLIGHT | command refused while saga pending |
MELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKED | attempted re-snap post-confirm |
MELMASTOON.RESERVATION.POLICY_VERSION_MISMATCH | I12 violation |
MELMASTOON.RESERVATION.PRIMARY_GUEST_REQUIRES_ID | I10 violation |
MELMASTOON.RESERVATION.INVALID_STAY_WINDOW | I4 violation |
MELMASTOON.RESERVATION.RETROACTIVE_DATE_CHANGE | I11 violation |
MELMASTOON.RESERVATION.CHECK_IN_TOO_EARLY | guard on check-in command |
MELMASTOON.RESERVATION.NO_ROOMS | I5 violation |
MELMASTOON.RESERVATION.QUOTE_EXPIRED | quote TTL elapsed before hold |
MELMASTOON.RESERVATION.OCCUPANT_COUNT_MISMATCH | I9 violation |
MELMASTOON.RESERVATION.CODE_COLLISION | I13 violation |
The full registry lives in docs/standards/ERROR_CODES.md.
7. Domain services
Domain services are pure functions over aggregates (no I/O):
ReservationCodeGenerator(tenantId, propertyId, attempts) → string— deterministic, collision-resistant; CRC-checked.CancellationPolicyEvaluator(reservation, policySnapshot, now) → { allowed, refundEligibilityMicro, penaltyMicro, ruleApplied }.OverstayDetector(reservation, now, propertyClock) → boolean.OccupancyValidator(items, additionalGuests, primaryGuest) → ValidationResult.StayWindowOverlapDetector(items: ReservationItem[]) → ConflictReport[](defense in depth for I3).ModificationDiffer(before, after) → ReservationModification— produces the audit row from two snapshots.FxSnapshotResolver(ratePlanCurrency, tenantBillingCurrency, pricingClient, clock) → FXSnapshot— orchestration helper used by use cases (lives in application layer; declared here for completeness).
8. Notes on group bookings, merging, splitting
- Group booking — one Reservation root with
items.length > 1; primary guest is the booker;AdditionalGuests are the other named occupants. Cancellation can be partial: cancel oneReservationItemat a time; the aggregate'sstatusstaysconfirmeduntil all items are cancelled. - Merge — two reservations of the same primary guest with adjacent or contiguous stay windows can be merged into one; the lower-ranked reservation is marked
cancelledwith reasonmerged_into:rsv_…and the surviving one absorbs its items, totals, and modifications. Folio impact is delegated tobilling-service. - Split — one group reservation can be split into N independent reservations (e.g., one per family in a wedding-block booking). Each split reservation gets a new id, the original is marked
cancelledwith reasonsplit_into:[rsv_…]. Used when payers diverge.
9. Test surface (unit-only here; broader tests in TESTING_STRATEGY)
The aggregate ships with these unit tests (under test/unit/domain/):
reservation.state-machine.spec.ts— every legal and illegal transition.reservation.invariants.spec.ts— I1..I14 (oneit()per invariant, both pass and fail cases).reservation.code-generator.spec.ts— collision behavior.cancellation-policy-evaluator.spec.ts— boundary conditions per policy snapshot.overstay-detector.spec.ts— DST and tenant time-zone edges.modification-differ.spec.ts— audit produced is symmetric and minimal.stay-window-overlap-detector.spec.ts— edge cases with single-night and >30-night stays.fx-snapshot.spec.ts— immutability post-confirm.
All tests use __builders__/ fixtures colocated under src/domain/. No test imports NestJS, Drizzle, or any I/O.