ADR-0042: Exchange Rate Layer 3a — CBR daily pull (variant A)
Status: accepted Date: 2026-04-29 Deciders: agent-claude
Контекст
Layer 3a в proposal pipeline (см. ADR-0035 §5 Currency invariant) до P6 был
identity-only stub: same-currency пары возвращали Rate{Multiplier:1.0, Source:"identity"}, любая foreign-pair возвращала ошибку. Multi-currency
suppliers — DKC (RUB+KZT по ADR-0024) и Systeme (RUB+EUR опционально) —
не могли быть корректно конвертированы в target_currency=RUB. ProposalRunner
per-candidate fallback на PricingUnavailable не активировался: пайплайн
просто падал.
Spec зафиксирован в
docs/superpowers/specs/2026-04-29-p6-exchange-rate-design.md.
P6 является частью roadmap-фазы «Production polish», которая закрывает
multi-currency как production-grade подсистему.
Решение
Реализуем variant A — минимальный CBR-only Layer 3a.
1. Источник: CBR daily XML feed
Единственный источник — https://www.cbr.ru/scripts/XML_daily.asp. Single-source,
без fallback chain. CBRClient (exchange/infra/cbr_client.go) делает HTTP GET,
парсит XML через golang.org/x/net/html/charset (windows-1251), возвращает
[]ParsedRate. Все ошибки (HTTP 5xx, parse, empty ValCurs, no-target-currencies)
оборачивают ErrCBRUnavailable через %w для errors.Is-матчинга.
2. Скоуп пар: RUB ↔ {KZT, USD, EUR}
Filter в CBR client (targetCurrencies). Прочие валюты, присутствующие в
CBR feed, игнорируются. Скоуп покрывает DKC (KZT) и Systeme (EUR) в P6;
расширяется конфигом без изменения кода.
3. Storage: PG таблица exchange_rates
PK on (from_currency, to_currency), overwrite-on-update. История курсов НЕ
сохраняется (variant A). Миграция Goose 0037_create_exchange_rates.sql.
4. Refresh: scheduled ticker + eager startup
*scheduler.Ticker с интервалом 24h. Eager-fetch через fx.Lifecycle.OnStart
(best-effort, 60s timeout, не блокирует запуск если CBR offline — логирует
предупреждение и продолжает).
5. Staleness cap: 7 дней
Resolver проверяет now - effective_at > 7d (строгое неравенство). Stale row
просто отсутствует в результирующем slice’е — sentinel error не вводится,
ProposalRunner per-candidate деградирует на PricingUnavailable через
существующий path proposal/app/proposal.go:209. Метрика
exchange_rate_resolve_unavailable_total{from,to,reason="stale|missing"}
различает причины для алертов.
6. Identity bypass
From == To всегда возвращает Rate{Multiplier:1.0, Source:"identity"},
не идёт в БД и не требует CBR fetch.
7. Reverse pair derivation
Refresher пишет ОБЕ направления per CBR row: forward 1 X = N RUB и
reverse 1 RUB = (1/N) X. Resolver не делает деление на read-path —
пара либо есть в таблице, либо нет.
8. Numeric precision
numeric(20,10) в БД. *big.Rat в Refresher для точного деления при
derivation reverse pair. Однократный cast в float64 на boundary с
dom.Rate.Multiplier.
9. Time injection
clock.Clock injected в Refresher для fetched_at. effective_at
парсится из CBR Date attribute как UTC start-of-day.
10. Layer 3a остаётся sole conversion point
ADR-0035 §5 Currency invariant не меняется. Pricing/stock/delivery leaves
pipeline’а остаются в supplier base currency; единственное место
конвертации — Layer 3a через ExchangeRateResolver.
11. ExchangeMetrics в domain/ (Phase E architectural decision)
ExchangeMetrics interface живёт в exchange/domain/ для разрыва
import-цикла app/ → infra/ (infra реализует metrics, app использует —
при metrics в app/ возникает цикл). app/ re-exports через type alias
для backward compatibility вызывающих слоёв.
Последствия
Плюсы
-
DKC multi-currency unblocked. KZT-наблюдения корректно конвертируются в RUB на Layer 3a. Конкретный DKC connector adapter — отдельный follow-up цикл.
-
Systeme EUR capability готова. EUR добавлен в
targetCurrenciesopt-in; Systeme connector adapter активирует при готовности. -
Graceful degradation. CBR outage > 7 дней → per-pair
PricingUnavailableвместо whole-pipeline error. Другие пары/кандидаты продолжают работать. -
Layer 3a в production шейпе без изменения contract’а с ProposalRunner.
Минусы / принятые trade-offs
-
Single-source. CBR outage > 7 дней = non-RUB candidates Unavailable до восстановления. Принято — частота acceptable; multi-source ECB fallback deferred.
-
No history. Невозможно ответить «по какому курсу мы считали 3 месяца назад». ClickHouse history deferred.
-
at time.Timeпараметрdom.ExchangeRateResolver.Ratesигнорируется (всегда «as of now»). Historical queries deferred; interface signature уже содержит параметр для forward compatibility. -
No supplier-rate routing. Если поставщик присылает свой rate в payload’е, мы его не используем. Deferred.
-
No customer-fixed-rate.
EffectivePolicyпараметр заранее проброшен в interface, но в P6 игнорируется.
Альтернативы, отклонённые
A. Lazy on-demand fetch
Resolver сам идёт в CBR при первом запросе пары, кеширует in-memory.
Отклонено: Latency на hot-path; при рестарте сервиса cold-start снова; нет single source-of-truth для observability. PG-backed scheduled pull даёт предсказуемый refresh без влияния на latency proposal pipeline’а.
B. Hybrid PG + on-demand refresh
Scheduled pull + дополнительный on-demand fetch при отсутствии rate в БД.
Отклонено: Добавляет complexity без необходимости в variant A. При
scheduled pull miss проще получить Unavailable (понятная degradation),
чем retry с неизвестным временем ответа CBR.
C. Multi-source с самого начала (CBR + ECB)
Fallback chain: если CBR недоступен, идём в ECB.
Отклонено: Premature; CBR в РФ-юрисдикции достаточен для коммерческой работы. Multi-source — High-Availability concern, не correctness concern. Deferred до известной потребности или regulatory requirement.
Реализация
Затронутые компоненты
Миграции (backend/migrations/):
0037_create_exchange_rates.sql— таблицаexchange_ratesс PK(from_currency, to_currency),multiplier numeric(20,10),effective_at,fetched_at,source.
Domain (backend/internal/core/exchange/domain/):
rate.go—PersistedRatestorage VO (*big.Rat Multiplier,Source,EffectiveAt,FetchedAt).dom.Rate(с float64 multiplier) живёт вproposal/domain/ports.goи не дублируется здесь.repository.go—Repositoryinterface (UpsertMany,GetByPairs). Sentinel-ошибки на read-path не введены: missing/stale пары silently опускаются из результата.metrics.go—ExchangeMetricsinterface +NoopExchangeMetricsimpl (interface в domain для разрыва app↔infra цикла, см. §11).errors.go—ErrCBRUnavailable(refresh-side failures).
Infrastructure (backend/internal/core/exchange/infra/):
cbr_client.go— HTTP client + XML parser для CBR daily feed.exchange_rate_repo_pg.go— PG-backedexcdom.Repository(UpsertMany через single tx; GetByPairs через batched(from,to) IN ((..),..)).exchange_rate_resolver_pg.go— PG-backeddom.ExchangeRateResolver(identity bypass + non-identity batched fetch + 7d staleness cap).exchange_rate_resolver_stub.go— identity-only stub (kept as test fixture andprovideResolverfallback when no PG pool).
Application (backend/internal/core/exchange/app/):
refresher.go—Refresher.Run(ctx)use-case (CBR fetch → forward+ reverse big.Rat → repo.UpsertMany).metrics.go—AtomicExchangeMetricsimpl (slog-mirror, sync.Map per-label) + type aliasesExchangeMetrics = excdom.ExchangeMetrics,NoopExchangeMetrics = excdom.NoopExchangeMetrics.
fx wiring (backend/internal/core/exchange/fx.go) — exchange.Module()
функция. Providers: provideCBRClient (env override + 30s timeout),
provideRepository (nil-tolerant), provideExchangeMetrics,
excapp.NewRefresher, provideResolver (PG impl при наличии pool, иначе
stub fallback). Invoke: registerRefresher — fx.Lifecycle с OnStart
eager-fetch (60s timeout, warn-and-continue) + scheduler.Ticker.Start;
OnStop — Ticker.Stop.
Caller (backend/cmd/api-server/main.go) — единственный caller
exchange.Module(). Signature не менялась — drop-in replacement.
Deferred items
-
Supplier-rate routing. Если поставщик присылает собственный rate в payload’е, использовать его вместо CBR. Потребует расширения
dom.Rate.Sourceenum и routing-policy вExchangeRateResolver. -
Customer-fixed-rate. Per-tenant override через
EffectivePolicyилиCustomerContext.EffectivePolicyпараметр уже проброшен в интерфейсе. -
Multi-source fallback chain (CBR → ECB → …). High-Availability concern. Deferred до regulatory requirement или известной потребности.
-
ClickHouse history; historical-rate queries (
at time.Time). Для аудита «по какому курсу считали X дней назад». Interface signature готов. -
Cross-pairs не через RUB (EUR↔USD напрямую). Текущая модель — все пары через RUB как base. Direct cross-pairs — deferred.
-
/metricsHTTP endpoint + Prometheus exporter.ExchangeMetricsinterface готов; Prometheus binding — deferred до Phase 7+ observability.
Ссылки
- Spec:
docs/superpowers/specs/2026-04-29-p6-exchange-rate-design.md - Plan:
docs/superpowers/plans/2026-04-29-p6-exchange-rate.md - ADR-0035 — Proposal pipeline layering (§5 Currency invariant — Layer 3a sole conversion point)
- ADR-0024 — Supplier connector contract (multi-currency capability у DKC/Systeme)
- ADR-0025 — Price observation extensions (PriceSet currency invariant)