#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) andbase_amount_minor+base_currency+fx. - On an aggregate:
currencyand, 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 isestimated, notreal. - Stale.
stale: truemeans 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_unsupportedon write for a currency the rate source does not cover;422 fx_unavailablewhen no rate exists at all (nothing is written).currency_mismatchonly where two amounts must be comparable without FX (e.g.propose_ad_budget_changewith 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 pivotembase='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 zawszestale=false→ liczymy dalej ostatnim znanym kursem +fx.stale+ alertfx_stale+ coverage bez zmian.- Waluta spoza źródła →
422 currency_unsupportedprzy 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=actualz kwotą > 0, w tej samej transakcji: wiersz dla bieżącej base_currency wg kursu zoccurred_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_ofmó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):
- walidacja waluty (obsługiwana przez źródło) → 2.
fx.rebase: dla każdego eventu actual bez wiersza w nowej walucie INSERT doevent_base_amountsz historycznego kursu zoccurred_on(brak historii → dociągnięcie; nieudane →422 fx_unavailable, nic nie zmieniamy) → 3. UPDATEowners.base_currency,base_currency_confirmed_at→ 4. AuditEntryowner.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.