From 0fead7c677728b3e28a2f0b6e2a39fee8c158ebc Mon Sep 17 00:00:00 2001 From: theprogrammersingh Date: Thu, 3 Sep 2026 12:46:05 +0530 Subject: [PATCH] feat(fx): lock a real ECB rate onto every foreign expense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Totals were honest about what they excluded but still excluded real spend, and the seed data was worse than that: its foreign rows already carried a converted_amount at rates nobody published — ₹87/$ for Figma, ₹94/€ for Sentry, against real ECB rates of ~₹95 and ~₹110. Those counted toward every total with nothing to explain them. backend/src/fx/ reads ECB rates from api.frankfurter.dev and caches them in fx_rates (migration 0003). ExpensesService now writes converted_amount, fx_rate and fx_rate_date together at the expense's own date; an edit re-locks on amount, currency or date. Frankfurter is deliberately the publisher the embedded converter already uses — Cambiaro calls it from the browser — so the advisory widget and the ledger agree without the ledger depending on the widget. Cambiaro itself cannot be the source: it is a static client-side app with no HTTP API, and reading a figure back out of the frame is what CurrencyConverter's missing output() and postMessage listener exist to prevent. Three things that look like details and are not: - fx_rate_date is not expense_date. The ECB publishes once per working day, so a Saturday expense locks Friday's rate, and the row prints the rate's own date. Real data exercises this: AWS filed 2026-08-29. - FxService.rateOn returns null rather than throwing. A currency API being down must not stop someone filing an expense; the row is excluded and counted, exactly as before, and the backfill fills it in later. - fx_rates is keyed on the date asked for, not the date the rate is from. Keyed the other way every weekend lookup would miss the cache forever. core/expense/amount.ts needed no logic change — foreign rows re-entered every total the moment converted_amount started being filled, which is what those rules were written to allow. Only a stale comment and a line of copy changed. backend/scripts/backfill-fx.mjs locks rates onto rows without one, and with --restate onto rows whose converted_amount has no rate behind it. It lives under backend/ because pnpm hoists nothing: a root script cannot resolve @nestjs/core. Applied to the hosted project: 7 rows, 0 failures, books up ₹25,136, and the dashboard's 14-day figure hand-checks to ₹54,669. Progress.md also corrects two entries that had gone stale: the deploy now passes all six verify:deploy checks, and CI has run on GitHub five times. --- CLAUDE.md | 63 +++- Progress.md | 108 +++---- backend/scripts/backfill-fx.mjs | 140 +++++++++ backend/src/expenses/expenses.module.ts | 2 + backend/src/expenses/expenses.service.spec.ts | 182 ++++++++++++ backend/src/expenses/expenses.service.ts | 62 +++- backend/src/fx/fx.module.ts | 14 + backend/src/fx/fx.service.spec.ts | 280 ++++++++++++++++++ backend/src/fx/fx.service.ts | 201 +++++++++++++ backend/src/supabase/mappers.ts | 21 +- backend/src/supabase/repositories.ts | 36 +++ backend/src/supabase/supabase.module.ts | 4 + backend/src/supabase/supabase.repositories.ts | 46 +++ backend/test/rbac.e2e-spec.ts | 2 + frontend/src/app/core/expense/amount.spec.ts | 34 +++ frontend/src/app/core/expense/amount.ts | 20 +- .../app/core/expense/expense-actions.spec.ts | 2 + frontend/src/app/core/format/money.spec.ts | 50 ++++ frontend/src/app/core/format/money.ts | 14 + .../src/app/pages/dashboard/dashboard.spec.ts | 2 + .../app/pages/dashboard/spend-pace.spec.ts | 2 + .../app/pages/expenses/expense-filter.spec.ts | 2 + .../src/app/pages/expenses/expenses.spec.ts | 61 +++- frontend/src/app/pages/expenses/expenses.ts | 46 ++- package.json | 1 + shared/src/domain.ts | 15 + supabase/migrations/0003_fx.sql | 62 ++++ 27 files changed, 1381 insertions(+), 91 deletions(-) create mode 100644 backend/scripts/backfill-fx.mjs create mode 100644 backend/src/expenses/expenses.service.spec.ts create mode 100644 backend/src/fx/fx.module.ts create mode 100644 backend/src/fx/fx.service.spec.ts create mode 100644 backend/src/fx/fx.service.ts create mode 100644 frontend/src/app/core/format/money.spec.ts create mode 100644 supabase/migrations/0003_fx.sql diff --git a/CLAUDE.md b/CLAUDE.md index a712617..77f5697 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ pnpm run dev # shared, then backend (:3000) + frontend (:4200) pnpm run build # shared -> backend -> frontend, in that order pnpm test # backend + frontend unit tests pnpm run test:e2e # backend e2e +pnpm run backfill:fx # lock ECB rates onto rows without one; dry run unless --apply ``` Both workspaces use **vitest** (Angular CLI 21 and Nest 12 both default to it now — not karma/jest). @@ -415,7 +416,8 @@ Getting them confused is how the UI briefly offered an Approve button that alway ## Module map **backend/** — `auth/` (argon2id + JWT, rotating refresh, guards), `expenses/` -(CRUD, search, the status state machine), `budgets/`, `reports/` (polled job for +(CRUD, search, the status state machine), `fx/` (ECB rates, the daily cache, +and the rate lock — no controller, deliberately), `budgets/`, `reports/` (polled job for the cancellation demo), `tool-calls/` (audit log), `orgs/`, `config/` (`GET /api/config` serves the Gemini model list so it is editable without a rebuild), `supabase/` (client + repository seam), `common/` (rate limiting). @@ -503,11 +505,7 @@ finished in both the source and the test count. ## Money: never add two currencies -There is no FX pass. `expenses.service.ts` writes `converted_amount` **only** -when the expense is already in the org's base currency, so it is `null` for -every foreign row — and the seed data has INR, USD and EUR. - -So a row counts toward a total only when it has a base-currency value, and the +A row counts toward a total only when it has a base-currency value, and the ones that do not are **counted and stated**, never dropped and never added: - Frontend: `core/expense/amount.ts` — `isConverted()`, `sumSpend()` (returns @@ -520,15 +518,58 @@ ones that do not are **counted and stated**, never dropped and never added: The earlier code fell back to the raw `amount`, on the reasoning that a slightly wrong number beat a bar reading zero. It was not slightly wrong: a $200 charge -was counted as ₹200. When a real FX pass starts filling `converted_amount`, -those rows re-enter every total with no code change. +was counted as ₹200. That rule is what let the FX pass land without touching a +line of `amount.ts`: foreign rows re-entered every total the moment +`converted_amount` started being filled. + +## FX: the rate is locked at write time, from the ECB + +`backend/src/fx/` converts a foreign expense **once**, at its own +`expense_date`, and records what it used. `ExpensesService.create` and +`.update` write three fields together — `converted_amount`, `fx_rate`, +`fx_rate_date` — because a converted amount with no rate cannot be defended and +a rate with no date cannot be reproduced. + +**Rates come from the ECB via `api.frankfurter.dev`**, which is deliberately the +same publisher the embedded converter shows: Cambiaro calls it from the browser, +so the advisory widget and the ledger agree without the ledger depending on the +widget. Cambiaro itself is **not** a rate source and cannot be one — it is a +static client-side app with no HTTP API (`/api/*` all 404), so its only +programmatic surface is `document.modelContext`, which exists inside a browser +page. Reading a figure out of the frame remains forbidden for the reasons below. + +Four things that look like details and are not: + +- **`fx_rate_date` is not `expense_date`.** The ECB publishes once per working + day, so a Saturday expense locks Friday's rate. Both dates are stored, and + the UI prints the rate's own date — claiming the expense's date would name a + rate nobody published. Real seed data exercises this: AWS on 2026-08-29. +- **`FxService.rateOn` never throws.** A missing rate returns `null`, the three + fields are written as null, and the row is excluded and counted — the state + the rules above already handle. A currency API being down must not stop + someone filing an expense. +- **`fx_rates` is keyed on the date *asked for*, not the date the rate is + from.** Keyed the other way, every weekend lookup would miss the cache + forever. The one entry that can go stale is today's while it still stands in + for an earlier day, before the ~16:00 CET publication. +- **An edit re-locks, and `expenseDate` is the easy one to miss.** Amount, + currency *and* date each invalidate the lock; all three fields move together, + including when the new lock fails. + +`backend/scripts/backfill-fx.mjs` (`pnpm run backfill:fx`, dry run unless +`--apply`) locks rates onto rows that have none, and with `--restate` onto rows +whose `converted_amount` has no rate behind it — which the demo seed's foreign +rows were, at hand-written rates nobody published. It lives under `backend/` +because pnpm hoists nothing: a root script cannot resolve `@nestjs/core`. **The embedded converter does not change this, and must not.** `converter/` frames a separate converter app on four surfaces (`/convert`, `/agent`, the dashboard's excluded-rows notice, and expense rows in another currency). It is -advisory: a rate a person reads off another site is not the historical rate -locked at entry, so nothing it shows may reach `converted_amount`, `sumSpend()`, -`sumByCategory()`, or the `excludedNotice()` copy. +advisory: a rate a person reads off another site *today* is not the historical +rate locked at entry, so nothing it shows may reach `converted_amount`, +`sumSpend()`, `sumByCategory()`, or the `excludedNotice()` copy. The FX pass +makes this sharper, not softer — there is now a real locked rate for the frame's +number to contradict. That is enforced structurally rather than by good intentions: `CurrencyConverter` has **no `output()`, no `postMessage` listener, and never diff --git a/Progress.md b/Progress.md index f0833bb..bc9115f 100644 --- a/Progress.md +++ b/Progress.md @@ -2,7 +2,7 @@ Tracks every feature in the PRD against what is actually in the codebase. -**Last audited:** 2026-09-03 · **Baseline:** 9 shared · 65 backend unit · 34 backend e2e · 786 frontend +**Last audited:** 2026-09-03 · **Baseline:** 12 shared · 93 backend unit · 34 backend e2e · 803 frontend Status is evidence-based, not aspirational. A row is `DONE` only when the code exists, is reachable from the running app, and has a test. A file existing is not @@ -143,29 +143,33 @@ the figure matches a hand-check against the expense rows. **Verify:** submit as member, approve as owner, confirm the member cannot approve their own expense. -## §6.5 Multi-currency — 🟡 ⚠️ +## §6.5 Multi-currency — ✅ | Item | Phase | Status | Notes | |---|---|---|---| | Original + converted amounts stored | 1 | ✅ | Columns exist. `core/expense/amount.ts` owns the rule: `sumSpend()` adds only base-currency rows and reports the rest | -| Live FX + daily cache | 1 | ⬜ | No FX client, no cache, no rates table. The embedded converter does **not** count — see below | -| Historical rate lock | 1 | ⬜ | No rate column | +| Live FX + daily cache | 1 | ✅ | `backend/src/fx/` reads ECB rates from `api.frankfurter.dev` and caches them in `fx_rates` (migration `0003`). Keyed on the date *asked for*, not the date the rate is from, or every weekend lookup would miss forever; the only entry that can go stale is today's while it still stands in for an earlier day. `FxService.rateOn` returns `null` rather than throwing, so an unreachable publisher cannot stop an expense being filed | +| Historical rate lock | 1 | ✅ | `expenses.fx_rate` + `fx_rate_date`, written with `converted_amount` at the expense's own date. **`fx_rate_date` is not `expense_date`** — the ECB publishes once per working day, so a Saturday expense locks Friday's rate, and the row prints the rate's own date. An edit re-locks on amount, currency *or* date. Verified live: AWS filed 2026-08-29 (a Saturday) carries the 2026-08-28 rate | | Embedded converter (advisory) | 1 | ✅ | `converter/currency-converter.ts` frames a separate converter app on `/convert`, `/agent`, the dashboard notice and foreign-currency expense rows. One frame at a time, lazily mounted, `CONVERTER_URL` from `GET /api/config` | -> **Totals are now honest about what they exclude.** `convertedAmount` is still -> only set when the currency already equals the base currency, so foreign rows -> have no base-currency value. They used to be added at face value — a $200 -> charge counted as ₹200. Now a row counts only when it has a converted value, -> and the ones that do not are **counted and stated**: `sumSpend()` returns -> `{total, excluded}`, `sumByCategory()` returns `unconverted`, and that surfaces -> as `BudgetStatus.unconvertedCount`, a muted line on the dashboard and budgets -> screens, and a field in the `get_budget_status` tool result so the Copilot can -> qualify the figure. Row labels follow the same rule — an unconverted $50 prints -> as `$50`, not `₹50`. +> **The exclusion rules were what made FX cheap to land.** `sumSpend()` returns +> `{total, excluded}` and `sumByCategory()` returns `unconverted` — a row counts +> only when it has a base-currency value, and the ones that do not are counted +> and stated rather than added at face value. That held, so foreign rows +> re-entered every total the moment `converted_amount` started being filled: +> **`core/expense/amount.ts` needed no logic change at all**, only a comment and +> a line of copy that had gone out of date. `unconvertedCount` still exists and +> still reaches `get_budget_status`; it is now normally 0, and means "no rate +> could be locked" rather than "FX does not exist". > -> This is the honest interim, not the feature: real FX (live rates, daily cache, -> historical lock) is still ⬜, and the moment `converted_amount` starts being -> filled, those rows re-enter every total with no code change. +> **The seed's foreign rows were worse than excluded — they were quietly +> wrong.** They shipped with a `converted_amount` at hand-written rates (₹87/$ +> for Figma, ₹94/€ for Sentry) that no publisher ever quoted, so they counted +> toward every total with nothing to explain them. `--restate` replaced all five +> with dated ECB rates, and picked up two real rows (AWS $200, Starbucks $45) +> that had no conversion at all. Applied 2026-09-03: 7 rows locked, 0 failures, +> books up ₹25,136, and the dashboard's 14-day figure hand-checks to ₹54,669 +> against its rows. > > **The embedded converter does not change any of that, deliberately.** It is a > reference a person reads, framed from a separate origin; it writes nothing, @@ -178,7 +182,8 @@ their own expense. > it, the change would be wrong. **Verify:** file expenses in two currencies and confirm the dashboard total is -not a naive sum, and that it says how many rows it left out. +not a naive sum. A foreign row shows what it was converted from and on which +day's rate; a row on a weekend names the preceding working day. ## §6.6 Analytics — 🟡 @@ -281,60 +286,59 @@ and the offline banner appearing and clearing on the network events. | Supabase call timeouts | ✅ | 8s deadline — a stall used to hang the request forever | | Unit tests for tool `execute()` | ✅ | — | | Structured logging / error tracking | ⬜ | Nest logger only; no Sentry-tier reporting | -| **CI** | 🟡 | `.github/workflows/ci.yml` runs the full Definition of Done gate on push and PR. Never executed by GitHub — verified by running its exact command sequence locally | +| **CI** | ✅ | `.github/workflows/ci.yml` runs the full Definition of Done gate on push and PR, and **has run on GitHub** — five successful runs, most recently on PR #4 | | Single-process deploy | ✅ | `server.mjs`; the routing contract it depends on is pinned by `routing-contract.e2e-spec.ts` | -## §12 Submission criteria — ⬜ +## §12 Submission criteria — 🟡 | Item | Status | Notes | |---|---|---| -| **Public deployed URL** | 🟡 | **Live at `https://actuo.onrender.com`** — `/api/health` returns 200. `server.mjs` composes Nest under `/api` with the Angular SSR handler from a committed `Dockerfile`; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README *Why a Dockerfile*). Still 🟡, not ✅, because the deployed site is **defective in two ways**: `/` does not server-render (see §8.5) and it served a literal `__PUBLIC_ORIGIN__` in `canonical`/`og:image`. The stamp half is fixed in `scripts/stamp-seo.mjs` and needs a redeploy; the SSR half needs `NG_ALLOWED_HOSTS` set on the service. `pnpm run verify:deploy ` reports both | +| **Public deployed URL** | ✅ | **Live and correct at `https://actuo.onrender.com`** — `pnpm run verify:deploy https://actuo.onrender.com` passes all six checks as of 2026-09-03: `/api/health` 200, `/` server-rendered (`ng-server-context` present), `/`, `sitemap.xml` and `robots.txt` all stamped, and `/api/config` reporting the converter. `server.mjs` composes Nest under `/api` with the Angular SSR handler from a committed `Dockerfile`; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README *Why a Dockerfile*). The two earlier defects — CSR fallback and a literal `__PUBLIC_ORIGIN__` — are both gone | | README | ✅ | Root `README.md`: what is WebMCP-specific and where, the flag setup, what works without it, and the deploy steps. Workspace READMEs are still starter boilerplate | -| Demo video | ⬜ | The script is the "What to look at" list in `README.md`. Worth filming only after the SSR fix lands, or it records the client-rendered site | +| Demo video | ⬜ | The last box left. The script is the "What to look at" list in `README.md`, and the SSR fix it was waiting on has landed — the deployed site server-renders, so a recording made now records the real thing | | Source with clear tool definitions | ✅ | `shared/src/tools.ts` | --- ## What to fix next -Every Phase 0 row is green as of 2026-09-03. The deploy exists and is healthy; -what is left is making it *correct*, the video, and Phase 1–3 features. - -1. **Make the live deploy correct.** It exists and is healthy at - `https://actuo.onrender.com`, but `/` is client-rendered and was shipping an - unstamped `__PUBLIC_ORIGIN__`. Two things remain, both on the Render service - rather than in this repo: set **`NG_ALLOWED_HOSTS`** so Angular stops falling - back to CSR, and set **`CONVERTER_URL`** so the cross-origin path runs. Then - redeploy — `PUBLIC_ORIGIN` is a build arg, so a restart cannot carry the stamp - fix. If the service was created by hand rather than from `render.yaml`, its - `envVars` were never applied, which would explain all of it. - *Verify:* `pnpm run verify:deploy https://actuo.onrender.com` passes every - check. +Every Phase 0 row is green, the deploy is live *and* correct, and real FX +landed on 2026-09-03. What is left is the rest of Phase 1, and the video. + +1. **Prove cross-origin from the deployed site.** `CONVERTER_URL` is set and + `/api/config` serves it, so this is very likely already working and merely + unverified. Open `https://actuo.onrender.com/agent` in flag-enabled Chrome + 151, confirm Cambiaro's seven tools are discovered across two public origins, + and ask the Copilot to convert €80. Ten minutes, and it turns §7's + cross-origin row from locally-proven into deploy-proven. 2. **Demo video** — the last §12 checkbox. The script is the "What to look at" list in `README.md`. -3. **Real FX** — live rates, a daily cache, a historical lock at write time. - Totals are honest about the gap now, but they still exclude real spend. -4. **Editing an existing budget** — `POST /budgets` inserts and there is no - PATCH, so a budget can be set once and not changed. The form hides categories - that already have one rather than offering a guaranteed 409. -5. **Everything else is Phase 1–3**: receipt OCR, notifications, recurring - templates (the table is not in the migration), multi-step approval chains, - comment threads, teams, tags, CSV import, PDF export, session management, - org invite/switch, `/api/analytics/*`, and packaging the Copilot as a - standalone script. +3. **Budgets depth** — `POST /budgets` inserts and there is no PATCH, so a + budget can be set once and not changed; the form hides categories that + already have one rather than offering a guaranteed 409. Threshold alerts + (80%) and rollover-vs-reset are the other two §6.3 rows, and `budgets.spec.ts` + already guards the rollover checkbox against returning without its behaviour. +4. **`/api/analytics/*`** — no controller; the dashboard derives everything + client-side. Standalone spend-by-category and a month-over-month delta tile + are the visible half. +5. **Recurring expenses** — `recurring_templates` is in PRD §8.7 and **absent + from the migrations**, so it needs `0004`. +6. **Org invites** — the last Phase 1 row, and the only one needing an external + service (Resend, plus a `sync: false` secret in `render.yaml`). +7. **Everything else is Phase 2–3**: receipt OCR, notifications, multi-step + approval chains, comment threads, teams, tags, CSV import, PDF export, + session management, and packaging the Copilot as a standalone script. ### Known rough edges, deliberately not fixed here - **The cross-origin path has not been run end to end from a *deployed* Actuo.** It is verified locally against the deployed converter (see §6.8), but nobody has yet loaded Actuo on Render, framed the converter from there, and watched - the Copilot call `convertCurrency` across two public origins. Two things remain: - the converter commits have to reach the deploy, and `CONVERTER_URL` has to be - set on the service. The converter's own `exposedTo` change is already merged - and live. Until then `/api/config` reports no converter and the surfaces show - the honest "not configured" state, which is correct but is not the demo. -- **CI has never run on GitHub.** The workflow was verified by running its exact - command sequence locally; `act` is not installed on this machine. + the Copilot call `convertCurrency` across two public origins. The two blockers + are gone — the converter commits are deployed and `CONVERTER_URL` is set, so + `/api/config` now serves `https://cambiaro.programmersingh.dev/`. All that is + left is opening `https://actuo.onrender.com/agent` in flag-enabled Chrome and + watching it work. - **The Firebase App Hosting backend may still be connected** with auto-rollouts, in which case it fails on every push. Deleting it is `firebase apphosting:backends:delete actuo --project actuo-2f1f3`. App Hosting diff --git a/backend/scripts/backfill-fx.mjs b/backend/scripts/backfill-fx.mjs new file mode 100644 index 0000000..e92ca0e --- /dev/null +++ b/backend/scripts/backfill-fx.mjs @@ -0,0 +1,140 @@ +/** + * Locks a real ECB rate onto expenses that do not have one (PRD §6.5). + * + * pnpm run backfill:fx # dry run, rows with no conversion + * pnpm run backfill:fx -- --restate # dry run, also rows whose + * # converted_amount has no rate + * pnpm run backfill:fx -- --restate --apply + * + * Two populations, and they are different problems: + * + * * **No conversion at all.** Excluded from every total and counted as such — + * honest but incomplete. + * * **A conversion with no rate** (`--restate`). Worse, because it is + * invisible: it counts toward every total and nothing says where it came + * from. The demo seed is exactly this, at hand-written rates (₹87/$ for + * Figma, ₹94/€ for Sentry) nobody published. + * + * Dry run by default. Nothing is written without `--apply`. + * + * It boots the real Nest context so it shares `FxService` (cache and weekend + * rule) and the API's service-role client — no second conversion path to keep + * in step. The queries are written out here rather than added to + * `ExpenseRepository`, whose every method is org-scoped for tenant isolation; + * an unscoped "all rows everywhere" read there would be one import away from a + * request path. + * + * It lives under `backend/` because pnpm hoists nothing: a root script cannot + * resolve `@nestjs/core`. Reads `dist/`, so `nest build` must have run. + */ + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../dist/app.module.js'; +import { FxService } from '../dist/fx/fx.service.js'; +import { SupabaseService } from '../dist/supabase/supabase.service.js'; + +const apply = process.argv.includes('--apply'); +const restate = process.argv.includes('--restate'); +const PAGE = 200; + +const money = (value, currency) => + value === null || value === undefined + ? '—' + : `${currency} ${Number(value).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +console.log(`\nFX backfill — ${apply ? 'APPLYING' : 'dry run'}${restate ? ', including rows to restate' : ''}\n`); + +// Logs stay at error: a full pass warns once per unreachable rate, and the +// table below is the actual output. +const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] }); +const fx = app.get(FxService); +const db = app.get(SupabaseService).getClient(); + +/** + * Rows that need a rate, oldest first. + * + * Paged by `id` rather than by offset: rows are updated as we go, and an + * offset walk over a set that is shrinking under it skips rows. + */ +async function* rowsNeedingRates() { + let after = ''; + + for (;;) { + let query = db + .from('expenses') + .select('id, org_id, amount, currency, converted_amount, fx_rate, base_currency, merchant, expense_date') + .is('deleted_at', null) + .is('fx_rate', null) + .order('id', { ascending: true }) + .limit(PAGE); + + if (after) query = query.gt('id', after); + if (!restate) query = query.is('converted_amount', null); + + const { data, error } = await query; + if (error) throw new Error(`Could not read expenses: ${error.message}`); + if (!data?.length) return; + + for (const row of data) yield row; + after = data[data.length - 1].id; + if (data.length < PAGE) return; + } +} + +let considered = 0; +let locked = 0; +let unchanged = 0; +const failed = []; + +for await (const row of rowsNeedingRates()) { + // Filtered here rather than in SQL: PostgREST cannot compare two columns in + // a filter, and a same-currency row has nothing to look up. + if (row.currency === row.base_currency) { + unchanged += 1; + continue; + } + considered += 1; + + const lock = await fx.lock(Number(row.amount), row.currency, row.base_currency, row.expense_date); + if (!lock) { + failed.push(row); + continue; + } + + const wasText = money(row.converted_amount, row.base_currency); + const nowText = money(lock.convertedAmount, row.base_currency); + const label = (row.merchant ?? 'Untitled').padEnd(18).slice(0, 18); + console.log( + ` ${row.expense_date} ${label} ${money(row.amount, row.currency).padStart(14)} ` + + `${wasText.padStart(14)} -> ${nowText.padStart(14)} @ ${lock.rate} on ${lock.rateDate}`, + ); + + if (apply) { + const { error } = await db + .from('expenses') + .update({ + converted_amount: lock.convertedAmount, + fx_rate: lock.rate, + fx_rate_date: lock.rateDate, + }) + .eq('id', row.id); + if (error) throw new Error(`Could not update ${row.id}: ${error.message}`); + } + locked += 1; +} + +console.log( + `\n${locked} row(s) ${apply ? 'locked' : 'would be locked'}, ` + + `${unchanged} already in the base currency, ${failed.length} without a rate.`, +); + +if (failed.length) { + console.log('\nNo rate could be found for these — they stay excluded and counted:'); + for (const row of failed) { + console.log(` ${row.id} ${row.expense_date} ${row.currency} ${row.merchant ?? ''}`); + } +} + +if (!apply && locked) console.log('\nNothing was written. Re-run with --apply.'); + +await app.close(); diff --git a/backend/src/expenses/expenses.module.ts b/backend/src/expenses/expenses.module.ts index f740baa..614700a 100644 --- a/backend/src/expenses/expenses.module.ts +++ b/backend/src/expenses/expenses.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { FxModule } from '../fx/fx.module.js'; import { ExpensesController } from './expenses.controller.js'; import { ExpensesService } from './expenses.service.js'; @Module({ + imports: [FxModule], controllers: [ExpensesController], providers: [ExpensesService], exports: [ExpensesService], diff --git a/backend/src/expenses/expenses.service.spec.ts b/backend/src/expenses/expenses.service.spec.ts new file mode 100644 index 0000000..e1185ec --- /dev/null +++ b/backend/src/expenses/expenses.service.spec.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Expense } from '@actuo/shared'; +import type { EnvService } from '../config/env.service.js'; +import type { FxService, LockedConversion } from '../fx/fx.service.js'; +import type { + AuditLogRepository, + CreateExpenseInput, + ExpenseRepository, + OrgRepository, + UpdateExpenseInput, +} from '../supabase/repositories.js'; +import type { AuthenticatedUser } from '../auth/auth.types.js'; +import { ExpensesService } from './expenses.service.js'; + +/** + * What this file is about: the three conversion fields on an expense, and the + * rule that they always move together (PRD §6.5). The state machine has its own + * spec; RBAC is covered end to end in `test/rbac.e2e-spec.ts`. + */ + +const USER: AuthenticatedUser = { + userId: 'user-1', + orgId: 'org-1', + email: 'priya@actuo.demo', + role: 'owner', +}; + +const EXPENSE_ID = 'exp-1'; + +function expense(overrides: Partial = {}): Expense { + return { + id: EXPENSE_ID, + orgId: USER.orgId, + userId: USER.userId, + categoryId: null, + amount: 20, + currency: 'USD', + convertedAmount: 1908.6, + fxRate: 95.43, + fxRateDate: '2026-08-14', + baseCurrency: 'INR', + merchant: 'Figma', + note: null, + status: 'draft', + receiptUrl: null, + expenseDate: '2026-08-14', + createdAt: '2026-08-14T09:00:00.000Z', + deletedAt: null, + ...overrides, + }; +} + +function createService(options: { lock?: LockedConversion | null; existing?: Expense } = {}) { + const created: CreateExpenseInput[] = []; + const patched: UpdateExpenseInput[] = []; + + const lock = vi.fn(async () => options.lock ?? null); + const fx = { lock } as unknown as FxService; + + const expenses = { + findById: async () => options.existing ?? expense(), + create: async (input: CreateExpenseInput) => { + created.push(input); + return expense(input as Partial); + }, + update: async (_orgId: string, _id: string, patch: UpdateExpenseInput) => { + patched.push(patch); + return expense(patch as Partial); + }, + } as unknown as ExpenseRepository; + + const orgs = { + findOrg: async () => ({ id: USER.orgId, name: 'Northwind', baseCurrency: 'INR' }), + } as unknown as OrgRepository; + + const audit = { append: async () => undefined } as unknown as AuditLogRepository; + const env = { baseCurrency: 'INR' } as unknown as EnvService; + + return { + service: new ExpensesService(env, fx, expenses, orgs, audit), + created, + patched, + lock, + }; +} + +const LOCKED: LockedConversion = { convertedAmount: 1908.6, rate: 95.43, rateDate: '2026-08-14' }; + +describe('create', () => { + it('locks the rate at the expense’s own date, not today', async () => { + const { service, created, lock } = createService({ lock: LOCKED }); + + await service.create(USER, { + amount: 20, + currency: 'USD', + expenseDate: '2026-08-14', + } as never); + + // The date argument is the whole point of a historical lock: a rate looked + // up today would make last month's spend drift every time it is read. + expect(lock).toHaveBeenCalledWith(20, 'USD', 'INR', '2026-08-14'); + expect(created[0]).toMatchObject({ + convertedAmount: 1908.6, + fxRate: 95.43, + fxRateDate: '2026-08-14', + }); + }); + + it('writes three nulls when no rate could be locked, and still saves the expense', async () => { + const { service, created } = createService({ lock: null }); + + // A currency API being down must not stop someone filing an expense. The + // row is saved, excluded from totals, and counted — the state sumSpend() + // and sumByCategory() already report honestly. + const saved = await service.create(USER, { + amount: 20, + currency: 'USD', + expenseDate: '2026-08-14', + } as never); + + expect(saved).toBeDefined(); + expect(created[0]).toMatchObject({ + convertedAmount: null, + fxRate: null, + fxRateDate: null, + }); + }); +}); + +describe('update', () => { + it('re-locks when the currency changes', async () => { + const { service, patched, lock } = createService({ lock: LOCKED, existing: expense() }); + + await service.update(USER, EXPENSE_ID, { currency: 'EUR' } as never); + + expect(lock).toHaveBeenCalledWith(20, 'EUR', 'INR', '2026-08-14'); + expect(patched[0]).toMatchObject({ fxRate: 95.43, fxRateDate: '2026-08-14' }); + }); + + it('re-locks when the amount changes', async () => { + const { service, lock } = createService({ lock: LOCKED, existing: expense() }); + + await service.update(USER, EXPENSE_ID, { amount: 50 } as never); + + expect(lock).toHaveBeenCalledWith(50, 'USD', 'INR', '2026-08-14'); + }); + + it('re-locks when only the DATE changes', async () => { + const { service, lock } = createService({ lock: LOCKED, existing: expense() }); + + // The easy one to miss. The lock is the rate on the expense's own day, so + // moving the day leaves a stored rate describing a conversion that never + // happened. + await service.update(USER, EXPENSE_ID, { expenseDate: '2026-07-01' } as never); + + expect(lock).toHaveBeenCalledWith(20, 'USD', 'INR', '2026-07-01'); + }); + + it('leaves the conversion alone when the edit cannot have changed it', async () => { + const { service, patched, lock } = createService({ lock: LOCKED, existing: expense() }); + + await service.update(USER, EXPENSE_ID, { merchant: 'Figma Inc' } as never); + + expect(lock).not.toHaveBeenCalled(); + expect(patched[0]).not.toHaveProperty('fxRate'); + expect(patched[0]).not.toHaveProperty('convertedAmount'); + }); + + it('clears all three when a re-lock fails, rather than leaving the old rate', async () => { + const { service, patched } = createService({ lock: null, existing: expense() }); + + await service.update(USER, EXPENSE_ID, { currency: 'EUR' } as never); + + // A leftover USD rate on a EUR expense is a wrong number that looks + // authoritative. Null is excluded and counted, which is visibly incomplete. + expect(patched[0]).toMatchObject({ + convertedAmount: null, + fxRate: null, + fxRateDate: null, + }); + }); +}); diff --git a/backend/src/expenses/expenses.service.ts b/backend/src/expenses/expenses.service.ts index 6132d17..2a6549c 100644 --- a/backend/src/expenses/expenses.service.ts +++ b/backend/src/expenses/expenses.service.ts @@ -8,6 +8,7 @@ import { import { EXPENSE_PAGE_DEFAULT, EXPENSE_PAGE_MAX } from '@actuo/shared'; import type { Expense, ExpenseStatus, Page, Role } from '@actuo/shared'; import { EnvService } from '../config/env.service.js'; +import { FxService } from '../fx/fx.service.js'; import { AUDIT_LOG_REPOSITORY, EXPENSE_REPOSITORY, @@ -36,6 +37,7 @@ export class ExpensesService { constructor( private readonly env: EnvService, + private readonly fx: FxService, @Inject(EXPENSE_REPOSITORY) private readonly expenses: ExpenseRepository, @Inject(ORG_REPOSITORY) private readonly orgs: OrgRepository, @Inject(AUDIT_LOG_REPOSITORY) private readonly audit: AuditLogRepository, @@ -61,6 +63,7 @@ export class ExpensesService { async create(user: AuthenticatedUser, dto: CreateExpenseDto): Promise { const baseCurrency = await this.baseCurrencyFor(user.orgId); + const locked = await this.fx.lock(dto.amount, dto.currency, baseCurrency, dto.expenseDate); const expense = await this.expenses.create({ orgId: user.orgId, @@ -70,10 +73,16 @@ export class ExpensesService { categoryId: dto.categoryId ?? null, amount: dto.amount, currency: dto.currency, - // PRD §6.5: converted_amount is filled by the FX pass. When the expense - // is already in the base currency there is nothing to convert, so it is - // set immediately; otherwise it stays null until rates are wired up. - convertedAmount: dto.currency === baseCurrency ? dto.amount : null, + // PRD §6.5. All three move together: an amount with no rate cannot be + // defended, a rate with no date cannot be reproduced. + // + // A null lock is normal, not a failure — the row is excluded and counted + // as it always was, and `backend/scripts/backfill-fx.mjs` fills it in + // later. Failing the save would let a currency API being down stop + // someone filing an expense. + convertedAmount: locked?.convertedAmount ?? null, + fxRate: locked?.rate ?? null, + fxRateDate: locked?.rateDate ?? null, baseCurrency, merchant: dto.merchant ?? null, note: dto.note ?? null, @@ -106,17 +115,10 @@ export class ExpensesService { if (hasFieldEdits) { this.assertCanEdit(user, expense); - const baseCurrency = expense.baseCurrency; - const nextCurrency = fields.currency ?? expense.currency; - const nextAmount = fields.amount ?? expense.amount; current = await this.expenses.update(user.orgId, id, { ...fields, - // Keep converted_amount consistent with whatever amount/currency now - // are: a stale conversion is worse than an honest null. - ...(fields.amount !== undefined || fields.currency !== undefined - ? { convertedAmount: nextCurrency === baseCurrency ? nextAmount : null } - : {}), + ...(await this.relockFor(expense, fields)), }); await this.safeAudit(user, 'expense.updated', id, { fields: Object.keys(fields) }); } @@ -271,6 +273,42 @@ export class ExpensesService { } } + /** + * The conversion fields to write when an edit invalidates the locked rate, + * or `{}` when it does not. + * + * The easy one to miss is **`expenseDate`**: the lock is the rate on the + * expense's own day, so moving the day leaves a rate describing a conversion + * that never happened. That is not recomputing history — the user changed + * the facts it was locked against. + * + * A failed re-lock clears all three rather than keeping the old rate: null is + * excluded and counted, a leftover rate is wrong and looks authoritative. + */ + private async relockFor( + expense: Expense, + fields: Partial>, + ): Promise>> { + const rateChanged = + fields.amount !== undefined || + fields.currency !== undefined || + fields.expenseDate !== undefined; + if (!rateChanged) return {}; + + const locked = await this.fx.lock( + fields.amount ?? expense.amount, + fields.currency ?? expense.currency, + expense.baseCurrency, + fields.expenseDate ?? expense.expenseDate, + ); + + return { + convertedAmount: locked?.convertedAmount ?? null, + fxRate: locked?.rate ?? null, + fxRateDate: locked?.rateDate ?? null, + }; + } + private async baseCurrencyFor(orgId: string): Promise { try { const org = await this.orgs.findOrg(orgId); diff --git a/backend/src/fx/fx.module.ts b/backend/src/fx/fx.module.ts new file mode 100644 index 0000000..6d28514 --- /dev/null +++ b/backend/src/fx/fx.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { FxService } from './fx.service.js'; + +/** + * No controller. Rates are not a public surface — they are read on the write + * path of an expense and by the backfill script, and exposing them would + * invite a client to convert a figure and send it back, which is exactly the + * boundary PRD §6.5 draws. + */ +@Module({ + providers: [FxService], + exports: [FxService], +}) +export class FxModule {} diff --git a/backend/src/fx/fx.service.spec.ts b/backend/src/fx/fx.service.spec.ts new file mode 100644 index 0000000..00ff987 --- /dev/null +++ b/backend/src/fx/fx.service.spec.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { FxRateRecord, FxRateRepository } from '../supabase/repositories.js'; +import { FxService } from './fx.service.js'; + +const TODAY = new Date().toISOString().slice(0, 10); + +/** A Frankfurter response body. `date` is the day the ECB actually published. */ +function body(rate: number, date: string) { + return { amount: 1, base: 'USD', date, rates: { INR: rate } }; +} + +function ok(payload: unknown) { + return { ok: true, status: 200, json: async () => payload } as Response; +} + +function createService(options: { + cached?: FxRateRecord | null; + onFind?: () => never; + onSave?: () => never; +} = {}) { + const saved: Array> = []; + + const rates: FxRateRepository = { + find: async () => { + options.onFind?.(); + return options.cached ?? null; + }, + save: async (record) => { + options.onSave?.(); + saved.push(record); + }, + }; + + return { service: new FxService(rates), saved }; +} + +function cached(overrides: Partial = {}): FxRateRecord { + return { + base: 'USD', + quote: 'INR', + asOfDate: '2026-08-14', + rateDate: '2026-08-14', + rate: 95.43, + source: 'frankfurter/ecb', + fetchedAt: '2026-08-14T17:00:00.000Z', + ...overrides, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('rateOn', () => { + it('answers an identity pair without asking anyone', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService(); + + // Not just an optimisation: Frankfurter rejects base === symbol, so a + // round trip here would fail and leave a base-currency expense unconverted. + await expect(service.rateOn('INR', 'INR', '2026-08-14')).resolves.toEqual({ + rate: 1, + rateDate: '2026-08-14', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('serves a cached rate without a network call', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService({ cached: cached() }); + + await expect(service.rateOn('USD', 'INR', '2026-08-14')).resolves.toEqual({ + rate: 95.43, + rateDate: '2026-08-14', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fetches on a miss, and writes what it got back to the cache', async () => { + const fetchMock = vi.fn().mockResolvedValue(ok(body(95.43, '2026-08-14'))); + vi.stubGlobal('fetch', fetchMock); + const { service, saved } = createService(); + + await expect(service.rateOn('usd', 'inr', '2026-08-14')).resolves.toEqual({ + rate: 95.43, + rateDate: '2026-08-14', + }); + + // Currencies are upper-cased before they reach the URL or the cache key, + // or the same pair would be cached twice under different spellings. + expect(String(fetchMock.mock.calls[0][0])).toContain('/2026-08-14?base=USD&symbols=INR'); + expect(saved).toEqual([ + { + base: 'USD', + quote: 'INR', + asOfDate: '2026-08-14', + rateDate: '2026-08-14', + rate: 95.43, + source: 'frankfurter/ecb', + }, + ]); + }); + + it('records the day the rate is really from when a weekend resolves backwards', async () => { + // 2026-08-16 is a Sunday. The ECB published on the Friday. + const fetchMock = vi.fn().mockResolvedValue(ok(body(95.43, '2026-08-14'))); + vi.stubGlobal('fetch', fetchMock); + const { service, saved } = createService(); + + await expect(service.rateOn('USD', 'INR', '2026-08-16')).resolves.toEqual({ + rate: 95.43, + rateDate: '2026-08-14', + }); + // Keyed by the day asked for, so the next Sunday lookup hits the cache; + // stamped with the day published, so the figure can be defended. + expect(saved[0]).toMatchObject({ asOfDate: '2026-08-16', rateDate: '2026-08-14' }); + }); + + it('reuses a past weekend entry rather than re-fetching it forever', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService({ + cached: cached({ asOfDate: '2026-08-16', rateDate: '2026-08-14' }), + }); + + // Resolved backwards, but the day asked for is in the past — Saturday's + // rate is never going to be published, so this entry is final. + await expect(service.rateOn('USD', 'INR', '2026-08-16')).resolves.toEqual({ + rate: 95.43, + rateDate: '2026-08-14', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("re-fetches today's rate while it is still standing in for an earlier day", async () => { + // Written before the ECB's ~16:00 CET publication, so it holds yesterday's + // rate under today's key. Treating that as final would pin it there. + const fetchMock = vi.fn().mockResolvedValue(ok(body(94.97, TODAY))); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService({ + cached: cached({ asOfDate: TODAY, rateDate: '2026-01-01', rate: 1 }), + }); + + await expect(service.rateOn('USD', 'INR', TODAY)).resolves.toEqual({ + rate: 94.97, + rateDate: TODAY, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("keeps today's rate once it has settled on today", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService({ + cached: cached({ asOfDate: TODAY, rateDate: TODAY, rate: 94.97 }), + }); + + await expect(service.rateOn('USD', 'INR', TODAY)).resolves.toEqual({ + rate: 94.97, + rateDate: TODAY, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns null rather than throwing when the publisher is unreachable', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNRESET'))); + const { service } = createService(); + + // The whole contract: a missing rate must never fail the expense save. + await expect(service.rateOn('USD', 'INR', '2026-08-14')).resolves.toBeNull(); + }); + + it('returns null on an HTTP error, such as a currency the ECB does not publish', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 } as Response)); + const { service } = createService(); + + await expect(service.rateOn('USD', 'XYZ', '2026-08-14')).resolves.toBeNull(); + }); + + it.each([ + ['a missing rate', { amount: 1, date: '2026-08-14', rates: {} }], + ['a zero rate', body(0, '2026-08-14')], + ['a rate that is not a number', { date: '2026-08-14', rates: { INR: 'lots' } }], + ['no date', { rates: { INR: 95.43 } }], + ['a date that is not one', { date: 'yesterday', rates: { INR: 95.43 } }], + ])('returns null on an unusable body: %s', async (_label, payload) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok(payload))); + const { service, saved } = createService(); + + // A zero here would be the dangerous case: it would write a converted + // amount of 0.00 that reads as a real figure everywhere downstream. + await expect(service.rateOn('USD', 'INR', '2026-08-14')).resolves.toBeNull(); + expect(saved).toEqual([]); + }); + + it('still returns the rate when the cache cannot be read', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok(body(95.43, '2026-08-14')))); + const { service } = createService({ + onFind: () => { + throw new Error('supabase unavailable'); + }, + }); + + await expect(service.rateOn('USD', 'INR', '2026-08-14')).resolves.toEqual({ + rate: 95.43, + rateDate: '2026-08-14', + }); + }); + + it('still returns the rate when the cache cannot be written', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok(body(95.43, '2026-08-14')))); + const { service } = createService({ + onSave: () => { + throw new Error('supabase unavailable'); + }, + }); + + await expect(service.rateOn('USD', 'INR', '2026-08-14')).resolves.toEqual({ + rate: 95.43, + rateDate: '2026-08-14', + }); + }); + + it('trims a timestamp to the date the column and the URL both want', async () => { + const fetchMock = vi.fn().mockResolvedValue(ok(body(95.43, '2026-08-14'))); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService(); + + await service.rateOn('USD', 'INR', '2026-08-14T09:30:00.000Z'); + expect(String(fetchMock.mock.calls[0][0])).toContain('/2026-08-14?'); + }); + + it('returns null for a date it cannot make sense of', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { service } = createService(); + + // Never concatenated into the URL unchecked. + await expect(service.rateOn('USD', 'INR', 'last Tuesday')).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('lock', () => { + it('converts and rounds to the two decimals the column stores', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok(body(95.43, '2026-08-14')))); + const { service } = createService(); + + // 20 * 95.43 = 1908.6 exactly; 12.34 * 95.43 = 1177.6062 rounds. + await expect(service.lock(20, 'USD', 'INR', '2026-08-14')).resolves.toEqual({ + convertedAmount: 1908.6, + rate: 95.43, + rateDate: '2026-08-14', + }); + await expect(service.lock(12.34, 'USD', 'INR', '2026-08-14')).resolves.toEqual({ + convertedAmount: 1177.61, + rate: 95.43, + rateDate: '2026-08-14', + }); + }); + + it('is exact for a base-currency amount', async () => { + const { service } = createService(); + + await expect(service.lock(6450, 'INR', 'INR', '2026-08-14')).resolves.toEqual({ + convertedAmount: 6450, + rate: 1, + rateDate: '2026-08-14', + }); + }); + + it('is null when no rate could be locked', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + const { service } = createService(); + + await expect(service.lock(20, 'USD', 'INR', '2026-08-14')).resolves.toBeNull(); + }); +}); diff --git a/backend/src/fx/fx.service.ts b/backend/src/fx/fx.service.ts new file mode 100644 index 0000000..e3fda0f --- /dev/null +++ b/backend/src/fx/fx.service.ts @@ -0,0 +1,201 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + FX_RATE_REPOSITORY, + type FxRateRepository, +} from '../supabase/repositories.js'; + +/** + * A rate, and the day it is actually from. + * + * `rateDate` is not always the day that was asked for — see `rateOn`. Callers + * that persist a conversion must persist both, or the figure cannot be + * defended later. + */ +export interface LockedRate { + /** One unit of the `from` currency expressed in the `to` currency. */ + rate: number; + rateDate: string; +} + +/** Everything an expense row needs to record a conversion, ready to write. */ +export interface LockedConversion extends LockedRate { + convertedAmount: number; +} + +/** + * ECB rates via the Frankfurter API — deliberately the same publisher the + * embedded converter shows (PRD §6.5), so the advisory widget and the ledger + * agree without the ledger depending on the widget. + * + * The deadline is tighter than the 8s Supabase gets: this sits on the expense + * save path, and a missing rate degrades to "excluded and counted" while a slow + * save is felt immediately. + */ +const FX_API_BASE = process.env.FX_API_BASE ?? 'https://api.frankfurter.dev/v1'; +const FX_TIMEOUT_MS = Number(process.env.FX_TIMEOUT_MS ?? 5000); +const FX_SOURCE = 'frankfurter/ecb'; + +@Injectable() +export class FxService { + private readonly logger = new Logger(FxService.name); + + constructor(@Inject(FX_RATE_REPOSITORY) private readonly rates: FxRateRepository) {} + + /** + * The rate for `from` -> `to` on `onDate`, or `null` if none could be had. + * + * **This never throws.** A missing rate is a normal outcome: the caller + * writes nulls, the row is excluded and counted, and the backfill picks it up + * later. A currency API being down must not stop someone filing an expense. + * + * `onDate` is what was asked for; `rateDate` is what the ECB published. They + * differ on weekends and holidays — it publishes once per working day, so a + * Sunday expense locks Friday's rate. + */ + async rateOn(from: string, to: string, onDate: string): Promise { + const base = from.toUpperCase(); + const quote = to.toUpperCase(); + const asOfDate = isoDate(onDate); + if (!asOfDate) return null; + + // Identity pair. Not an optimisation — Frankfurter rejects a request whose + // base and symbol are the same, so this is the only correct answer, and it + // is exact rather than a rounded round trip. + if (base === quote) return { rate: 1, rateDate: asOfDate }; + + const cached = await this.readCache(base, quote, asOfDate); + if (cached) return cached; + + const fetched = await this.fetchRate(base, quote, asOfDate); + if (!fetched) return null; + + // Cache write failures are not the caller's problem: we have the rate, and + // the only cost is re-fetching it next time. + try { + await this.rates.save({ base, quote, asOfDate, ...fetched, source: FX_SOURCE }); + } catch (error) { + this.logger.warn(`Could not cache ${base}->${quote} for ${asOfDate}: ${reason(error)}`); + } + + return fetched; + } + + /** + * `rateOn`, applied to an amount and rounded for storage. + * + * The rounding lives here rather than in each caller because + * `expenses.converted_amount` is `numeric(14,2)`: rounding in one place is + * what stops a row's stored figure and a recomputed one from disagreeing in + * the last paisa. + */ + async lock( + amount: number, + from: string, + to: string, + onDate: string, + ): Promise { + const locked = await this.rateOn(from, to, onDate); + if (!locked) return null; + return { ...locked, convertedAmount: round2(amount * locked.rate) }; + } + + /** + * A cached rate, if there is one that is still true. + * + * Staleness has exactly one case. A past day's ECB rate never changes, and + * neither does a rate that resolved to the very day it was asked for. The + * only entry that can go out of date is one for **today** that resolved + * backwards — which is what happens before the ECB publishes at ~16:00 CET. + * Treating that as permanent would pin yesterday's rate to today forever. + */ + private async readCache( + base: string, + quote: string, + asOfDate: string, + ): Promise { + let cached; + try { + cached = await this.rates.find(base, quote, asOfDate); + } catch (error) { + // A cache read failing must not cost us the rate itself. + this.logger.warn(`FX cache unreadable for ${base}->${quote}: ${reason(error)}`); + return null; + } + if (!cached) return null; + + const resolvedBackwards = cached.rateDate !== cached.asOfDate; + const notYetSettled = cached.asOfDate >= today(); + if (resolvedBackwards && notYetSettled) return null; + + return { rate: cached.rate, rateDate: cached.rateDate }; + } + + /** + * One request to the rate publisher. + * + * The date goes in the path rather than using `/latest`, even for today: one + * code path for every date, and the response's own `date` field tells us + * which day we actually got. A future-dated expense resolves the same way, + * backwards to the last publication. + */ + private async fetchRate( + base: string, + quote: string, + asOfDate: string, + ): Promise { + const url = `${FX_API_BASE}/${asOfDate}?base=${base}&symbols=${quote}`; + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(FX_TIMEOUT_MS) }); + if (!response.ok) { + // 404 is the normal answer for a currency the ECB does not publish. + this.logger.warn(`FX lookup ${base}->${quote} on ${asOfDate}: HTTP ${response.status}`); + return null; + } + + const body = (await response.json()) as { date?: unknown; rates?: Record }; + const rate = Number(body?.rates?.[quote]); + const rateDate = isoDate(body?.date); + + // Validate rather than trust: a malformed body must not become a zero + // rate, which would silently write a converted amount of 0.00 and read + // as a real figure everywhere downstream. + if (!rateDate || !Number.isFinite(rate) || rate <= 0) { + this.logger.warn(`FX lookup ${base}->${quote} on ${asOfDate} returned an unusable body.`); + return null; + } + + return { rate, rateDate }; + } catch (error) { + this.logger.warn(`FX lookup ${base}->${quote} on ${asOfDate} failed: ${reason(error)}`); + return null; + } + } +} + +/** Today in UTC, as YYYY-MM-DD — the same shape the dates being compared use. */ +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * Narrow a value to a `YYYY-MM-DD` date, or null. + * + * `expense_date` is a DATE column and the API path segment is a bare date, so + * a timestamp has to be trimmed rather than passed through. The shape is + * checked because these strings are concatenated into a URL. + */ +function isoDate(value: unknown): string | null { + if (typeof value !== 'string') return null; + const date = value.slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null; +} + +/** Money rounds to 2dp; floats otherwise leak 0.30000000000000004. */ +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function reason(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/backend/src/supabase/mappers.ts b/backend/src/supabase/mappers.ts index 7d4ad88..bca0e17 100644 --- a/backend/src/supabase/mappers.ts +++ b/backend/src/supabase/mappers.ts @@ -21,7 +21,12 @@ import type { AuditLogEntry, ToolCallLogEntry, } from '@actuo/shared'; -import type { OrgMember, RefreshTokenRecord, UserRecord } from './repositories.js'; +import type { + FxRateRecord, + OrgMember, + RefreshTokenRecord, + UserRecord, +} from './repositories.js'; type Row = Record; @@ -67,6 +72,8 @@ export const toExpense = (r: Row): Expense => ({ amount: num(r.amount), currency: r.currency, convertedAmount: numOrNull(r.converted_amount), + fxRate: numOrNull(r.fx_rate), + fxRateDate: r.fx_rate_date ?? null, baseCurrency: r.base_currency, merchant: r.merchant ?? null, note: r.note ?? null, @@ -77,6 +84,18 @@ export const toExpense = (r: Row): Expense => ({ deletedAt: r.deleted_at ?? null, }); +export const toFxRate = (r: Row): FxRateRecord => ({ + base: r.base, + quote: r.quote, + asOfDate: r.as_of_date, + rateDate: r.rate_date, + // A rate is numeric(20,10); see the note at the top about stringy numerics. + // Here it would not concatenate, it would multiply as NaN, which is worse. + rate: num(r.rate), + source: r.source, + fetchedAt: r.fetched_at, +}); + export const toBudget = (r: Row): Budget => ({ id: r.id, orgId: r.org_id, diff --git a/backend/src/supabase/repositories.ts b/backend/src/supabase/repositories.ts index 85343ca..4e9c8f0 100644 --- a/backend/src/supabase/repositories.ts +++ b/backend/src/supabase/repositories.ts @@ -99,6 +99,9 @@ export interface CreateExpenseInput { amount: number; currency: string; convertedAmount: number | null; + /** The rate behind `convertedAmount`, and the day it is from. Null together. */ + fxRate: number | null; + fxRateDate: string | null; baseCurrency: string; merchant: string | null; note: string | null; @@ -113,6 +116,8 @@ export type UpdateExpenseInput = Partial< | 'amount' | 'currency' | 'convertedAmount' + | 'fxRate' + | 'fxRateDate' | 'merchant' | 'note' | 'expenseDate' @@ -139,6 +144,23 @@ export interface AppendToolCallInput { output: unknown; } +/** + * One row of the `fx_rates` cache (PRD §6.5). + * + * `asOfDate` is the day that was asked for; `rateDate` is the day the rate is + * actually from. See the comment in `0003_fx.sql` for why both are stored and + * why the key is on the former. + */ +export interface FxRateRecord { + base: string; + quote: string; + asOfDate: string; + rateDate: string; + rate: number; + source: string; + fetchedAt: string; +} + // Interfaces export interface UserRepository { @@ -190,6 +212,19 @@ export interface BudgetRepository { }): Promise; } +/** + * The rate cache. Deliberately tiny: read one, write one. + * + * There is no delete and no invalidate — staleness is decided by the caller + * (`FxService`), because the rule depends on today's date and on the row's own + * `rateDate`, and putting it here would push a clock into the storage layer. + */ +export interface FxRateRepository { + find(base: string, quote: string, asOfDate: string): Promise; + /** Upsert: a re-fetch of a stale same-day rate must overwrite, not conflict. */ + save(record: Omit): Promise; +} + export interface ToolCallLogRepository { append(input: AppendToolCallInput): Promise; list( @@ -233,6 +268,7 @@ export const USER_REPOSITORY = Symbol('USER_REPOSITORY'); export const ORG_REPOSITORY = Symbol('ORG_REPOSITORY'); export const EXPENSE_REPOSITORY = Symbol('EXPENSE_REPOSITORY'); export const BUDGET_REPOSITORY = Symbol('BUDGET_REPOSITORY'); +export const FX_RATE_REPOSITORY = Symbol('FX_RATE_REPOSITORY'); export const TOOL_CALL_LOG_REPOSITORY = Symbol('TOOL_CALL_LOG_REPOSITORY'); export const REFRESH_TOKEN_REPOSITORY = Symbol('REFRESH_TOKEN_REPOSITORY'); export const AUDIT_LOG_REPOSITORY = Symbol('AUDIT_LOG_REPOSITORY'); diff --git a/backend/src/supabase/supabase.module.ts b/backend/src/supabase/supabase.module.ts index 215ad63..da68696 100644 --- a/backend/src/supabase/supabase.module.ts +++ b/backend/src/supabase/supabase.module.ts @@ -5,6 +5,7 @@ import { AUDIT_LOG_REPOSITORY, BUDGET_REPOSITORY, EXPENSE_REPOSITORY, + FX_RATE_REPOSITORY, ORG_REPOSITORY, REFRESH_TOKEN_REPOSITORY, TOOL_CALL_LOG_REPOSITORY, @@ -14,6 +15,7 @@ import { SupabaseAuditLogRepository, SupabaseBudgetRepository, SupabaseExpenseRepository, + SupabaseFxRateRepository, SupabaseOrgRepository, SupabaseRefreshTokenRepository, SupabaseToolCallLogRepository, @@ -40,6 +42,7 @@ import { { provide: ORG_REPOSITORY, useClass: SupabaseOrgRepository }, { provide: EXPENSE_REPOSITORY, useClass: SupabaseExpenseRepository }, { provide: BUDGET_REPOSITORY, useClass: SupabaseBudgetRepository }, + { provide: FX_RATE_REPOSITORY, useClass: SupabaseFxRateRepository }, { provide: TOOL_CALL_LOG_REPOSITORY, useClass: SupabaseToolCallLogRepository }, { provide: REFRESH_TOKEN_REPOSITORY, useClass: SupabaseRefreshTokenRepository }, { provide: AUDIT_LOG_REPOSITORY, useClass: SupabaseAuditLogRepository }, @@ -51,6 +54,7 @@ import { ORG_REPOSITORY, EXPENSE_REPOSITORY, BUDGET_REPOSITORY, + FX_RATE_REPOSITORY, TOOL_CALL_LOG_REPOSITORY, REFRESH_TOKEN_REPOSITORY, AUDIT_LOG_REPOSITORY, diff --git a/backend/src/supabase/supabase.repositories.ts b/backend/src/supabase/supabase.repositories.ts index e000039..edc5d57 100644 --- a/backend/src/supabase/supabase.repositories.ts +++ b/backend/src/supabase/supabase.repositories.ts @@ -29,6 +29,8 @@ import type { CreateExpenseInput, ExpenseQuery, ExpenseRepository, + FxRateRecord, + FxRateRepository, ListAuditQuery, OrgMember, OrgRepository, @@ -288,6 +290,8 @@ export class SupabaseExpenseRepository implements ExpenseRepository { amount: input.amount, currency: input.currency, converted_amount: input.convertedAmount, + fx_rate: input.fxRate, + fx_rate_date: input.fxRateDate, base_currency: input.baseCurrency, merchant: input.merchant, note: input.note, @@ -306,6 +310,8 @@ export class SupabaseExpenseRepository implements ExpenseRepository { if (patch.amount !== undefined) row.amount = patch.amount; if (patch.currency !== undefined) row.currency = patch.currency; if (patch.convertedAmount !== undefined) row.converted_amount = patch.convertedAmount; + if (patch.fxRate !== undefined) row.fx_rate = patch.fxRate; + if (patch.fxRateDate !== undefined) row.fx_rate_date = patch.fxRateDate; if (patch.merchant !== undefined) row.merchant = patch.merchant; if (patch.note !== undefined) row.note = patch.note; if (patch.expenseDate !== undefined) row.expense_date = patch.expenseDate; @@ -443,6 +449,46 @@ export class SupabaseBudgetRepository implements BudgetRepository { } } +@Injectable() +export class SupabaseFxRateRepository implements FxRateRepository { + constructor(private readonly supabase: SupabaseService) {} + + async find(base: string, quote: string, asOfDate: string): Promise { + const { data, error } = await this.supabase + .getClient() + .from('fx_rates') + .select('*') + .eq('base', base) + .eq('quote', quote) + .eq('as_of_date', asOfDate) + .maybeSingle(); + if (error) fail(error, 'FX rate lookup'); + return data ? map.toFxRate(data) : null; + } + + async save(record: Omit): Promise { + const { error } = await this.supabase + .getClient() + .from('fx_rates') + .upsert( + { + base: record.base, + quote: record.quote, + as_of_date: record.asOfDate, + rate_date: record.rateDate, + rate: record.rate, + source: record.source, + fetched_at: new Date().toISOString(), + }, + // Upsert rather than insert: today's rate is re-fetched once the ECB + // publishes, and that second write must replace the provisional row + // rather than raise a unique violation the caller would have to catch. + { onConflict: 'base,quote,as_of_date' }, + ); + if (error) fail(error, 'FX rate cache write'); + } +} + @Injectable() export class SupabaseToolCallLogRepository implements ToolCallLogRepository { constructor(private readonly supabase: SupabaseService) {} diff --git a/backend/test/rbac.e2e-spec.ts b/backend/test/rbac.e2e-spec.ts index df21ae4..32d82f8 100644 --- a/backend/test/rbac.e2e-spec.ts +++ b/backend/test/rbac.e2e-spec.ts @@ -98,6 +98,8 @@ class FakeExpenseRepository { amount: 6450, currency: 'INR', convertedAmount: 6450, + fxRate: 1, + fxRateDate: '2026-08-10', baseCurrency: 'INR', merchant: 'Uber', note: 'Airport transfer', diff --git a/frontend/src/app/core/expense/amount.spec.ts b/frontend/src/app/core/expense/amount.spec.ts index 2ebe655..10f31ca 100644 --- a/frontend/src/app/core/expense/amount.spec.ts +++ b/frontend/src/app/core/expense/amount.spec.ts @@ -19,6 +19,8 @@ function expense(overrides: Partial = {}): Expense { amount: 100, currency: 'INR', convertedAmount: null, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', merchant: 'Barista', note: null, @@ -147,6 +149,38 @@ describe('expenseAmount', () => { }); }); +describe('a row the FX pass has locked a rate onto', () => { + // The point of the whole design: nothing in this file changed to make these + // pass. A foreign row re-enters every total the moment `convertedAmount` is + // filled, which is what `sumSpend()` was written to allow. + const locked = expense({ + amount: 20, + currency: 'USD', + convertedAmount: 1908.6, + fxRate: 95.43, + fxRateDate: '2026-08-14', + baseCurrency: 'INR', + }); + + it('counts as converted', () => { + expect(isConverted(locked)).toBe(true); + }); + + it('is labelled in the base currency, not the one it was filed in', () => { + expect(expenseCurrency(locked)).toBe('INR'); + expect(expenseAmount(locked)).toBe(1908.6); + }); + + it('is added to the total rather than excluded from it', () => { + expect(sumSpend([locked])).toEqual({ total: 1908.6, excluded: 0 }); + }); + + it('is still excluded when the rate could not be locked', () => { + const unlocked = expense({ currency: 'USD', convertedAmount: null, fxRate: null }); + expect(sumSpend([unlocked])).toEqual({ total: 0, excluded: 1 }); + }); +}); + describe('excludedNotice', () => { it('says nothing when nothing was excluded', () => { expect(excludedNotice(0)).toBeNull(); diff --git a/frontend/src/app/core/expense/amount.ts b/frontend/src/app/core/expense/amount.ts index 86d0e1d..00212c1 100644 --- a/frontend/src/app/core/expense/amount.ts +++ b/frontend/src/app/core/expense/amount.ts @@ -24,10 +24,11 @@ export function expenseAmount(expense: Expense): number { /** * Whether this row's value is expressed in the org's base currency. * - * There is no FX pass yet (PRD §6.5), so `convertedAmount` is filled only when - * the expense was already filed in the base currency and is `null` otherwise. - * Anything false here is a number in a *different unit*, and the currency to - * print beside it is `expense.currency`, not `expense.baseCurrency`. + * `convertedAmount` is filled by the FX pass (PRD §6.5) from the ECB rate on + * the expense's own date, and stays `null` when no rate could be locked — an + * unreachable publisher, or a currency the ECB does not publish. Anything + * false here is a number in a *different unit*, and the currency to print + * beside it is `expense.currency`, not `expense.baseCurrency`. */ export function isConverted(expense: Expense): boolean { return expense.convertedAmount !== null || expense.currency === expense.baseCurrency; @@ -86,10 +87,17 @@ export function sumSpend(expenses: readonly Expense[]): SpendTotal { return { total, excluded }; } -/** One line of copy for an excluded-rows notice, or `null` when nothing was. */ +/** + * One line of copy for an excluded-rows notice, or `null` when nothing was. + * + * The copy used to say conversion "isn't live yet", which was true before the + * FX pass and is not now. An excluded row today means no rate could be locked + * for it, which is a narrower and rarer thing — and the reason it names the + * rate rather than the feature. + */ export function excludedNotice(excluded: number): string | null { if (excluded <= 0) return null; const noun = excluded === 1 ? 'expense' : 'expenses'; const verb = excluded === 1 ? 'isn’t' : 'aren’t'; - return `${excluded} ${noun} in other currencies ${verb} included — currency conversion isn’t live yet.`; + return `${excluded} ${noun} in other currencies ${verb} included — no exchange rate could be locked for ${excluded === 1 ? 'it' : 'them'}.`; } diff --git a/frontend/src/app/core/expense/expense-actions.spec.ts b/frontend/src/app/core/expense/expense-actions.spec.ts index 54e0d8f..2d619fe 100644 --- a/frontend/src/app/core/expense/expense-actions.spec.ts +++ b/frontend/src/app/core/expense/expense-actions.spec.ts @@ -22,6 +22,8 @@ function expense(overrides: Partial = {}): Expense { amount: 100, currency: 'INR', convertedAmount: 100, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', merchant: 'Barista', note: null, diff --git a/frontend/src/app/core/format/money.spec.ts b/frontend/src/app/core/format/money.spec.ts new file mode 100644 index 0000000..23c66c2 --- /dev/null +++ b/frontend/src/app/core/format/money.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { formatDate, formatDay, formatMoney, formatRate } from './money.js'; + +describe('formatMoney', () => { + it('renders whole units with the currency’s own symbol', () => { + expect(formatMoney(1908.6, 'INR')).toContain('1,909'); + expect(formatMoney(20, 'USD')).toContain('20'); + }); + + it('falls back to a bare number rather than throwing on a bad code', () => { + expect(formatMoney(1234, '')).toBe('1,234'); + }); + + it('is a dash for a value that is not a number', () => { + expect(formatMoney(Number.NaN, 'INR')).toBe('—'); + }); +}); + +describe('formatRate', () => { + it('keeps the decimals a rate needs, which formatMoney would round away', () => { + // formatMoney caps at whole units — 95.43 would print as "₹95", which is + // not the rate that was used. + expect(formatRate(95.43)).toBe('95.43'); + }); + + it('survives the reverse direction of a pair', () => { + // 1 INR = 0.0105 USD. Rounded to whole units this would be "0". + expect(formatRate(0.010530062)).toBe('0.0105301'); + }); + + it('carries no currency symbol, because a rate is not an amount', () => { + expect(formatRate(95.43)).not.toMatch(/[₹$€]/); + }); + + it('is a dash for a value that is not a number', () => { + expect(formatRate(Number.NaN)).toBe('—'); + }); +}); + +describe('formatDay / formatDate', () => { + it('reads a date-only string by hand, so it does not shift a day west of UTC', () => { + expect(formatDay('2026-08-14')).toBe('14 Aug'); + expect(formatDate('2026-08-14')).toBe('14 Aug 2026'); + }); + + it('passes anything it cannot parse straight through', () => { + expect(formatDate('soon')).toBe('soon'); + }); +}); diff --git a/frontend/src/app/core/format/money.ts b/frontend/src/app/core/format/money.ts index 23ada69..48ded64 100644 --- a/frontend/src/app/core/format/money.ts +++ b/frontend/src/app/core/format/money.ts @@ -36,6 +36,20 @@ export function formatMoney(amount: number, currency: string, locale = DEFAULT_L } } +/** + * An FX rate, as a plain number with no currency symbol. + * + * Deliberately not `formatMoney`: that caps at whole units, which is right for + * an expense list and useless for a rate — 95.27 would print as "₹95", and the + * reverse direction (1 INR = 0.0105 USD) as "₹0". A rate is also not an amount + * of money in either currency, so a symbol on it would be wrong as well as + * imprecise; the caller names both currencies around it instead. + */ +export function formatRate(rate: number, locale = DEFAULT_LOCALE): string { + if (!Number.isFinite(rate)) return '—'; + return new Intl.NumberFormat(locale, { maximumSignificantDigits: 6 }).format(rate); +} + /** * `2026-08-27` → `27 Aug`. Expense dates are date-only strings, so they are * split by hand: `new Date('2026-08-27')` parses as UTC midnight and renders as diff --git a/frontend/src/app/pages/dashboard/dashboard.spec.ts b/frontend/src/app/pages/dashboard/dashboard.spec.ts index 96bd6b5..7d5f88a 100644 --- a/frontend/src/app/pages/dashboard/dashboard.spec.ts +++ b/frontend/src/app/pages/dashboard/dashboard.spec.ts @@ -17,6 +17,8 @@ function expense(overrides: Partial = {}): Expense { amount: 100, currency: 'INR', convertedAmount: null, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', merchant: 'Barista', note: null, diff --git a/frontend/src/app/pages/dashboard/spend-pace.spec.ts b/frontend/src/app/pages/dashboard/spend-pace.spec.ts index ef050b4..43b1a6f 100644 --- a/frontend/src/app/pages/dashboard/spend-pace.spec.ts +++ b/frontend/src/app/pages/dashboard/spend-pace.spec.ts @@ -24,6 +24,8 @@ function expense(overrides: Partial = {}): Expense { amount: 100, currency: 'INR', convertedAmount: null, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', merchant: 'Barista', note: null, diff --git a/frontend/src/app/pages/expenses/expense-filter.spec.ts b/frontend/src/app/pages/expenses/expense-filter.spec.ts index f73189f..0f35b64 100644 --- a/frontend/src/app/pages/expenses/expense-filter.spec.ts +++ b/frontend/src/app/pages/expenses/expense-filter.spec.ts @@ -23,6 +23,8 @@ function expense(overrides: Partial = {}): Expense { amount: 100, currency: 'INR', convertedAmount: null, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', merchant: 'Barista', note: null, diff --git a/frontend/src/app/pages/expenses/expenses.spec.ts b/frontend/src/app/pages/expenses/expenses.spec.ts index 32a054a..7c8bf56 100644 --- a/frontend/src/app/pages/expenses/expenses.spec.ts +++ b/frontend/src/app/pages/expenses/expenses.spec.ts @@ -17,6 +17,8 @@ function expense(overrides: Partial = {}): Expense { amount: 100, currency: 'INR', convertedAmount: null, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', merchant: 'Barista', note: null, @@ -178,9 +180,9 @@ describe('Expenses', () => { describe('currency labelling', () => { /** * `baseCurrency || currency` printed an unconverted $50 under the org's ₹ - * symbol. There is no FX pass (PRD §6.5), so `convertedAmount` is null for - * every foreign row — the label was wrong for all of them, by roughly a - * factor of ninety. + * symbol — wrong by roughly a factor of ninety. A row is unconverted when + * the FX pass could not lock a rate for it (PRD §6.5), which is rarer than + * it was but still the case the label has to get right. */ it('labels an unconverted row with the currency it was filed in', async () => { api.get.mockResolvedValue( @@ -193,6 +195,57 @@ describe('Expenses', () => { expect(text()).not.toContain('₹'); }); + describe('the locked rate', () => { + const figma = expense({ + id: 'figma', + merchant: 'Figma', + amount: 20, + currency: 'USD', + convertedAmount: 1908.6, + fxRate: 95.43, + // A Sunday expense. The ECB published on the Friday, and the line must + // say the Friday — claiming the Sunday would name a rate that never + // existed. + expenseDate: '2026-08-16', + fxRateDate: '2026-08-14', + baseCurrency: 'INR', + }); + + it('shows what the figure was converted from, at what rate and on what day', async () => { + api.get.mockResolvedValue(page([figma])); + create(); + await settle(); + + const cell = tableRows()[0].querySelector('td:nth-child(4)') as HTMLElement; + expect(cell.textContent).toContain('$20'); + expect(cell.textContent).toContain('1 USD = 95.43 INR'); + expect(cell.textContent).toContain('14 Aug 2026'); + expect(cell.textContent).not.toContain('16 Aug 2026'); + }); + + it('says nothing on a row filed in the base currency', async () => { + api.get.mockResolvedValue( + page([expense({ id: 'inr', currency: 'INR', baseCurrency: 'INR', fxRate: 1 })]), + ); + create(); + await settle(); + + // Nothing was converted, so there is nothing to explain and a "1 INR = + // 1 INR" line would be pure noise on most of the table. + expect(tableRows()[0].textContent).not.toContain('1 INR = '); + }); + + it('says nothing when no rate could be locked', async () => { + api.get.mockResolvedValue( + page([expense({ id: 'usd', currency: 'USD', convertedAmount: null, fxRate: null })]), + ); + create(); + await settle(); + + expect(tableRows()[0].textContent).not.toContain(' = '); + }); + }); + /** * The rate lookup is offered on exactly the rows the totals leave out — * `isConverted()` is the same predicate `sumSpend()` excludes on — so the @@ -280,6 +333,8 @@ describe('Expenses', () => { amount: 50, currency: 'USD', convertedAmount: 4200, + fxRate: null, + fxRateDate: null, baseCurrency: 'INR', }), ]), diff --git a/frontend/src/app/pages/expenses/expenses.ts b/frontend/src/app/pages/expenses/expenses.ts index bf135dc..e21d7cc 100644 --- a/frontend/src/app/pages/expenses/expenses.ts +++ b/frontend/src/app/pages/expenses/expenses.ts @@ -13,7 +13,7 @@ import type { Expense, Page, TransitionAction } from '@actuo/shared'; import { ApiClient, ApiError } from '../../core/api/api-client.js'; import { Badge, Button, EmptyState, ErrorState, Input, Skeleton } from '../../ui'; -import { formatDate, formatMoney } from '../../core/format/money.js'; +import { formatDate, formatMoney, formatRate } from '../../core/format/money.js'; import { expenseAmount, expenseCurrency, isConverted } from '../../core/expense/amount.js'; import { CurrencyConverter } from '../../converter/currency-converter.js'; import { ConverterSession } from '../../converter/converter-session.js'; @@ -200,11 +200,14 @@ const PAGE_SIZE = EXPENSE_PAGE_MAX; - - {{ amountText(row) }} + + + {{ amountText(row) }} + @if (lockedRateText(row); as locked) { + {{ locked }} + } + @if (lockedRateText(row); as locked) { +

{{ locked }}

+ } + @if (row.note) {

{{ row.note }}

} @@ -811,6 +822,29 @@ export class Expenses { return formatMoney(expenseAmount(expense), expenseCurrency(expense)); } + /** + * What the figure above it was converted from, and on what rate — or null + * when there is nothing to explain. + * + * This is the visible difference between the locked rate and the advisory + * converter sitting on the same row. The converter answers "what is this + * worth today"; this line says what the org's books actually used, on the + * expense's own date, and it never changes afterwards. + * + * The date shown is `fxRateDate`, not `expenseDate`, and they legitimately + * differ: the ECB publishes once per working day, so a Saturday expense + * carries Friday's rate. Printing the expense's own date here would claim a + * rate that was never published. + */ + protected lockedRateText(expense: Expense): string | null { + if (expense.fxRate === null || expense.fxRateDate === null) return null; + if (expense.currency === expense.baseCurrency) return null; + + const original = formatMoney(expense.amount, expense.currency); + const rate = formatRate(expense.fxRate); + return `${original} · 1 ${expense.currency} = ${rate} ${expense.baseCurrency} on ${formatDate(expense.fxRateDate)}`; + } + reload(): void { this.patched.set({}); this.rowError.set(null); diff --git a/package.json b/package.json index ad08edf..d4df56b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build": "pnpm run build:shared && pnpm --filter backend run build && pnpm --filter frontend run build && pnpm run build:seo", "build:seo": "node scripts/stamp-seo.mjs", "verify:deploy": "node scripts/verify-deploy.mjs", + "backfill:fx": "pnpm --filter backend exec node scripts/backfill-fx.mjs", "start": "node server.mjs", "test": "pnpm --filter @actuo/shared run test && pnpm --filter backend run test && pnpm --filter frontend run test", "test:e2e": "pnpm --filter backend run test:e2e" diff --git a/shared/src/domain.ts b/shared/src/domain.ts index 50b09f9..bab888a 100644 --- a/shared/src/domain.ts +++ b/shared/src/domain.ts @@ -169,6 +169,21 @@ export interface Expense { currency: string; convertedAmount: number | null; baseCurrency: string; + /** + * The rate locked at write time — 1 unit of `currency` in `baseCurrency` — + * and the day that rate is from (PRD §6.5). + * + * `fxRateDate` is not always `expenseDate`. The ECB publishes once per + * working day, so an expense filed on a Sunday locks Friday's rate, and + * saying which day it came from is the difference between an auditable + * figure and a number nobody can defend. + * + * Both are null together, and that is the honest "no rate could be locked" + * state: `convertedAmount` is null too, and the row is excluded from totals + * and counted rather than added at face value. + */ + fxRate: number | null; + fxRateDate: string | null; merchant: string | null; note: string | null; status: ExpenseStatus; diff --git a/supabase/migrations/0003_fx.sql b/supabase/migrations/0003_fx.sql new file mode 100644 index 0000000..3ae6286 --- /dev/null +++ b/supabase/migrations/0003_fx.sql @@ -0,0 +1,62 @@ +-- Actuo — FX rates and the historical rate lock (PRD §6.5). +-- +-- Two things, deliberately separate: +-- +-- * `fx_rates` is a CACHE — nothing here cannot be re-fetched. +-- * `expenses.fx_rate` / `fx_rate_date` are a LOCK, and ledger data. Once +-- written they are never recomputed: the point of a historical lock is that +-- today's rate does not retroactively change what last month cost. +-- +-- Rates are ECB, read through the Frankfurter API by backend/src/fx/. Nothing +-- the embedded converter displays reaches these columns — it is advisory and +-- has no channel back into the app. + +begin; + +-- fx_rates — the daily cache + +create table if not exists fx_rates ( + base text not null, + quote text not null, + + -- The date we ASKED for, and the date the rate we got is actually from. + -- + -- They differ, and both matter. The ECB publishes once per working day, so a + -- request for a Sunday resolves backwards: asking Frankfurter for 2026-08-16 + -- returns {"date":"2026-08-14", ...}. Recording only one of these would mean + -- either losing the provenance of the rate or never being able to answer + -- "what did we use for a Sunday expense" without re-deriving the weekend + -- rule. + -- + -- The primary key is on `as_of_date`, not `rate_date`, on purpose: keyed by + -- rate_date, every Sunday lookup would miss the cache forever and re-fetch. + as_of_date date not null, + rate_date date not null, + + -- Wide enough for both directions of a pair: 1 USD = 94.97 INR and + -- 1 INR = 0.010530 USD both have to round-trip. + rate numeric(20, 10) not null check (rate > 0), + + -- So a future second publisher is distinguishable rather than mixed in. + source text not null default 'frankfurter/ecb', + fetched_at timestamptz not null default now(), + + primary key (base, quote, as_of_date) +); + +-- expenses — the historical rate lock + +-- `converted_amount` has existed since 0001 but carried no rate and no date. +-- These make it auditable: a figure that can be re-derived and defended. +-- +-- Null must stay allowed — it means "no rate could be locked", the state +-- sumSpend() and sumByCategory() already exclude and count rather than guess. +alter table expenses add column if not exists fx_rate numeric(20, 10); +alter table expenses add column if not exists fx_rate_date date; + +-- Same posture as every other table in 0001: no policies, so anon and +-- authenticated keys can read nothing, while the service role backend/ uses +-- bypasses RLS entirely. +alter table fx_rates enable row level security; + +commit;