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:
- 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. - The booking saga lives in its orchestrator. The booking saga touches
inventory-service,pricing-service,payment-gateway-service,lock-integration-service, andnotification-service. Per the platform architectural commitment (02 §15), saga state is never stored in another service. We are the orchestrator; we keep the state. - 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-serviceviaports/AIClient.
3. Aggregates owned
| Aggregate | Cardinality | Purpose | Identity prefix |
|---|---|---|---|
Reservation | root, 1 per booking | State machine, totals, channel, payment summary, FX snapshot, version (OCC) | rsv_ |
ReservationItem | 1..N per reservation | Per-room-per-stay-window assignment with nightly rate breakdown | (composite) |
Guest | 1 (primary contact) | Booker identity, locale, contact channels, preferences | gst_ |
AdditionalGuest | 0..N | Other occupants, optional ID where required | (composite) |
SpecialRequest | 0..N | Free-text + structured tags (halal, vegetarian, baby-cot, late check-in, …) | (composite, ULID) |
ReservationModification | 0..N (audit) | Append-only audit row per modification | (composite, ULID) |
ReservationHold | 0..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)
- Compute and pin a price quote (
POST /api/v1/reservations/quotes) backed bypricing-servicewith explicit TTL and FX snapshot. - Place a 10-minute inventory hold (
POST /api/v1/reservations/holds) by emittingmelmastoon.reservation.held.v1and waiting formelmastoon.inventory.allocation.committed.v1. - Confirm the reservation when
melmastoon.payment.transaction.captured.v1arrives, advancingheld → confirmedand emittingmelmastoon.reservation.confirmed.v1. - Cancel under policy (
POST /api/v1/reservations/:id/cancel), driving compensation: refund eligibility (viabilling-servicepolicy) → refund (viapayment-gateway-service) → release allocation (viainventory-service) → revoke key (vialock-integration-service) → notice (vianotification-service). - 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. - Walk-in (
POST /api/v1/reservations/:id/walk-in) — combined create+check-in for guests who arrive without a prior booking. - Group booking (multi-room single-payer) — one Reservation root with N
ReservationItems and a single folio reference. - 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. - Check-out (
POST /api/v1/reservations/:id/check-out) — emits the events that drive folio close, key revoke, and housekeeping turnover. - No-show, early-checkout, over-stay — policy-driven transitions producing the events
billing-serviceneeds to settle. - Reservation merge / group split — for guests who split a stay across rooms, or for groups that need to fan into independent reservations.
- Hold-expiry sweeper — cron-style worker that fires
melmastoon.reservation.hold_expired.v1for any hold whose TTL elapsed without confirmation; triggersinventory-serviceto release the allocation. - 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.
- 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
- No cross-tenant references. Every aggregate carries a
TenantIdvalue object; constructor refuses missing or mismatched values. (MELMASTOON.RESERVATION.CROSS_TENANT_REFERENCE) - Confirmed reservation has captured-or-pending-cash payment. A reservation cannot enter
confirmedwithout either a captured payment intent reference orpaymentMethod == cash_on_arrival && paymentStatus == pending_cash. (MELMASTOON.RESERVATION.CONFIRM_WITHOUT_PAYMENT) - No two
ReservationItems hold the same room for overlapping dates within the same property. (MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED— backed byinventory-serviceallocation events; we mirror the constraint at the domain layer for defense in depth.) - Check-in not before scheduled date unless
earlyCheckInAllowed = trueon the rate plan or front-desk override. (MELMASTOON.RESERVATION.CHECK_IN_TOO_EARLY) - No modification while saga is mid-flight. The state machine refuses commands while
pendingSagaStep != null. (MELMASTOON.RESERVATION.SAGA_IN_FLIGHT) - State transitions follow the declared graph only (DOMAIN_MODEL §3). (
MELMASTOON.RESERVATION.ILLEGAL_TRANSITION) - OCC version checked on every save. (
MELMASTOON.RESERVATION.STALE_VERSION) - FX snapshot is immutable post-confirm. (
MELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKED)
8. Hot read paths
| Read | Frequency | Caching strategy |
|---|---|---|
| Front-desk arrivals board (today's check-ins for a property) | every 30 s per active operator | Memorystore 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 list | every 60 s | Memorystore, TTL 60 s |
| Guest stay lookup by email/phone | low | Postgres indexed lookup; no cache |
| Reservation by id | high (saga, BFF, audit) | Postgres primary lookup; no cache (must always be authoritative) |
| Group reservation list | low | Postgres |
9. Cost & scale envelope
| Dimension | Target |
|---|---|
| Reservations per tenant per day | 1 (smallest guesthouse) → 1,000 (large chain property) |
| Active concurrent holds per tenant | up 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 schedule | every 30 s |
| Cloud Run min replicas (hot path) | 3 |
| Cloud SQL Postgres CPU | shared 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_arrivalconfirms 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 apending_cashcharge 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/pubsubfor outbox publishing.- Ports the application layer depends on (interfaces only):
ReservationRepositoryEventPublisher(outbox-backed)Clock,IdGeneratorPricingClient(callspricing-service)InventoryClient(callsinventory-service)PaymentClient(callspayment-gateway-service)LockClient(callslock-integration-service)NotificationClient(callsnotification-service)AIClient(callsai-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
- Booking saga: 04 Event-Driven Architecture §7
- API conventions: 05 API Design
- Schema, RLS, ID prefixes: 06 Data Models
- Lock lifecycle interaction: 09 Lock & Key Integration
- Payment selection and cash-on-arrival: 10 Payments Architecture
- Naming, error codes: standards/NAMING.md, standards/ERROR_CODES.md