Busflow Docs

Internal documentation portal

Skip to content
πŸ“¦ Resource Reviewed 11 May 2026

ADR-035: FX Rate Snapshotting on PriceMatrix ​

Status: βœ… Approved β€” 2026-05-11 Origin: Strategic Blind Spot SB-1, Kalkulations-Engine Protocol Β§1bImpacts: schema-backoffice.md Β§price_matrices, kalkulations-engine.md Β§CurrencyProfile, price-matrix-variant-spec.md Β§Edge States


Context ​

The CostingSheet carries a CurrencyProfile value object (fx_config) with exchange rates and risk buffers for foreign-currency procurement items. The CalculationEngine applies these rates during Phase 1 (Cost Calculation) to convert CZK/GBP/CHF costs into the operator's base currency (EUR).

However, the PriceMatrix β€” which derives selling prices from that cost base β€” stores no FX context. An auditor examining a PriceMatrix can see the EUR selling prices but cannot determine which exchange rates produced them if the CostingSheet's fx_config has since changed.

This is the same audit gap that ADR-011 identified for pricing rules and config. ADR-011 established the principle: every PriceMatrix version must contain its complete derivation context. FX rates are the remaining gap.

Additional sub-questions (SB-1) ​

SB-1 embeds three distinct concerns:

  1. When does the system capture FX rates? β€” At CostingSheet creation (manually) or PriceMatrix generation (snapshot).
  2. How often does the system refresh rates? β€” Manual entry, daily automated pull, or real-time API call.
  3. What happens when rates drift after snapshotting? β€” Silent absorption, staleness warning, or publication gate.

Timeline risk ​

Bus tourism FX exposure spans months: an operator creates a CostingSheet in January for a July tour, publishes prices in March, and pays the supplier in September (net 60 post-trip). The buffer_percentage on CurrencyProfile hedges this gap, but the base rate itself can become stale.

Decision ​

Phase 2a: FX Config Snapshot on PriceMatrix ​

Add one snapshot column to price_matrices:

ColumnTypeConstraintsDescription
fx_config_snapshotJSONBNOT NULLDeep copy of CostingSheet.fx_config at PriceMatrix generation time. Immutable after creation. Engine must explicitly set {} for EUR-only tours (no FX applies).

Snapshot semantics follow ADR-011 exactly:

  • Deep copy at generation time. The engine serializes the CostingSheet's current fx_config into this column when creating a new PriceMatrix version.
  • Immutable after creation. If the operator updates FX rates on the CostingSheet, the engine generates a new PriceMatrix version with a fresh snapshot.
  • NOT NULL, no default. EUR-only tours store {}, not null. The engine must explicitly provide the value β€” matching the ADR-011 sibling columns (pricing_rules_snapshot, pricing_config_snapshot).

Phase 2a: Extended FxRate Value Object ​

Extend the FxRate interface within CurrencyProfile with provenance tracking:

typescript
interface FxRate {
  target_currency: string;       // e.g., "CZK", "GBP"
  rate: number;                  // Spot rate at capture time
  buffer_percentage: number;     // Risk buffer (e.g., 3%)
  source: 'MANUAL' | 'ECB_DAILY';  // NEW β€” rate provenance
  captured_at: string;              // NEW β€” ISO 8601 timestamp
}

Phase 2b: ECB Reference Rate Infrastructure ​

Add a shared (non-tenant) reference table:

ColumnTypeConstraintsDescription
idUUIDPK
sourceVARCHARNot NullECB_DAILY
base_currencyVARCHARNot NullAlways EUR for ECB
target_currencyVARCHARNot Nulle.g., CZK, GBP, CHF
rateDECIMALNot NullMid-market reference rate
published_atDATENot NullECB publication date
fetched_atTIMESTAMPTZNot NullWhen Busflow ingested it

Unique constraint: (source, base_currency, target_currency, published_at).

Data source: ECB publishes ~30 EUR pairs daily at 16:00 CET via https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml (free, no auth, no rate limits). A BullMQ cron job fetches and upserts daily.

UX behavior: When the operator adds a foreign-currency procurement item to a CostingSheet, the Pricing Screen pre-fills the fx_config rate from the latest ECB entry. The operator can override with a manually negotiated bank rate. A staleness badge displays the rate age (e.g., "EUR/CZK: 25.42 β€” captured 12 days ago").

Phase 3: Threshold-Based Publication Gate ​

At PriceMatrix publication (DRAFT β†’ PUBLISHED), the engine compares the fx_config_snapshot rates against the current ECB reference rates. If any rate drifts beyond a configurable operator threshold (default: Β±2%), the system:

  1. Emits an FxRateStale domain event.
  2. Displays a confirmation dialog: "EUR/CZK rate has moved 3.1% since this price was calculated. Publish anyway?"
  3. Records the operator's acknowledgment in the change_events audit trail.

The system does not block publication β€” the operator retains full control. The gate provides informed consent, not a veto.

Not Planned: Full Hedge Engine (AP10) ​

A continuous FX risk engine with volatility tracking, optimal buffer suggestions, and forward rate locking remains the scope of AP10 β€” Multi-Currency FX Risk Engine. This ADR defers AP10 until multi-currency tour adoption exceeds ~10% of platform bookings.

Self-Containment Guarantee (Updated) ​

After Phase 2a, every PriceMatrix version contains its complete derivation context:

DataVersioned?Location
Margin rulesβœ…PriceMatrix.margin_config
Resolved pricesβœ…PriceMatrix.variants[]
Variable cost per variantβœ…PriceVariant.variable_cost_snapshot
PricingRules (demographics)βœ…PriceMatrix.pricing_rules_snapshot (ADR-011)
Season/early-bird configβœ…PriceMatrix.pricing_config_snapshot (ADR-011)
FX rates & risk buffersβœ…PriceMatrix.fx_config_snapshot (this ADR)
Cost baseβœ…CostingSheet via FK

Consequences ​

Positive:

  • Closes the last audit gap in the PriceMatrix derivation chain
  • Enables historical FX comparison: "Why did v3 cost more than v2?" now includes FX movement
  • ECB daily pull eliminates manual rate research for operators
  • Staleness badge provides passive risk awareness without operator FX expertise
  • Phase 3 gate ensures operators make informed decisions at publication time

Negative:

  • JSONB storage overhead (~0.5–1 KB per matrix version for fx_config_snapshot, bounded by the number of foreign currencies)
  • Schema migration: one new NOT NULL column with default '{}'::jsonb
  • Phase 2b requires a BullMQ cron job and the fx_reference_rates shared table
  • ECB mid-market rates don't reflect bank spread (mitigated by buffer_percentage)

Neutral:

  • No API surface change β€” fx_config_snapshot is internal audit data, not exposed to Commerce
  • fx_reference_rates is a shared table, not per-tenant β€” no RLS needed
  • Phase 3 gate is advisory, not blocking β€” no change to the PriceMatrix state machine

Internal documentation β€” Busflow