Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save StephenBrown2/a1c3a0cd19f8f2fac0c76c2b93ed20fc to your computer and use it in GitHub Desktop.

Select an option

Save StephenBrown2/a1c3a0cd19f8f2fac0c76c2b93ed20fc to your computer and use it in GitHub Desktop.
Actual Budget Multi-Currency Implementation Plan

Actual Budget — Multi-Currency Implementation Plan

A Working Document

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.


Definition of Done (mandatory for EVERY PR in this plan)

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:

  1. Lintyarn lint clean (0 errors; warnings unchanged from baseline). Use yarn lint:fix to auto-fix.
  2. Typecheckyarn workspace @actual-app/web run typecheck and (if loot-core touched) yarn workspace @actual-app/core run typecheck.
  3. Unit tests — affected workspaces (vitest). Add unit coverage for every currency/decimal change (round-trip tests are the cheapest proof of correctness).
  4. Functional E2EE2E_USE_BUILD=1 yarn playwright test --browser=chromium against 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.
  5. VRT (visual regression)yarn vrt screenshots. Do not skip this even for "logic-only" changes: any change that touches a rendered amount/string can shift pixels. VRT only asserts under VRT=1 (via toMatchThemeScreenshots), so functional E2E does not cover it.

VRT must run in the playwright container, never on the host. The committed baselines are *-chromium-linux.png generated in mcr.microsoft.com/playwright:<ver>-jammy. Host font rendering (e.g. ubuntu 26.04) differs and produces false diffs. Run VRT via gh act (see below) or yarn vrt:docker.

Running CI locally with gh act (and its gotchas)

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 in listArtifacts when 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 the gh-act extension. 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) so upload-artifact/download-artifact work.
  • Run sharded matrix jobs ONE SHARD AT A TIME. The functional and vrt jobs 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 …, then shard:2, then shard:3. (In real CI each shard is an isolated runner, so this is purely an act limitation.)
  • Set --env REACT_APP_NETLIFY=true so isNonProductionEnvironment() keeps the "Create test file" button in the production build (every e2e beforeEach needs it).
  • Kill stray host servers and orphaned containers first. A lingering host vite/serve-build on 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 docker group (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-artifacts

0. Context and Prior Art

Existing branches to have locally before starting

git 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)

What to take vs. what to leave from prior art

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)

Release notes format (applies to every PR in this plan)

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 behavior
  • Enhancements — improves existing functionality
  • Features — new user-visible capability
  • Maintenance — 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]'.


The North Star design principles (non-negotiable)

These principles govern every design decision in this plan. When in doubt, return to them.

  1. 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.

  2. 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.
  3. 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.

  4. 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.

  5. 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.

  6. 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.


1. First-Principles: Decimal Precision

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:

  1. Do not change an existing currency's decimalPlaces value without a data migration. A currency already in the list (e.g., CLP at decimalPlaces: 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.

  2. 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 has defaultCurrencyCode set 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).

Critical pre-merge check for decimal precision PRs

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.ts

Identify 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.


PR DP-1 — Core utility helpers

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):

  • Currency type in currencies.ts already has decimalPlaces: number — do NOT re-add it.
  • amountToInteger(amount, decimalPlaces = 2) and integerToAmount(integer, decimalPlaces = 2) already accept a decimalPlaces parameter — do NOT change their signatures.
  • JPY, KRW, and IRR already have decimalPlaces: 0 — verify, don't touch them.
  • integerToCurrencyWithDecimal and appendDecimals already exist with different signatures — do NOT re-implement them; instead follow the instructions below for each.
  • Do NOT correct CLP or any other existing entry's decimalPlaces — 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's decimalPlaces. When called with '' (None entry), returns 2. Unknown codes return 2 as safe default.
  • Do NOT add new currencies. Do NOT change any existing entry's decimalPlaces value.

packages/loot-core/src/shared/util.ts

  • integerToCurrencyWithDecimal — add an optional currency?: string second parameter. When provided, derive decimalPlaces from getDecimalPlaces(currency) and use that to format. When omitted, preserve the existing behavior (current hardcoded % 100 logic). 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 it appendDecimals — 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 of integerToCurrencyWithDecimal.
    export function amountToCurrencyInteger(amount: number, currency: string): number {
      return amountToInteger(amount, getDecimalPlaces(currency));
    }
  • Do NOT change amountToInteger or integerToAmount — they already accept decimalPlaces as 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) to currencies.ts: returns the number of decimal places for a currency code (unknown codes return 2 as safe default). Adds amountToCurrencyInteger(amount, currency) to util.ts and an optional currency param on integerToCurrencyWithDecimal. 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.


PR DP-2 — loot-core server: decimal-aware amount handling

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:

  1. For importers: use the currency stated in the import file format (e.g., YNAB5's currency_format field per account).
  2. For all other server paths: use prefs.defaultCurrencyCode. When defaultCurrencyCode is '' (None/unset), getDecimalPlaces returns 2 — correct fallback, no special case.
  3. Never fall back to 'USD'.

Files to change:

packages/loot-core/src/server/importers/ynab5.ts

  • Replace hardcoded Math.round(amount * 100) calls with amountToCurrencyInteger(amount, currency) where currency is read from the YNAB5 export's currency_format field 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 / 10 to convert milliunits → cents (2dp). Replace with amountToInteger(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 using prefs.defaultCurrencyCode precision, 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.defaultCurrencyCode to integerToCurrencyWithDecimal when formatting the Amount column.

packages/loot-core/src/server/accounts/app.ts

  • createAccount starting balance: encode using amountToCurrencyInteger(balance, prefs.defaultCurrencyCode).
  • closeAccount and reopenAccount balance 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 using amountToCurrencyInteger(amount, prefs.defaultCurrencyCode).

packages/loot-core/src/server/api.ts

  • Public API getTransactions and getAccounts responses: format amounts using integerToCurrencyWithDecimal(amount, prefs.defaultCurrencyCode) rather than ÷100.
  • Public API addTransactions and updateTransaction inputs: parse using amountToCurrencyInteger(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 using amountToCurrencyInteger(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.defaultCurrencyCode to 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, not 123400).
  • 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 stores 50000 (not 5000000).

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 decimalPlaces via amountToCurrencyInteger and integerToCurrencyWithDecimal instead of assuming 2dp everywhere. Currency is resolved from the import format or from prefs.defaultCurrencyCode; no behavior change for existing 2dp budgets.

Depends on: feat/currency-decimal-core Part of: #3255, #5748


PR DP-3 — desktop-client: transaction table and search

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 / formatCurrencyAmount do not exist in table/utils.ts. The real amount chokepoints are serializeTransaction / deserializeTransaction; both gained an optional currency param using DP-1's integerToCurrencyWithDecimal(int, currency) and amountToCurrencyInteger(amount, currency).
  • getCurrencyPrecisionMultiplier was never built. DP-1 shipped getDecimalPlaces + amountToCurrencyInteger; use those.
  • Amount search lives in queries/index.ts (transactionsSearch), not useTransactionsSearch (which only forwards to it). transactionsSearch gained a currency param (encodes via amountToCurrencyInteger and divides by 10^getDecimalPlaces(currency) instead of hardcoded 100). useTransactionsSearch gained an optional currency param (defaults ''; mobile callers untouched — they are DP-5).
  • accounts/mutations.ts has no amount-encoding code — nothing to change there (spec was wrong).
  • useFormulaExecution.ts deferred — its integerToAmount(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 via useSyncedPref('defaultCurrencyCode') in the Transaction and TransactionError leaf components (not prop-drilled, because the table is virtualized). Account.tsx reads defaultCurrencyCode and passes it to transactionsSearch. MC-2 swaps the leaf reads to account.currency.
  • Tests landed in table/utils.test.ts (new) covering serialize/deserialize round-trips for USD/JPY/'' — not the spec's parseCurrencyAmount tests.

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:

  1. Read prefs.defaultCurrencyCode from the budget preferences.
  2. When defaultCurrencyCode is '' (None/unset), getDecimalPlaces returns 2 — correct fallback, no special case.
  3. Never fall back to 'USD'. Once MC-2 lands, these sites will be updated to use account.currency instead.

Files to change:

packages/desktop-client/src/components/transactions/table/utils.ts

  • parseCurrencyAmount(str, currency): replace parseFloat(str) * 100 with amountToCurrencyInteger(parseFloat(str), currency).
  • formatCurrencyAmount(integer, currency): replace integer / 100 with integerToCurrencyWithDecimal(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.currency will replace this in MC-2; structure the prop to make that swap easy.

packages/desktop-client/src/components/accounts/Account.tsx

  • Read prefs.defaultCurrencyCode and pass as currency prop to TransactionsTable.

packages/desktop-client/src/hooks/useTransactionsSearch.ts

  • Amount search comparisons (searching by amount like >50) must parse the query using getCurrencyPrecisionMultiplier(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 with amountToCurrencyInteger(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


PR DP-4 — desktop-client: budget UI components

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 via useSyncedPref('defaultCurrencyCode'), parse with amountToCurrencyInteger, display/value with integerToCurrencyWithDecimal / integerToCurrencyAmount.
  • BalanceWithCarryover.tsx — no change needed. It already displays via the useFormat() hook, which is already currency-aware (reads defaultCurrencyCode, uses activeCurrency.decimalPlaces).
  • budget/util.ts — no change needed. makeBalanceAmountStyle only 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 raw amountToInteger/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


PR DP-5 — desktop-client: modals, mobile, settings, and E2E

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 raw integerToCurrency: mobile/TransactionList.tsx, mobile/TransactionListItem.tsx, CloseAccountModal.tsx, autocomplete/CategoryAutocomplete.tsx (in CategoryItem), accounts/BalanceHistoryGraph.tsx (note: that file already imports date-fns format, so the hook was bound to formatValue to avoid a name collision).
  • EditFieldModal.tsx uses useFormat().fromEdit for the string case and amountToCurrencyInteger(value, format.currency.code) for the number case (per the suggestion to reuse forEdit/fromEdit).
  • ImportTransactionsModal.tsx encodes parsed file amounts with amountToCurrencyInteger(amount, prefs.defaultCurrencyCode) (import currency mapping is MC-10).
  • FocusableAmountInput.tsx gained a currency prop and computes decimalPlaces for both display and live typing. The shared appendDecimals(amountText, hideDecimals) was generalized to appendDecimals(amountText, decimalPlaces) in loot-core/src/shared/util.ts (single caller) so 0dp currencies type whole numbers.
  • mobile/TransactionEdit.tsx: its local serializeTransaction / deserializeTransaction gained a currency param; ChildTransactionEdit, Footer, TransactionEditInner, and TransactionEditUnconnected each read defaultCurrencyCode and thread it (incl. into the currency-aware util/AmountInput, which already uses forEdit/fromEdit internally).
  • CreateLocalAccountModal.tsx — no change needed. It sends a decimal balance via toRelaxedNumber; the server's createAccount (DP-2) already encodes it currency-aware. (The currency picker is MC-2.)
  • settings/Currency.tsx — no change needed. No amount previews; useFormat is 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.ts round-trip unit tests (1234 → 1234, not 123400). 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 currency prop. At DP-5 time, pass prefs.defaultCurrencyCode (MC-2 will later switch this to account.currency — structure the prop name to make the swap transparent).

packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx

  • Pass prefs.defaultCurrencyCode as currency to FocusableAmountInput.

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


PR DP-6 — Historical data migration for non-2dp budget currencies

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): change decimalPlaces from 2 to 0.
  • 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_meta sentinel.
  • Only run when dp !== 2 (non-2dp budget currency).
  • Does NOT migrate per-account currency data — at this point account.currency doesn'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 defaultCurrencyCode is 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 actual decimalPlaces. Also corrects the decimalPlaces metadata 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)


PR DP-7 — Remove "hide decimal places" setting

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 hideFraction preference.

packages/loot-core/src/types/prefs.ts

  • Remove hideFraction (or equivalent) from the prefs type.

packages/loot-core/src/shared/util.ts

  • Remove the hideFraction path from getNumberFormat and 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 hideFraction path.
  • Confirm that JPY/KRW/IRR formatting tests pass using decimalPlaces: 0 alone, with no hideFraction flag.

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 hideFraction from prefs, settings UI, and the number formatter.

Depends on: feat/currency-decimal-migration


PR DP-8 — Rescale stored amounts when the budget currency's precision changes

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:

  1. User changes the Default Currency in settings/Currency.tsx.
  2. Compute oldDp = getDecimalPlaces(previousCode) and newDp = getDecimalPlaces(nextCode).
  3. If oldDp === newDp (e.g. USD→EUR, both 2dp): apply the change with no rescale and no warning.
  4. 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).
  5. On confirm: persist defaultCurrencyCode = nextCode and 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: when oldDp !== newDp, open the confirmation modal instead of writing the pref directly. Only write defaultCurrencyCode + 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 defaultCurrencyCode is 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


1.5 Individual New Currency PRs (after DP-6)

When to open these PRs: Only after DP-6 is merged and live. The full DP pipeline must exist so that: (a) getDecimalPlaces is 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 currencies array in currencies.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

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.


2. Multi-Currency Core: Data Model and Preferences

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.


PR MC-1 — Wire defaultCurrencyCode pref through the multi-currency stack

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: string is present and typed correctly.
  • Fix any NumberFormat type issues identified in commit b7728a3 (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 getCurrency calls that pass a hardcoded 'USD' where defaultCurrencyCode should 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 defaultCurrencyCode pref as the budget's base currency. Removes any || 'USD' fallbacks — the correct unset value is '' (None), which maps to 2dp with no symbol. Introduces isMultiCurrencyEnabled(prefs) gate: all FX and foreign- account features are disabled when defaultCurrencyCode === ''. 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


PR MC-2 — Account currency field and DB migration

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 amount when the budget currency changes — it must never touch a foreign account's native transaction_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: string to AccountEntity.

packages/loot-core/src/server/accounts/app.ts

  • createAccount: accept and persist currency field; default to prefs.defaultCurrencyCode.
  • getAccounts: include currency in the returned fields.
  • Do not allow currency to 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's defaultCurrencyCode = '' default. Do NOT use DEFAULT '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 currency differs from defaultCurrencyCode AND is not ''. Accounts in the budget currency (or '') show no badge.

packages/loot-core/src/server/api.ts

  • Expose currency in 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.currency in 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 currency field 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 on isMultiCurrencyEnabled(prefs) — the picker is hidden when no defaultCurrencyCode has been set. Foreign-currency accounts display a currency badge in the sidebar. Includes CRDT schema registration for accounts.currency.

Depends on: feat/default-currency-code-wiring


PR MC-3 — exchange_rates table and manual rate entry

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 bulkImportExchangeRates and 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 rates values are numbers (single-date) or objects (multi-date).
  • base defaults to prefs.defaultCurrencyCode if 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 skipped count.

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_rates table 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


PR MC-4 — Transaction dual-amount fields and DB migration

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 NEVER null. For same-currency transactions, transaction_amount is null and amount is authoritative. For foreign transactions, transaction_amount holds the native charge, transaction_currency identifies it, and amount is 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: number field: 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: if transaction_currency is present and differs from account.currency or defaultCurrencyCode:
    • Store transaction_amount and transaction_currency as provided.
    • If exchange_rate is 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, store exchange_rate = null and set amount = transaction_amount as a temporary placeholder; flag the transaction for review.
    • When exchange_rate is known: amount = round(transaction_amount × exchange_rate).
    • Store exchange_rate_date as the date of the rate actually used (may differ from transaction.date if the rate was a fallback to a prior date).

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_date in 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, and exchange_rate_date to 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), amount is 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


PR MC-5 — Cross-currency transfer handling

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-currency amount.
  • 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 optional receivedAmount and exchangeRate parameters.
  • When from.currency !== to.currency:
    • Require both amount (sent, in from.currency) and receivedAmount (in to.currency) to be provided. If receivedAmount is 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) (if to.currency === defaultCurrency, amount = +receivedAmount directly).
  • When from.currency === to.currency (or both equal budget currency): existing behavior, both legs have equal amount, no transaction_amount/transaction_currency needed.

packages/loot-core/src/server/accounts/app.ts

  • Update the transfer creation handler to call the new makeTransfer signature.

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


3. Multi-Currency UI: Transaction Display


PR MC-6 — Transaction amount: hover tooltip for foreign transactions

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_currency set, a multi-line tooltip appears (multi-line keeps the amount column width narrow):
    ≈ 51.20
    rate: 1 EUR = 1.0823
    date: 2026-05-01
    
    Where 51.20 is formatted using defaultCurrencyCode (no symbol if '').
  • If exchange_rate_date differs from the transaction's own date (estimated rate), add a fourth line:
    ~ estimated (no exact rate for this date)
    
  • For transactions where transaction_currency is null or equals defaultCurrencyCode (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_currency is set and differs from defaultCurrencyCode.
  • 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


PR MC-7 — Envelope budget view: base-currency enforcement

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 amount field, not transaction_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


PR MC-8 — Account balance: hover tooltip for foreign accounts

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:
    ≈ 2,534.10  (rate: 1 EUR = 1.0823, today)
    
    Where 2,534.10 is formatted using defaultCurrencyCode.
  • 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.currency is set, non-empty, and differs from defaultCurrencyCode.
  • 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


4. Multi-Currency UI: Reports and Import


PR MC-9 — Reports: base-currency aggregation audit and fix

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_amount instead of amount.
  • 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 amount field, not transaction_amount. Adds tests to prevent regression. [If no fixes were needed, state: "Confirmed existing report queries use amount throughout; adds test coverage to prevent future regressions."]

Depends on: feat/transaction-dual-amount


PR MC-10 — Import: currency column mapping

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_currency for each transaction.
  • If the currency differs from defaultCurrencyCode (or from the target account's currency), trigger the exchange rate lookup for that transaction's date (from the exchange_rates table) to populate amount.

packages/loot-core/src/server/importers/ (shared import utility)

  • Add a helper that, given a list of transactions with optional transaction_currency fields, batch-looks-up exchange rates and populates amount for 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 amount populated 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


5. Live Rate Fetching (Provider Plugins)

⚠️ 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 commit 5857a6b is 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_rates table, fetch them. This keeps rates current without requiring user interaction.

PR OXR-1 — Exchange rate provider abstraction + credentials in DB

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: false
  • fetchRates always returns {} (no rates fetched; manual entry only)
  • isConfigured always returns true
  • This is the default when no provider is selected.

packages/loot-core/src/server/exchange-rates/app.ts

  • Update getExchangeRate to: 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_settings table.
  • getProviderConfig(), setProviderConfig(provider, credentials).

CRDT schema registration (included in this PR):

  • Register exchange_rate_settings table 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)


PR OXR-2 — Provider implementation: mempool.space

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 ExchangeRateProvider interface.
  • requiresCredentials: false — no signup, no configuration.
  • isConfigured(credentials): always returns true.
  • 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: isConfigured returns true regardless of credentials argument.
  • Test: network failure returns {} without throwing.

packages/desktop-client/src/components/settings/ExchangeRates.tsx

  • Add mempool.space as 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


PR OXR-3 — Provider implementation: Open Exchange Rates

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 ExchangeRateProvider interface.
  • requiresCredentials: true.
  • isConfigured(credentials): returns true if credentials.app_id is 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 base is '' (None), store with base_currency: ''.
    • Cache in exchange_rates table with source: 'oxr'.
    • Minimum 24h between fetches for the same date; free plan: 1000 req/month.
  • 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: isConfigured returns false when app_id empty; true when 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


6. Cleanup and Enhancement

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.


PR CLEAN-1 — Currency formatting function renames

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 formatformatCurrency (or the equivalent name chosen in commit 8cbe346) throughout the formatting utility layer.
  • Port the renames from the commit without modification — no logic changes.
  • Run yarn typecheck to 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


PR CLEAN-2 — Bitcoin satoshi display formatter

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): string that produces the satoshi comma grouping style defined in commit 808650a (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


7. Appendix: PR Dependency Graph

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]

8. Appendix: Submission Checklist Per PR

Before opening any PR — see "Definition of Done" near the top; all five gates are mandatory:

  • Lintyarn lint clean (0 errors)
  • Typecheckyarn 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 E2EE2E_USE_BUILD=1 yarn playwright test --browser=chromium against a production build, all pass
  • VRTyarn vrt screenshots pass, run in the playwright container (via gh act one shard at a time, or yarn vrt:docker); never on the host
  • No new // @ts-ignore or as any casts 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)

9. Appendix: Claude Code Session Setup

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 patterns

Last 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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment