Skip to main content

APPLICATION_LOGIC — pricing-service

Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL

Strategic anchors: 02 §2 Application Architecture · 02 §7 Event Backbone · 05 API Design · 08 AI Architecture

The application layer is the orchestration tier between the framework-free domain layer and the infrastructure adapters. It exposes use cases (commands), queries, and ports (interfaces). Use cases are the only entry points for state mutation; queries are read-only. All cross-service interactions go through ports — never through inline fetch or vendor SDKs. The derivation pipeline is implemented as a pure pipeline composed in the application layer over domain functions; infrastructure provides repositories, the FX provider, and the AI client.


1. Ports (interfaces only — under src/application/ports/)

// rate-plan.repository.port.ts
export interface RatePlanRepository {
findById(id: RatePlanId, tx?: UnitOfWork): Promise<RatePlan | null>;
findByCode(tenantId: TenantId, propertyId: PropertyId, code: string): Promise<RatePlan | null>;
findCandidatesForRequest(req: QuoteRequestSnapshot): Promise<RatePlan[]>;
save(plan: RatePlan, tx: UnitOfWork): Promise<void>; // OCC-checked
}

// rate-rule.repository.port.ts
export interface RateRuleRepository {
findByRatePlan(ratePlanId: RatePlanId): Promise<RateRule[]>;
save(rule: RateRule, tx: UnitOfWork): Promise<void>; // OCC-checked
}

// promotion.repository.port.ts
export interface PromotionRepository {
findByCode(tenantId: TenantId, code: string): Promise<Promotion | null>;
save(promo: Promotion, tx: UnitOfWork): Promise<void>;
redeemAtomic(promotionId: PromotionId, request: QuoteRequestSnapshot, tx: UnitOfWork): Promise<RedemptionOutcome>;
// RedemptionOutcome = { ok: true; redemptionId: string } | { ok: false; reason: 'cap_exceeded' | 'expired' | 'not_applicable' }
}

// tax-rule.repository.port.ts
export interface TaxRuleRepository {
findApplicable(tenantId: TenantId, jurisdiction: { country: string; region?: string }, atDate: ISODate, scope: 'room' | 'fee' | 'all'): Promise<TaxRule[]>;
save(rule: TaxRule, tx: UnitOfWork): Promise<void>;
}

// fee-rule.repository.port.ts
export interface FeeRuleRepository {
findApplicable(tenantId: TenantId, propertyId: PropertyId, atDate: ISODate, ratePlanId: RatePlanId): Promise<FeeRule[]>;
save(rule: FeeRule, tx: UnitOfWork): Promise<void>;
}

// price-quote.repository.port.ts
export interface PriceQuoteRepository {
findById(id: PriceQuoteId): Promise<PriceQuote | null>;
save(quote: PriceQuote, tx: UnitOfWork): Promise<void>;
markStale(id: PriceQuoteId, reason: PriceQuote['staleReason'], tx: UnitOfWork): Promise<void>;
findExpired(now: ISODate, batchSize: number): Promise<PriceQuote[]>;
}

// fx-snapshot.repository.port.ts
export interface FxSnapshotRepository {
latest(base: CurrencyCode, quote: CurrencyCode): Promise<FxRateSnapshot | null>;
save(snap: FxRateSnapshot, tx: UnitOfWork): Promise<void>;
}

// fx-provider.port.ts
export interface FxProviderClient {
fetchRates(bases: CurrencyCode[], quotes: CurrencyCode[]): Promise<Array<{ base: CurrencyCode; quote: CurrencyCode; rate: number; capturedAt: ISODate; providerRef?: string }>>;
}

// dynamic-suggestion.repository.port.ts
export interface DynamicSuggestionRepository {
findById(id: DynamicSuggestionId): Promise<DynamicPricingSuggestion | null>;
findOpenForProperty(tenantId: TenantId, propertyId: PropertyId): Promise<DynamicPricingSuggestion[]>;
save(s: DynamicPricingSuggestion, tx: UnitOfWork): Promise<void>;
}

// ai.client.port.ts (consumes ai-orchestrator-service)
export interface AIClient {
requestDynamicPricing(req: DynamicPricingRequest): Promise<DynamicPricingResult>;
}

// tenant.client.port.ts (read-through cache backed)
export interface TenantClient {
resolveConfig(tenantId: TenantId): Promise<{
billingCurrency: CurrencyCode;
defaultJurisdiction: { country: string; region?: string };
locale: BCP47;
fxPin?: { base: CurrencyCode; quote: CurrencyCode; rate: number } | null;
quoteTtlSeconds?: number; // tenant override
}>;
}

// property.client.port.ts
export interface PropertyClient {
resolveRoomTypes(tenantId: TenantId, roomTypeIds: RoomTypeId[]): Promise<Array<{ id: RoomTypeId; baseOccupancy: number; maxOccupancy: number; jurisdiction?: { country: string; region?: string } }>>;
}

// supporting infra ports
export interface EventPublisher { publish(envelope: EventEnvelope, tx: UnitOfWork): Promise<void>; }
export interface Clock { now(): ISODate; today(timeZone: string): ISODate; }
export interface IdGenerator { quoteId(): PriceQuoteId; ratePlanId(): RatePlanId; ruleId(): RateRuleId; ulid(): string; }
export interface IdentityResolver { resolve(req: AuthenticatedRequest): ActorRef; }
export interface UnitOfWork { /* opaque tx handle */ }

2. Use cases (commands)

2.1 CalculateQuoteUseCase

The hottest path on the platform. Pure orchestration over the domain rate-rules engine.

class CalculateQuoteUseCase {
constructor(private readonly deps: {
ratePlans: RatePlanRepository;
rules: RateRuleRepository;
promos: PromotionRepository;
taxRules: TaxRuleRepository;
feeRules: FeeRuleRepository;
quotes: PriceQuoteRepository;
fx: FxSnapshotRepository;
tenant: TenantClient;
property: PropertyClient;
publisher: EventPublisher;
clock: Clock;
ids: IdGenerator;
uowFactory: () => UnitOfWork;
}) {}

async execute(cmd: CalculateQuoteCommand): Promise<PriceQuote> {
const tenantCfg = await this.deps.tenant.resolveConfig(cmd.tenantId);
const request: QuoteRequestSnapshot = normalize(cmd, tenantCfg);

// step 1 ResolveRatePlan
const candidates = await this.deps.ratePlans.findCandidatesForRequest(request);
const plan = resolveRatePlan(candidates, request); // pure
if (!plan) throw new DomainError('MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND');

// promo (resolved early so derivation has redemption context, but redemption is at end)
const promo = request.promoCode ? await this.deps.promos.findByCode(cmd.tenantId, request.promoCode) : null;

// load rules + applicable tax/fee
const rules = await this.deps.rules.findByRatePlan(plan.id);
const planWithRules = { ...plan, rules };
const fxNeeded = request.displayCurrency && request.displayCurrency !== plan.currency;
const fxSnapshot = fxNeeded ? await this.deps.fx.latest(plan.currency, request.displayCurrency!) : null;
if (fxNeeded && !fxSnapshot) throw new DomainError('MELMASTOON.PRICING.FX_SNAPSHOT_INVALID');
if (fxSnapshot && this.deps.clock.now() > fxSnapshot.hardExpireAt) throw new DomainError('MELMASTOON.PRICING.FX_SNAPSHOT_STALE');

const propertyJurisdiction = (await this.deps.property.resolveRoomTypes(cmd.tenantId, request.roomTypeIds))[0]?.jurisdiction
?? tenantCfg.defaultJurisdiction;

const feeRules = await this.deps.feeRules.findApplicable(cmd.tenantId, request.propertyId, request.stayWindow.start, plan.id);
const taxRules = await this.deps.taxRules.findApplicable(cmd.tenantId, propertyJurisdiction, request.stayWindow.start, 'room');

// step 7 sharia guard (early; refuses construction)
const guard = shariaGuard(plan, plan.depositPolicy, feeRules);
if (!guard.ok) throw new DomainError('MELMASTOON.PRICING.SHARIA_GUARD_FAILED', guard.reason);

// steps 2..6 — pure pipeline
const nightly = deriveNightly(request, planWithRules, /* roomTypeBias */, feeRules, taxRules, fxSnapshot, this.deps.clock, FLOOR_MICRO);

// promo redemption (race-safe at infra layer; happens inside the same UoW as quote save)
const uow = this.deps.uowFactory();
let promoApplied: { id: PromotionId; code: string; redemptionId: string } | undefined;
if (promo && canRedeem(promo, request, this.deps.clock.now())) {
const outcome = await this.deps.promos.redeemAtomic(promo.id, request, uow);
if (outcome.ok) {
promoApplied = { id: promo.id, code: promo.code, redemptionId: outcome.redemptionId };
nightly.forEach(n => applyPromoToNightly(n, promo)); // pure mutation of breakdown
}
}

// pin
const totals = sumTotals(nightly, plan.currency, fxSnapshot);
const ttlSeconds = tenantCfg.quoteTtlSeconds ?? DEFAULT_QUOTE_TTL_SECONDS;
const quote: PriceQuote = {
id: this.deps.ids.quoteId(),
tenantId: cmd.tenantId,
propertyId: request.propertyId,
requestedAt: this.deps.clock.now(),
expiresAt: addSeconds(this.deps.clock.now(), ttlSeconds),
ttlSeconds,
status: 'live',
request,
derivation: { steps: deriveStepLog(/* … */), shariaGuardPasses: true },
ratePlan: { id: plan.id, version: plan.version, snapshotName: plan.code },
fxSnapshot: fxSnapshot ? { id: fxSnapshot.id, base: fxSnapshot.base, quote: fxSnapshot.quote, rate: fxSnapshot.rate, capturedAt: fxSnapshot.capturedAt, stale: this.deps.clock.now() > fxSnapshot.staleAfter } : undefined,
promoApplied,
totals,
};

await this.deps.quotes.save(quote, uow);
await this.deps.publisher.publish(envelope('melmastoon.pricing.quote.created.v1', quote), uow);
await uow.commit();
return quote;
}
}

Errors: MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND, MELMASTOON.PRICING.RATE_PLAN_INACTIVE, MELMASTOON.PRICING.FX_SNAPSHOT_STALE, MELMASTOON.PRICING.PROMO_OVEROBLIGATION, MELMASTOON.PRICING.SHARIA_GUARD_FAILED, MELMASTOON.PRICING.DERIVATION_FAILED, MELMASTOON.PRICING.CURRENCY_MISMATCH.

2.2 CreateRatePlanUseCase

class CreateRatePlanUseCase {
async execute(cmd: CreateRatePlanCommand): Promise<RatePlan> {
assertActor(cmd.actor, ['revenue_manager','gm','owner']);
const plan: RatePlan = constructDraft(cmd, this.ids.ratePlanId(), this.clock.now());
const uow = this.uowFactory();
await this.ratePlans.save(plan, uow);
await this.publisher.publish(envelope('melmastoon.pricing.rate_plan.created.v1', plan), uow);
await uow.commit();
return plan;
}
}

Errors: MELMASTOON.GENERAL.VALIDATION_FAILED, MELMASTOON.PRICING.PROMO_CODE_COLLISION (for code-uniqueness within tenant/property).

2.3 UpdateRateRuleUseCase

class UpdateRateRuleUseCase {
async execute(cmd: UpdateRateRuleCommand): Promise<RateRule> {
assertActor(cmd.actor, ['revenue_manager','gm','owner']);
const plan = await this.ratePlans.findById(cmd.ratePlanId);
if (!plan) throw new DomainError('MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND');
if (plan.status === 'archived') throw new DomainError('MELMASTOON.PRICING.RATE_PLAN_INACTIVE');
const existing = plan.rules.find(r => r.id === cmd.ruleId);
if (!existing) throw new DomainError('MELMASTOON.GENERAL.RESOURCE_NOT_FOUND');

// OCC check
if (existing.version !== cmd.expectedVersion) throw new DomainError('MELMASTOON.PRICING.STALE_VERSION');

const next: RateRule = applyRuleEdit(existing, cmd, cmd.actor, this.clock.now());

const uow = this.uowFactory();
await this.rules.save(next, uow);
await this.publisher.publish(envelope('melmastoon.pricing.rate_rule.updated.v1', { ratePlanId: plan.id, before: existing, after: next }), uow);
await uow.commit();
return next;
}
}

2.4 ApplyPromotionUseCase

A standalone use case used by the BFF "validate code before showing it on the booking page" flow. Does not redeem; only checks applicability.

class ApplyPromotionUseCase {
async execute(cmd: ApplyPromotionCommand): Promise<{ valid: boolean; reason?: string; promo?: { code: string; discountPct?: number; discountFlatMicro?: bigint; currency?: CurrencyCode } }> {
const promo = await this.promos.findByCode(cmd.tenantId, cmd.code);
if (!promo) return { valid: false, reason: 'not_found' };
if (promo.status !== 'active') return { valid: false, reason: 'inactive' };
if (promo.usageCap > 0 && promo.usageCount >= promo.usageCap) return { valid: false, reason: 'cap_reached' };
if (!isApplicable(promo, cmd.context)) return { valid: false, reason: 'not_applicable' };
return { valid: true, promo: { code: promo.code, discountPct: promo.discountPct, discountFlatMicro: promo.discountFlatMicro, currency: promo.currency } };
}
}

2.5 RefreshFxSnapshotUseCase

class RefreshFxSnapshotUseCase {
async execute(cmd: { bases: CurrencyCode[]; quotes: CurrencyCode[]; trigger: 'cron' | 'manual'; actor: ActorRef }): Promise<FxRateSnapshot[]> {
const upstream = await safe(() => this.fxProvider.fetchRates(cmd.bases, cmd.quotes));
if (upstream.kind === 'failure') {
this.metrics.increment('pricing.fx_refresh.failed', { reason: upstream.reason });
// do NOT throw — keep cached snapshot, set 'stale' flag at read-time via staleAfter
return [];
}
const uow = this.uowFactory();
const saved: FxRateSnapshot[] = [];
for (const r of upstream.value) {
const snap = constructSnapshot(r, FX_STALE_AFTER_HOURS, FX_HARD_EXPIRE_HOURS, this.ids.ulid(), this.clock.now());
await this.fx.save(snap, uow);
await this.publisher.publish(envelope('melmastoon.pricing.fx_snapshot.updated.v1', snap), uow);
saved.push(snap);
}
await uow.commit();
return saved;
}
}

2.6 GenerateDynamicPricingSuggestionUseCase

Triggered ad-hoc by revenue manager UI or by the AI orchestrator's scheduled batch. Persists an advisory suggestion. Never mutates a RateRule.

class GenerateDynamicPricingSuggestionUseCase {
async execute(cmd: { tenantId: TenantId; propertyId: PropertyId; roomTypeId: RoomTypeId; date: ISODate; actor: ActorRef }): Promise<DynamicPricingSuggestion> {
assertActor(cmd.actor, ['revenue_manager','gm','owner','system']);
const result = await this.ai.requestDynamicPricing({
tenantId: cmd.tenantId, propertyId: cmd.propertyId, roomTypeId: cmd.roomTypeId, date: cmd.date,
signals: await this.collectSignals(cmd),
});
const suggestion: DynamicPricingSuggestion = {
id: this.ids.ulid() as DynamicSuggestionId,
tenantId: cmd.tenantId, propertyId: cmd.propertyId, roomTypeId: cmd.roomTypeId, date: cmd.date,
baselineRateMicro: result.baselineMicro,
suggestedRange: result.range,
currency: result.currency,
signals: result.signals,
rationale: result.rationale,
status: 'generated',
expiresAt: addHours(this.clock.now(), 24),
aiProvenance: result.provenance,
createdAt: this.clock.now(),
};
const uow = this.uowFactory();
await this.suggestions.save(suggestion, uow);
await this.publisher.publish(envelope('melmastoon.pricing.dynamic_suggestion.generated.v1', suggestion), uow);
await uow.commit();
return suggestion;
}
}

2.7 AcceptDynamicPricingSuggestionUseCase (HITL gate)

class AcceptDynamicPricingSuggestionUseCase {
async execute(cmd: { suggestionId: DynamicSuggestionId; chosenMicro: bigint; ratePlanId: RatePlanId; actor: ActorRef; reason?: string }): Promise<RateRule> {
assertActor(cmd.actor, ['revenue_manager','gm','owner']); // strict: HITL roles only
const s = await this.suggestions.findById(cmd.suggestionId);
if (!s) throw new DomainError('MELMASTOON.GENERAL.RESOURCE_NOT_FOUND');
if (s.status !== 'generated') throw new DomainError('MELMASTOON.AI.HITL_REQUIRED');
if (s.expiresAt < this.clock.now()) throw new DomainError('MELMASTOON.PRICING.QUOTE_EXPIRED');
if (cmd.chosenMicro < s.suggestedRange.lowMicro || cmd.chosenMicro > s.suggestedRange.highMicro) {
throw new DomainError('MELMASTOON.GENERAL.VALIDATION_FAILED', 'chosen rate outside advisory range');
}
const plan = await this.ratePlans.findById(cmd.ratePlanId);
if (!plan) throw new DomainError('MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND');

const newRule: RateRule = {
id: this.ids.ruleId(),
tenantId: s.tenantId,
ratePlanId: cmd.ratePlanId,
priority: AI_OVERRIDE_PRIORITY, // tuned to win over base BAR for the date
scope: { dateRange: { start: s.date, end: addDays(s.date, 1) }, roomTypeIds: [s.roomTypeId] },
baseMicro: cmd.chosenMicro,
multiplier: 1.0,
surchargeMicro: 0n,
source: 'ai_accepted',
aiProvenance: s.aiProvenance,
version: 0,
createdAt: this.clock.now(), createdBy: cmd.actor,
updatedAt: this.clock.now(), updatedBy: cmd.actor,
};

const uow = this.uowFactory();
await this.rules.save(newRule, uow);
await this.suggestions.save({ ...s, status: 'accepted', acceptedRule: newRule.id, acceptedBy: cmd.actor, acceptedAt: this.clock.now() }, uow);
await this.publisher.publish(envelope('melmastoon.pricing.dynamic_suggestion.accepted.v1', { suggestionId: s.id, ruleId: newRule.id, actor: cmd.actor }), uow);
await this.publisher.publish(envelope('melmastoon.pricing.rate_rule.created.v1', newRule), uow);
await uow.commit();
return newRule;
}
}

3. Read queries

class GetRatePlanQuery {
async execute(req: { tenantId: TenantId; id: RatePlanId }): Promise<RatePlan | null> { /* … */ }
}

class GetQuoteByIdQuery {
async execute(req: { tenantId: TenantId; id: PriceQuoteId }): Promise<PriceQuote | null> {
const q = await this.quotes.findById(req.id);
if (!q || q.tenantId !== req.tenantId) return null;
if (q.expiresAt < this.clock.now() && q.status === 'live') {
// lazy expire on read
const uow = this.uowFactory();
await this.quotes.save({ ...q, status: 'expired' }, uow);
await this.publisher.publish(envelope('melmastoon.pricing.quote.expired.v1', { quoteId: q.id, reason: 'ttl' }), uow);
await uow.commit();
return { ...q, status: 'expired' };
}
return q;
}
}

class ListOpenSuggestionsQuery { /* … */ }
class GetLatestFxSnapshotQuery { /* … */ }

4. Saga participation

pricing-service is not a saga orchestrator. It participates in two sagas:

  1. Booking saga (reservation-service §6) — provides the quote (POST /quotes), is read-only thereafter; if melmastoon.inventory.allocation.failed.v1 arrives for a property/date that backs an open quote, we mark the quote stale and emit quote.expired.v1 early.
  2. Dynamic-pricing HITL sagamelmastoon.ai.suggestion.dynamic_pricing.v1 lands → we persist as a generated suggestion → backoffice operator opens it → AcceptDynamicPricingSuggestion materializes a RateRulerate_rule.created.v1 published. No compensation needed (no money or inventory changes); a wrongly accepted suggestion is undone via UpdateRateRuleUseCase to disable the rule.

5. Inbox handlers (consumed events)

EventHandler
melmastoon.tenant.config_updated.v1RefreshTenantConfigCacheHandler — invalidates the in-process tenant-config cache and the per-tenant tax/fee/FX caches
melmastoon.property.room_type.updated.v1ReconcileRoomTypeLinkagesHandler — disables/enables RatePlanRoomType rows whose room type changed materially (deletion, capacity change)
melmastoon.inventory.allocation.failed.v1MarkQuotesStaleHandler — finds open quotes referencing the affected (propertyId, roomTypeId, date) and emits quote.expired.v1 with staleReason='inventory_failed'
melmastoon.ai.suggestion.dynamic_pricing.v1IngestDynamicSuggestionHandler — wraps GenerateDynamicPricingSuggestionUseCase with actor.type='system'

Every handler is idempotent on message id (inbox dedupe table) and commutative within an orderingKey (<tenantId>:<aggregateId>).


6. RBAC matrix (used by AuthorizationDecider)

ActionRoles allowedAttribute checks
CalculateQuoteguest, anonymous_booking, staff_front_desk, revenue_manager, gm, owner, system (booking-saga)tenantId match; staff also need propertyId access
CreateRatePlan, UpdateRatePlan, Publish/Archiverevenue_manager, gm, ownerpropertyId access
Append/UpdateRateRulerevenue_manager, gm, ownerpropertyId access via plan
CreatePromotion, Activate/Deactivaterevenue_manager, gm, ownertenantId match
Upsert TaxRulegm, owner, tenant_admintenantId match
Upsert FeeRulerevenue_manager, gm, ownerpropertyId access
Refresh FX (manual)gm, owner, tenant_admin, system (cron)tenantId match
Generate DynamicSuggestionrevenue_manager, gm, owner, system (ai-orch)propertyId access
Accept DynamicSuggestionrevenue_manager, gm, owner ONLYpropertyId access; HITL gate enforced server-side
Read Quoteguest (own), staff_*, system (reservation)tenantId match; guest-owned quote requires session-token match

Authorization decisions are evaluated in the application layer guard before the use case runs; denials produce an audit-service event.


7. Cross-cutting concerns

  • Idempotency: every mutating use case accepts an Idempotency-Key; a pricing.idempotency_keys (key, tenantId, useCase, request_hash, response_hash, created_at) table dedupes for 24 h.
  • OCC: every aggregate save passes expectedVersion; mismatch → MELMASTOON.PRICING.STALE_VERSION.
  • Outbox-publisher: every domain event produced by a use case is appended to the same Postgres transaction as the aggregate save; the outbox relay publishes to Pub/Sub at-least-once (see 04 §6).
  • Caching: RatePlan, TaxRule, FeeRule, FxRateSnapshot are read through Memorystore with explicit invalidation on *.updated.v1 events. Cache keys are tenant-scoped.

8. Constants

export const DEFAULT_QUOTE_TTL_SECONDS = 1800; // 30 minutes
export const FX_STALE_AFTER_HOURS = 24;
export const FX_HARD_EXPIRE_HOURS = 72;
export const FLOOR_MICRO = 1_000_000n; // 1 unit of plan currency
export const AI_OVERRIDE_PRIORITY = 100_000; // wins over manual rules unless a higher-priority rule exists
export const MAX_RULES_PER_PLAN = 5_000;
export const MAX_PROMOS_ACTIVE_PER_TENANT = 200;