Skip to main content

SERVICE_OVERVIEW — reservation-service

Bundle index: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL · SYNC_CONTRACT · AI_INTEGRATION · SECURITY_MODEL · OBSERVABILITY · TESTING_STRATEGY · DEPLOYMENT_TOPOLOGY · FAILURE_MODES · LOCAL_DEV_SETUP · SERVICE_READINESS · SERVICE_RISK_REGISTER · MIGRATION_PLAN

Strategic anchors: 02 Enterprise Architecture · 04 Event-Driven Architecture · 05 API Design · 06 Data Models · 09 Lock & Key · 10 Payments

1. Purpose

reservation-service owns the Reservation aggregate for Ghasi Melmastoon — the multi-tenant hotel SaaS platform whose backoffice is an Electron offline-first desktop app and whose cloud is GCP. A Reservation is the single record-of-truth for "a guest agreed to stay at a tenant's property on these dates, in these rooms, at this rate, paying this way, through this channel". Every other service operates on projections of, or reactions to, this aggregate.

The service exists for three reasons that no other service can satisfy:

  1. One state machine, one place. A Reservation has 8+ legal states (quoted, held, confirmed, checked_in, in_house, checked_out, cancelled, no_show, expired_hold). Distributing those transitions across services would dissolve the invariants. They live here, enforced in pure-TypeScript domain code with optimistic concurrency.
  2. The booking saga lives in its orchestrator. The booking saga touches inventory-service, pricing-service, payment-gateway-service, lock-integration-service, and notification-service. Per the platform architectural commitment (02 §15), saga state is never stored in another service. We are the orchestrator; we keep the state.
  3. The desktop must work offline. Front-desk staff check guests in, modify reservations, and capture walk-ins on flaky links. The Reservation aggregate replicates to the desktop SQLite under a strict per-aggregate conflict policy (SYNC_CONTRACT); the cloud service is the conflict-resolution authority.

2. Bounded context

Context name: Reservations Domain class: Core (revenue-critical, fastest-evolving, deepest test investment) Ubiquitous language: Quote, Hold, Reservation, ReservationItem, Guest, AdditionalGuest, SpecialRequest, Modification, Stay, StayWindow, Channel, Folio (reference only — owned by billing-service), KeyCredential (reference only — owned by lock-integration-service), RatePlan (reference only — owned by pricing-service), InventoryAllocation (reference only — owned by inventory-service).

What is in: the Reservation lifecycle, hold management, modification audit, walk-in flow, group booking, no-show / early-checkout / overstay, FX snapshot, channel attribution, the booking saga itself.

What is out:

  • Availability allocation → inventory-service.
  • Price calculation, rate-plan rules, currency conversion math → pricing-service.
  • Money capture, refunds, vendor adapters (PayPal/Visa/MFS/cash) → payment-gateway-service.
  • Folio ledger entries → billing-service.
  • Key issuance, vendor adapters (TTLock/Salto/Assa Abloy/Wiegand) → lock-integration-service.
  • Housekeeping turnover tasks → housekeeping-service (we just emit the events that trigger them).
  • Multi-channel notifications → notification-service.
  • AI suggestions → ai-orchestrator-service via ports/AIClient.

3. Aggregates owned

AggregateCardinalityPurposeIdentity prefix
Reservationroot, 1 per bookingState machine, totals, channel, payment summary, FX snapshot, version (OCC)rsv_
ReservationItem1..N per reservationPer-room-per-stay-window assignment with nightly rate breakdown(composite)
Guest1 (primary contact)Booker identity, locale, contact channels, preferencesgst_
AdditionalGuest0..NOther occupants, optional ID where required(composite)
SpecialRequest0..NFree-text + structured tags (halal, vegetarian, baby-cot, late check-in, …)(composite, ULID)
ReservationModification0..N (audit)Append-only audit row per modification(composite, ULID)
ReservationHold0..1 (TTL)Short-lived hold record while payment is in flight(composite, ULID)

Booking (in-funnel pre-confirmation working state) uses prefix bkg_ and is captured in this same service as a transient projection of a held Reservation; once confirmed, only rsv_ is referenced.

4. Responsibilities (numbered)

  1. Compute and pin a price quote (POST /api/v1/reservations/quotes) backed by pricing-service with explicit TTL and FX snapshot.
  2. Place a 10-minute inventory hold (POST /api/v1/reservations/holds) by emitting melmastoon.reservation.held.v1 and waiting for melmastoon.inventory.allocation.committed.v1.
  3. Confirm the reservation when melmastoon.payment.transaction.captured.v1 arrives, advancing held → confirmed and emitting melmastoon.reservation.confirmed.v1.
  4. Cancel under policy (POST /api/v1/reservations/:id/cancel), driving compensation: refund eligibility (via billing-service policy) → refund (via payment-gateway-service) → release allocation (via inventory-service) → revoke key (via lock-integration-service) → notice (via notification-service).
  5. Modify with explicit sub-types: date_change, room_change, room_added, room_removed, guest_count_change, rate_change, guest_profile_update, special_request_added. Each sub-type runs its own mini-saga.
  6. Walk-in (POST /api/v1/reservations/:id/walk-in) — combined create+check-in for guests who arrive without a prior booking.
  7. Group booking (multi-room single-payer) — one Reservation root with N ReservationItems and a single folio reference.
  8. Check-in (POST /api/v1/reservations/:id/check-in) — emits the events that drive folio open, key issue, and housekeeping cleanup of the prior occupancy.
  9. Check-out (POST /api/v1/reservations/:id/check-out) — emits the events that drive folio close, key revoke, and housekeeping turnover.
  10. No-show, early-checkout, over-stay — policy-driven transitions producing the events billing-service needs to settle.
  11. Reservation merge / group split — for guests who split a stay across rooms, or for groups that need to fan into independent reservations.
  12. Hold-expiry sweeper — cron-style worker that fires melmastoon.reservation.hold_expired.v1 for any hold whose TTL elapsed without confirmation; triggers inventory-service to release the allocation.
  13. FX snapshot — at confirm-time, persist the rate that will be used to convert between the rate plan's currency (e.g., USD) and the tenant's billing currency (e.g., IRR for Iran tenants). Never re-quote.
  14. Channel attribution — preserve channel = direct | meta | walk_in | phone_by_staff | ota (Phase 3+) on every reservation for revenue analysis and partner reconciliation.

5. Upstream / downstream context map

┌─────────────────────┐
│ tenant-service │ hold-window default,
│ │ cancellation policy version,
│ │ channel attribution rules
└──────────┬──────────┘
│ (settings.changed.v1)
┌─────────────────┐ │ ┌──────────────────────────┐
│ pricing-service │ ── quote/FX ──┐ │ │ property-service │
└─────────────────┘ │ │ │ (room/property metadata) │
▼ ▼ └──────────┬───────────────┘
┌─────────────────┐ ┌──────────────────────────┐ │
│ inventory-svc │◀────────┤ reservation-service │◀───────────────┘
│ (allocate, hold,│ │ (saga orchestrator) │ (room.taken_out_of_order.v1)
│ release) │────────▶│ │
└─────────────────┘ alloc └────┬─────────┬────────┬──┘
events │ │ │
│ pay │ key │ notify
▼ ▼ ▼
┌──────────────┐ ┌────────────┐ ┌────────────────┐
│ payment- │ │ lock- │ │ notification- │
│ gateway-svc │ │ integration│ │ service │
└──────┬───────┘ └─────┬──────┘ └────────────────┘
│ tx events │ key events
▼ ▼
┌──────────────────────────────┐
│ billing-service (folio) │ (subscribes to
│ │ reservation.confirmed.v1,
└──────────────────────────────┘ checked_in.v1, checked_out.v1)

6. Booking saga — ASCII sequence diagram

The booking saga is the single most-trafficked multi-service orchestration on the platform. The Reservation aggregate is its state. Each step has an explicit compensation.

Guest BFF (booking) reservation-svc inventory-svc pricing-svc payment-gateway-svc lock-integration-svc notification-svc
│ │ │ │ │ │ │ │
│ POST /quotes │ │ │ │ │ │ │
├───────────────▶│ POST /quotes │ │ │ │ │ │
│ ├──────────────────────▶│ GetQuote(price+FX) │ │ │ │ │
│ │ │────────────────────────────────────────▶ │ │ │ │
│ │ │ ◀─── quote{price, fxSnapshot, ttl} ─────│ │ │ │
│ │ ◀── 200 quote ────────│ │ │ │ │ │
│ │ │ │ │ │ │ │
│ POST /holds │ │ │ │ │ │ │
├───────────────▶│ POST /holds │ │ │ │ │ │
│ ├──────────────────────▶│ HoldReservation │ │ │ │ │
│ │ │ (1) state=held │ │ │ │ │
│ │ │ outbox: held.v1 │ │ │ │
│ │ │ ────────────────▶ inventory.allocate │ │ │ │
│ │ │ │ commit alloc │ │ │ │
│ │ │ ◀──── inventory.allocation.committed.v1 │
│ │ │ │ │ │ │ │
│ │ │ CreatePaymentIntent (idempotent) │ │ │ │
│ │ │ ──────────────────────────────────────────────────────────────▶│ │ │
│ │ ◀── 201 hold{paymentIntentClientSecret, holdExpiresAt} │ │ │
│ │ │ │ │ │ │ │
│ Pay (PayPal, │ │ │ │ │ │ │
│ Visa, cash │ │ │ │ │ │ │
│ on arrival, │ │ │ │ │ │ │
│ MFS) │ │ │ │ │ │ │
├───────────────▶│ Payment provider webhook ────────────────────────────────────────────────────────────▶│ │ │
│ │ │ ◀── payment.transaction.captured.v1 (or pending_cash) │ │
│ │ │ (2) state=confirmed │ │
│ │ │ outbox: confirmed.v1 │ │
│ │ │ ────────────────────────────────────────────────────────────────────────────────────────▶ notification.send │
│ │ │ (if stay starts today) │ │
│ │ │ ────────────────────────────────────────────────────────────▶ lock.issue (deferred to chk-in if future) │
│ │ │ │ │
│ ◀── 200 confirmed (rsvId, rsvCode) ────────────────────────────────────────────────────────────────────────────────────────────── │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ ◀── confirm email/SMS │
│ │ │ │ │ │ │ (RTL/LTR per locale) │
│ │ │ │ │ │ │ │
│ ─── Compensation paths (any saga step failure) ───
│ inv.failed.v1 → cancel held reservation, refund pending payment if any
│ payment.failed.v1 → cancel held reservation, release inventory allocation
│ lock.failed.v1 (chk-in) → allow check-in with requires_manual_key=true; alert front desk
│ hold TTL elapsed → reservation.hold_expired.v1 → inventory release → cleanup

7. Key invariants enforced in the domain layer

  1. No cross-tenant references. Every aggregate carries a TenantId value object; constructor refuses missing or mismatched values. (MELMASTOON.RESERVATION.CROSS_TENANT_REFERENCE)
  2. Confirmed reservation has captured-or-pending-cash payment. A reservation cannot enter confirmed without either a captured payment intent reference or paymentMethod == cash_on_arrival && paymentStatus == pending_cash. (MELMASTOON.RESERVATION.CONFIRM_WITHOUT_PAYMENT)
  3. No two ReservationItems hold the same room for overlapping dates within the same property. (MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED — backed by inventory-service allocation events; we mirror the constraint at the domain layer for defense in depth.)
  4. Check-in not before scheduled date unless earlyCheckInAllowed = true on the rate plan or front-desk override. (MELMASTOON.RESERVATION.CHECK_IN_TOO_EARLY)
  5. No modification while saga is mid-flight. The state machine refuses commands while pendingSagaStep != null. (MELMASTOON.RESERVATION.SAGA_IN_FLIGHT)
  6. State transitions follow the declared graph only (DOMAIN_MODEL §3). (MELMASTOON.RESERVATION.ILLEGAL_TRANSITION)
  7. OCC version checked on every save. (MELMASTOON.RESERVATION.STALE_VERSION)
  8. FX snapshot is immutable post-confirm. (MELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKED)

8. Hot read paths

ReadFrequencyCaching strategy
Front-desk arrivals board (today's check-ins for a property)every 30 s per active operatorMemorystore key rsv:arrivals:<tenantId>:<propertyId>:<localDate>, TTL 30 s, invalidated on confirmed.v1, cancelled.v1, dates_changed.v1, checked_in.v1
Front-desk in-house listevery 60 sMemorystore, TTL 60 s
Guest stay lookup by email/phonelowPostgres indexed lookup; no cache
Reservation by idhigh (saga, BFF, audit)Postgres primary lookup; no cache (must always be authoritative)
Group reservation listlowPostgres

9. Cost & scale envelope

DimensionTarget
Reservations per tenant per day1 (smallest guesthouse) → 1,000 (large chain property)
Active concurrent holds per tenantup to 200
Saga events per confirmed reservation~12 (held, alloc.committed, intent.created, captured, confirmed, key.issued, notification.delivered ×2, audit ×3, sync.cursor.advanced)
p99 booking-funnel saga latency< 5 s including external payment capture
Hold-expiry worker scheduleevery 30 s
Cloud Run min replicas (hot path)3
Cloud SQL Postgres CPUshared with other PMS-core services on a regional, HA instance

10. Decision log (anchors)

  • Why we own the saga state and not a separate saga-service — saga state is intrinsic to the Reservation aggregate; isolating it would force a synchronous read across services for every transition. (See 02 §7.3.)
  • Why holds are 10 minutes — long enough for the slowest payment provider (MFS in low-bandwidth markets) to confirm; short enough to not strand inventory. Tenant-overridable in tenant.settings.
  • Why FX is snapped at confirm-time, not check-in — guest committed at confirm; re-quoting at check-in invites disputes, especially with IRR volatility.
  • Why cash_on_arrival confirms the reservation immediately — for the operating market (Afghanistan, Tajikistan, Iran rural), cash is the dominant rail; requiring online capture would block the funnel. The folio carries a pending_cash charge until front desk records the cash event.
  • Why we replicate to desktop SQLite — front-desk operations must continue through 5-hour blackouts. (See ADR-0003 Electron offline-first.)

11. What this service depends on (libraries, ports, infrastructure)

  • NestJS for presentation + DI composition root (out of the domain layer).
  • Drizzle ORM for Postgres access in the infrastructure layer.
  • @google-cloud/pubsub for outbox publishing.
  • Ports the application layer depends on (interfaces only):
    • ReservationRepository
    • EventPublisher (outbox-backed)
    • Clock, IdGenerator
    • PricingClient (calls pricing-service)
    • InventoryClient (calls inventory-service)
    • PaymentClient (calls payment-gateway-service)
    • LockClient (calls lock-integration-service)
    • NotificationClient (calls notification-service)
    • AIClient (calls ai-orchestrator-service)
    • IdentityResolver (resolves actor from JWT)

The domain layer depends on nothing outside @ghasi/domain-primitives and the standard library. CI fails the build on any framework or I/O import inside src/domain/.

12. References