#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. Poniżej progu z §15.5b (≤500 eventów do rebase): synchronicznie jak wyżej, bez zmian.

15.5c Landing i panel: jeden flow (1.0.0-rc.26, R-173, decyzja Architekta). Wariant: osobne subdomeny, zwykły link, zero przekazywania sesji. operlance.app = landing (apps/landing), app.operlance.app = panel (apps/web), api.operlance.app = API. Uzasadnienie: (1) podścieżka /app wymagałaby reverse proxy przed dwiema usługami Railway i basePath w Next.js, czyli nowej infrastruktury bez zysku dla Ownera; (2) tożsamość powstaje w panelu (Google §2.19a lub magic link), więc nie ma czego przekazywać z landingu; (3) przekazywanie tokenu/sesji między domenami to ryzyko (wyciek przez URL, referer, historię), więc jest zakazane. Zasady:

  • CTA „Get started”/„Start free trial” prowadzą do ${NEXT_PUBLIC_APP_URL}/signup (z ?plan=<starter|operator|studio|scale>&interval=monthly|yearly, a przycisk Google z ?provider=google), „Sign in” do /login; to zwykłe linki (env landingu, build-arg w Dockerfile.next, domyślnie https://app.operlance.app; landing nie woła API, więc WAITLIST_CORS_ORIGIN i POST /v1/waitlist są martwe: endpoint zostaje jako deprecated, nie jest usuwany w tym wydaniu). Opcjonalnie ?email= tylko jako prefill pola magic link, nigdy jako uwierzytelnienie.
  • Ciasteczko sesji jest host-only na app.operlance.app (bez Domain=), Secure, HttpOnly, SameSite=Lax; landing nie ma dostępu do sesji i nie jest jej potrzebne.
  • /signup i /login w panelu kończą się tym samym find-or-create po e-mailu (§2.19/§2.19a), różnią się tylko tekstem i tym, że /signup zapamiętuje wybrany plan (rc.34, wizard v2 §6.1: plan/interval z query, tylko wartości z listy planów, w localStorage panelu do kroku billing, nieznana wartość ignorowana). Parametry query to wyłącznie preselekcja, nigdy uwierzytelnienie ani sesja.
  • Po zalogowaniu panel czyta Me.onboarding (§15.5a): next_step != null → przekierowanie do /onboarding na ten krok (wznawialność), null → dashboard. Niezalogowany wchodzący na dowolną trasę panelu → /login.
  • PANEL_URL w API = https://app.operlance.app; CORS_ORIGINS zawiera tylko origin panelu. Domeny operlance.app (landing), app. (web), api. (api), mcp. (mcp) działają na Railway jako custom domains; wartości domyślne NEXT_PUBLIC_* w kodzie wskazują na nie, adresy *.up.railway.app nie są używane w konfiguracji.

15.5b Rebase asynchroniczny (1.0.0-rc.25, R-147, propozycja Backendu, P3, niepilne): powyżej 500 eventów. Ten sam krok 1 (walidacja) i te same reguły FX co wyżej, ale wykonanie partiami zamiast jednej transakcji, żeby uniknąć timeoutu Railway przy dużym ledgerze (~100k eventów):

  • PATCH /v1/me {base_currency}: gdy count(actual events bez wiersza w nowej walucie) > 500 → 202 { job_id, rebase_in_progress: true } zamiast 200; poniżej progu zostaje 200 synchronicznie (§15.5, bez zmian).
  • Migracja: owners.pending_base_currency text?, owners.rebase_job jsonb? ({job_id, total, done}), kolumny na Ownerze, nie osobna tabela (stan jednego pola w toku, nie historia audytowa; historia i tak jest w job_runs i AuditEntry).
  • Job (apps/api/src/jobs.ts, wzorzec istniejących jobów partiowych): batch 500 eventów na wywołanie, aktualizuje rebase_job.done; ostatni batch → w jednej transakcji UPDATE owners SET base_currency=pending_base_currency, base_currency_confirmed_at=now(), pending_base_currency=null, rebase_job=null + AuditEntry owner.base_currency_changed. Błąd w trakcie → job_runs.status=error, Owner zostaje na starej walucie i ze starym rebase_job (retry ręczny albo automatyczny wg konwencji jobów), żadnego stanu połowicznego widocznego Ownerowi (odczyty finansowe cały czas liczą w starej walucie, dopóki pending_base_currency nie zniknie).
  • Me.onboarding.rebase_in_progress: boolean (= owners.rebase_job is not null) w GET /v1/me, żeby panel pokazał progres/blokadę zmiany waluty ponownie w trakcie. To NIE jest krok steps[] z §15.5a (R-168), rebase nie jest częścią sekwencji onboardingu, może wystąpić w dowolnym momencie życia konta.

15.5a Onboarding Ownera: kroki, kolejność, self-serve wizard (1.0.0-rc.22, R-168, spec §58 rozszerzone o nowy flow z R-169 do R-173). Rozgraniczenie od get_onboarding (MCP tool, spec §21): to jest KONTEKST BIZNESU dla agenta AI (podsumowanie stanu firmy, żeby agent wiedział z czym pracuje), zawsze dostępny, bez zmian tutaj. Me.onboarding to co innego: postęp SAMEGO Ownera w skonfigurowaniu konta po rejestracji, dla wizarda w panelu (R-170).

  • Me.onboarding = { base_currency_confirmed: boolean /* istniejące, §15.5 */, demo_active: boolean /* istniejące w kodzie (apps/api/src/routes/workspaces.ts), teraz udokumentowane */, steps: OnboardingStep[], next_step: "identity"|"agent"|"integrations"|"billing"|null /* wyliczane, null = wszystko done/skipped poza identity i billing, które nigdy nie są skipped */ }.
  • OnboardingStep = { key: "identity"|"agent"|"integrations"|"billing", status: "pending"|"done"|"skipped", required: boolean, completed_at?: string }. Kolejność stała: identity → agent → integrations → billing.
  • identity (required: true, nigdy skipped; w wizardzie v2 krok „Workspace”): rejestracja + wybór metody logowania (magic_link dziś, oauth_google z R-169) + nazwa pierwszego biznesu i potwierdzenie base_currency. Od rc.34 (wizard v2) pierwszy biznes powstaje tutaj: POST /v1/businesses {name, currency}, potem PATCH /v1/me {base_currency}; przy wznowionym wizardzie z istniejącym biznesem pola nazwy nie ma. Powód: connect integracji wymaga business_id (rc.28), więc biznes musi istnieć przed krokami agent i integrations, także gdy Owner pominie agenta. status="done" automatycznie w momencie base_currency_confirmed_at (§15.5), nie ma osobnego zapisu, to ten sam fakt co dziś, tylko widoczny też jako krok w steps[].
  • agent (required: false, skipped dozwolone): stworzenie pierwszego agenta z szablonu Passportu (R-091, POST /v1/agents/:id/passport/from-template). Od rc.33 krok tworzy domyślnego agenta „Operlance agent” (szablon operlance, §2.8) bez wyboru szablonu. Ten szablon nie wymaga business_id. Ekran domyślny nie ma żadnych pól; inne role, nazwa, limity i ważność tokenu są pod zwiniętym „Advanced settings” (§2.8). Biznes już istnieje z kroku identity (rc.34), krok agent go nie tworzy. Zapisy tego kroku działają w pending_card (wyjątek §2.16). status="done" automatycznie, gdy Owner ma ≥1 Agent; Owner może też jawnie skip przez PATCH /v1/me {onboarding_step: {key:"agent", status:"skipped"}} bez tworzenia agenta.
  • integrations (required: false, skipped dozwolone): podłączenie ≥1 źródła danych (dowolny provider, mock lub live). W trybie real (INTEGRATIONS_MODE=live / panel NEXT_PUBLIC_API_MODE=real) krok NIE oferuje opcji demo (rc.27, Właściciel: konto demo znika, ścieżka ma być realna); POST /v1/demo/seed istnieje wyłącznie dla Ownera konta demo (seed) i w pending_card jest zablokowany. demo_active zostaje w Me.onboarding jako pole odczytu dla konta demo. status="done" automatycznie, gdy Owner ma ≥1 Integration w status≠disconnected (lub demo_active=true, tylko konto demo); jawny skip jak wyżej.
  • billing (required: true, nigdy skipped, R-171): ostatni krok, status="done" dopiero po plan_status wychodzącym z pending_card (Stripe Checkout potwierdzony, §2.16). Dopóki billing.status≠done, panel (poza samym wizardem) może być zablokowany decyzją Frontendu, kontrakt tego nie wymusza, tylko udostępnia stan.
  • Dane kontaktowe Ownera (1.0.0-rc.40, decyzja Właściciela przez Mózga, Backend, migracja 0036): full_name (1..120), phone (E.164: + i 7..15 cyfr, pierwsza cyfra 1-9), address {line1 (1..200), line2? (..200), city (1..100), postal_code (1..20), country (ISO 3166-1 alpha-2, wielkie litery)}. Ustawiane przez PATCH /v1/me (także w pending_card, to część kroku identity), zwracane w Me (null, gdy nie podane) oraz Me.profile_complete: boolean (= full_name, phone i address ustawione). Walidacja: 400 validation_error z path pola. Krok identity ma status="done", gdy base_currency jest potwierdzone oraz profile_complete, ale tylko dla Ownera w plan_status=pending_card (jeszcze w wizardzie); Owner, który przeszedł billing (trial, aktywny, demo, konta sprzed rc.40), zachowuje identity=done bez względu na profil, żeby wdrożenie nie wrzuciło istniejących kont z powrotem do wizarda; panel pokazuje mu wtedy nieblokujące przypomnienie w ustawieniach. Dane osobowe: w AuditEntry tylko nazwy zmienionych pól, nigdy wartości; nie trafiają do logów żądań (redakcja jak credentials).
  • next_step: pierwszy key w kolejności ze status="pending"; null, gdy identity i billing mają status="done" (jedyne dwa wymagane) niezależnie od stanu agent/integrations (mogły być skipped). Wizard w R-170 czyta next_step zamiast liczyć to sam po stronie Frontendu (wznawialność „przerwany wizard wraca w to samo miejsce" z zakresu R-170 realizowana tu, nie tam).
  • Zapis: agent/integrations przez PATCH /v1/me {onboarding_step} TYLKO gdy Owner jawnie klika „Pomiń” (status:"skipped"), przejście na "done" jest zawsze automatyczne z faktycznego stanu danych (Agent/Integration istnieje), nigdy ręczne, żeby steps[] nie mógł skłamać względem rzeczywistości. identity/billing nie są ustawialne przez ten endpoint w ogóle (400 validation_error), bo obie wartości wynikają wyłącznie z innych, już istniejących zapisów (base_currency, plan_status).
  • AuditEntry onboarding.step_skipped (owner) przy jawnym skip; brak osobnego wpisu dla automatycznego done (już jest audyt tworzenia Agenta/Integracji/zmiany planu).

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.