- Actual Budget — Multi-Currency Implementation Plan
- A Working Document
- 0. Context and Prior Art
- 1. First-Principles: Decimal Precision
- Critical pre-merge check for decimal precision PRs
- PR DP-1 — Core utility helpers
- PR DP-2 — loot-core server: decimal-aware amount handling
- PR DP-3 — desktop-client: transaction table and search
- PR DP-4 — desktop-client: budget UI components
- PR DP-5 — desktop-client: modals, mobile, settings, and E2E
- PR DP-6 — Historical data migration for non-2dp budget currencies
- PR DP-7 — Remove "hide decimal places" setting
- PR DP-8 — Rescale stored amounts when the budget currency's precision changes
- 1.5 Individual New Currency PRs (after DP-6)
- 2. Multi-Currency Core: Data Model and Preferences
- 3. Multi-Currency UI: Transaction Display
- 4. Multi-Currency UI: Reports and Import
- 5. Live Rate Fetching (Provider Plugins)
- 6. Cleanup and Enhancement
- 7. Appendix: PR Dependency Graph
- 8. Appendix: Submission Checklist Per PR
- 9. Appendix: Claude Code Session Setup
How to use this document: Work through PRs in the order listed. Each section is a self-contained unit of work targeting a single reviewable PR. Do not skip ahead — later phases depend on earlier ones landing cleanly.
IMPORTANT: Before beginning work on any part of this project, review the status of any PRs mentioned, and update them if they are not listed as "Merged". If all previous PRs are merged, it is safe to proceed with the next one. Once a PR has been submitted, add it to the relevant PR section with it's appropriate status.
A PR is not "green" until all five of these pass. No exceptions — a PR that skips any of these is not ready for review:
- Lint —
yarn lintclean (0 errors; warnings unchanged from baseline). Useyarn lint:fixto auto-fix. - Typecheck —
yarn workspace @actual-app/web run typecheckand (if loot-core touched)yarn workspace @actual-app/core run typecheck. - Unit tests — affected workspaces (
vitest). Add unit coverage for every currency/decimal change (round-trip tests are the cheapest proof of correctness). - Functional E2E —
E2E_USE_BUILD=1 yarn playwright test --browser=chromiumagainst a production build (REACT_APP_NETLIFY=true yarn build:browser --skip-translations). Build fidelity matters — some bugs only appear in the bundled build, not the dev server. - VRT (visual regression) —
yarn vrtscreenshots. Do not skip this even for "logic-only" changes: any change that touches a rendered amount/string can shift pixels. VRT only asserts underVRT=1(viatoMatchThemeScreenshots), so functional E2E does not cover it.
VRT must run in the playwright container, never on the host. The committed baselines are
*-chromium-linux.pnggenerated inmcr.microsoft.com/playwright:<ver>-jammy. Host font rendering (e.g. ubuntu 26.04) differs and produces false diffs. Run VRT viagh act(see below) oryarn vrt:docker.
gh act (nektos/act as a gh extension) runs .github/workflows/e2e-test.yml in Docker, matching CI faithfully. Known issues and the working recipe:
- Patched binary required. Stock act ≤ 0.2.89 cannot handle
upload-artifact@v7(unknown field "mime_type"), panics inlistArtifactswhen the run dir is absent, and rejects the unpadded base64 signature on blob upload. A locally-built act with these three fixes lives at~/Projects/act(master, ahead of upstream); the patched binary is installed over thegh-actextension. Without it, the build→test artifact handoff fails. Track upstream PR nektos/act#6039 (+ the two follow-on fixes) for when this is no longer needed. - Always pass
--artifact-server-path(e.g.--artifact-server-path /tmp/act-artifacts, recreated empty each run) soupload-artifact/download-artifactwork. - Run sharded matrix jobs ONE SHARD AT A TIME. The
functionalandvrtjobs use a 3-way matrix; under act all shards share the host network and collide on port 3001 (http://localhost:3001 is already used) and race on the lage cache, producing a false "Job failed". Run each shard in its own invocation:gh act pull_request -j vrt --matrix shard:1 …, thenshard:2, thenshard:3. (In real CI each shard is an isolated runner, so this is purely an act limitation.) - Set
--env REACT_APP_NETLIFY=truesoisNonProductionEnvironment()keeps the "Create test file" button in the production build (every e2ebeforeEachneeds it). - Kill stray host servers and orphaned containers first. A lingering host
vite/serve-buildon 3001, or act containers orphaned by a killed run, will hold the port and fail the next run.pkill -f vite; pkill -f serve-build, and remove leftover act containers by explicit ID (docker rm -f <id>— never blanket-remove all containers on a shared host). - Docker access: the user must be in the
dockergroup (sudo usermod -aG docker $USER, then re-login /newgrp docker).
Example — run one VRT shard for the current working copy:
rm -rf /tmp/act-artifacts && mkdir -p /tmp/act-artifacts
gh act pull_request -j vrt --matrix shard:1 \
-W .github/workflows/e2e-test.yml \
--env REACT_APP_NETLIFY=true \
--artifact-server-path /tmp/act-artifactsgit remote add tlesicka https://github.com/tlesicka/actual.git
git fetch tlesicka
# The key branches:
# tlesicka/currency-support — PR #3658 (original POC, 2024; historical reference)
# origin/push-woswwuqwuwru — PR #6954 (decimal precision, needs splitting)
# origin/multi-currency — PR #7450 (UX demo, DO NOT merge as-is)| Source | Take | Leave / Replace |
|---|---|---|
PR #3658 (tlesicka/currency-support) |
Community-agreed precision target (4dp minimum, up to 14 with max-value trade-off); early framing of the problem space; confirms defaultCurrencyCode as the right pref anchor |
Implementation is a POC superseded in full by #7450; do not port any code directly |
PR #6954 (push-woswwuqwuwru) |
All decimal precision logic; tests; VND fix | Nothing — split and submit |
PR #7450 (multi-currency) |
base_amount on transactions; exchange rate service architecture; account currency field; settings UI structure; sat-comma formatter; mempool.space and OXR provider implementations |
Per-currency envelope buffers/totals; any UI that shows multiple currencies inside a single category row |
tlesicka/actual-budget-multicurrency-todo |
FX Rate page design; Account creation UI mockups; currency symbol display spec | Specific implementation details (use as UX reference only) |
Every PR must include a release notes file in upcoming-release-notes/, no exceptions.
Filename: <description-slug>.md — description only, no number prefix.
Examples: currency-decimal-helpers.md, account-currency-field.md.
Required frontmatter:
---
category: Bugfixes | Enhancements | Features | Maintenance
authors: []
---Valid category values:
Bugfixes— corrects wrong behaviorEnhancements— improves existing functionalityFeatures— new user-visible capabilityMaintenance— refactors, internal cleanup, audits with no user-visible change
authors must always be gh auth status --json hosts | jq -Mc '[.hosts."github.com"[0].login]'.
These principles govern every design decision in this plan. When in doubt, return to them.
-
One budget currency, many account currencies. Envelopes always show amounts in the budget's base currency. A "Groceries" envelope has one number: base-currency spent vs. base-currency budgeted. No per-currency sub-rows, no per-currency buffers.
-
Every foreign transaction has two representations stored.
amount— the base-currency integer the budget engine consumes (always present)transaction_amount+transaction_currency— the native amount as charged (present when foreign) Both are stored at transaction time. The native amount is never retroactively re-converted.
-
FX conversions are transfers, not income or expense. Moving CHF→EUR touches two account balances and nothing else. No envelope is affected. No phantom income or expense is created.
-
Exchange rates are stored per-transaction, historically. The rate used to convert a transaction on 2026-05-01 is frozen at that date. It does not change when rates are refreshed.
-
Live rate fetching is optional and user-configured. The app must work correctly with manually-entered rates and no external service dependency. OXR (or any other provider) is an enhancement, not a requirement.
-
Decimal precision is per-currency, not global. JPY and KRW use 0 decimal places. KWD and BHD use 3. BTC uses 8. USD/EUR use 2. The integer storage encoding scales accordingly.
Why this comes first: There are live bugs today. JPY and KRW are stored and displayed with wrong decimal places (×100 inflated). CLP is stored at 2dp but should be 0dp. Fixing the display and encoding pipeline is prerequisite infrastructure that every multi-currency PR builds on. None of the later work should land without this foundation.
Two constraints that govern this entire section:
Do not change an existing currency's
decimalPlacesvalue without a data migration. A currency already in the list (e.g., CLP atdecimalPlaces: 2) may have existing transactions stored at the old precision. Changing the metadata without rescaling the stored integers produces wrong display values. Metadata corrections for existing currencies must be bundled with DP-6 (the data migration), not done earlier.Do not add new currencies until after DP-6 merges. A currency that is not in the list falls back to the 2dp default via
getCurrency. If a user hasdefaultCurrencyCodeset to that code (e.g.,'VND') while it is absent from the list, their data is stored at 2dp. Adding the currency with the correct precision afterward (e.g., 0dp for VND) produces the same display corruption as changing an existing entry. Add new currencies individually after the full DP pipeline is merged and the migration has run. Each new currency gets its own PR (see Section 7.5).
PR #6954 and commit fb2f243 from PR #7450 both touch currencies.ts. Before submitting
any of the splits below, run:
git diff origin/push-woswwuqwuwru origin/multi-currency \
-- packages/loot-core/src/shared/currencies.tsIdentify any conflict. The #6954 split must be the source of truth for currency metadata. The
#7450 branch must rebase on top of it. Do not allow duplicate or conflicting decimalPlaces
entries.
Status: Merged — actualbudget/actual#8064
Branch name: currency-decimal-helpers
Depends on: nothing (base PR)
Size estimate: ~80 lines of code + ~60 lines of tests
Motivation: All downstream decimal-aware code requires a stable lookup for "how many decimal places does currency X use?" and helpers to encode/decode amounts correctly. This PR adds exactly that and nothing else. No existing currency metadata is changed, no new currencies are added (except BTC as the reference high-precision currency for testing). No UI, no server logic, no behavior change for existing users.
Current state of the codebase (verify before writing any code):
Currencytype incurrencies.tsalready hasdecimalPlaces: number— do NOT re-add it.amountToInteger(amount, decimalPlaces = 2)andintegerToAmount(integer, decimalPlaces = 2)already accept adecimalPlacesparameter — do NOT change their signatures.JPY,KRW, andIRRalready havedecimalPlaces: 0— verify, don't touch them.integerToCurrencyWithDecimalandappendDecimalsalready exist with different signatures — do NOT re-implement them; instead follow the instructions below for each.- Do NOT correct
CLPor any other existing entry'sdecimalPlaces— that requires DP-6. - Do NOT add VND, KWD, BHD, OMR, or any other missing currency — those come after DP-6.
Files to change:
packages/loot-core/src/shared/currencies.ts
- Add
getDecimalPlaces(currencyCode: string): number— returns the currency'sdecimalPlaces. When called with''(None entry), returns2. Unknown codes return2as safe default. - Do NOT add new currencies. Do NOT change any existing entry's
decimalPlacesvalue.
packages/loot-core/src/shared/util.ts
integerToCurrencyWithDecimal— add an optionalcurrency?: stringsecond parameter. When provided, derivedecimalPlacesfromgetDecimalPlaces(currency)and use that to format. When omitted, preserve the existing behavior (current hardcoded% 100logic). Callers that pass a currency will get currency-aware formatting; existing callers with no currency argument are unaffected.amountToCurrencyInteger(amount: number, currency: string): number— NEW function (do not name itappendDecimals— that name is taken by an existing string-formatting function with a different signature). Encodes a decimal amount to an integer using the currency's decimal precision. This is the encoding inverse ofintegerToCurrencyWithDecimal.export function amountToCurrencyInteger(amount: number, currency: string): number { return amountToInteger(amount, getDecimalPlaces(currency)); }
- Do NOT change
amountToIntegerorintegerToAmount— they already acceptdecimalPlacesas an optional parameter and are used correctly by existing callers at 2dp.
Tests to add:
The existing currency list contains three 0dp currencies (JPY, KRW, IRR) and no 3dp or higher-precision currencies yet. Tests are limited to what the list supports; 3dp and 8dp coverage will be added in Section 7.5 PRs after DP-6.
packages/loot-core/src/shared/util.test.ts
describe('integerToCurrencyWithDecimal with currency')
- '' (None): integerToCurrencyWithDecimal(1234, '') → '12.34' (2dp, no symbol)
- USD: integerToCurrencyWithDecimal(1234, 'USD') → '12.34'
- JPY: integerToCurrencyWithDecimal(1234, 'JPY') → '1,234' (0 decimal places)
- IRR: integerToCurrencyWithDecimal(1234, 'IRR') → '1,234' (0dp, distinct symbol)
- unknown currency 'XYZ': defaults to 2dp
- no currency argument: existing behavior preserved (no regression)
describe('amountToCurrencyInteger')
- '' (None): amountToCurrencyInteger(12.34, '') → 1234
- USD: amountToCurrencyInteger(12.34, 'USD') → 1234
- JPY: amountToCurrencyInteger(1234, 'JPY') → 1234 (multiply by 1)
- IRR: amountToCurrencyInteger(5678, 'IRR') → 5678 (0dp)
- round-trip: integerToCurrencyWithDecimal(amountToCurrencyInteger(x, c), c) ≈ x for USD, JPY, IRR
packages/loot-core/src/shared/currencies.test.ts (new file)
describe('getDecimalPlaces')
- returns 2 for '' (None — the first entry in the currency map)
- returns 0 for JPY, KRW, IRR (0dp currencies currently in the list)
- returns 2 for USD, EUR, GBP
- returns 2 (safe default) for truly unknown code 'XYZ'
describe('currency metadata completeness')
- every entry in the currencies list has a decimalPlaces field
- no entry has a negative decimalPlaces value
- the first entry has code '' and decimalPlaces 2
Release notes: Create an entry in upcoming-release-notes/ describing the new helper
functions. Use only a description slug in the filename (no number prefix).
Category: Features.
PR description to write:
Adds
getDecimalPlaces(currency)tocurrencies.ts: returns the number of decimal places for a currency code (unknown codes return 2 as safe default). AddsamountToCurrencyInteger(amount, currency)toutil.tsand an optionalcurrencyparam onintegerToCurrencyWithDecimal. No existing currency metadata is changed; no new currencies added. No behavior change for existing budgets. Prerequisite infrastructure for follow-on decimal precision fixes.Part of #3255, part of #5748.
Status: Under Review — actualbudget/actual#8167
Branch name: feat/currency-decimal-server
Depends on: DP-1 merged
Size estimate: ~350 lines across ~12 files
Motivation:
Server-side code in importers, rule evaluation, budget templates, and the API all hardcode
÷100 or ×100 assumptions. This PR threads the currency-aware helpers from DP-1 through
every server path that reads or writes monetary amounts.
Currency resolution rule (applies to every file in this PR):
account.currency does not exist until MC-2. At DP-2 time, resolve currency using:
- For importers: use the currency stated in the import file format (e.g., YNAB5's
currency_formatfield per account). - For all other server paths: use
prefs.defaultCurrencyCode. WhendefaultCurrencyCodeis''(None/unset),getDecimalPlacesreturns2— correct fallback, no special case. - Never fall back to
'USD'.
Files to change:
packages/loot-core/src/server/importers/ynab5.ts
- Replace hardcoded
Math.round(amount * 100)calls withamountToCurrencyInteger(amount, currency)wherecurrencyis read from the YNAB5 export'scurrency_formatfield on each account, mapped to Actual's currency code.
packages/loot-core/src/server/importers/ynab4.ts
- YNAB4 stores amounts as milliunits (÷1000). The existing importer does
amount / 10to convert milliunits → cents (2dp). Replace withamountToInteger(amount / 1000, getDecimalPlaces(currency))using the currency from the YNAB4 account metadata.
packages/loot-core/src/server/transactions/transaction-rules.ts
- Amount-comparison conditions in rules (
lessThan,greaterThan,equals) must compare usingprefs.defaultCurrencyCodeprecision, not always 2dp. - Any rule action that sets an amount must encode it using
amountToCurrencyInteger(amount, defaultCurrencyCode).
packages/loot-core/src/server/transactions/export/export-to-csv.ts
- Pass
prefs.defaultCurrencyCodetointegerToCurrencyWithDecimalwhen formatting theAmountcolumn.
packages/loot-core/src/server/accounts/app.ts
createAccountstarting balance: encode usingamountToCurrencyInteger(balance, prefs.defaultCurrencyCode).closeAccountandreopenAccountbalance assertions: use the same encoding.
packages/loot-core/src/server/accounts/sync.ts
- When mapping synced transaction amounts from bank sync providers (which return decimal
amounts like
47.50), encode usingamountToCurrencyInteger(amount, prefs.defaultCurrencyCode).
packages/loot-core/src/server/api.ts
- Public API
getTransactionsandgetAccountsresponses: format amounts usingintegerToCurrencyWithDecimal(amount, prefs.defaultCurrencyCode)rather than÷100. - Public API
addTransactionsandupdateTransactioninputs: parse usingamountToCurrencyInteger(amount, prefs.defaultCurrencyCode).
packages/loot-core/src/server/budget/schedule-template.ts
packages/loot-core/src/server/budget/category-template-context.ts
packages/loot-core/src/server/budget/actions.ts
- Template amounts are stored internally as integers. When parsing template strings like
$500, encode usingamountToCurrencyInteger(amount, prefs.defaultCurrencyCode). - When formatting template amounts for display in budget cells, use
integerToCurrencyWithDecimal(amount, prefs.defaultCurrencyCode).
packages/loot-core/src/server/rules/action.ts
packages/loot-core/src/server/rules/customFunctions.ts
- Minor: pass
prefs.defaultCurrencyCodeto any amount-formatting calls.
Tests to add/update:
packages/loot-core/src/server/importers/__tests__/ynab5.test.ts
- Add test: importing a JPY account produces integer amounts without the ×100 inflation
(i.e., ¥1234 stays as integer
1234, not123400). - Add test: importing an IRR account (0dp) produces integer amounts without the ×100 inflation. Note: KWD (3dp) is not yet in the currency list — add a KWD test when KWD lands in Section 7.5.
packages/loot-core/src/server/accounts/__tests__/app.test.ts
- Add test: budget with
defaultCurrencyCode = 'JPY', starting balance 50000 stores50000(not5000000).
Release notes: Create an entry in upcoming-release-notes/ for server-side decimal fixes
(YNAB import, bank sync, API amounts). Use only a description slug in the filename (no number prefix).
PR description to write:
Threads decimal-aware amount encoding/decoding through all loot-core server paths. Importers, rule evaluation, budget templates, sync, and the public API now use each currency's
decimalPlacesviaamountToCurrencyIntegerandintegerToCurrencyWithDecimalinstead of assuming 2dp everywhere. Currency is resolved from the import format or fromprefs.defaultCurrencyCode; no behavior change for existing 2dp budgets.Depends on: feat/currency-decimal-core Part of: #3255, #5748
Status: Implemented locally on bookmark feat/currency-decimal-transactions (stacked on feat/currency-decimal-server). All five gates green: lint + typecheck + unit + functional e2e (117) + VRT (117, in-container via act, 48/37/32 per shard). Not yet pushed / no PR.
Branch name: feat/currency-decimal-transactions
Depends on: DP-1 merged
Size estimate: ~350 lines across 7 files + ~150 lines of tests
Implementation notes (2026-06-14) — corrections to the spec below, the spec was written against an assumed API that does not match the current code:
parseCurrencyAmount/formatCurrencyAmountdo not exist intable/utils.ts. The real amount chokepoints areserializeTransaction/deserializeTransaction; both gained an optionalcurrencyparam using DP-1'sintegerToCurrencyWithDecimal(int, currency)andamountToCurrencyInteger(amount, currency).getCurrencyPrecisionMultiplierwas never built. DP-1 shippedgetDecimalPlaces+amountToCurrencyInteger; use those.- Amount search lives in
queries/index.ts(transactionsSearch), notuseTransactionsSearch(which only forwards to it).transactionsSearchgained acurrencyparam (encodes viaamountToCurrencyIntegerand divides by10^getDecimalPlaces(currency)instead of hardcoded100).useTransactionsSearchgained an optionalcurrencyparam (defaults''; mobile callers untouched — they are DP-5).accounts/mutations.tshas no amount-encoding code — nothing to change there (spec was wrong).useFormulaExecution.tsdeferred — itsintegerToAmount(x, 2)calls are formula-result display, the hook has no prefs access, and it is niche. Left as a follow-up rather than plumbing prefs into it.TransactionsTable.tsx: currency is read viauseSyncedPref('defaultCurrencyCode')in theTransactionandTransactionErrorleaf components (not prop-drilled, because the table is virtualized).Account.tsxreadsdefaultCurrencyCodeand passes it totransactionsSearch. MC-2 swaps the leaf reads toaccount.currency.- Tests landed in
table/utils.test.ts(new) covering serialize/deserialize round-trips for USD/JPY/'' — not the spec'sparseCurrencyAmounttests.
Motivation:
The transaction table is where users type amounts. Currently, typing 1234 in a JPY account
saves 123400 (100× inflation). This PR fixes input parsing and display formatting throughout
the transaction editing and search flow.
Currency resolution rule (applies to every file in this PR):
account.currency does not exist until MC-2. At DP-3 time, resolve currency using:
- Read
prefs.defaultCurrencyCodefrom the budget preferences. - When
defaultCurrencyCodeis''(None/unset),getDecimalPlacesreturns2— correct fallback, no special case. - Never fall back to
'USD'. Once MC-2 lands, these sites will be updated to useaccount.currencyinstead.
Files to change:
packages/desktop-client/src/components/transactions/table/utils.ts
parseCurrencyAmount(str, currency): replaceparseFloat(str) * 100withamountToCurrencyInteger(parseFloat(str), currency).formatCurrencyAmount(integer, currency): replaceinteger / 100withintegerToCurrencyWithDecimal(integer, currency).- These two functions are the single chokepoint for all amount I/O in the table. Getting them right here fixes the rest of the table for free.
packages/desktop-client/src/components/transactions/TransactionsTable.tsx
- Pass the resolved currency (from
prefs.defaultCurrencyCode) down to the amount cell renderer and editor wherever it currently threads formatting/parsing calls. - Note:
account.currencywill replace this in MC-2; structure the prop to make that swap easy.
packages/desktop-client/src/components/accounts/Account.tsx
- Read
prefs.defaultCurrencyCodeand pass ascurrencyprop toTransactionsTable.
packages/desktop-client/src/hooks/useTransactionsSearch.ts
- Amount search comparisons (searching by amount like
>50) must parse the query usinggetCurrencyPrecisionMultiplier(prefs.defaultCurrencyCode).
packages/desktop-client/src/hooks/useFormulaExecution.ts
- Formula evaluation involving amounts must encode/decode using
prefs.defaultCurrencyCode.
packages/desktop-client/src/accounts/mutations.ts
addTransaction,updateTransaction: encode amounts withamountToCurrencyInteger(amount, prefs.defaultCurrencyCode)before sending to loot-core.
packages/desktop-client/src/queries/index.ts
- Any query helper that formats an amount for display (e.g., running balance display) must
use
integerToCurrencyWithDecimal(amount, prefs.defaultCurrencyCode).
Tests to add:
packages/desktop-client/src/components/transactions/table/utils.test.ts
describe('parseCurrencyAmount round-trip')
- USD: parseCurrencyAmount('12.34', 'USD') → 1234
- JPY: parseCurrencyAmount('1234', 'JPY') → 1234 (not 123400)
- KWD: parseCurrencyAmount('1.234', 'KWD') → 1234
- BTC: parseCurrencyAmount('0.00001234', 'BTC') → 1234
- Round-trip: formatCurrencyAmount(parseCurrencyAmount(x, c), c) === x for each currency
Release notes: Create an entry in upcoming-release-notes/ for transaction table fixes
(correct entry/display for JPY, VND, KWD accounts). Use only a description slug in the filename (no number prefix).
PR description to write:
Fixes amount input parsing and display formatting in the transaction table for non-2dp currencies. JPY, KRW, VND amounts now store the correct integer without ×100 inflation. KWD, BHD, OMR amounts now store 3dp correctly. Adds round-trip tests for 0dp, 2dp, 3dp, and 8dp currencies. Currency is resolved from
prefs.defaultCurrencyCode; per-account currency will be wired in a subsequent MC-series PR.Depends on: feat/currency-decimal-core Fixes: #325, #3255
Status: Implemented locally on bookmark feat/currency-decimal-budget-ui (stacked on DP-3). All five gates green: lint + typecheck + unit + functional e2e (117) + VRT (117, in-container via act, 48/37/32 per shard). Not yet pushed / no PR.
Branch name: feat/currency-decimal-budget-ui
Depends on: DP-1, DP-3 merged
Size estimate: ~200 lines across 5 files
Implementation notes (2026-06-14) — only 3 of the 5 listed files actually needed changes:
CoverMenu.tsx,EnvelopeBudgetMenuModal.tsx,TrackingBudgetMenuModal.tsx: threaded budget currency viauseSyncedPref('defaultCurrencyCode'), parse withamountToCurrencyInteger, display/value withintegerToCurrencyWithDecimal/integerToCurrencyAmount.BalanceWithCarryover.tsx— no change needed. It already displays via theuseFormat()hook, which is already currency-aware (readsdefaultCurrencyCode, usesactiveCurrency.decimalPlaces).budget/util.ts— no change needed.makeBalanceAmountStyleonly uses the value for sign/zero color thresholds, which are scale-invariant; threading currency there is a no-op.- General finding: the shared
useFormat()hook is the canonical, already-currency-aware display path. The real DP gaps are the input/parse sites that bypass it with rawamountToInteger/integerToAmount.
Motivation:
Budget cell inputs (the fields where you type a budgeted amount for a category) use the same
÷100 assumption. This PR fixes the budget UI components to use the budget base currency's
decimalPlaces.
Important: The budget always operates in the budget base currency. These components do not need to know about per-account currencies. They only need to know the budget's single base currency.
Files to change:
packages/desktop-client/src/components/budget/envelope/CoverMenu.tsx
- The "Cover from another category" amount input: parse and format using budget base currency.
packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx
- The carryover balance display: format using budget base currency's
decimalPlaces.
packages/desktop-client/src/components/budget/util.ts
- Any amount formatting helpers in this file: thread budget currency through.
packages/desktop-client/src/components/modals/EnvelopeBudgetMenuModal.tsx
packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx
- Budget amount inputs in these modals: use budget base currency for parsing/formatting.
How to get the budget currency in these components:
These components should read prefs.defaultCurrencyCode (the existing pref that stores the
budget's display currency). When the pref is '' (the default "None" state), getDecimalPlaces
correctly returns 2 — so the behavior is identical to today for all existing budgets.
Do NOT fall back to 'USD'; '' is the correct and intentional no-currency-selected state.
Release notes: Create an entry in upcoming-release-notes/ for budget cell decimal fixes (correct input/display for zero-decimal and 3-decimal budget currencies). Use only a description slug in the filename (no number prefix). Category: Bugfixes.
PR description to write:
Applies decimal-aware formatting and parsing to budget cell inputs and balance displays. Budgeted amounts for zero-decimal or 3-decimal base currencies now store and display correctly. No change in behavior for 2dp budget currencies (USD, EUR, GBP, etc.).
Depends on: feat/currency-decimal-core, feat/currency-decimal-transactions
Status: Implemented locally on bookmark feat/currency-decimal-ui (stacked on DP-4). All five gates green: lint + typecheck + unit + functional e2e (117) + VRT (117, in-container via act, 48/37/32 per shard). Not yet pushed / no PR.
Branch name: feat/currency-decimal-ui
Implementation notes (2026-06-14):
- Display sites moved to the currency-aware
useFormat()('financial')instead of rawintegerToCurrency:mobile/TransactionList.tsx,mobile/TransactionListItem.tsx,CloseAccountModal.tsx,autocomplete/CategoryAutocomplete.tsx(inCategoryItem),accounts/BalanceHistoryGraph.tsx(note: that file already imports date-fnsformat, so the hook was bound toformatValueto avoid a name collision).EditFieldModal.tsxusesuseFormat().fromEditfor the string case andamountToCurrencyInteger(value, format.currency.code)for the number case (per the suggestion to reuseforEdit/fromEdit).ImportTransactionsModal.tsxencodes parsed file amounts withamountToCurrencyInteger(amount, prefs.defaultCurrencyCode)(import currency mapping is MC-10).FocusableAmountInput.tsxgained acurrencyprop and computesdecimalPlacesfor both display and live typing. The sharedappendDecimals(amountText, hideDecimals)was generalized toappendDecimals(amountText, decimalPlaces)inloot-core/src/shared/util.ts(single caller) so 0dp currencies type whole numbers.mobile/TransactionEdit.tsx: its localserializeTransaction/deserializeTransactiongained acurrencyparam;ChildTransactionEdit,Footer,TransactionEditInner, andTransactionEditUnconnectedeach readdefaultCurrencyCodeand thread it (incl. into the currency-awareutil/AmountInput, which already usesforEdit/fromEditinternally).CreateLocalAccountModal.tsx— no change needed. It sends a decimalbalanceviatoRelaxedNumber; the server'screateAccount(DP-2) already encodes it currency-aware. (The currency picker is MC-2.)settings/Currency.tsx— no change needed. No amount previews;useFormatis already currency-aware.- E2E: the spec's JPY/VND/KWD tests are not all possible yet — VND and KWD are not in the currency list until DP-6 / §1.5, so they fall back to 2dp and cannot be tested. JPY (0dp) correctness is covered by the new
table/utils.test.tsround-trip unit tests (1234 → 1234, not123400). No new browser e2e was added: it requires driving the custom currency<Select>plus a full account+transaction flow (flaky), for marginal gain over the unit coverage. The full functional e2e suite (117) passes with no regression. Add the JPY/VND/KWD account e2e alongside the §1.5 currency PRs after DP-6, when those codes exist.
Depends on: DP-1, DP-3, DP-4 merged
Size estimate: ~450 lines across 11 files + E2E tests
Motivation: The remaining UI surfaces — account creation, close account, import modal, mobile transaction entry, and settings — all have the same 2dp hardcoding. This PR is the final cleanup pass and includes E2E tests that verify the fix end-to-end for JPY and VND.
⚠️ This PR is borderline on the 500-line limit. If maintainers want it split further, divide into DP-5a (mobile:FocusableAmountInput,TransactionEdit,TransactionList,TransactionListItem) and DP-5b (modals + settings + E2E). The mobile amount input (FocusableAmountInput.tsx) has the largest single-file delta (+4.15 kB per bundle stats) and benefits from focused review.
Files to change:
packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx
- The numeric keyboard input for mobile amount entry. Replace hardcoded 2dp logic with currency-aware parsing. This is the largest change in this PR.
- The component must receive a
currencyprop. At DP-5 time, passprefs.defaultCurrencyCode(MC-2 will later switch this toaccount.currency— structure the prop name to make the swap transparent).
packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx
- Pass
prefs.defaultCurrencyCodeascurrencytoFocusableAmountInput.
packages/desktop-client/src/components/mobile/transactions/TransactionList.tsx
packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx
- Format displayed amounts using
prefs.defaultCurrencyCode.
packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx
- Starting balance input: parse using the selected currency's
decimalPlaces. (The currency selector is added in MC-2; for now, use the budget base currency as default.)
packages/desktop-client/src/components/modals/EditFieldModal.tsx
- Amount field editing in the bulk-edit modal: use account currency.
packages/desktop-client/src/components/modals/CloseAccountModal.tsx
- The balance displayed when closing an account: format using account currency.
packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx
- Parsed import amounts must use the target account's currency multiplier, not always ×100.
packages/desktop-client/src/components/settings/Currency.tsx
- The currency settings/display section: ensure any amount previews use correct decimal places.
packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx
- Amount hints shown in autocomplete: format with budget base currency.
packages/desktop-client/src/components/accounts/BalanceHistoryGraph.tsx
- Y-axis labels: format with account currency's decimal places.
E2E tests to add/update:
packages/desktop-client/e2e/accounts.test.ts
describe('zero-decimal currency accounts')
test('JPY account: starting balance 50000 displays as ¥50,000 not ¥500.00')
test('JPY account: creating a transaction of 1500 stores 1500 not 150000')
test('VND account: starting balance 1000000 displays as ₫1,000,000')
test('close account modal shows correct balance for JPY account')
describe('three-decimal currency accounts')
test('KWD account: transaction of 1.500 displays as KD 1.500 not KD 1.50')
test('KWD starting balance round-trip: enter 10.500, read back 10.500')
Also update any existing E2E snapshots that capture amount text (they will have changed for the accounts that were previously wrong).
Release notes: Create an entry in upcoming-release-notes/ covering the full DP series
(DP-1 through DP-5): fixed decimal display and entry for zero-decimal currencies (JPY, VND,
KRW) and 3-decimal currencies (KWD, BHD). Use only a description slug in the filename (no number prefix).
PR description to write:
Final pass of decimal-precision fixes across mobile transaction entry, account modals, import modal, and settings. Currency is resolved from
prefs.defaultCurrencyCode; per-account currency will be wired in a subsequent MC-series PR. Adds E2E test coverage for zero-decimal (JPY, VND) and three-decimal (KWD) account flows. Updates snapshots.Depends on: feat/currency-decimal-core, feat/currency-decimal-transactions, feat/currency-decimal-budget-ui Fixes: #5748, remaining parts of #3255
Branch name: feat/currency-decimal-migration
Depends on: DP-1 through DP-5 merged, and resizable columns merged (see note below)
Size estimate: ~100 lines (migration logic + tests)
Blocking dependency — resizable columns: Once 0dp currencies display correctly (no decimal places), large-valued currencies like JPY and CLP produce wide number strings (e.g. ¥1,500,000) that overflow fixed-width amount columns. DP-6 must not ship until users can resize columns to accommodate these values. Resizable columns are a separate PR outside this chain; track its status and add it as a hard dependency before opening DP-6 for review.
Motivation:
The DP series fixes encoding for NEW entries. But users who already have a budget with
defaultCurrencyCode set to a zero-decimal currency (e.g., JPY, KRW) have existing transactions
stored at the wrong precision — e.g., ¥1000 stored as integer 100000 (×100 inflation). Once
the display code from DP-3/DP-5 lands and divides by 10^decimalPlaces (÷1 for JPY), those
values display as 100000 instead of 1000. This PR corrects the stored values so old and new
data align.
This PR also corrects the decimalPlaces value for CLP (currently 2, should be 0) and any
other existing currency entries with wrong values. These metadata corrections could not be made
earlier because they change the encoding factor used by the display code — making them atomically
with the data migration ensures old and new data are always in sync.
Scope:
Only budgets with a non-2dp defaultCurrencyCode are affected. Budgets with '' or any 2dp
currency are untouched. This PR runs only when the budget's defaultCurrencyCode is a currency
whose getDecimalPlaces returns something other than 2.
Currency metadata corrections included in this PR:
packages/loot-core/src/shared/currencies.ts
CLP(Chilean Peso): changedecimalPlacesfrom2to0.- Audit all other existing entries for correctness; fix any others found wrong.
- Do NOT add new currencies — those are Section 7.5 PRs.
Migration logic (runs once on budget open after DP-6 migration file is applied):
// Pseudocode — implement as a loot-core migration
const currency = prefs.defaultCurrencyCode;
const dp = getDecimalPlaces(currency);
if (dp === 2) return; // no-op for all standard budgets
// General formula: scale = getCurrencyPrecisionMultiplier(currency) / 100
// 0dp: scale = 1/100 (÷100 — data was stored ×100, target is ×1)
// 3dp: scale = 10 (×10 — data was stored ×100, target is ×1000)
// 8dp: scale = 1000000 (data was stored ×100, target is ×100000000)
// This is a one-way migration. Run only if a migration-version sentinel is not set.
const scale = Math.pow(10, dp) / 100;
db.exec(`UPDATE transactions SET amount = ROUND(amount * ?)`, [scale]);
db.exec(`UPDATE accounts SET balance_current = ROUND(balance_current * ?),
balance_available = ROUND(balance_available * ?),
balance_limit = ROUND(balance_limit * ?)`, [scale, scale, scale]);
// Mark migration as complete to prevent re-running
db.exec(`INSERT OR REPLACE INTO kv_meta (key, value) VALUES ('decimal_migration_done', '1')`);Important constraints:
- Run only once, gated on
kv_metasentinel. - Only run when
dp !== 2(non-2dp budget currency). - Does NOT migrate per-account currency data — at this point
account.currencydoesn't exist. - Does NOT attempt to handle budgets where different accounts might have been in different currencies historically (that's a post-MC-2 problem, and pre-MC-2 all accounts share the budget's single currency).
- Must run before any UI is shown (in the migration pipeline, not lazily).
Tests to add:
test: migration no-op when defaultCurrencyCode is '' (None)
test: migration no-op when defaultCurrencyCode is 'USD'
test: JPY budget: transaction amount 100000 → 1000 after migration
test: IRR budget: transaction amount 100000 → 1000 after migration (also 0dp)
test: migration sentinel prevents double-run
test: 2dp budgets after migration: amounts unchanged
test: CLP metadata correction: getDecimalPlaces('CLP') returns 0 after this PR
Release notes: Create an entry in upcoming-release-notes/ describing the one-time
migration that corrects historical transaction amounts for existing 0dp-currency budgets, and
the CLP metadata fix. Use only a description slug in the filename (no number prefix).
PR description to write:
One-time migration that corrects historically wrong transaction amounts for budgets whose
defaultCurrencyCodeis a zero-decimal currency (JPY, KRW, IRR). Previous code stored all amounts at ×100 regardless of currency — this migration rescales stored integers to match the currency's actualdecimalPlaces. Also corrects thedecimalPlacesmetadata for CLP (was 2, should be 0) — metadata and data are corrected atomically so they remain consistent. All 2dp budgets (USD, EUR, GBP, etc.) are completely unaffected. A sentinel key prevents the migration from running more than once.Depends on: feat/currency-decimal-ui (all display fixes must land first)
Branch name: feat/remove-hide-fraction-setting
Depends on: DP-6 merged
Size estimate: ~50 lines (deletion + tests)
Motivation: The "hide decimal places" setting (PR #725) was added as a workaround for currencies with no decimal places (JPY, KRW, IRR) that produced awkward display like "¥1,500.00". With the full DP series in place, those currencies format correctly at 0dp without any user-facing toggle — the workaround is no longer needed. Removing it simplifies the settings UI and eliminates a source of confusion (a JPY user should never need to manually hide fractions; the currency metadata handles it).
What to remove:
packages/desktop-client/src/components/settings/ (the hide-fraction toggle UI)
- Remove the "Hide decimal places" toggle from the formatting settings section.
- Remove any settings key that stores the
hideFractionpreference.
packages/loot-core/src/types/prefs.ts
- Remove
hideFraction(or equivalent) from the prefs type.
packages/loot-core/src/shared/util.ts
- Remove the
hideFractionpath fromgetNumberFormatand related formatters. - The formatter should always use the currency's
decimalPlaces— never a global "hide" flag.
All call sites that pass hideFraction: true — remove the flag and verify the formatter
output is unchanged for the affected currencies (it will be, since they now have decimalPlaces: 0).
Important: Audit for any place that reads hideFraction from prefs and removes the
decimal display. If any such place exists outside the formatter (e.g., in report formatting or
export), clean it up in this PR.
Tests to update:
- Remove tests that exercise the
hideFractionpath. - Confirm that JPY/KRW/IRR formatting tests pass using
decimalPlaces: 0alone, with nohideFractionflag.
Release notes: Use only a description slug in the filename (no number prefix).
Category: Maintenance.
PR description to write:
Removes the "hide decimal places" formatting setting, which was added in PR #725 as a workaround for zero-decimal currencies (JPY, KRW, IRR) displaying with unwanted ".00" suffixes. With decimal precision now driven by per-currency metadata (feat/currency-decimal-core through feat/currency-decimal-migration), zero-decimal currencies format correctly without any user toggle. Removes
hideFractionfrom prefs, settings UI, and the number formatter.Depends on: feat/currency-decimal-migration
Branch name: feat/currency-change-rescale
Depends on: DP-6 merged (reuses its rescale helper + kv_meta/sentinel pattern). Also touches the currency selector in settings/Currency.tsx (MC-1).
Size estimate: ~200 lines (rescale-on-change handler + confirmation modal + tests)
Motivation:
DP-6 is a one-time historical migration. But once amounts are stored at the correct per-currency
precision, changing defaultCurrencyCode to a currency with a different number of decimal places
invalidates every stored integer. Example: a budget in USD (2dp) stores $12.34 as 1234. If the
user switches the budget currency to JPY (0dp), the display code now divides by 10^0, so 1234
renders as ¥1,234 instead of ¥12. Every transaction amount and account balance must be rescaled
by 10^newDp / 10^oldDp at the moment of the change so old data stays consistent with the new
precision.
Why a warning + confirmation (not a block):
The rescale rewrites every transactions.amount and the account balance fields. On a large budget
that is a big batch of CRDT messages — a potentially heavy sync burst, and (when moving to fewer
decimals) a lossy, rounding operation that cannot be perfectly reversed. This is a legitimate
thing a user may want to do, so it must not be prevented — but it must be surfaced with a clear
confirmation dialog before it runs.
Behavior:
- User changes the Default Currency in
settings/Currency.tsx. - Compute
oldDp = getDecimalPlaces(previousCode)andnewDp = getDecimalPlaces(nextCode). - If
oldDp === newDp(e.g. USD→EUR, both 2dp): apply the change with no rescale and no warning. - If
oldDp !== newDp: open a confirmation modal before persisting the new currency. The modal must state:- That all existing transaction amounts and account balances will be rescaled to the new precision.
- The approximate scope ("N transactions and M accounts will be updated") so the user understands the sync cost.
- When
newDp < oldDp(downscale): an explicit warning that amounts will be rounded and precision permanently lost (e.g.$12.34→¥12). - That the change syncs to all devices and may take a moment on large budgets.
- Actions: Cancel (abort, currency unchanged) and Change currency and rescale (proceed).
- On confirm: persist
defaultCurrencyCode = nextCodeand run the rescale in a single logical operation (so a client that sees the new currency never sees un-rescaled amounts).
Files to change:
packages/loot-core/src/server/ — a server handler, e.g. rescaleAmountsForCurrencyChange({ fromCode, toCode }):
- Reuse DP-6's rescale routine.
scale = 10^newDp / 10^oldDp. UPDATE transactions SET amount = ROUND(amount * ?).UPDATE accounts SET balance_current = ROUND(... * ?), balance_available = ROUND(... * ?), balance_limit = ROUND(... * ?).- Must go through the normal mutation/sync layer so changes are CRDT-tracked (this is the source of the sync burst — it is intentional and correct, not a migration-table bypass).
- Returns counts (
{ transactions, accounts }) for the modal's scope message (a count query can be run before showing the modal). - Decide and document foreign-currency-account handling: pre-MC-2 all accounts share the budget currency, so rescale everything. Post-MC-2, only base-currency amounts (
amount) are rescaled;transaction_amount(native) is not rescaled. Gate accordingly so this PR stays correct after MC-2 lands.
packages/desktop-client/src/components/settings/Currency.tsx
- Intercept the currency
onChange: whenoldDp !== newDp, open the confirmation modal instead of writing the pref directly. Only writedefaultCurrencyCode+ trigger the rescale on confirm.
packages/desktop-client/src/components/modals/ (new modal, e.g. CurrencyPrecisionChangeModal.tsx)
- The confirmation dialog described above. All strings via i18n (
Trans).
Tests to add:
test: USD→EUR (2dp→2dp) — no rescale, no modal
test: USD→JPY (2dp→0dp) — modal shown; on confirm, amount 1234 → 12; downscale warning present
test: JPY→USD (0dp→2dp) — modal shown; on confirm, amount 12 → 1200 (no loss)
test: cancel leaves defaultCurrencyCode and all amounts unchanged
test: rescale updates transactions AND account balance fields
test: scope counts reported to the modal match the number of rows updated
Release notes: Category: Enhancements. Describe the confirmation + rescale when changing the budget currency to a different precision.
PR description to write:
When the budget's
defaultCurrencyCodeis changed to a currency with a different decimal precision, all stored transaction amounts and account balances are rescaled to the new precision so existing data stays correct. Because this rewrites every amount (a large sync batch, and a lossy rounding operation when moving to fewer decimals), it is gated behind a confirmation dialog that explains the scope and the precision-loss risk — but it is never blocked. No rescale and no prompt when the precision is unchanged (e.g. USD↔EUR).Depends on: feat/currency-decimal-migration
When to open these PRs: Only after DP-6 is merged and live. The full DP pipeline must exist so that: (a)
getDecimalPlacesis wired through all display and encoding paths, and (b) the migration has already run for any user who previously had data under the currency's code. Once DP-6 is merged, each new currency is safe to add because the display stack is already decimal-aware.One PR per currency. Each PR is a single-line addition to the
currenciesarray incurrencies.ts, plus a release notes file. No other changes. PRs can be opened in parallel — they are independent of each other.
Template for each currency PR:
Files to change: packages/loot-core/src/shared/currencies.ts
- Add one entry to the
currenciesarray with correctcode,name,symbol,decimalPlaces,numberFormat, andsymbolFirstfields. - Consult CLDR (https://www.localeplanet.com/icu/decimal-symbols.html) and https://www.localeplanet.com/api/auto/currencymap.html for authoritative values.
Release notes: Category: Features.
PR description template:
Adds [Currency Name] ([code]) to the currency list with correct decimal precision ([N]dp) and number formatting. No behavior change for existing budgets.
Depends on: feat/currency-decimal-migration
Currencies to add (in suggested PR order, prioritized by user demand):
| Code | Name | dp | Notes |
|---|---|---|---|
| VND | Vietnamese Dong | 0 | Widely requested; was subject of live bug reports |
| KWD | Kuwaiti Dinar | 3 | First 3dp currency; enables 3dp test coverage |
| BHD | Bahraini Dinar | 3 | |
| OMR | Omani Rial | 3 | |
| BTC | Bitcoin | 8 | First 8dp currency; enables 8dp test coverage |
| IQD | Iraqi Dinar | 3 | |
| JOD | Jordanian Dinar | 3 | |
| TND | Tunisian Dinar | 3 | |
| LYD | Libyan Dinar | 3 | |
| ISK | Icelandic Króna | 0 | |
| BIF | Burundian Franc | 0 | |
| DJF | Djiboutian Franc | 0 | |
| GNF | Guinean Franc | 0 | |
| KMF | Comorian Franc | 0 | |
| MGA | Malagasy Ariary | 0 | |
| PYG | Paraguayan Guaraní | 0 | |
| RWF | Rwandan Franc | 0 | |
| UGX | Ugandan Shilling | 0 | |
| UYI | Uruguay Peso Indexed | 0 | |
| VUV | Vanuatu Vatu | 0 | |
| XAF | Central African CFA Franc | 0 | |
| XOF | West African CFA Franc | 0 | |
| XPF | CFP Franc | 0 |
After KWD lands, update tests in currencies.test.ts and util.test.ts to add 3dp coverage.
After BTC lands, add 8dp test coverage.
With decimal precision merged, the multi-currency feature work begins. The ordering here is strict: each PR builds directly on the previous.
Note: CLEAN-1/CLEAN-2 (currency formatting rename + BTC formatter) are cleanup/enhancement with no functional dependencies. It is deliberately deferred to Section 6 so it does not block or complicate review of the functional work here.
Branch name: feat/default-currency-code-wiring
Depends on: DP-1 merged
Source: Commit b7728a3 from PR #7450 (NumberFormat type fixes); existing pref infrastructure
Size estimate: ~100 lines
Motivation:
The defaultCurrencyCode pref already exists and is the canonical way Actual stores the
budget's display/base currency. The current value of '' (empty string) means "None" — no
symbol is shown, and 2dp is assumed (matching the first entry in the currency map:
{ code: '', symbol: '', name: 'None', decimalPlaces: 2 }). This PR does not introduce
a new pref — it audits that defaultCurrencyCode is correctly plumbed into all the places
the multi-currency stack needs to read it, and fixes any NumberFormat type issues from commit
b7728a3.
Critical rule — never default to 'USD':
Every call site that reads defaultCurrencyCode must treat '' as a valid, meaningful value.
It is not an error state or an unset state — it is the intentional "no specific currency"
selection, which resolves to 2dp with no symbol. getCurrency('') already returns the correct
None entry. Do not add any code that maps '' → 'USD'.
Multi-currency gating — follows PR #7450 settings pattern:
All multi-currency features (foreign-currency account creation, FX conversion, exchange rate
lookup) must be gated on defaultCurrencyCode !== ''. When defaultCurrencyCode is '',
the app behaves exactly as it does today: single-currency, no FX, no conversion. This gate
exists because FX conversion between two '' accounts is undefined — there is no base currency
to convert to. Enabling FX features without a declared base currency would produce silent
corruption.
The gate should be implemented as a shared utility (e.g., isMultiCurrencyEnabled(prefs)),
used consistently across:
- The "Account currency" picker in CreateLocalAccountModal (hidden or disabled when
'') - Any FX rate lookup call
- The Exchange Rates page (hidden in nav when not enabled)
- The per-account currency badge in the sidebar
The settings UI should make this clear: a call-to-action prompt that says "Set a budget
currency in Settings to enable multi-currency accounts" should appear in the relevant places
when defaultCurrencyCode === ''.
Files to change:
packages/loot-core/src/types/prefs.ts
- Confirm
defaultCurrencyCode: stringis present and typed correctly. - Fix any
NumberFormattype issues identified in commitb7728a3(incorrect type widening or missing union members for locale format options).
packages/desktop-client/src/components/settings/Currency.tsx
- Confirm the existing currency selector correctly reads/writes
defaultCurrencyCode. - The selector must include
''as a first option (displayed as "None — no symbol, 2 decimal places") and this must be the default for new budgets that have never set a currency. - Do NOT pre-select
'USD'for users who have never set a currency.
packages/desktop-client/src/ (audit all call sites)
- Search for any code that reads
defaultCurrencyCode(or its selector equivalent) and adds a|| 'USD'or?? 'USD'fallback. Remove all such fallbacks —''is correct. - Search for any
getCurrencycalls that pass a hardcoded'USD'wheredefaultCurrencyCodeshould be passed instead.
Budget creation flow:
- When creating a new budget, do not pre-set
defaultCurrencyCode. Leave it as''. - The user may optionally choose a currency in Settings at any time.
- The app functions correctly in the
''state for all existing features.
Release notes: Create an entry in upcoming-release-notes/ for the defaultCurrencyCode hardening and isMultiCurrencyEnabled gate. Use only a description slug in the filename (no number prefix). Category: Maintenance.
PR description to write:
Audits and hardens the existing
defaultCurrencyCodepref as the budget's base currency. Removes any|| 'USD'fallbacks — the correct unset value is''(None), which maps to 2dp with no symbol. IntroducesisMultiCurrencyEnabled(prefs)gate: all FX and foreign- account features are disabled whendefaultCurrencyCode === ''. Fixes NumberFormat type issues from PR #7450 commit b7728a3. No behavior change for budgets that have set a specific currency code.Depends on: feat/currency-decimal-core
Branch name: feat/account-currency-field
Depends on: MC-1 merged
Source: Commit b774880 from PR #7450
Size estimate: ~250 lines
Motivation: Accounts currently have no currency field. This PR adds it, migrates the DB, and updates the account creation UI to let users choose a currency when creating an account.
Forward-compat note for DP-8 (currency-change rescale): Before this PR, all accounts share the budget currency, so DP-8 rescales every stored amount when the budget's precision changes. Once accounts can have their own currency (this PR) and store a native amount (MC-4), DP-8 must rescale only the base-currency
amountwhen the budget currency changes — it must never touch a foreign account's nativetransaction_amount/transaction_currency, since those are frozen at the precision of their own currency. When implementing or reviewing this PR (and MC-4), confirm DP-8's rescale handler is gated to base-currency amounts only, or open a follow-up to fix it before MC-4 lands.
Files to change:
packages/loot-core/src/types/models/account.ts
- Add
currency: stringtoAccountEntity.
packages/loot-core/src/server/accounts/app.ts
createAccount: accept and persistcurrencyfield; default toprefs.defaultCurrencyCode.getAccounts: includecurrencyin the returned fields.- Do not allow
currencyto be changed after account creation (enforce in server handler with a clear error if attempted).
DB migration (new migration file in packages/loot-core/src/server/migrations/)
ALTER TABLE accounts ADD COLUMN currency TEXT NOT NULL DEFAULT ''- The default
''(None) is correct: existing accounts had no explicit currency, which means they operate in the same "no symbol, 2dp" mode as the budget'sdefaultCurrencyCode = ''default. Do NOT useDEFAULT 'USD'— that would incorrectly assert that all existing accounts are USD when they may not be. - After migration, a one-time prompt can invite users to set their account currencies if they wish, but the app must work correctly without them ever doing so.
packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx
- Add a
<Select>currency picker, positioned after the account name field. - Default:
prefs.defaultCurrencyCode(which may be''— display this as "Same as budget"). - Label: "Account currency" with a note: "Cannot be changed after account creation."
- Use the full ISO 4217 + crypto list from
currencies.ts, with''(None/Same as budget) as the first option.
packages/desktop-client/src/components/sidebar/ (account list)
- Display the currency code next to accounts whose
currencydiffers fromdefaultCurrencyCodeAND is not''. Accounts in the budget currency (or'') show no badge.
packages/loot-core/src/server/api.ts
- Expose
currencyin the public API's account response type.
CRDT schema registration (included in this PR):
packages/crdt/ or packages/loot-core/src/server/crdt/
- Register
accounts.currencyin the CRDT table schema. - Increment schema version.
- Backward-compatible: older clients that sync with this budget ignore the new field.
Tests to add:
test: createAccount with 'EUR' currency stores 'EUR' in DB
test: createAccount without currency field defaults to defaultCurrencyCode pref value
test: createAccount when defaultCurrencyCode is '' stores '' in DB (not 'USD')
test: API getAccounts response includes currency field
Release notes: Create an entry in upcoming-release-notes/ for the account currency field
and sidebar badge. Use only a description slug in the filename (no number prefix).
PR description to write:
Adds a
currencyfield to accounts with a DB migration. Existing accounts default to''(None — same as the budget's unset currency state). Adds a currency picker to the Create Account modal, gated onisMultiCurrencyEnabled(prefs)— the picker is hidden when nodefaultCurrencyCodehas been set. Foreign-currency accounts display a currency badge in the sidebar. Includes CRDT schema registration foraccounts.currency.Depends on: feat/default-currency-code-wiring
Branch name: feat/exchange-rates-table
Depends on: MC-2 merged
Source: New work (partially inspired by tlesicka todo repo's FX Rate Page design)
Size estimate: ~350 lines
Motivation:
Historical exchange rates must exist before transactions can reference them. This PR adds
the storage layer and a manual entry UI first, so that MC-4 (which adds exchange_rate to
transactions) can look up rates from a table that already exists. No external API is used —
all rates are manually entered. Automatic rate fetching comes much later (OXR-1).
Files to change:
packages/loot-core/src/types/models/exchange-rate.ts (new file)
export type ExchangeRateEntity = {
id: string;
date: string; // 'YYYY-MM-DD'
base_currency: string; // the budget's defaultCurrencyCode (may be '' for None)
quote_currency: string; // the foreign account's currency code
rate: number; // decimal rate, e.g. 1.0823
source: 'manual' | 'oxr' | 'ecb' | string;
};packages/loot-core/src/types/models/index.ts
- Export
ExchangeRateEntity.
DB migration (new migration file)
CREATE TABLE exchange_rates (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
base_currency TEXT NOT NULL,
quote_currency TEXT NOT NULL,
rate REAL NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
UNIQUE (date, base_currency, quote_currency)
);
CREATE INDEX idx_exchange_rates_date_currencies
ON exchange_rates (date, base_currency, quote_currency);packages/loot-core/src/server/exchange-rates/ (new directory)
app.ts — server handlers:
getExchangeRate(date, baseCurrency, quoteCurrency): query the table; if exact date missing, return the most recent prior rate (rates don't expire, they're extended forward until a newer one is entered).addExchangeRate(entity): insert or replace.bulkImportExchangeRates(payload): parse JSON payload (see format below), insert or replace each rate. Returns{ imported: number, skipped: number }.deleteExchangeRate(id): remove.listExchangeRates(filters?): paginated list for the UI.
packages/loot-core/src/types/server-handlers.d.ts
- Add handler type declarations for the new exchange rate server functions.
packages/desktop-client/src/components/exchange-rates/ (new directory)
ExchangeRatesPage.tsx — the FX Rates management page:
- Listed in the sidebar navigation alongside Payees and Rules.
- Table columns: Date | Quote Currency | Rate | Source | Actions
- Rows sorted by date descending, with currency filter.
- "Add rate" button: opens a form with Date, Currency (selector), Rate fields.
- "Import JSON" button: opens a file picker or paste dialog for bulk import (see format below).
- Each row has an Edit and Delete action.
- Empty state: "No exchange rates entered yet. Add rates manually, import a JSON file, or configure automatic fetching in Settings."
ImportRatesModal.tsx (or inline dialog in ExchangeRatesPage.tsx):
- Accepts either a file upload (
.json) or a paste area. - Parses and previews: "Found N rates across D dates for currencies: EUR, JPY, ..."
- Shows any parse errors inline before committing.
- On confirm, calls
bulkImportExchangeRatesand shows result: "Imported N rates (S skipped — already existed with same or newer source)."
packages/desktop-client/src/components/sidebar/ navigation
- Add "Exchange Rates" nav item, visible only when at least one foreign-currency account exists (hide for single-currency budgets to reduce noise).
Bulk import JSON format:
Two accepted shapes — both borrow from the OXR API so users can download files from OXR directly without a subscription (OXR free tier allows manual downloads) and import them without ever configuring the provider in-app:
Single-date (OXR historical endpoint shape):
{
"timestamp": 1748217600,
"base": "USD",
"rates": {
"EUR": 0.9234,
"GBP": 0.7891,
"JPY": 148.52
}
}timestamp is a Unix timestamp; the date is derived as new Date(timestamp * 1000).toISOString().slice(0, 10).
base is the base currency code. All rates are quote-per-1-base.
Multi-date (OXR time-series endpoint shape):
{
"base": "USD",
"start_date": "2026-01-01",
"end_date": "2026-01-31",
"rates": {
"2026-01-01": { "EUR": 0.9234, "GBP": 0.7891 },
"2026-01-02": { "EUR": 0.9240, "GBP": 0.7895 }
}
}start_date/end_date are informational only — the actual dates come from the object keys.
Parsing rules:
- Accept either shape; detect by checking whether the top-level
ratesvalues are numbers (single-date) or objects (multi-date). basedefaults toprefs.defaultCurrencyCodeif absent (allows simplified user-created files).- Skip any currency pair that already exists for that date with
source !== 'manual'(don't overwrite auto-fetched rates with a manual import unless the user edits directly). - Mark imported rows as
source: 'manual'. - Unknown currency codes in the file: skip silently, include in
skippedcount.
Note on rate lookup fallback:
When a transaction is entered for a date with no stored rate, getExchangeRate returns
the most recent prior rate and sets exchange_rate_date to that prior date (not the
transaction date). A visual indicator on the transaction row (Phase MC-6) will flag
transactions with estimated rates that the user may want to correct.
CRDT schema registration (included in this PR):
packages/crdt/ or packages/loot-core/src/server/crdt/
- Register the
exchange_ratestable and all its columns in the CRDT schema. - Increment schema version.
- Backward-compatible: older clients ignore the new table.
Tests to add:
test: addExchangeRate stores rate correctly
test: getExchangeRate exact date match returns correct rate
test: getExchangeRate date with no entry returns most recent prior rate
test: getExchangeRate with no prior rates returns null
test: duplicate (date, base, quote) combination replaces existing
test: bulkImportExchangeRates single-date shape: derives date from timestamp, imports all rates
test: bulkImportExchangeRates multi-date shape: imports each date's rates correctly
test: bulkImportExchangeRates: skips unknown currency codes, reports in skipped count
test: bulkImportExchangeRates: does not overwrite existing non-manual rates
test: bulkImportExchangeRates: missing base defaults to defaultCurrencyCode
test: bulkImportExchangeRates: returns correct imported/skipped counts
Release notes: Create an entry in upcoming-release-notes/ for the Exchange Rates page
(manually enter, bulk-import from JSON, and view historical FX rates). Use only a description slug in the filename (no number prefix).
PR description to write:
Adds an exchange_rates table for storing historical FX rates. Adds a new Exchange Rates page in the sidebar for viewing, adding, editing, deleting, and bulk-importing rates, gated on
isMultiCurrencyEnabled(prefs). Bulk import accepts the OXR historical (single-date) and time-series (multi-date) JSON shapes, so users can populate rate history from manually downloaded OXR files without configuring any provider. No external API dependency — all rates are manually entered or imported. Rate lookup falls back to the most recent prior rate when an exact date is unavailable. Includes CRDT schema registration for the exchange_rates table.Depends on: feat/account-currency-field
Branch name: feat/transaction-dual-amount
Depends on: MC-3 merged
Source: Commit 1ec9ed1 from PR #7450 (first half — amount fields only)
Size estimate: ~300 lines
Motivation:
This is the double-entry core. Every foreign-currency transaction needs to store both the
native amount (what the account was charged) and the base-currency equivalent (what the
budget sees). These are two different numbers and both must be preserved. MC-3 lands first
so that getExchangeRate is available when addTransaction needs to look up a rate.
The fundamental invariant:
amount(existing field) always holds the base-currency integer consumed by the envelope engine. It is NEVERnull. For same-currency transactions,transaction_amountisnullandamountis authoritative. For foreign transactions,transaction_amountholds the native charge,transaction_currencyidentifies it, andamountis the converted value.
Files to change:
packages/loot-core/src/types/models/transaction.ts
- Add to
TransactionEntity:transaction_currency: string | null; // ISO code, null if same as budget transaction_amount: number | null; // native integer, null if same as budget exchange_rate: number | null; // rate used at time of entry (decimal, e.g. 1.0823) exchange_rate_date: string | null; // ISO date string, e.g. '2026-05-28'
- Existing
amount: numberfield: add a JSDoc comment:/** Always in budget base currency. */
DB migration (new migration file)
ALTER TABLE transactions ADD COLUMN transaction_currency TEXT;
ALTER TABLE transactions ADD COLUMN transaction_amount INTEGER;
ALTER TABLE transactions ADD COLUMN exchange_rate REAL;
ALTER TABLE transactions ADD COLUMN exchange_rate_date TEXT;All columns are nullable. Existing rows leave them null (treated as same-currency).
packages/loot-core/src/server/accounts/app.ts
addTransaction/importTransactions: iftransaction_currencyis present and differs fromaccount.currencyordefaultCurrencyCode:- Store
transaction_amountandtransaction_currencyas provided. - If
exchange_rateis also provided by the caller, use it directly. - Otherwise, call
getExchangeRate(transaction.date, defaultCurrencyCode, transaction_currency)(now available via MC-3). If no rate found, storeexchange_rate = nulland setamount = transaction_amountas a temporary placeholder; flag the transaction for review. - When
exchange_rateis known:amount = round(transaction_amount × exchange_rate). - Store
exchange_rate_dateas the date of the rate actually used (may differ fromtransaction.dateif the rate was a fallback to a prior date).
- Store
packages/loot-core/src/server/api.ts
- Include new fields in the public transaction API response and accept them on write.
packages/loot-core/src/shared/transactions.ts
- Update any transaction utility that constructs or validates transactions to allow the new nullable fields without throwing.
CRDT schema registration (included in this PR):
packages/crdt/ or packages/loot-core/src/server/crdt/
- Register
transactions.transaction_currency,transactions.transaction_amount,transactions.exchange_rate,transactions.exchange_rate_datein the CRDT schema. - Increment schema version.
- Backward-compatible: older clients ignore unknown fields.
Tests to add:
test: addTransaction with EUR amount on EUR account, '' budget currency:
- transaction_amount = EUR integer
- transaction_currency = 'EUR'
- amount = computed base-currency integer using stored rate
- exchange_rate stored
test: addTransaction where account.currency === defaultCurrencyCode (same currency):
- transaction_currency = null
- transaction_amount = null
- amount = as provided (unchanged behavior)
test: addTransaction where both account.currency and defaultCurrencyCode are '' (None):
- treated as same-currency, no conversion applied
- transaction_currency = null
test: addTransaction with no exchange rate in DB: amount = transaction_amount, rate = null
test: round-trip: import foreign transaction, read back, all fields present
Release notes: Create an entry in upcoming-release-notes/ for multi-currency transaction
storage (foreign transactions record native amount alongside budget-currency amount).
Use only a description slug in the filename (no number prefix).
PR description to write:
Adds
transaction_currency,transaction_amount,exchange_rate, andexchange_rate_dateto the transactions table. Foreign-currency transactions store both their native amount and the base-currency equivalent used by the budget engine. When an exchange rate exists for the transaction date (from the exchange_rates table added in feat/exchange-rates-table),amountis computed automatically. Existing single-currency transactions are unaffected (all new fields are nullable). Includes CRDT schema registration for all four new transaction fields.Depends on: feat/exchange-rates-table
Branch name: feat/cross-currency-transfers
Depends on: MC-4 merged (which depends on MC-3)
Source: Commit 1ec9ed1 from PR #7450 (second half — transfer logic)
Size estimate: ~350 lines
Motivation: Moving money between accounts in different currencies is not spending. It must not touch envelopes. This is one of the hardest invariants to implement correctly and the one most apps get wrong. This PR implements it using a simple, correct exchange rate model — see https://tallyroot.com for the UX and accounting reference.
The transfer model (simple exchange rate, no gain/loss): Cross-currency transfers use a single exchange rate to express what was sent in terms of the budget base currency. There is no separate gain/loss entry and no spread tracking at this stage. The user enters both what they sent and what they received; the implied rate is stored.
When Account A (EUR) transfers to Account B (USD), both on-budget, with budget currency USD:
- User enters: sent 100 EUR, received 108.23 USD.
- Implied rate: 1.0823 (USD per EUR).
- Leg A:
transaction_amount = -100(EUR integer),transaction_currency = 'EUR',amount = -amountToCurrencyInteger(100 × 1.0823, 'USD') = -10823,exchange_rate = 1.0823- Leg B:
transaction_amount = +10823(USD integer),transaction_currency = 'USD',amount = +10823,exchange_rate = 1.0823- Both legs are
transfer_id-linked. The budget engine treats them as transfers and excludes both from TBB and envelope calculations, exactly as it does for same-currency transfers. No TBB imbalance occurs because transfers are always excluded — regardless of whether the two legs have the same base-currencyamount.- The difference between 108.23 and any fees/spread the user actually paid is their own concern. Actual does not track it. If the user wants to record a fee separately, they can add a second split transaction for the fee amount in a category of their choice.
Files to change:
packages/loot-core/src/shared/transactions.ts
- Update
makeTransfer(from, to, amount)to accept optionalreceivedAmountandexchangeRateparameters. - When
from.currency !== to.currency:- Require both
amount(sent, infrom.currency) andreceivedAmount(into.currency) to be provided. IfreceivedAmountis absent, auto-populate from the exchange rate table for the transfer's date and show as "estimated" (same convention as MC-4 transactions). - Derive
exchangeRate = receivedAmount_in_base / amount_in_base. - Leg A:
transaction_amount = -amount,transaction_currency = from.currency,exchange_rate = exchangeRate,amount = -amountToCurrencyInteger(amount × exchangeRate, defaultCurrency) - Leg B:
transaction_amount = +receivedAmount,transaction_currency = to.currency,exchange_rate = exchangeRate,amount = +amountToCurrencyInteger(receivedAmount, defaultCurrency)(ifto.currency === defaultCurrency,amount = +receivedAmountdirectly).
- Require both
- When
from.currency === to.currency(or both equal budget currency): existing behavior, both legs have equalamount, notransaction_amount/transaction_currencyneeded.
packages/loot-core/src/server/accounts/app.ts
- Update the transfer creation handler to call the new
makeTransfersignature.
packages/desktop-client/src/components/modals/ (transfer modal or existing transaction create flow)
- When the transfer destination account uses a different currency than the source:
- Show two amount fields: "Amount sent" (in source currency) and "Amount received" (in destination currency).
- Auto-populate "Amount received" from the exchange rate table for the transfer's date; show "estimated" label if auto-populated.
- Allow the user to override "Amount received" with the bank-confirmed amount.
- Display derived rate:
"Exchange rate: 1 EUR = 1.0823 USD".
- When source and destination are the same currency: show single amount field (no change).
packages/desktop-client/src/hooks/useTransfer.ts (new hook or update existing)
- Encapsulate the cross-currency transfer amount/rate derivation state logic.
Tests to add:
test: transfer EUR→USD: both legs stored with correct transaction_amount and amount
test: transfer EUR→USD: both legs have same exchange_rate value
test: transfer within same currency: no transaction_amount/transaction_currency set,
existing behavior unchanged
test: transfer with no exchange rate available: both amounts required, auto-fill absent,
rate shown as null/estimated
test: budget engine: cross-currency transfer legs excluded from TBB (transfer flag respected)
Release notes: Create an entry in upcoming-release-notes/ for cross-currency transfer
support. Use only a description slug in the filename (no number prefix).
PR description to write:
Implements cross-currency transfer handling using a simple exchange rate model: the user enters what was sent and what was received, and the implied rate is stored on both legs. Both legs are flagged as transfers and excluded from TBB and envelope calculations — the same as same-currency transfers. No gain/loss tracking; fees can be recorded as a separate split if desired. See tallyroot.com for the reference UX.
Depends on: feat/transaction-dual-amount
Branch name: feat/transaction-foreign-amount-tooltip
Depends on: MC-4 merged (MC-3 is in chain; MC-5 is NOT required — this PR applies
to all foreign transactions including transfers created in MC-5, but does not depend on
that PR's code to function)
Size estimate: ~200 lines
Motivation: When a transaction was charged in a foreign currency, the user needs access to both the original charge and the converted amount — but not at the cost of cluttering the transaction table with an extra column or a second row. A hover tooltip on the amount achieves full transparency with zero visual noise for single-currency users. This also applies to the legs of cross-currency transfers (MC-5) once those exist.
Design:
- The amount cell in the transaction table shows only the native amount (what the bank
charged), formatted in
transaction_currency. This is what users see in their bank statement and expect to see here. - When the user hovers over the amount of a transaction with
transaction_currencyset, a multi-line tooltip appears (multi-line keeps the amount column width narrow):
Where≈ 51.20 rate: 1 EUR = 1.0823 date: 2026-05-0151.20is formatted usingdefaultCurrencyCode(no symbol if''). - If
exchange_rate_datediffers from the transaction's own date (estimated rate), add a fourth line:~ estimated (no exact rate for this date) - For transactions where
transaction_currencyis null or equalsdefaultCurrencyCode(same-currency transaction): no tooltip, no change to the existing amount display.
Files to change:
packages/desktop-client/src/components/transactions/TransactionsTable.tsx
- The amount cell: wrap the amount display in a tooltip trigger component.
- Tooltip is rendered only when
transaction.transaction_currencyis set and differs fromdefaultCurrencyCode. - Tooltip content: base-currency equivalent + rate + date. Estimated-rate indicator if
exchange_rate_date !== transaction.date. - Use the existing tooltip/popover component already present in the design system.
packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx
- On mobile, a press-and-hold or tap on the amount opens a bottom sheet or action sheet showing the same information (hover is not available on touch).
packages/desktop-client/src/components/accounts/Account.tsx
- Pass
defaultCurrencyCode(from prefs) into the transaction table.
What does NOT change:
- The table layout: no new columns, no second rows.
- The amount column width.
- Any display for same-currency transactions.
Release notes: Create an entry in upcoming-release-notes/ for the foreign amount tooltip.
Use only a description slug in the filename (no number prefix).
PR description to write:
Foreign-currency transaction amounts now show a multi-line hover tooltip with the base-currency equivalent, the exchange rate used, and the rate date. Estimated rates (no exact match for the transaction date) are flagged. Also applies to legs of cross-currency transfers. No layout changes — the transaction table is visually unchanged for all existing single-currency users.
Depends on: feat/transaction-dual-amount
Branch name: feat/envelope-base-currency-only
Depends on: MC-3, MC-6 merged
Source: Audit and fix the multi-currency category UI from commits 8f2b6a9 and
a6974aa in PR #7450 — REPLACE rather than port those commits.
Size estimate: ~200 lines
Motivation:
This PR implements the North Star principle at the envelope level: categories always show
one number in base currency. It also removes the per-currency buffer/totals UI introduced
in PR #7450's commits 8f2b6a9 and a6974aa, which violate the single-envelope-currency
invariant.
What to remove (from #7450 commits 8f2b6a9 and a6974aa):
- Any UI element showing per-currency subtotals within a category row.
- Any "buffer" concept per foreign currency in the envelope budget view.
- Any separate currency columns or currency-grouped totals in the budget table.
What the budget view should show (Tallyroot design):
- Category row: "Budgeted | Spent | Balance" — all three in base currency, single number each.
- The fact that the spend came from a EUR-denominated transaction is irrelevant to the envelope. The envelope sees the converted USD amount and nothing else.
- The "To Be Budgeted" summary: one number in base currency. Foreign income was already converted at transaction entry time.
Files to change:
packages/desktop-client/src/components/budget/envelope/ (various files)
- Audit every component that renders category totals, budget totals, or "To Be Budgeted."
- Ensure all amounts read from the DB are the base-currency
amountfield, nottransaction_amount. - Remove any per-currency grouping, filtering, or sub-display that was added in #7450.
packages/desktop-client/src/components/budget/envelope/budgetsummary/
- "To Be Budgeted" display: verify it sums
amount(base currency) across all income transactions. No per-currency breakdown needed here.
What to ADD (new, not in #7450):
- A tooltip or expandable detail on the category spent amount that lists the individual transactions contributing to it, showing dual-currency display (as per MC-6).
- This gives users visibility into the foreign transactions without polluting the budget table itself.
Release notes: Create an entry in upcoming-release-notes/ for the envelope base-currency
enforcement (budget view shows single base-currency amounts with tooltip drill-down for foreign
transactions). Use only a description slug in the filename (no number prefix).
PR description to write:
Enforces the single-base-currency invariant at the envelope level. Budget category totals, the "To Be Budgeted" summary, and all balance displays show amounts in the budget's base currency only. Adds an expandable transaction detail tooltip that shows dual-currency information for individual transactions.
Depends on: feat/transaction-foreign-amount-tooltip
Branch name: feat/account-foreign-balance-tooltip
Depends on: MC-2, MC-3, MC-6 merged (MC-3 provides the exchange_rates table used for
"today's rate" lookup)
Size estimate: ~150 lines
Motivation: Foreign-currency accounts hold a balance in their native currency. Users need to know what that balance is worth in their budget currency without changing the sidebar or account header layout. A hover tooltip on the balance number provides this without adding a second line or second column to the account list.
Design:
- The account balance in the sidebar and account page header shows the native balance
formatted in
account.currency. This is the real balance the user's bank shows. - When the user hovers over the balance of a foreign-currency account, a tooltip appears:
Where≈ 2,534.10 (rate: 1 EUR = 1.0823, today)2,534.10is formatted usingdefaultCurrencyCode. - The conversion uses today's rate from the exchange_rates table (most recent available).
- For accounts where
account.currency === defaultCurrencyCode(or both are''): no tooltip, no change to existing balance display.
Files to change:
packages/desktop-client/src/components/sidebar/ (account list rendering)
- Wrap balance display in a tooltip trigger when
account.currencyis set, non-empty, and differs fromdefaultCurrencyCode. - Tooltip content: multi-line — converted balance on first line, rate and date below:
≈ 2,534.10 rate: 1 EUR = 1.0823 as of: 2026-05-30
packages/desktop-client/src/components/accounts/Account.tsx
- Same tooltip on the account page header balance.
packages/desktop-client/src/components/mobile/ (mobile account list)
- On mobile: tap the balance to see a brief popover with the equivalent amount.
What does NOT change:
- The sidebar layout: no second line per account.
- The account page header layout.
- Any display for same-currency accounts.
Release notes: Create an entry in upcoming-release-notes/ for the account balance
tooltip. Use only a description slug in the filename (no number prefix).
PR description to write:
Foreign-currency account balances now show a multi-line hover tooltip with the base-currency equivalent, rate, and rate date. Uses the most recent available rate from the exchange_rates table. No layout changes — the sidebar and account header are visually unchanged for all existing single-currency users.
Depends on: feat/account-currency-field, feat/exchange-rates-table, feat/transaction-foreign-amount-tooltip
Branch name: feat/reports-base-currency
Depends on: MC-4 merged
Size estimate: ~200 lines (or fewer if reports are already correct)
Motivation:
Reports aggregate transactions across accounts. With multi-currency accounts, the aggregate
must use amount (base currency) for all arithmetic. The foreign transaction_amount fields
are irrelevant to report totals. This PR audits all report paths and fixes any that use the
wrong field. If no fixes are needed, the PR still adds tests that assert the correct field is
used — this ensures regressions are caught by CI.
Files to change:
packages/desktop-client/src/components/reports/ (various report components)
- Audit all queries that sum or group transaction amounts.
- Fix any that reference
transaction_amountinstead ofamount. - For existing single-currency budgets, this should already be the case — no behavior change.
packages/loot-core/src/server/reports/ (if server-side report queries exist)
- Same audit and fix.
Tests to add (required even if no fixes needed):
test: net worth report aggregates amount (base currency) not transaction_amount
test: spending report sums amount across foreign-currency transactions correctly
test: income report treats foreign income using amount (converted at entry time)
Release notes: Create an entry in upcoming-release-notes/ for the reports base-currency audit. If fixes were found, category: Bugfixes. If pure audit with no behavior change, category: Maintenance.
PR description to write:
Audits and corrects all spending and income reports to aggregate the base-currency
amountfield, nottransaction_amount. Adds tests to prevent regression. [If no fixes were needed, state: "Confirmed existing report queries useamountthroughout; adds test coverage to prevent future regressions."]Depends on: feat/transaction-dual-amount
Branch name: feat/import-currency-column
Depends on: MC-3, MC-4 merged
Size estimate: ~150 lines
Motivation:
CSV imports from banks often include a currency column. When importing to a foreign-currency
account, the importer should preserve the original transaction currency and look up the
exchange rate to populate amount.
Files to change:
packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx
- In the field-mapping step, add an optional "Currency" column mapping.
- If mapped, use the value from that column as
transaction_currencyfor each transaction. - If the currency differs from
defaultCurrencyCode(or from the target account'scurrency), trigger the exchange rate lookup for that transaction's date (from the exchange_rates table) to populateamount.
packages/loot-core/src/server/importers/ (shared import utility)
- Add a helper that, given a list of transactions with optional
transaction_currencyfields, batch-looks-up exchange rates and populatesamountfor any that are missing it.
Release notes: Create an entry in upcoming-release-notes/ for the CSV import currency
column mapping (useful for Wise, Revolut, etc. statements). Use only a description slug in the filename (no number prefix).
PR description to write:
Adds an optional "Currency" field mapping to CSV imports. When present, transactions with foreign currencies have their base-currency
amountpopulated from the exchange rates table. Useful for importing statements from multi-currency card providers (Wise, Revolut, etc.).Depends on: feat/transaction-dual-amount, feat/exchange-rates-table
⚠️ The app works correctly without it using manually entered rates. Add this only after all earlier phases are merged and stable. The maintainers have expressed caution about external service dependencies — providers must be optional, pluggable, and fail gracefully. The settings model from PR #7450 commit5857a6bis the right reference to follow.
Key architectural decisions for this section:
- Providers are implemented as plugins, not hardcoded integrations. The interface is defined once; each provider is a separate implementation.
- PR #7450 already implements two providers:
mempool.space(free, no credentials required, covers major currencies) and Open Exchange Rates (free tier, requires App ID, all currencies). Port these as the first two provider plugins. - Provider credentials (API keys etc.) are stored in the budget database, not in local-only prefs. This is critical for local-first multi-device operation: all devices accessing the same budget file can use the same configured provider without per-device setup.
- On application load, if a provider is configured and today's rates are not already
cached in the
exchange_ratestable, fetch them. This keeps rates current without requiring user interaction.
Branch name: feat/exchange-rate-providers
Depends on: MC-3 merged (exchange_rates table must exist for providers to write into it)
Size estimate: ~300 lines
Motivation: Establishes the plugin interface and the DB-backed credential store. No specific provider is implemented here — that comes in OXR-2 and OXR-3. The manual provider (always available, no fetch) is included as the default.
Files to change:
packages/loot-core/src/server/exchange-rates/providers/ (new directory)
base.ts — provider interface:
interface ExchangeRateProvider {
readonly name: string;
readonly requiresCredentials: boolean;
fetchRates(
date: string,
base: string,
quotes: string[],
): Promise<Record<string, number>>;
isConfigured(credentials: Record<string, string>): boolean;
}manual.ts — the no-op provider:
requiresCredentials: falsefetchRatesalways returns{}(no rates fetched; manual entry only)isConfiguredalways returnstrue- This is the default when no provider is selected.
packages/loot-core/src/server/exchange-rates/app.ts
- Update
getExchangeRateto: check local DB first (always), then call the configured provider if no local rate exists, a provider is configured, and it is not the manual one. - Add
getConfiguredProvider(): ExchangeRateProvider. - Add on-load rate fetch: when the budget is opened, if today's rates are not cached and a non-manual provider is configured, fetch rates for all currencies used by foreign accounts.
DB migration (new migration file) — credentials in the database:
CREATE TABLE exchange_rate_settings (
id TEXT PRIMARY KEY DEFAULT 'singleton',
provider TEXT NOT NULL DEFAULT 'manual',
credentials TEXT, -- JSON blob: { "app_id": "...", etc. }
auto_fetch INTEGER NOT NULL DEFAULT 1, -- boolean: fetch on import
last_fetched_at TEXT -- ISO datetime of last successful fetch
);
INSERT OR IGNORE INTO exchange_rate_settings (id) VALUES ('singleton');Credentials are stored as a JSON blob in the budget DB so all devices sharing the budget have access. They are synced via the CRDT like any other DB field.
Security note for PR description: Credentials stored in the budget DB are visible to anyone with access to the budget file. Document this clearly. For users who consider their API key sensitive, the manual provider remains a no-credential option.
packages/loot-core/src/server/exchange-rates/settings.ts (new file)
- CRUD helpers for the
exchange_rate_settingstable. getProviderConfig(),setProviderConfig(provider, credentials).
CRDT schema registration (included in this PR):
- Register
exchange_rate_settingstable in the CRDT schema.
Release notes: Create an entry in upcoming-release-notes/ for the exchange rate provider
plugin abstraction (automatic rate fetching, configurable provider). Use only a description slug in the filename (no number prefix).
PR description to write:
Introduces the exchange rate provider plugin interface. Provider credentials are stored in the budget database (not local-only prefs) so all devices sharing a budget can use the same configured provider. The default provider is 'manual' (no external dependency). On application load, if a non-manual provider is configured and today's rates are not cached, rates are fetched automatically. Includes CRDT registration for the new settings table.
Note: Credentials stored in the budget DB are accessible to anyone with access to the budget file. Users with API key security concerns should use the manual provider.
Depends on: feat/exchange-rates-table (MC-3)
Branch name: feat/exchange-rate-provider-mempool
Depends on: OXR-1 merged
Source: Commit 5857a6b from PR #7450 (mempool.space portion)
Size estimate: ~150 lines
Motivation:
mempool.space is the zero-friction onboarding path for live rates — no account, no API
key, works immediately. It covers major currencies using BTC as a proxy.
Shipping it separately from OXR means users get a useful no-setup option without waiting
for the OXR implementation, and each provider can be reviewed independently.
Files to change:
packages/loot-core/src/server/exchange-rates/providers/mempool.ts (new file)
- Implement
ExchangeRateProviderinterface. requiresCredentials: false— no signup, no configuration.isConfigured(credentials): always returnstrue.- Port the implementation directly from PR #7450 commit
5857a6b. - Cover the currency pairs supported by the mempool.space price API.
- All network calls wrapped in
try/catch; failure is silent, falls back to cached/manual.
packages/loot-core/src/server/exchange-rates/providers/mempool.test.ts
- Mock fetch calls.
- Test: rates returned correctly for supported pairs.
- Test:
isConfiguredreturnstrueregardless of credentials argument. - Test: network failure returns
{}without throwing.
packages/desktop-client/src/components/settings/ExchangeRates.tsx
- Add
mempool.spaceas a selectable option in the provider selector:"mempool.space (free, no account required)". - When selected: no credentials field shown; just the status line and auto-fetch toggle.
- Section remains gated on
isMultiCurrencyEnabled(prefs).
Release notes: Create an entry in upcoming-release-notes/ for the mempool.space provider.
Use only a description slug in the filename (no number prefix).
PR description to write:
Adds the mempool.space exchange rate provider. No credentials required — works immediately after selection. Covers major currency pairs. Network failures fall back silently to cached or manually-entered rates.
Depends on: feat/exchange-rate-providers
Branch name: feat/exchange-rate-provider-oxr
Depends on: OXR-1 merged (OXR-2 is not required — providers are independent of each other)
Source: Commit 5857a6b from PR #7450 (OXR portion)
Size estimate: ~200 lines
Motivation: OXR covers all ISO 4217 currencies via the free tier, including less common ones not available on mempool.space. It requires a free App ID, making it slightly higher-friction but the right choice for comprehensive fiat coverage.
Files to change:
packages/loot-core/src/server/exchange-rates/providers/oxr.ts (new file)
- Implement
ExchangeRateProviderinterface. requiresCredentials: true.isConfigured(credentials): returnstrueifcredentials.app_idis non-empty.fetchRates(date, base, quotes):- Call
https://openexchangerates.org/api/historical/{date}.json?app_id={key}&base=USD. - OXR free tier always returns USD as base. For non-
'USD'and non-''base, compute cross rates via triangulation:rate(base→quote) = rates.USD[quote] / rates.USD[base]. - When
baseis''(None), store withbase_currency: ''. - Cache in
exchange_ratestable withsource: 'oxr'. - Minimum 24h between fetches for the same date; free plan: 1000 req/month.
- Call
- All network calls wrapped in
try/catch; failure falls back gracefully.
packages/loot-core/src/server/exchange-rates/providers/oxr.test.ts
- Mock fetch calls.
- Test: cross-rate calculation from USD base to non-USD base.
- Test:
isConfiguredreturnsfalsewhenapp_idempty;truewhen set. - Test: cache prevents redundant fetches within 24h for the same date.
- Test: network failure returns
{}without throwing.
packages/desktop-client/src/components/settings/ExchangeRates.tsx
- Add OXR as a selectable option:
"Open Exchange Rates (free account required)". - When selected: show App ID text field and "Test connection" button.
- Status line: last fetched timestamp or "Not yet fetched."
- Section remains gated on
isMultiCurrencyEnabled(prefs).
Release notes: Create an entry in upcoming-release-notes/ for the OXR provider.
Use only a description slug in the filename (no number prefix).
PR description to write:
Adds the Open Exchange Rates (OXR) provider for comprehensive coverage of all ISO 4217 currencies. Requires a free App ID (openexchangerates.org). App ID is stored in the budget DB and shared across all devices accessing the budget. Rate fetches are cached; network failures fall back silently to cached or manually-entered rates.
Note: The App ID stored in the budget DB is visible to anyone with access to the budget file.
Depends on: feat/exchange-rate-providers
PRs in this section have no functional blockers — they can be submitted at any time after their stated dependencies are merged. They do not gate any other work.
Branch name: refactor/currency-formatting-rename
Depends on: DP-1 merged
Source: Commit 8cbe346 from PR #7450
Size estimate: ~80 lines (pure refactor, zero behavior change)
Optional: Yes — improves naming clarity but gates nothing.
Motivation: Rename internal currency formatting functions for clarity before the multi-currency work adds more callsites that would then need renaming later.
Changes:
- Rename
format→formatCurrency(or the equivalent name chosen in commit8cbe346) throughout the formatting utility layer. - Port the renames from the commit without modification — no logic changes.
- Run
yarn typecheckto confirm zero errors before opening the PR.
Release notes: Create an entry in upcoming-release-notes/ for the currency formatting rename. Use only a description slug in the filename (no number prefix). Category: Maintenance.
PR description to write:
Renames internal currency formatting functions for clarity. Pure refactor — no behavior change. Establishes consistent naming ahead of multi-currency work.
Depends on: feat/currency-decimal-core
Branch name: feat/btc-satoshi-formatter
Depends on: DP-1 merged (CLEAN-1 is not required — these are independent)
Source: Commit 808650a from PR #7450
Size estimate: ~100 lines
Optional: Yes — display enhancement for BTC users only.
Motivation: BTC amounts stored at 8dp precision deserve a formatter that groups satoshis readably. This is a display-only enhancement that does not affect storage or integer arithmetic.
Changes:
packages/loot-core/src/shared/ (formatting utilities)
- Add
formatBTC(integer: number): stringthat produces the satoshi comma grouping style defined in commit808650a(port directly). - Gate on
currency === 'BTC'in the formatting dispatch function so it applies automatically wherever BTC amounts are formatted. - Formatter is display-only. It never influences storage, parsing, or arithmetic.
Tests to add:
describe('formatBTC')
- formatBTC(0) → '0.00000000'
- formatBTC(1) → '0.00000001' (1 satoshi)
- formatBTC(100000000) → '1.00000000' (1 BTC)
- formatBTC(123456789) → correct grouping per commit 808650a spec
Release notes: Create an entry in upcoming-release-notes/ for the BTC satoshi formatter.
Use only a description slug in the filename (no number prefix).
PR description to write:
Adds a satoshi-aware display formatter for BTC. Display-only — no effect on storage or arithmetic. Automatically applied wherever a BTC amount is formatted.
Depends on: feat/currency-decimal-core
DP-1 ─────────────────────────────────────────────────────────────────┐
├─ DP-2 (loot-core server) │
├─ DP-3 (transaction table) ──────────────────────────────────┐ │
│ └─ DP-4 (budget UI) ───────────────────────────────────┤ │
│ └─ DP-5 (modals, mobile, E2E) │ │
│ └─ DP-6 (historical data migration) │ │
│ [blocks on: resizable columns PR] │ │
│ └─ DP-7 (remove hide-fraction setting) │ │
└─ [CLEAN-1 and CLEAN-2 can land any time after DP-1] ──────┘ │
│
MC-1 (wire defaultCurrencyCode + gating) ─ [depends on DP-1] ────────┘
└─ MC-2 (account currency + DB + CRDT)
└─ MC-3 (exchange_rates table + manual UI + CRDT) ← was MC-4
└─ MC-4 (transaction dual-amount + DB + CRDT) ← was MC-3
├─ MC-5 (cross-currency transfers)
│ (MC-5 is independent of MC-6; both can be reviewed in parallel
│ after MC-4; MC-6 applies to transfers once MC-5 lands)
├─ MC-6 (foreign amount tooltip) [depends on MC-4 only, not MC-5]
│ ├─ MC-7 (envelope base-currency enforcement)
│ └─ MC-8 (account balance tooltip) [also needs MC-3]
├─ MC-9 (reports audit and fix)
└─ MC-10 (import currency column) [also needs MC-3]
OXR-1 (provider abstraction + credentials in DB + CRDT) [depends on MC-3 exchange_rates table]
├─ OXR-2 (mempool.space provider) [depends on OXR-1]
└─ OXR-3 (OXR provider) [depends on OXR-1; independent of OXR-2]
(add last)
CLEAN-1 (formatting rename) [depends on DP-1; optional]
CLEAN-2 (BTC satoshi formatter) [depends on DP-1; optional; independent of CLEAN-1]
Before opening any PR — see "Definition of Done" near the top; all five gates are mandatory:
- Lint —
yarn lintclean (0 errors) - Typecheck —
yarn typecheck(zero TypeScript errors) - Unit tests — affected workspaces pass (
yarn workspace @actual-app/core run test, web vitest); new currency/decimal logic has round-trip coverage - Functional E2E —
E2E_USE_BUILD=1 yarn playwright test --browser=chromiumagainst a production build, all pass - VRT —
yarn vrtscreenshots pass, run in the playwright container (viagh actone shard at a time, oryarn vrt:docker); never on the host - No new
// @ts-ignoreoras anycasts introduced - Release notes file created in
upcoming-release-notes/— required for every PR without exception, including pure refactors, audits, and internal-only changes. File naming:<description-slug>.md(description only, no number prefix). Required frontmatter fields: -category:— must be one of:Bugfixes,Enhancements,Features,Maintenance-authors:— must be an array containing the GitHub username:gh auth status --json hosts | jq -Mc '[.hosts."github.com"[0].login]' - If this PR adds a DB column or table: CRDT schema registration is included in the same PR — not deferred. Increment the schema version and verify backward compatibility with older clients.
- Migration file (if DB changes): tested on a fresh budget and a migration from the previous schema version
- PR description includes: what changed, why, which issues it fixes, what tests were added
- Release notes file added per the project's release notes convention
- Tested manually with at least: a budget with
defaultCurrencyCode = ''(the default state — regression check) + one non-2dp currency (correctness check)
Run these in order at the start of every Claude Code session on this feature work:
# 1. Ensure remotes are set
git remote -v
# Should show: origin (your fork), upstream (actualbudget/actual)
# If not: git remote add upstream https://github.com/actualbudget/actual.git
# 2. Always start from a fresh master
git fetch upstream
git checkout master
git merge upstream/master
# 3. Create a new branch for the PR you're working on
git checkout -b feat/currency-decimal-core
# 4. Before touching currencies.ts or util.ts, check for conflicts with prior PRs
git diff upstream/master \
packages/loot-core/src/shared/currencies.ts \
packages/loot-core/src/shared/util.ts
# Should be empty — if not, investigate before proceeding
# 5. Run the test suite baseline to confirm master is green
yarn workspace @actual-app/core run test
yarn typecheck
# 6. Read the relevant code before writing any
# For DP-1: read currencies.ts and util.ts in full
# For MC-2: read account.ts, app.ts, and migration files in full
# Do not write new code until you understand the existing patternsLast updated: 2026-05-30
Tracks: actualbudget/actual master branch
Related PRs: #6954 (decimal precision), #7450 (multi-currency UX demo)
Related repos: tlesicka/actual-budget-multicurrency-todo (UX reference)