#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. 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 wDockerfile.next, domyślniehttps://app.operlance.app; landing nie woła API, więcWAITLIST_CORS_ORIGINiPOST /v1/waitlistsą 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(bezDomain=),Secure,HttpOnly,SameSite=Lax; landing nie ma dostępu do sesji i nie jest jej potrzebne. /signupi/loginw panelu kończą się tym samym find-or-create po e-mailu (§2.19/§2.19a), różnią się tylko tekstem i tym, że/signupzapamiętuje wybrany plan (rc.34, wizard v2 §6.1:plan/intervalz query, tylko wartości z listy planów, wlocalStoragepanelu do krokubilling, 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/onboardingna ten krok (wznawialność),null→ dashboard. Niezalogowany wchodzący na dowolną trasę panelu →/login. PANEL_URLw API =https://app.operlance.app;CORS_ORIGINSzawiera tylko origin panelu. Domenyoperlance.app(landing),app.(web),api.(api),mcp.(mcp) działają na Railway jako custom domains; wartości domyślneNEXT_PUBLIC_*w kodzie wskazują na nie, adresy*.up.railway.appnie 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}: gdycount(actual events bez wiersza w nowej walucie) > 500→202 { job_id, rebase_in_progress: true }zamiast200; poniżej progu zostaje200synchronicznie (§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 wjob_runsi AuditEntry). - Job (
apps/api/src/jobs.ts, wzorzec istniejących jobów partiowych): batch 500 eventów na wywołanie, aktualizujerebase_job.done; ostatni batch → w jednej transakcjiUPDATE 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 starymrebase_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ókipending_base_currencynie zniknie). Me.onboarding.rebase_in_progress: boolean(= owners.rebase_job is not null) wGET /v1/me, żeby panel pokazał progres/blokadę zmiany waluty ponownie w trakcie. To NIE jest kroksteps[]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, nigdyskipped; w wizardzie v2 krok „Workspace”): rejestracja + wybór metody logowania (magic_linkdziś,oauth_googlez R-169) + nazwa pierwszego biznesu i potwierdzeniebase_currency. Od rc.34 (wizard v2) pierwszy biznes powstaje tutaj:POST /v1/businesses {name, currency}, potemPATCH /v1/me {base_currency}; przy wznowionym wizardzie z istniejącym biznesem pola nazwy nie ma. Powód: connect integracji wymagabusiness_id(rc.28), więc biznes musi istnieć przed krokamiagentiintegrations, także gdy Owner pominie agenta.status="done"automatycznie w momenciebase_currency_confirmed_at(§15.5), nie ma osobnego zapisu, to ten sam fakt co dziś, tylko widoczny też jako krok wsteps[].agent(required: false,skippeddozwolone): 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” (szablonoperlance, §2.8) bez wyboru szablonu. Ten szablon nie wymagabusiness_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 krokuidentity(rc.34), krokagentgo nie tworzy. Zapisy tego kroku działają wpending_card(wyjątek §2.16).status="done"automatycznie, gdy Owner ma ≥1Agent; Owner może też jawnieskipprzezPATCH /v1/me {onboarding_step: {key:"agent", status:"skipped"}}bez tworzenia agenta.integrations(required: false,skippeddozwolone): podłączenie ≥1 źródła danych (dowolny provider, mock lub live). W trybie real (INTEGRATIONS_MODE=live/ panelNEXT_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/seedistnieje wyłącznie dla Ownera konta demo (seed) i wpending_cardjest zablokowany.demo_activezostaje wMe.onboardingjako pole odczytu dla konta demo.status="done"automatycznie, gdy Owner ma ≥1Integrationwstatus≠disconnected(lubdemo_active=true, tylko konto demo); jawnyskipjak wyżej.billing(required: true, nigdyskipped, R-171): ostatni krok,status="done"dopiero poplan_statuswychodzącym zpending_card(Stripe Checkout potwierdzony, §2.16). Dopókibilling.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 przezPATCH /v1/me(także wpending_card, to część krokuidentity), zwracane wMe(null, gdy nie podane) orazMe.profile_complete: boolean(=full_name,phoneiaddressustawione). Walidacja:400 validation_errorzpathpola. Krokidentitymastatus="done", gdybase_currencyjest potwierdzone orazprofile_complete, ale tylko dla Ownera wplan_status=pending_card(jeszcze w wizardzie); Owner, który przeszedł billing (trial, aktywny, demo, konta sprzed rc.40), zachowujeidentity=donebez 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 jakcredentials). next_step: pierwszykeyw kolejności zestatus="pending";null, gdyidentityibillingmająstatus="done"(jedyne dwa wymagane) niezależnie od stanuagent/integrations(mogły byćskipped). Wizard w R-170 czytanext_stepzamiast liczyć to sam po stronie Frontendu (wznawialność „przerwany wizard wraca w to samo miejsce" z zakresu R-170 realizowana tu, nie tam).- Zapis:
agent/integrationsprzezPATCH /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, żebysteps[]nie mógł skłamać względem rzeczywistości.identity/billingnie 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 automatycznegodone(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.