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:
- 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.
- 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
publishedrate plans, applicable tax/fee rules, and the latestFxRateSnapshotto Electron SQLite — but never quotes themselves. Quotes are issued only by the cloud authority so promo redemption counters and FX freshness stay correct. - AI dynamic pricing must remain advisory. Rate suggestions go through
ai-orchestrator-serviceand surface to the revenue manager with provenance. The engine refuses to mutate anyRatePlanwithout an explicit human-in-the-loop accept; a misbehaving model can never silently raise prices. - Sharia-compliant rate semantics are first-class. A
ratePlan.shariaCompliant=trueflag forbids compounding deposits, percentage interest on late payments, and any surcharge labeled asriba. 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 viainventory.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
| Aggregate | Cardinality | Purpose | Identity prefix |
|---|---|---|---|
RatePlan | per tenant + property | Container for rules, refundability, channel scope, currency, sharia flag | rate_ |
RateRule | 1..N per RatePlan | Date×room-type×DOW priced rule with priority | rru_ |
RatePlanRoomType | linkage | Plan → applicable room types | (composite) |
Discount | 0..N per RatePlan | LOS, advance-purchase, loyalty, last-minute | dsc_ |
Promotion | per tenant | Promo code with usage cap and scoping | prm_ |
PromotionRedemption | per redemption | Audit + counter row | (composite, ULID) |
TaxRule | per tenant + jurisdiction | VAT, tourism tax, hotel tax | tax_ |
FeeRule | per tenant + property | Resort fee, cleaning fee, service charge | fee_ |
PriceQuote | short-lived (TTL 30 min) | Pinned derivation context returned to caller | qte_ |
FxRateSnapshot | per (base, quote, day) | Daily FX with provider provenance | fxs_ |
DynamicPricingSuggestion | per (property, roomType, date) | Advisory rate range awaiting HITL | dps_ |
4. Responsibilities (numbered)
- CRUD rate plans with state machine
draft → published → archivedand validation that all referenced room types and tax rules exist for the tenant. - CRUD rate rules scoped to a rate plan, with priority ordering and unambiguous tie-break.
- Compose taxes and fees from
TaxRuleandFeeRuleregistries keyed by(country, region, propertyId?, ratePlanCategory?, dateRange). - Manage promotions — create, activate, deactivate, redeem (race-safe), expire.
- Issue price quotes via
POST /quotes; persist as a short-livedPriceQuoteaggregate with TTL (default 30 min) and full derivation context. - Re-fetch quotes via
GET /quotes/:idwhile live; expire viaquote-expiryworker every 60 s. - Refresh FX snapshots via configured provider every 6 h with cached fallback (up to 24 h with
stale=trueflag) and tenant-pinned override path. - Ingest AI dynamic-pricing suggestions from
ai-orchestrator-service, persist as advisoryDynamicPricingSuggestion, surface to backoffice; on HITL accept, materialize as a pinned overrideRateRulewithsource='ai_accepted'. - Apply Sharia compliance constraints — refuse derivations that would compound deposits or apply percentage interest on
shariaCompliant=trueplans. - Replicate rate definitions to the Electron desktop via
sync-service(server-authoritative for definitions; quotes never replicate). - 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).
- 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
- No cross-tenant references — every aggregate constructor refuses missing or mismatched
TenantId. (MELMASTOON.PRICING.CROSS_TENANT_REFERENCE) - Published RatePlan is immutable in core fields —
currency,shariaCompliant,refundability,channelScopecannot change after publish; rules can only be appended (mutations create a new version). (MELMASTOON.PRICING.RATE_PLAN_LOCKED) - Promo redemption never exceeds
usageCap— enforced by row-version + retry, with deterministic upper bound. (MELMASTOON.PRICING.PROMO_OVEROBLIGATION) - Discount cascade can never drive nightly below
floorMicro— overflow guard short-circuits with explicit error code. (MELMASTOON.PRICING.DERIVATION_FAILEDwith detaildiscount_overflow) - Tax base never includes another exclusive tax twice — composition order is fixed; double-tax is an invariant violation.
- 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 thanhardExpireHours(default 72h) without a tenant override. quote-expiryis monotonic — onceexpiresAt < now(), the quote is immutable and any redemption attempt returnsMELMASTOON.PRICING.QUOTE_EXPIRED.- AI suggestion can never mutate a rate plan directly — the orchestration refuses any path from
dynamic_suggestion.generated.v1to aRateRulewithout a HITL accept. (MELMASTOON.AI.HITL_REQUIRED) - 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
| Read | Frequency | Caching strategy |
|---|---|---|
GET /rate-plans/:id for booking funnel | high (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 high | Memorystore key fx:<base>:<quote>:latest, TTL 60 s; server hot-fills on fx_snapshot.updated.v1 |
TaxRule registry for (country, region, date) | very high | Memorystore key tax:<tenantId>:<country>:<region>:<yyyy-mm-dd>, TTL 3600 s, invalidated on tax_rule.updated.v1 |
FeeRule registry for (propertyId, date) | very high | Memorystore key fee:<tenantId>:<propertyId>:<yyyy-mm-dd>, TTL 3600 s |
GET /quotes/:id | medium (quote re-fetch from BFF) | Postgres primary lookup; no cache (must reflect promo redemption state) |
9. Cost & scale envelope
| Dimension | Target |
|---|---|
| Quotes per tenant per day | 10 (small guesthouse) → 50,000 (large chain property in peak season) |
| Active rate plans per tenant | 4–20 typical; 200 hard cap per tenant before plan-limit alert |
| Rate rules per plan | 50–500 typical; 5,000 hard cap per plan |
| Promotions per tenant per quarter | 5–50 |
| Quote latency p99 | < 250 ms (cache-warmed); p99 < 500 ms cold |
| Quote latency p50 | < 60 ms |
| FX refresh schedule | every 6 h |
| Quote-expiry sweeper | every 60 s |
| Cloud Run min replicas (hot path) | 3 |
| Cloud SQL Postgres | shared 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/pubsubfor 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,DynamicSuggestionRepositoryEventPublisher(outbox-backed)Clock,IdGenerator,IdentityResolverFxProviderClient(calls configured FX provider; default: ECB + tenant-pinned override)AIClient(callsai-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
- API conventions: 05 API Design
- Schema, RLS, ID prefixes: 06 Data Models
- Payments interaction: 10 Payments Architecture
- AI provenance + HITL: 08 AI Architecture
- Naming, error codes: standards/NAMING.md, standards/ERROR_CODES.md
- Booking saga consumer: reservation-service bundle