Skip to main content

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

FromToCommand / eventGuards
quotedheldHoldReservationquote not expired; inventory commit succeeds
quotedcancelledCancelReservation (auto, before hold)always allowed
heldconfirmedpayment.transaction.captured.v1 OR cash_on_arrival && ConfirmReservationhold not expired; payment matches expected total; OCC
heldexpired_holdhold-expiry sweeperTTL elapsed without confirm
heldcancelledCancelReservation OR inventory.allocation.failed.v1 OR payment.transaction.failed.v1always allowed
confirmedcheck_in_startedStartCheckInnow ≥ stay.start − earlyWindow (or override); no pendingSagaStep
check_in_startedchecked_inCompleteCheckIn (after lock-issued.v1 OR manual-key flag)folio opened
confirmedcancelledCancelReservationpolicy-allowed; OCC
confirmedno_showRecordNoShow (auto or manual)now ≥ stay.start + noShowGrace
confirmedconfirmedModifyReservation (date_change, room_change, ...)sub-saga gates met
checked_incheckout_startedStartCheckOutfolio not in dispute
checkout_startedchecked_outCompleteCheckOutkey revoked or revoke-queued; folio closed
checked_inchecked_out (early)RecordEarlyCheckoutrefund 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)

#InvariantError code
I1Reservation.tenantId matches every nested aggregate's tenant contextMELMASTOON.RESERVATION.CROSS_TENANT_REFERENCE
I2A confirmed reservation has either payment.totalCapturedMicro >= grandTotal.amountMicro OR payment.method == 'cash_on_arrival' && payment.status == 'pending_cash'MELMASTOON.RESERVATION.CONFIRM_WITHOUT_PAYMENT
I3No two ReservationItems within the same property hold the same roomId for overlapping stayWindowsMELMASTOON.RESERVATION.OVERBOOKING_BLOCKED
I4stayWindow.end > stayWindow.start and nights == diffDays(end, start)MELMASTOON.RESERVATION.INVALID_STAY_WINDOW
I5items.length >= 1 for any reservation past quotedMELMASTOON.RESERVATION.NO_ROOMS
I6pendingSagaStep == null is required before any operator-initiated modificationMELMASTOON.RESERVATION.SAGA_IN_FLIGHT
I7version strictly increases on every save; mismatch raises stale-version errorMELMASTOON.RESERVATION.STALE_VERSION
I8fxSnapshot is set on or before transition to confirmed and is immutable afterwardMELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKED
I9additionalGuests.length + 1 (primary) matches sum of item occupants per stay night within ±1 (housekeeping tolerance)MELMASTOON.RESERVATION.OCCUPANT_COUNT_MISMATCH
I10Children flagged isChildWithoutId cannot be the primary guestMELMASTOON.RESERVATION.PRIMARY_GUEST_REQUIRES_ID
I11Modifications on a checked_in reservation cannot mutate stayWindow.start to a past dateMELMASTOON.RESERVATION.RETROACTIVE_DATE_CHANGE
I12Cancellation policy version on the modification audit row must equal the policy version active at confirm-time or an explicit policyOverride actor of role gm/ownerMELMASTOON.RESERVATION.POLICY_VERSION_MISMATCH
I13reservationCode is unique per tenantMELMASTOON.RESERVATION.CODE_COLLISION
I14Holds cannot exceed tenant.settings.maxConcurrentHoldsPerProperty (default 200)MELMASTOON.RESERVATION.HOLD_LIMIT_EXCEEDED

5. Domain events (names; payloads in EVENT_SCHEMAS)

EventEmitted on
ReservationQuoteRequestedRequestQuote use case
ReservationQuoteCreatedquote computed and pinned
ReservationHeldhold placed, awaiting payment
ReservationHoldExpiredsweeper fires
ReservationConfirmedheld → confirmed
ReservationCancelledany → cancelled
ReservationModifiedmodification recorded
ReservationDatesChangedsub-type with explicit re-allocation payload
ReservationCheckInStartedcheck-in flow begun
ReservationCheckedIncheck-in complete
ReservationCheckoutStartedcheck-out flow begun
ReservationCheckedOutcheck-out complete
ReservationNoShowno-show declared
ReservationEarlyCheckoutearly checkout recorded
ReservationOverstayedoverstay detected (event only; no state change)
SpecialRequestAddedrequest 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)

CodeMeaning
MELMASTOON.RESERVATION.CROSS_TENANT_REFERENCEaggregate referenced another tenant's id
MELMASTOON.RESERVATION.OVERBOOKING_BLOCKEDitem conflict within tenant; mirror of inventory guard
MELMASTOON.RESERVATION.CONFIRM_WITHOUT_PAYMENTI2 violation
MELMASTOON.RESERVATION.ILLEGAL_TRANSITIONstate-machine violation
MELMASTOON.RESERVATION.STALE_VERSIONOCC mismatch
MELMASTOON.RESERVATION.HOLD_EXPIREDcommand issued against expired hold
MELMASTOON.RESERVATION.HOLD_LIMIT_EXCEEDEDtenant limit
MELMASTOON.RESERVATION.SAGA_IN_FLIGHTcommand refused while saga pending
MELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKEDattempted re-snap post-confirm
MELMASTOON.RESERVATION.POLICY_VERSION_MISMATCHI12 violation
MELMASTOON.RESERVATION.PRIMARY_GUEST_REQUIRES_IDI10 violation
MELMASTOON.RESERVATION.INVALID_STAY_WINDOWI4 violation
MELMASTOON.RESERVATION.RETROACTIVE_DATE_CHANGEI11 violation
MELMASTOON.RESERVATION.CHECK_IN_TOO_EARLYguard on check-in command
MELMASTOON.RESERVATION.NO_ROOMSI5 violation
MELMASTOON.RESERVATION.QUOTE_EXPIREDquote TTL elapsed before hold
MELMASTOON.RESERVATION.OCCUPANT_COUNT_MISMATCHI9 violation
MELMASTOON.RESERVATION.CODE_COLLISIONI13 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 one ReservationItem at a time; the aggregate's status stays confirmed until 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 cancelled with reason merged_into:rsv_… and the surviving one absorbs its items, totals, and modifications. Folio impact is delegated to billing-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 cancelled with reason split_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 (one it() 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.