feat(fx): lock a real ECB rate onto every foreign expense - #5
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_amountat rates nobody published (₹87/$ for Figma, ₹94/€ forSentry, 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 fromapi.frankfurter.devand caches them in anew
fx_ratestable (migration0003).ExpensesServicewritesconverted_amount,fx_rateandfx_rate_datetogether at the expense's owndate; 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 missingoutput()and
postMessagelistener exist to prevent.Three things that look like details and are not
fx_rate_dateis notexpense_date. The ECB publishes once per workingday, 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.rateOnreturnsnullrather than throwing. A currency APIbeing 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_ratesis 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.tsneeded no logic change — foreign rows re-enteredevery total the moment
converted_amountstarted being filled, which is whatthose rules were written to allow. Only a stale comment and a line of copy moved.
Backfill
backend/scripts/backfill-fx.mjslocks rates onto rows without one, and with--restateonto rows whoseconverted_amounthas no rate behind it. Alreadyapplied 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
0003and the backfill arealready applied to the hosted Supabase project, so merging is safe on its own.
Checks
pnpm test,pnpm run test:e2e,pnpm run buildproduct checked by hand
Progress.mdalso corrects two entries that had gone stale: the deploy passesall
verify:deploychecks, and CI has run on GitHub.