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 добавлен в targetCurrencies opt-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.goPersistedRate storage VO (*big.Rat Multiplier, Source, EffectiveAt, FetchedAt). dom.Rate (с float64 multiplier) живёт в proposal/domain/ports.go и не дублируется здесь.
  • repository.goRepository interface (UpsertMany, GetByPairs). Sentinel-ошибки на read-path не введены: missing/stale пары silently опускаются из результата.
  • metrics.goExchangeMetrics interface + NoopExchangeMetrics impl (interface в domain для разрыва app↔infra цикла, см. §11).
  • errors.goErrCBRUnavailable (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-backed excdom.Repository (UpsertMany через single tx; GetByPairs через batched (from,to) IN ((..),..)).
  • exchange_rate_resolver_pg.go — PG-backed dom.ExchangeRateResolver (identity bypass + non-identity batched fetch + 7d staleness cap).
  • exchange_rate_resolver_stub.go — identity-only stub (kept as test fixture and provideResolver fallback when no PG pool).

Application (backend/internal/core/exchange/app/):

  • refresher.goRefresher.Run(ctx) use-case (CBR fetch → forward+ reverse big.Rat → repo.UpsertMany).
  • metrics.goAtomicExchangeMetrics impl (slog-mirror, sync.Map per-label) + type aliases ExchangeMetrics = 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

  1. Supplier-rate routing. Если поставщик присылает собственный rate в payload’е, использовать его вместо CBR. Потребует расширения dom.Rate.Source enum и routing-policy в ExchangeRateResolver.

  2. Customer-fixed-rate. Per-tenant override через EffectivePolicy или CustomerContext. EffectivePolicy параметр уже проброшен в интерфейсе.

  3. Multi-source fallback chain (CBR → ECB → …). High-Availability concern. Deferred до regulatory requirement или известной потребности.

  4. ClickHouse history; historical-rate queries (at time.Time). Для аудита «по какому курсу считали X дней назад». Interface signature готов.

  5. Cross-pairs не через RUB (EUR↔USD напрямую). Текущая модель — все пары через RUB как base. Direct cross-pairs — deferred.

  6. /metrics HTTP endpoint + Prometheus exporter. ExchangeMetrics interface готов; 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)