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:
- When does the system capture FX rates? β At CostingSheet creation (manually) or PriceMatrix generation (snapshot).
- How often does the system refresh rates? β Manual entry, daily automated pull, or real-time API call.
- 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:
| Column | Type | Constraints | Description |
|---|---|---|---|
fx_config_snapshot | JSONB | NOT NULL | Deep 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_configinto 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{}, notnull. 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:
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:
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID | PK | |
source | VARCHAR | Not Null | ECB_DAILY |
base_currency | VARCHAR | Not Null | Always EUR for ECB |
target_currency | VARCHAR | Not Null | e.g., CZK, GBP, CHF |
rate | DECIMAL | Not Null | Mid-market reference rate |
published_at | DATE | Not Null | ECB publication date |
fetched_at | TIMESTAMPTZ | Not Null | When 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:
- Emits an
FxRateStaledomain event. - Displays a confirmation dialog: "EUR/CZK rate has moved 3.1% since this price was calculated. Publish anyway?"
- Records the operator's acknowledgment in the
change_eventsaudit 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:
| Data | Versioned? | 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 NULLcolumn with default'{}'::jsonb - Phase 2b requires a BullMQ cron job and the
fx_reference_ratesshared table - ECB mid-market rates don't reflect bank spread (mitigated by
buffer_percentage)
Neutral:
- No API surface change β
fx_config_snapshotis internal audit data, not exposed to Commerce fx_reference_ratesis a shared table, not per-tenant β no RLS needed- Phase 3 gate is advisory, not blocking β no change to the PriceMatrix state machine