Skip to content

feat(fx): lock a real ECB rate onto every foreign expense - #5

Merged
theprogrammersingh merged 1 commit into
mainfrom
feat/fx-rate-lock
Sep 3, 2026
Merged

feat(fx): lock a real ECB rate onto every foreign expense#5
theprogrammersingh merged 1 commit into
mainfrom
feat/fx-rate-lock

Conversation

@theprogrammersingh

Copy link
Copy Markdown
Owner

Closes PRD §6.5 "Live FX + daily cache" and "Historical rate lock" (Phase 1).

Why

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), so they counted toward every
total with nothing to explain them.

What

backend/src/fx/ reads ECB rates from api.frankfurter.dev and caches them in a
new fx_rates table (migration 0003). ExpensesService 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 exactly 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 moved.

Backfill

backend/scripts/backfill-fx.mjs locks rates onto rows without one, and with
--restate onto rows whose converted_amount has no rate behind it. Already
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 against its rows.

Deploying

No environment variable changes. Migration 0003 and the backfill are
already applied to the hosted Supabase project, so merging is safe on its own.

Checks

  • typecheck (backend + frontend), pnpm test, pnpm run test:e2e, pnpm run build
  • 12 shared · 114 backend unit · 34 backend e2e · 803 frontend
  • every backfilled rate verified against the ECB independently, and every
    product checked by hand

Progress.md also corrects two entries that had gone stale: the deploy passes
all verify:deploy checks, and CI has run on GitHub.

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.
@theprogrammersingh
theprogrammersingh merged commit c32665b into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant