DOMAIN_MODEL — pricing-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS
Strategic anchors: 02 Enterprise Architecture §3 · 04 Event-Driven Architecture §3 · 10 Payments Architecture · 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, the rate-rules engine, and the price derivation pipeline; 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 RoomTypeId = Branded<string, 'RoomTypeId'>; // rmt_…
export type RatePlanId = Branded<string, 'RatePlanId'>; // rate_…
export type RateRuleId = Branded<string, 'RateRuleId'>; // rru_…
export type DiscountId = Branded<string, 'DiscountId'>; // dsc_…
export type PromotionId = Branded<string, 'PromotionId'>; // prm_…
export type TaxRuleId = Branded<string, 'TaxRuleId'>; // tax_…
export type FeeRuleId = Branded<string, 'FeeRuleId'>; // fee_…
export type PriceQuoteId = Branded<string, 'PriceQuoteId'>; // qte_…
export type FxSnapshotId = Branded<string, 'FxSnapshotId'>; // fxs_…
export type DynamicSuggestionId = Branded<string, 'DynamicSuggestionId'>; // dps_…
export type ReservationId = Branded<string, 'ReservationId'>; // rsv_… (reference only)
// Money — bigint micro-units; rate plan currency is the base of derivation
export interface Money {
amountMicro: bigint; // e.g. 12_500_000n = 12.50
currency: CurrencyCode;
}
export type CurrencyCode = 'USD' | 'EUR' | 'IRR' | 'AFN' | 'TJS' | 'PKR' | 'GBP' | 'AED' | 'SAR';
export const CURRENCY_ROUNDING: Record<CurrencyCode, bigint> = {
USD: 10_000n, // round to 0.01 (10_000 micro)
EUR: 10_000n,
GBP: 10_000n,
AED: 10_000n,
SAR: 10_000n,
AFN: 1_000_000n, // round to 1.00
TJS: 10_000n,
PKR: 1_000_000n,
IRR: 1_000_000_000n, // round to 1000 IRR (high inflation; nearest 1000 unit)
};
// Date / time
export interface DateRange { start: ISODate; end: ISODate; } // [start, end) — start inclusive, end exclusive
export interface StayWindow extends DateRange { nights: number; }
export type DayOfWeek = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
// Channel scope
export type ChannelScope = 'direct' | 'meta' | 'walk_in' | 'phone_by_staff' | 'ota' | 'all';
// FX snapshot
export interface FxRateSnapshot {
id: FxSnapshotId;
base: CurrencyCode;
quote: CurrencyCode;
rate: number; // 1 base = rate * quote
source: 'provider:ecb' | 'provider:openexchange' | 'tenant_pinned' | 'manual_override';
capturedAt: ISODate; // last refresh
staleAfter: ISODate; // capturedAt + staleAfterHours
hardExpireAt: ISODate; // capturedAt + hardExpireHours
providerRef?: string; // upstream provider response id
}
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 FxRateSnapshot.
2. Aggregates and entities
2.1 RatePlan (aggregate root)
export interface RatePlan {
id: RatePlanId;
tenantId: TenantId;
propertyId: PropertyId;
code: string; // e.g. 'BAR', 'WEEKLY', 'GOV-AF', 'CORP-ACME', 'NREF', 'PKG-HALAL'
displayName: LocalizedString; // {ps,fa,ar,en}
category: RatePlanCategory;
channelScope: ChannelScope[]; // empty == 'all'
currency: CurrencyCode; // base currency of this plan
refundability: Refundability;
depositPolicy: DepositPolicy;
advancePurchaseDays?: number; // booking must be made N days before stay start
minLOS?: number; // minimum nights
maxLOS?: number;
bookableFrom?: ISODate;
bookableUntil?: ISODate;
shariaCompliant: boolean; // forbids compounding deposits, riba-tagged fees
packageInclusions?: PackageInclusion[]; // breakfast, halal-meal-plan, airport-pickup
status: RatePlanStatus; // §3 state machine
version: number; // OCC + immutable-after-publish version stamp
publishedAt?: ISODate;
publishedBy?: ActorRef;
archivedAt?: ISODate;
archivedReason?: string;
createdAt: ISODate;
createdBy: ActorRef;
updatedAt: ISODate;
updatedBy: ActorRef;
// Composition (loaded as needed)
rules: RateRule[];
roomTypeLinks: RatePlanRoomType[];
discounts: Discount[];
appliesToFeeRuleIds: FeeRuleId[];
appliesToTaxRuleIds: TaxRuleId[];
}
export type RatePlanCategory =
| 'bar' // Best Available Rate
| 'weekly' // Weekly stay rate (LOS-baked)
| 'government' // Gov agency rate
| 'corporate' // Corporate negotiated
| 'non_refundable'
| 'package' // Room + something
| 'group' // Group block rate
| 'promotional'; // Time-bound promo
export type RatePlanStatus = 'draft' | 'published' | 'archived';
export type Refundability =
| { kind: 'fully_refundable'; cutoffHoursBeforeStart: number }
| { kind: 'partially_refundable'; cutoffHoursBeforeStart: number; penaltyPct: number }
| { kind: 'non_refundable' };
export type DepositPolicy =
| { kind: 'none' }
| { kind: 'flat'; amountMicro: bigint; currency: CurrencyCode }
| { kind: 'pct_of_total'; pct: number } // 0..1
| { kind: 'first_night' };
// NOTE: kind 'compounding' is intentionally absent — compounding deposits are not allowed,
// regardless of shariaCompliant flag.
export type PackageInclusion =
| { kind: 'breakfast'; halal: boolean; perNight: boolean }
| { kind: 'meal_plan'; type: 'half_board' | 'full_board' | 'all_inclusive'; halal: boolean }
| { kind: 'airport_pickup'; oneWay: boolean }
| { kind: 'late_checkout'; latestHourLocal: number };
2.2 RateRule
export interface RateRule {
id: RateRuleId;
tenantId: TenantId;
ratePlanId: RatePlanId;
priority: number; // higher wins on tie; deterministic
scope: RateRuleScope;
baseMicro: bigint; // base nightly micro in plan currency
multiplier: number; // applied multiplicatively (default 1.0)
surchargeMicro: bigint; // applied additively (default 0)
losDiscount?: LosDiscount; // optional rule-level LOS discount
source: 'manual' | 'imported' | 'ai_accepted';
aiProvenance?: AIProvenance; // present when source == 'ai_accepted'
notes?: string; // <= 500 chars
version: number; // OCC
createdAt: ISODate;
createdBy: ActorRef;
updatedAt: ISODate;
updatedBy: ActorRef;
}
export interface RateRuleScope {
dateRange: DateRange;
daysOfWeek?: DayOfWeek[]; // empty/absent == all
roomTypeIds?: RoomTypeId[]; // empty/absent == all linked types
occupancyBand?: { adultsMin?: number; adultsMax?: number };
}
export interface LosDiscount {
thresholdNights: number; // applies when stay nights >= threshold
kind: 'pct' | 'flat';
amount: number; // pct: 0..1; flat: micro per night
}
2.3 RatePlanRoomType (linkage)
export interface RatePlanRoomType {
tenantId: TenantId;
ratePlanId: RatePlanId;
roomTypeId: RoomTypeId;
baseRoomTypeMultiplier?: number; // optional bias (e.g. SUITE 1.5x of base)
enabled: boolean;
linkedAt: ISODate;
linkedBy: ActorRef;
}
2.4 Discount
export interface Discount {
id: DiscountId;
tenantId: TenantId;
ratePlanId: RatePlanId;
kind: DiscountKind;
config: DiscountConfig; // discriminated by kind
enabled: boolean;
priorityInPipeline: number; // explicit ordering (lower = applied earlier)
createdAt: ISODate;
updatedAt: ISODate;
}
export type DiscountKind = 'los' | 'advance_purchase' | 'last_minute' | 'loyalty' | 'corporate_negotiated';
export type DiscountConfig =
| { kind: 'los'; thresholdNights: number; pct?: number; flatMicroPerNight?: bigint }
| { kind: 'advance_purchase'; minDaysBefore: number; pct?: number; flatMicro?: bigint }
| { kind: 'last_minute'; maxDaysBefore: number; markupPct: number /* note: markup, not discount */ }
| { kind: 'loyalty'; tier: 'silver' | 'gold' | 'platinum'; pct: number }
| { kind: 'corporate_negotiated'; corporateClientId: string; pct: number };
2.5 Promotion
export interface Promotion {
id: PromotionId;
tenantId: TenantId;
code: string; // unique per tenant (case-insensitive)
displayName: LocalizedString;
validFrom: ISODate;
validUntil: ISODate; // exclusive
applicableRatePlanIds?: RatePlanId[]; // empty == all
applicablePropertyIds?: PropertyId[]; // empty == all
applicableChannels?: ChannelScope[]; // empty == all
usageCap: number; // hard cap; 0 == unlimited
usageCount: number; // server-tracked counter (race-safe via OCC)
perGuestCap?: number; // optional (looked up via reservation history; best-effort)
discountPct?: number; // 0..1
discountFlatMicro?: bigint; // mutually exclusive with pct
currency?: CurrencyCode; // required if flat
status: PromotionStatus; // draft | active | inactive | expired
shariaCompliant: boolean; // mirrors plan flag; forbids riba-tagged copy
createdAt: ISODate;
updatedAt: ISODate;
}
export type PromotionStatus = 'draft' | 'active' | 'inactive' | 'expired';
2.6 TaxRule and FeeRule
export interface TaxRule {
id: TaxRuleId;
tenantId: TenantId;
jurisdiction: { country: 'AF' | 'TJ' | 'IR' | 'PK' | 'AE' | 'SA' | 'GB' | 'US' | string; region?: string };
scope: 'room' | 'fee' | 'all';
appliesToPropertyIds?: PropertyId[]; // empty == all
category: 'vat' | 'tourism' | 'hotel_tax' | 'service_tax';
rate: TaxRate; // pct or flat
inclusiveOfDisplayPrice: boolean;
validFrom: ISODate;
validUntil?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
}
export type TaxRate =
| { kind: 'pct'; pct: number } // 0..1
| { kind: 'flat'; amountMicro: bigint; currency: CurrencyCode };
export interface FeeRule {
id: FeeRuleId;
tenantId: TenantId;
propertyId?: PropertyId; // null == all properties of tenant
category: 'resort' | 'cleaning' | 'service' | 'tourism_bed' | 'late_checkout' | 'other';
rate: FeeRate;
cadence: 'per_night' | 'per_stay';
inclusiveOfDisplayPrice: boolean;
appliesToRatePlanIds?: RatePlanId[]; // empty == all
validFrom: ISODate;
validUntil?: ISODate;
shariaTag?: 'halal' | 'riba_forbidden' | null; // 'riba_forbidden' refuses application on shariaCompliant plans
createdAt: ISODate;
updatedAt: ISODate;
}
export type FeeRate =
| { kind: 'flat'; amountMicro: bigint; currency: CurrencyCode }
| { kind: 'pct_of_room'; pct: number };
2.7 PriceQuote
export interface PriceQuote {
id: PriceQuoteId;
tenantId: TenantId;
propertyId: PropertyId;
requestedAt: ISODate;
expiresAt: ISODate; // requestedAt + ttlSeconds (default 1800)
ttlSeconds: number;
status: 'live' | 'expired' | 'redeemed' | 'stale';
staleReason?: 'inventory_failed' | 'fx_invalidated' | 'rate_plan_archived';
request: QuoteRequestSnapshot; // full normalized inputs
derivation: QuoteDerivation; // every step's intermediate
ratePlan: { id: RatePlanId; version: number; snapshotName: string };
fxSnapshot?: { id: FxSnapshotId; base: CurrencyCode; quote: CurrencyCode; rate: number; capturedAt: ISODate; stale: boolean };
promoApplied?: { id: PromotionId; code: string; redemptionId: string };
totals: QuoteTotals;
redeemedByReservationId?: ReservationId;
redeemedAt?: ISODate;
}
export interface QuoteRequestSnapshot {
roomTypeIds: RoomTypeId[];
stayWindow: StayWindow;
occupants: Array<{ adults: number; children: number; infants: number }>;
channel: ChannelScope;
ratePlanHint?: RatePlanId;
promoCode?: string;
displayCurrency?: CurrencyCode;
}
export interface QuoteDerivation {
steps: DerivationStep[]; // ordered; one per pipeline stage (§ SERVICE_OVERVIEW.md §6)
shariaGuardPasses: boolean;
}
export interface DerivationStep {
step: 'resolve_rate_plan' | 'derive_nightly_base' | 'apply_discounts'
| 'compose_fees' | 'compose_taxes' | 'apply_fx' | 'sharia_guard' | 'pin';
inputs: Record<string, unknown>;
outputs: Record<string, unknown>;
notes?: string;
}
export interface QuoteTotals {
subtotal: Money; // sum of nightly post-multiplier post-surcharge
discountTotal: Money; // sum of all discount lines (positive = subtraction)
feeTotal: Money; // sum of exclusive fees only
taxTotal: Money; // sum of exclusive taxes only
inclusiveAdjustments: Money;// inclusive fees+taxes already inside subtotal
grandTotal: Money; // base currency
inDisplayCurrency?: Money; // present when displayCurrency != ratePlan.currency
}
2.8 DynamicPricingSuggestion
export interface DynamicPricingSuggestion {
id: DynamicSuggestionId;
tenantId: TenantId;
propertyId: PropertyId;
roomTypeId: RoomTypeId;
date: ISODate;
baselineRateMicro: bigint;
suggestedRange: { lowMicro: bigint; midMicro: bigint; highMicro: bigint };
currency: CurrencyCode;
signals: SuggestionSignals;
rationale: string; // short LLM explanation, locale-agnostic
status: 'generated' | 'accepted' | 'rejected' | 'expired';
expiresAt: ISODate; // suggestions are stale after 24 h
acceptedRule?: RateRuleId; // present when accepted; the materialized override
acceptedBy?: ActorRef;
acceptedAt?: ISODate;
rejectedBy?: ActorRef;
rejectedAt?: ISODate;
rejectedReason?: string;
aiProvenance: AIProvenance;
createdAt: ISODate;
}
export interface SuggestionSignals {
occupancyPct: number; // 0..1 forecast for the date
bookingPace: number; // bookings/day in last 7 days vs historical
competitorRateProxy?: number; // optional, in plan currency, micro
eventsNearby: Array<{ kind: 'religious' | 'cultural' | 'sport' | 'business'; name: string; weight: number }>;
}
3. RatePlan state machine
┌─────────┐ PublishRatePlan ┌────────────┐
│ draft │ ───────────────────▶ │ published │ ───┐
└────┬────┘ └────┬───────┘ │ ArchiveRatePlan
│ EditRatePlan (unlimited) │ EditRatePlan (rules append-only;
▼ │ core fields locked)
┌─────────┐ ▼
│ draft │ ┌────────────┐
└─────────┘ │ archived │ (terminal; future bookings preserve quote)
└────────────┘
| From | To | Command | Guards |
|---|---|---|---|
draft | published | PublishRatePlan | rule-set passes validation; at least one room-type linkage; currency set; shariaGuard passes if shariaCompliant |
draft | archived | ArchiveRatePlan | always allowed |
published | published | EditRatePlan (append rule, edit non-core fields) | core-field set unchanged; OCC version increments |
published | archived | ArchiveRatePlan | always; existing pinned quotes survive until TTL; new derivations refuse |
archived | (none) | — | terminal |
Any transition not in the table raises MELMASTOON.PRICING.RATE_PLAN_LOCKED or MELMASTOON.PRICING.RATE_PLAN_INACTIVE.
4. Promotion state machine
draft ──Activate──▶ active ──Deactivate──▶ inactive
│ │ │
│ │ validUntil < now │ validUntil < now
▼ ▼ ▼
expired (terminal) expired expired
active promotions are race-checked at redemption: usageCount < usageCap (usageCap=0 means unlimited).
5. Invariants (enforced in domain code)
| # | Invariant | Error code |
|---|---|---|
| I1 | Every aggregate carries matching tenantId; constructors refuse missing/mismatched values | MELMASTOON.PRICING.CROSS_TENANT_REFERENCE |
| I2 | Published RatePlan cannot mutate currency, shariaCompliant, refundability, channelScope, category | MELMASTOON.PRICING.RATE_PLAN_LOCKED |
| I3 | RatePlan.publish requires ≥1 RatePlanRoomType enabled and a valid rule cascade | MELMASTOON.GENERAL.VALIDATION_FAILED |
| I4 | RateRule.priority is a positive integer; ties broken by (createdAt asc, id asc) deterministically | MELMASTOON.PRICING.RULE_PRIORITY_INVALID |
| I5 | Discount cascade output never falls below floorMicro (default 1 unit of plan currency); overflow short-circuits | MELMASTOON.PRICING.DERIVATION_FAILED (detail discount_overflow) |
| I6 | Promotion.usageCount <= Promotion.usageCap (when cap > 0) | MELMASTOON.PRICING.PROMO_OVEROBLIGATION |
| I7 | Promotion.code unique per tenant (case-insensitive) | MELMASTOON.PRICING.PROMO_CODE_COLLISION |
| I8 | TaxRule validity intervals do not overlap for the same (tenant, jurisdiction, scope, category) triple | MELMASTOON.PRICING.TAX_RULE_OVERLAP |
| I9 | FxRateSnapshot.rate > 0; refresh advances capturedAt; refusing staleAfter < hardExpireAt ordering | MELMASTOON.PRICING.FX_SNAPSHOT_INVALID |
| I10 | PriceQuote.expiresAt > requestedAt; once now > expiresAt quote is immutable | MELMASTOON.PRICING.QUOTE_EXPIRED |
| I11 | PriceQuote.derivation is complete and reproducible (re-run yields identical totals when inputs unchanged) | MELMASTOON.PRICING.DERIVATION_NON_DETERMINISTIC |
| I12 | DynamicPricingSuggestion.acceptedRule may only be created via AcceptDynamicPricingSuggestionUseCase (not by direct rate-rule mutation) | MELMASTOON.AI.HITL_REQUIRED |
| I13 | On shariaCompliant plans, no FeeRule.shariaTag == 'riba_forbidden' may apply; no compounding deposit policy may be selected | MELMASTOON.PRICING.SHARIA_GUARD_FAILED |
| I14 | Money arithmetic across different currencies refuses without an explicit FxRateSnapshot | MELMASTOON.PRICING.CURRENCY_MISMATCH |
| I15 | version strictly increases on every save; mismatch raises stale-version | MELMASTOON.PRICING.STALE_VERSION |
6. Domain events (names; payloads in EVENT_SCHEMAS)
| Event | Emitted on |
|---|---|
RatePlanCreated | CreateRatePlanUseCase |
RatePlanUpdated | UpdateRatePlanUseCase |
RatePlanArchived | ArchiveRatePlanUseCase |
RateRuleCreated | AppendRateRuleUseCase |
RateRuleUpdated | UpdateRateRuleUseCase |
PromotionCreated | CreatePromotionUseCase |
PromotionActivated | ActivatePromotionUseCase |
PromotionDeactivated | DeactivatePromotionUseCase |
QuoteRequested | quote request received (analytics) |
QuoteCreated | quote pinned |
QuoteExpired | sweeper / inventory-fail invalidation |
TaxRuleUpdated | UpsertTaxRuleUseCase |
FxSnapshotUpdated | RefreshFxSnapshotUseCase |
DynamicSuggestionGenerated | suggestion ingested from ai-orchestrator-service |
DynamicSuggestionAccepted | revenue-manager HITL accept |
All events are wrapped in the canonical envelope from 04 §4. The causationId is the event that triggered the transition (e.g., ai.suggestion.dynamic_pricing.v1 for DynamicSuggestionGenerated).
7. Domain errors (catalog excerpt)
| Code | Meaning |
|---|---|
MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND | RatePlan does not exist or is not active for the requested date |
MELMASTOON.PRICING.RATE_PLAN_INACTIVE | RatePlan is draft or archived |
MELMASTOON.PRICING.RATE_PLAN_LOCKED | Attempt to mutate immutable field on published plan |
MELMASTOON.PRICING.RULE_PRIORITY_INVALID | Rule priority is not a positive integer |
MELMASTOON.PRICING.QUOTE_EXPIRED | Quote TTL elapsed |
MELMASTOON.PRICING.QUOTE_STALE | Quote invalidated by inventory or FX event |
MELMASTOON.PRICING.PROMO_OVEROBLIGATION | Redemption would exceed usageCap |
MELMASTOON.PRICING.PROMO_CODE_COLLISION | Promo code already exists for tenant |
MELMASTOON.PRICING.PROMO_NOT_APPLICABLE | Promo scope/property/channel mismatch |
MELMASTOON.PRICING.TAX_RULE_OVERLAP | Conflicting validity intervals |
MELMASTOON.PRICING.CURRENCY_MISMATCH | Cross-currency math without FX snapshot |
MELMASTOON.PRICING.FX_SNAPSHOT_STALE | Snapshot exceeds hard expire and no override |
MELMASTOON.PRICING.FX_SNAPSHOT_INVALID | Snapshot fails value/order invariants |
MELMASTOON.PRICING.DERIVATION_FAILED | Pipeline could not derive a final amount |
MELMASTOON.PRICING.DERIVATION_NON_DETERMINISTIC | I11 violated in test/replay |
MELMASTOON.PRICING.SHARIA_GUARD_FAILED | Derivation would violate Sharia constraints |
MELMASTOON.PRICING.STALE_VERSION | OCC mismatch |
MELMASTOON.PRICING.CROSS_TENANT_REFERENCE | Aggregate referenced another tenant's id |
8. Domain services (pure functions over aggregates; no I/O)
// rate-rules-engine.ts — the heart of the derivation pipeline
export interface NightlyDerivation {
date: ISODate;
roomTypeId: RoomTypeId;
ruleId: RateRuleId;
baseMicro: bigint;
multiplier: number;
surchargeMicro: bigint;
preDiscountMicro: bigint; // baseMicro * multiplier + surchargeMicro
appliedDiscounts: Array<{ kind: DiscountKind; amountMicro: bigint }>;
postDiscountMicro: bigint; // never below floorMicro
appliedFeesInclusiveMicro: bigint;
appliedTaxesInclusiveMicro: bigint;
feeBreakdown: Array<{ feeRuleId: FeeRuleId; amountMicro: bigint; inclusive: boolean }>;
taxBreakdown: Array<{ taxRuleId: TaxRuleId; amountMicro: bigint; inclusive: boolean }>;
}
// Pure: given rules + request, returns nightly derivations deterministically
export function deriveNightly(
request: QuoteRequestSnapshot,
plan: RatePlan,
roomTypeBias: Map<RoomTypeId, number>,
feeRules: FeeRule[],
taxRules: TaxRule[],
fxSnapshot: FxRateSnapshot | null,
clock: { today: ISODate },
floorMicro: bigint,
): NightlyDerivation[] { /* … pure code … */ }
// Tie-break: deterministic by (priority desc, scopeSpecificity desc, createdAt asc, id asc)
export function selectRule(rules: RateRule[], date: ISODate, dow: DayOfWeek, roomTypeId: RoomTypeId): RateRule | null;
// Sharia guard
export function shariaGuard(plan: RatePlan, deposit: DepositPolicy, fees: FeeRule[]): { ok: true } | { ok: false; reason: string };
// Promo redemption check (race-safe at infrastructure layer; this is the pure check)
export function canRedeem(promo: Promotion, request: QuoteRequestSnapshot, now: ISODate): boolean;
// Discount cascade (pure)
export function applyDiscounts(
preDiscountMicro: bigint,
discounts: Discount[],
request: QuoteRequestSnapshot,
now: ISODate,
loyaltyTier: 'silver' | 'gold' | 'platinum' | null,
floorMicro: bigint,
): { postDiscountMicro: bigint; lines: Array<{ kind: DiscountKind; amountMicro: bigint }> };
The rate-rules engine is fully unit-tested with property-based tests (fast-check) for monotonicity (LOS discount never increases price), idempotency (re-derivation stable), and invariant preservation (output never < floor).
9. Notes on rate-plan archival with active future bookings
When a RatePlan is archived:
- Existing pinned
PriceQuotes remain valid until their TTL. - Existing
Reservations referencing the plan are unaffected —reservation-serviceholds the pinned snapshot. - New quote requests with
ratePlanHint=<archivedId>returnMELMASTOON.PRICING.RATE_PLAN_INACTIVE. - Search-aggregation receives
rate_plan.archived.v1and removes the plan from listings. - Audit row (
actor,archivedAt,archivedReason) is mandatory.
10. Test surface (unit-only here; broader tests in TESTING_STRATEGY)
The aggregates ship with these unit tests under test/unit/domain/:
rate-plan.state-machine.spec.ts— every legal and illegal transition.rate-plan.invariants.spec.ts— I1..I3, I13, I15.rate-rule.tie-break.spec.ts— deterministic priority/specificity ordering.rate-rules-engine.derive-nightly.spec.ts— golden cases per rate-plan category.discount-cascade.property.spec.ts— fast-check properties (monotonicity, floor preservation).promotion.race.spec.ts— usage-cap honoring under simulated concurrency (pure, stub clock).promotion.applicability.spec.ts— scope checks (property, channel, plan, dates).tax-rule.overlap.spec.ts— validity-interval invariant.fx-snapshot.spec.ts— freshness windows; rounding policy per currency.sharia-guard.spec.ts— refusal cases (riba-tagged fee, compounding deposit).quote.derivation.deterministic.spec.ts— replay yields identical totals (I11).dynamic-suggestion.hitl.spec.ts— accepting a suggestion materializes aRateRulewithsource='ai_accepted'and provenance preserved.
All tests use __builders__/ fixtures colocated under src/domain/. No test imports NestJS, Drizzle, or any I/O.