Skip to main content

SERVICE_OVERVIEW — pricing-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 · 08 AI Architecture · 10 Payments Architecture

1. Purpose

pricing-service owns the Pricing & Rates bounded context for Ghasi Melmastoon — the multi-tenant hotel SaaS platform whose backoffice is an Electron offline-first desktop app and whose cloud is GCP. The service exists to convert a structured booking inquiry — (tenantId, propertyId, roomTypeId[], stayWindow, occupants, channel, ratePlanHint?, promoCode?, displayCurrency?) — into a deterministic, auditable, time-bounded PriceQuote that reservation-service will pin into a Reservation. It also owns the entire rate definition lifecycle so revenue managers, tenant admins, and the AI dynamic-pricing assistant operate on a single source of truth.

The service exists for four reasons no other service can satisfy:

  1. One pricing math, one place. Nightly rates, multipliers, surcharges, LOS discounts, advance-purchase rules, last-minute markups, taxes (VAT/tourism), fees (resort/cleaning), and currency conversion are a single deterministic pipeline. Distributing the math would create silent drift between the booking page, the folio, the desktop walk-in screen, and the analytics warehouse.
  2. Rate definitions must be replicable to the desktop. Front-desk staff in low-bandwidth markets (Afghanistan, Tajikistan, Iran rural) must quote walk-ins offline. We replicate published rate plans, applicable tax/fee rules, and the latest FxRateSnapshot to Electron SQLite — but never quotes themselves. Quotes are issued only by the cloud authority so promo redemption counters and FX freshness stay correct.
  3. AI dynamic pricing must remain advisory. Rate suggestions go through ai-orchestrator-service and surface to the revenue manager with provenance. The engine refuses to mutate any RatePlan without an explicit human-in-the-loop accept; a misbehaving model can never silently raise prices.
  4. Sharia-compliant rate semantics are first-class. A ratePlan.shariaCompliant=true flag forbids compounding deposits, percentage interest on late payments, and any surcharge labeled as riba. The engine refuses derivations that would violate this constraint. Hotels in our target markets need to publish certifiable rates.

2. Bounded context

Context name: Pricing & Rates Domain class: Core (revenue-critical, frequently evolving) Ubiquitous language: RatePlan, RateRule, RatePlanRoomType, Discount, Promotion, PromotionRedemption, TaxRule, FeeRule, PriceQuote, FxRateSnapshot, NightlyDerivation, RateBand, ChannelScope, Refundability, DepositPolicy, AdvancePurchaseWindow, LosDiscount, LastMinuteMarkup, PackageInclusion, ShariaCompliance, DynamicPricingSuggestion.

What is in:

  • Rate plan and rate rule lifecycle, validation, and publication.
  • Tax and fee rule registry per jurisdiction.
  • Promo code lifecycle and race-safe redemption counting.
  • Quote derivation pipeline (derive nightlyBase → applyMultipliers → applyDiscounts → composeFees → composeTaxes → applyFx → round).
  • FX snapshot refresh, freshness flag, manual override.
  • AI dynamic-pricing suggestion ingestion and HITL acceptance.

What is out:

  • Inventory availability — inventory-service. We assume requested room types exist; we do not check whether allocation is possible. (Quote may go stale via inventory.allocation.failed.v1.)
  • Money capture and refund execution — payment-gateway-service.
  • Folio/ledger entries — billing-service (we provide the tax/fee snapshot it consumes).
  • Reservation lifecycle — reservation-service.
  • The model itself — Vertex AI / ONNX Runtime via ai-orchestrator-service. We never call a model directly.
  • Channel manager / OTA rate distribution (Phase 3+).

3. Aggregates owned

AggregateCardinalityPurposeIdentity prefix
RatePlanper tenant + propertyContainer for rules, refundability, channel scope, currency, sharia flagrate_
RateRule1..N per RatePlanDate×room-type×DOW priced rule with priorityrru_
RatePlanRoomTypelinkagePlan → applicable room types(composite)
Discount0..N per RatePlanLOS, advance-purchase, loyalty, last-minutedsc_
Promotionper tenantPromo code with usage cap and scopingprm_
PromotionRedemptionper redemptionAudit + counter row(composite, ULID)
TaxRuleper tenant + jurisdictionVAT, tourism tax, hotel taxtax_
FeeRuleper tenant + propertyResort fee, cleaning fee, service chargefee_
PriceQuoteshort-lived (TTL 30 min)Pinned derivation context returned to callerqte_
FxRateSnapshotper (base, quote, day)Daily FX with provider provenancefxs_
DynamicPricingSuggestionper (property, roomType, date)Advisory rate range awaiting HITLdps_

4. Responsibilities (numbered)

  1. CRUD rate plans with state machine draft → published → archived and validation that all referenced room types and tax rules exist for the tenant.
  2. CRUD rate rules scoped to a rate plan, with priority ordering and unambiguous tie-break.
  3. Compose taxes and fees from TaxRule and FeeRule registries keyed by (country, region, propertyId?, ratePlanCategory?, dateRange).
  4. Manage promotions — create, activate, deactivate, redeem (race-safe), expire.
  5. Issue price quotes via POST /quotes; persist as a short-lived PriceQuote aggregate with TTL (default 30 min) and full derivation context.
  6. Re-fetch quotes via GET /quotes/:id while live; expire via quote-expiry worker every 60 s.
  7. Refresh FX snapshots via configured provider every 6 h with cached fallback (up to 24 h with stale=true flag) and tenant-pinned override path.
  8. Ingest AI dynamic-pricing suggestions from ai-orchestrator-service, persist as advisory DynamicPricingSuggestion, surface to backoffice; on HITL accept, materialize as a pinned override RateRule with source='ai_accepted'.
  9. Apply Sharia compliance constraints — refuse derivations that would compound deposits or apply percentage interest on shariaCompliant=true plans.
  10. Replicate rate definitions to the Electron desktop via sync-service (server-authoritative for definitions; quotes never replicate).
  11. Multi-currency display — render totals in tenant billing currency (or display override) using the active FX snapshot, honoring per-currency rounding (IRR → 1000, AFN → 1, USD → 0.01, AED → 0.01, TJS → 0.01).
  12. Emit lifecycle events for every state change so search-aggregation, billing, analytics, and audit stay current.

5. Upstream / downstream context map

┌─────────────────────┐
│ tenant-service │ billing currency, jurisdiction defaults,
│ │ tax-rule baseline, sharia opt-in, FX-pin override
└──────────┬──────────┘
│ tenant.config_updated.v1

┌──────────────────┐ │ ┌─────────────────────┐
│ property-service │ ────┘ │ ai-orchestrator-svc │
│ room types, │ │ dynamic pricing │
│ jurisdiction │ │ model + provenance │
└────────┬─────────┘ └──────────┬──────────┘
│ room_type.updated.v1 │ ai.suggestion.dynamic_pricing.v1
│ │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ pricing-service │
│ (RatePlan, RateRule, Tax/Fee, Promotion, FX, Quote) │
└──────┬───────────────────────────────┬───────────┬───────┘
│ quote.created.v1 │ rate_plan.* / tax_rule.*
│ rate_rule.* │ promotion.*
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ reservation-svc │ ◀── (server-to-server) ── /quotes
│ (booking saga) │ │ search-aggregation, │
│ │ │ billing, analytics, │
│ │ │ audit, sync-service │
└──────────────────┘ └──────────────────────┘


┌───────────────────────┐
│ Electron desktop │
│ replicates plans+rules│
│ + latest FX snapshot │
└───────────────────────┘

6. Quote derivation — ASCII pipeline

The derivation pipeline is the most-trafficked code path on the platform. It is pure given a fixed snapshot of inputs (rate rules, tax rules, fee rules, FX snapshot, promo state). Determinism is enforced by tests.

INPUT: QuoteRequest{ tenantId, propertyId, roomTypes[N], stayWindow, occupants[], channel,
ratePlanHint?, promoCode?, displayCurrency? }

step 1 ResolveRatePlan
- if ratePlanHint provided, validate it covers all roomTypes[] for stayWindow
- else select best plan: filter (active && covers room types && channel allowed)
then rank by (basePriority desc, mostSpecificDateScope, lowestPriceForGuest)

step 2 DeriveNightlyBase
- for each night in stayWindow, for each requested roomType:
find applicable RateRule by (date, dayOfWeek, roomTypeId, priority)
-> RateBand{ baseMicro, multiplier, surchargeMicro }
nightlyBaseMicro = round(baseMicro * multiplier) + surchargeMicro

step 3 ApplyDiscounts (ordered: LOS → advancePurchase → lastMinute → loyalty → promo)
- LOS: if nights >= threshold, subtract pct or flat
- AdvancePurchase: if (today + advanceDays) <= stayStart, subtract pct
- LastMinute: if (stayStart - today) < lastMinuteWindow, ADD markup (negative discount)
- Loyalty: if guest has loyalty tier in payload, subtract pct
- Promotion: if promoCode validates, redemption increment, then subtract per-promo math
- Negative-discount overflow guard: nightly cannot drop below floorMicro

step 4 ComposeFees
- for each FeeRule applicable (jurisdiction × ratePlan × propertyId × dateInRange):
if ratePerNight, add per-night feeMicro to a fees bucket
if flat, add once per stay
mark inclusive vs exclusive of display price

step 5 ComposeTaxes
- for each TaxRule applicable (jurisdiction × dateInRange × scope:room|fee):
compute taxBase = (subtotal - inclusiveFees - inclusiveTaxes)
taxMicro = round(taxBase * ratePct) for percent rules
taxMicro = flatMicro for flat rules
mark inclusive vs exclusive

step 6 ApplyFx
- if displayCurrency != ratePlan.currency:
load FxRateSnapshot(ratePlan.currency → displayCurrency, today)
displayMicro = round(amountMicro * fxRate, displayCurrency.rounding)
record snapshot.id, fxRate.value, fxRate.capturedAt, fxRate.stale flag
- else: identity

step 7 ShariaGuard
- if ratePlan.shariaCompliant == true:
refuse if any deposit policy uses compounding semantics
refuse if lastMinute markup is labeled "interest" (it never is, but check)
refuse if any fee category in the registry is tagged 'riba'

step 8 PinQuote
- persist PriceQuote with full breakdown, expiresAt = now + 30 min
- publish melmastoon.pricing.quote.created.v1

OUTPUT: PriceQuote{ id, ttl, derivation, nightly[], fees[], taxes[], discount lines,
grandTotalMicro (base + display), fxSnapshot?, promoApplied? }

7. Key invariants enforced in the domain layer

  1. No cross-tenant references — every aggregate constructor refuses missing or mismatched TenantId. (MELMASTOON.PRICING.CROSS_TENANT_REFERENCE)
  2. Published RatePlan is immutable in core fieldscurrency, shariaCompliant, refundability, channelScope cannot change after publish; rules can only be appended (mutations create a new version). (MELMASTOON.PRICING.RATE_PLAN_LOCKED)
  3. Promo redemption never exceeds usageCap — enforced by row-version + retry, with deterministic upper bound. (MELMASTOON.PRICING.PROMO_OVEROBLIGATION)
  4. Discount cascade can never drive nightly below floorMicro — overflow guard short-circuits with explicit error code. (MELMASTOON.PRICING.DERIVATION_FAILED with detail discount_overflow)
  5. Tax base never includes another exclusive tax twice — composition order is fixed; double-tax is an invariant violation.
  6. FX snapshot freshness — derivations using a snapshot older than staleAfterHours (default 24h) are flagged in the response and emit a metric; quotes refuse to derive if the snapshot is older than hardExpireHours (default 72h) without a tenant override.
  7. quote-expiry is monotonic — once expiresAt < now(), the quote is immutable and any redemption attempt returns MELMASTOON.PRICING.QUOTE_EXPIRED.
  8. AI suggestion can never mutate a rate plan directly — the orchestration refuses any path from dynamic_suggestion.generated.v1 to a RateRule without a HITL accept. (MELMASTOON.AI.HITL_REQUIRED)
  9. Sharia compliance is checked at every derivation step for plans flagged shariaCompliant=true; engine refuses to produce a quote that violates the constraint.

8. Hot read paths

ReadFrequencyCaching strategy
GET /rate-plans/:id for booking funnelhigh (every quote)Memorystore key rp:<tenantId>:<ratePlanId>:v<version>, TTL 600 s, invalidated on rate_plan.updated.v1/archived.v1
Resolve applicable RateRule for (date, roomType)very high (per night)Process-local LRU keyed by (ratePlanId, version) warmed on read
Latest FX snapshot for (base, quote)very highMemorystore key fx:<base>:<quote>:latest, TTL 60 s; server hot-fills on fx_snapshot.updated.v1
TaxRule registry for (country, region, date)very highMemorystore key tax:<tenantId>:<country>:<region>:<yyyy-mm-dd>, TTL 3600 s, invalidated on tax_rule.updated.v1
FeeRule registry for (propertyId, date)very highMemorystore key fee:<tenantId>:<propertyId>:<yyyy-mm-dd>, TTL 3600 s
GET /quotes/:idmedium (quote re-fetch from BFF)Postgres primary lookup; no cache (must reflect promo redemption state)

9. Cost & scale envelope

DimensionTarget
Quotes per tenant per day10 (small guesthouse) → 50,000 (large chain property in peak season)
Active rate plans per tenant4–20 typical; 200 hard cap per tenant before plan-limit alert
Rate rules per plan50–500 typical; 5,000 hard cap per plan
Promotions per tenant per quarter5–50
Quote latency p99< 250 ms (cache-warmed); p99 < 500 ms cold
Quote latency p50< 60 ms
FX refresh scheduleevery 6 h
Quote-expiry sweeperevery 60 s
Cloud Run min replicas (hot path)3
Cloud SQL Postgresshared with other revenue-core services on a regional, HA instance

10. Decision log (anchors)

  • Why we own the entire derivation pipeline (and not just rate-plan CRUD) — pricing math is the most-disputed surface in hotel ops. A single deterministic engine with full provenance ends "the screen says one thing, the folio says another" arguments. (See 02 §7.4.)
  • Why quotes are short-lived (30 min) and not persisted as Reservations — quotes are pre-commit; carrying them as long-lived state pollutes the read model and blurs ownership with reservation-service. The booking saga either pins the quote within TTL or re-quotes.
  • Why FX snapshots are pinned per-day, not per-second — guest-facing rates must be stable through a session; sub-day FX volatility is absorbed at the snapshot boundary. Tenants may pin (override) a rate per-day for their billing currency to take currency risk into their own hands.
  • Why AI dynamic pricing is advisory and HITL-gated — autonomous price mutation is a high-risk surface. Revenue managers in our target markets demand visibility into every rate change. (See 08 AI Architecture.)
  • Why we replicate rate definitions to desktop but not quotes — definitions are slow-moving, server-authoritative; quotes carry promo redemption state and FX snapshot identity that must be authoritative. Walk-in quoting offline uses the desktop derivation engine over replicated definitions; redemption and persistence happen at sync time.
  • Why Sharia compliance is a domain-layer flag, not a config toggle — refusing to compound deposits is a derivation-time invariant, not a presentation choice. Encoding it at the domain layer guarantees no path silently violates it.

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.
  • Memorystore (Redis) for hot rate/tax/fee/FX caches.
  • Ports the application layer depends on (interfaces only):
    • RatePlanRepository, RateRuleRepository, PromotionRepository, TaxRuleRepository, FeeRuleRepository, PriceQuoteRepository, FxSnapshotRepository, DynamicSuggestionRepository
    • EventPublisher (outbox-backed)
    • Clock, IdGenerator, IdentityResolver
    • FxProviderClient (calls configured FX provider; default: ECB + tenant-pinned override)
    • AIClient (calls ai-orchestrator-service)
    • TenantClient (resolves tenant config: currency, locale, jurisdiction defaults)
    • PropertyClient (resolves room types)

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