#Currencies

Operlance keeps every record in its original currency and never rewrites it. The owner picks one base currency during onboarding; all aggregates (overview, summaries, forecast, free capital, ads) are reported in it. A business can additionally be displayed in its own currency with ?display_currency=<ISO>.

#What you get back

  • On a record (event, subscription, reservation, approval): amount_minor + currency (original) and base_amount_minor + base_currency + fx.
  • On an aggregate: currency and, in the envelope, fx: { base_currency, display_currency, as_of, source, stale, currencies[], estimated_share }.
"fx": { "rate": "0.25378000", "as_of": "2026-09-17", "source": "ecb", "kind": "snapshot", "stale": false }

#Rules an agent must respect

  • Snapshots. Actual events are converted once, at the rate of their occurred_on, and never again. History is deterministic.
  • Current rate. Planned, reserved and subscription amounts are converted at read time. The share of a result that came from such conversions is fx.estimated_share, it is estimated, not real.
  • Stale. stale: true means the rate is older than 4 days or the last fetch failed. Numbers are still computed with the last known rate; say so when you report them.
  • Errors. 422 currency_unsupported on write for a currency the rate source does not cover; 422 fx_unavailable when no rate exists at all (nothing is written). currency_mismatch only where two amounts must be comparable without FX (e.g. propose_ad_budget_change with different currencies).
  • Amounts are integers in minor units with the currency's ISO 4217 exponent: PLN 2, JPY 0, KWD 3.
  • Limits in a passport are checked in the limit's currency at the current rate.

Rates are reference rates (ECB via Frankfurter) with an EUR pivot; the API endpoint GET /v1/fx/rates returns the rates the system used.

Source: KONTRAKT §14 (multi-currency) (Polish, verbatim from the contract)

#14. Wielowalutowość (0.4.0)

14.1 Zasada. Rekord zawsze przechowuje oryginał {amount_minor, currency} i nigdy nie jest przeliczany „w miejscu". Agregaty Ownera zawsze w base_currency. Business.currency = wyłącznie waluta prezentacji biznesu: panel używa jej jako domyślnego display_currency w widoku biznesu; nie wpływa na żadne wyliczenie, snapshot ani limit. Landing zawsze USD.

15.2 Kursy, fx_rates (base, quote, as_of date, rate numeric(18,8), source, fetched_at), PK (base, quote, as_of).

  • Tryb live: kursy referencyjne ECB (Frankfurter, bez klucza), zapisywane z pivotem base='EUR'; kurs A→B = rate(EUR→B) / rate(EUR→A); A=B → 1 (source="identity"). Pobieranie: raz dziennie po stronie serwera (po publikacji ECB ~16:00 CET) + na żądanie, gdy brakuje kursu dla potrzebnej daty (z dociągnięciem historii). Klient/agent nigdy nie podaje kursu.
  • Kurs „na dzień D" = najnowszy wiersz z as_of ≤ D (weekendy/święta). Dla D > dziś → najnowszy znany.
  • stale=true (FxSummary/FxInfo kind=current), gdy użyty kurs bieżący jest starszy niż 4 dni kalendarzowe albo ostatnie pobranie się nie powiodło; snapshot (kind=snapshot) ma zawsze stale=false → liczymy dalej ostatnim znanym kursem + fx.stale + alert fx_stale + coverage bez zmian.
  • Waluta spoza źródła → 422 currency_unsupported przy zapisie. Brak jakiegokolwiek kursu (pusta tabela + awaria źródła) → 422 fx_unavailable (nic nie zapisujemy bez snapshotu).
  • Tryb mock: statyczne wiersze z seeda (source="mock"), resolver akceptuje też wiersz bezpośredni/odwrotny.
  • Przeliczenie: to_minor = round_half_up(from_minor × rate × 10^(exp_to − exp_from)), wykładniki ISO 4217 (JPY 0, KWD 3…), tabela w @operlance/core. Arytmetyka na decimal/bigint, nie float.

15.3 Snapshot dla actual, tabela event_base_amounts (event_id, base_currency, base_amount_minor, rate, rate_as_of, source, computed_at), PK (event_id, base_currency), append-only (tylko INSERT).

  • Przy zapisie eventu state=actual z kwotą > 0, w tej samej transakcji: wiersz dla bieżącej base_currency wg kursu z occurred_on. Dotyczy też ingestu (EventSink). Raz zapisany snapshot nie zmienia się przy wahaniach kursu → historia deterministyczna.
  • Osobna tabela zamiast kolumn na evencie: event pozostaje nietknięty (append-only), a zmiana base_currency tylko DODAJE wiersze dla nowej waluty; powrót do poprzedniej waluty używa istniejących wierszy.
  • Uwaga: event z occurred_on = dziś zapisany przed publikacją kursu dostaje kurs z poprzedniego dnia roboczego, rate_as_of mówi, który kurs faktycznie użyto; nie korygujemy.

15.4 Planned / reserved / subskrypcje / cash snapshots / forecast, przeliczane przy odczycie po najnowszym kursie (fx.kind="current"). Część wyniku wynikająca z takiego przeliczenia walut obcych ma w provenance_summary klasę estimated i jest raportowana w fx.estimated_share. Kwoty w base_currency nie są „estimated" z powodu FX.

15.5 Onboarding i zmiana waluty. base_currency domyślnie podpowiedziane USD, ale wybór jest jawnym krokiem: dopóki base_currency_confirmed_at jest null, me.onboarding.base_currency_confirmed=false, a UI blokuje panel ekranem wyboru. PATCH /v1/me {base_currency} (tylko Owner; ta sama wartość = samo potwierdzenie):

  1. walidacja waluty (obsługiwana przez źródło) → 2. fx.rebase: dla każdego eventu actual bez wiersza w nowej walucie INSERT do event_base_amounts z historycznego kursu z occurred_on (brak historii → dociągnięcie; nieudane → 422 fx_unavailable, nic nie zmieniamy) → 3. UPDATE owners.base_currency, base_currency_confirmed_at → 4. AuditEntry owner.base_currency_changed (owner) + fx.rebase (system, z licznikami). Całość w jednej transakcji; oryginały nietknięte. MVP: synchronicznie.

15.6 Limity i rezerwacje. Limit check: kwota akcji przeliczana do limits.currency po kursie bieżącym; sumy dzienne/miesięczne = wcześniejsze akcje agenta (oryginały) przeliczone do waluty limitu po kursie bieżącym. limit_checked w AuditEntry zapisuje kwotę oryginalną, przeliczoną i kurs. Rezerwacja ma własną walutę; consumed_minor = Σ actual z reservation_id przeliczone do waluty rezerwacji po kursie z occurred_on eventu. Subskrypcja: alokacje liczone w walucie subskrypcji, dopiero potem przeliczane.

15.7 currency_mismatch, gdzie zostaje. Tylko tam, gdzie para kwot musi być porównywalna bez FX: propose_ad_budget_change (current.currency ≠ proposed.currency), refund z refunds_event_id w innej walucie niż zwracany event, params_override zmieniający walutę w Approval. Wszędzie indziej różne waluty są legalne.

15.8 Demo seed (Backend). Owner base PLN (potwierdzone); subskrypcje mieszane, np. Claude (USD), Hetzner (EUR), Postiz (PLN); kursy mock EUR→PLN, EUR→USD z pivotem EUR.