diff --git a/.github/workflows/e2e-vrt-comment.yml b/.github/workflows/e2e-vrt-comment.yml index 723f5deae14..ed308c8b9dd 100644 --- a/.github/workflows/e2e-vrt-comment.yml +++ b/.github/workflows/e2e-vrt-comment.yml @@ -51,8 +51,57 @@ jobs: echo "VRT tests passed or skipped for PR #$PR_NUMBER" fi - - name: Comment on PR with VRT report link + - name: Check if VRT Update - Generate is already running + id: vrt_update_generate if: steps.metadata.outputs.should_comment == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const prNumber = parseInt('${{ steps.metadata.outputs.pr_number }}', 10); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + const headSha = pr.data.head.sha; + + let runs = ( + await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'vrt-update-generate.yml', + head_sha: headSha, + per_page: 20, + }) + ).data.workflow_runs; + + // issue_comment-triggered runs may not match head_sha filter; fall back to branch + SHA. + if (runs.length === 0) { + runs = ( + await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'vrt-update-generate.yml', + branch: pr.data.head.ref, + per_page: 20, + }) + ).data.workflow_runs.filter((run) => run.head_sha === headSha); + } + + const activeRun = runs.find( + (run) => run.status === 'queued' || run.status === 'in_progress', + ); + + core.setOutput('active', activeRun ? 'true' : 'false'); + if (activeRun) { + core.setOutput('run_url', activeRun.html_url); + core.info( + `VRT Update - Generate is ${activeRun.status} for PR head ${headSha}: ${activeRun.html_url}`, + ); + } + + - name: Comment on PR with VRT report link + if: steps.metadata.outputs.should_comment == 'true' && steps.vrt_update_generate.outputs.active != 'true' uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 with: number: ${{ steps.metadata.outputs.pr_number }} @@ -64,3 +113,17 @@ jobs: VRT tests ❌ failed. [View the test report](${{ steps.metadata.outputs.artifact_url }}). To update the VRT screenshots, comment `/update-vrt` on this PR. The VRT update operation takes about 50 minutes. + + - name: Comment on PR with VRT report link (update already running) + if: steps.metadata.outputs.should_comment == 'true' && steps.vrt_update_generate.outputs.active == 'true' + uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 + with: + number: ${{ steps.metadata.outputs.pr_number }} + header: vrt-comment + hide_and_recreate: true + hide_classify: OUTDATED + message: | + + VRT tests ❌ failed. [View the test report](${{ steps.metadata.outputs.artifact_url }}). + + [**VRT Update - Generate**](${{ steps.vrt_update_generate.outputs.run_url }}) is already running for this PR's latest commit. Open that workflow run to watch progress. diff --git a/.github/workflows/vrt-update-generate.yml b/.github/workflows/vrt-update-generate.yml index f412cf067d1..46d3509abed 100644 --- a/.github/workflows/vrt-update-generate.yml +++ b/.github/workflows/vrt-update-generate.yml @@ -69,6 +69,16 @@ jobs: with: download-translations: 'false' + # Web VRT: manifest under e2e/.vrt-manifest records expected PNGs; prune step removes orphans only. + - name: Prepare VRT snapshot manifest + run: rm -rf packages/desktop-client/e2e/.vrt-manifest + + - name: Clear Electron VRT baselines + run: | + if [ -d packages/desktop-electron/e2e/__screenshots__ ]; then + find packages/desktop-electron/e2e/__screenshots__ -type f -name '*.png' -delete + fi + - name: Run VRT Tests on Desktop app continue-on-error: true run: | @@ -78,6 +88,9 @@ jobs: continue-on-error: true run: yarn vrt --update-snapshots + - name: Prune orphaned web VRT snapshots + run: yarn vrt:prune + - name: Create patch with PNG changes only id: create-patch run: | @@ -87,10 +100,18 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - # Stage only PNG files - git add "**/*.png" + # Stage VRT snapshot changes + git add --no-ignore-removal -- packages/desktop-client/e2e packages/desktop-electron/e2e + + # Unstage non-PNG files (not included in VRT patch) + mapfile -t non_png < <(git diff --cached --name-only | grep -vE '\.png$' || true) + if [ "${#non_png[@]}" -gt 0 ]; then + echo "Unstaging non-PNG files (not included in VRT patch):" + printf ' %s\n' "${non_png[@]}" + git reset HEAD -- "${non_png[@]}" + fi - # Check if there are any changes + # Check if there are any real changes if git diff --staged --quiet; then echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "No VRT changes to commit" diff --git a/.gitignore b/.gitignore index 4900b75f918..333040e74ff 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ packages/desktop-client/public/kcab packages/desktop-client/locale packages/desktop-client/playwright-report packages/desktop-client/test-results +packages/desktop-client/e2e/.vrt-manifest packages/desktop-electron/client-build packages/desktop-electron/build packages/desktop-electron/.electron-symbols diff --git a/package.json b/package.json index 6e66b230cd2..9d00c52bbd3 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "e2e:desktop": "yarn build:desktop --skip-exe-build --skip-translations && yarn workspace desktop-electron e2e", "playwright": "yarn workspace @actual-app/web run playwright", "vrt": "yarn workspace @actual-app/web run vrt", + "vrt:prune": "yarn workspace @actual-app/web run prune-vrt-snapshots", "vrt:docker": "./bin/run-vrt", "rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core", "rebuild-node": "yarn workspace @actual-app/core rebuild", diff --git a/packages/desktop-client/README.md b/packages/desktop-client/README.md index d79a5656208..a3ad9517278 100644 --- a/packages/desktop-client/README.md +++ b/packages/desktop-client/README.md @@ -59,6 +59,9 @@ yarn vrt:docker # To update snapshots, use the following command: yarn vrt:docker --e2e-start-url https://ip:port --update-snapshots + + # After updating, remove PNGs that are no longer referenced by tests: + yarn vrt:prune ``` Run manually: diff --git a/packages/desktop-client/bin/prune-vrt-snapshots.mjs b/packages/desktop-client/bin/prune-vrt-snapshots.mjs new file mode 100644 index 00000000000..a676acb4aa7 --- /dev/null +++ b/packages/desktop-client/bin/prune-vrt-snapshots.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * Remove PNGs under e2e/*-snapshots that are not listed in the VRT manifest + * (written during the last `yarn vrt` run). Safe to no-op when the manifest is empty. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.join(__dirname, '..'); +const manifestDir = + process.env.VRT_SNAPSHOT_MANIFEST_DIR ?? + path.join(pkgRoot, 'e2e', '.vrt-manifest'); + +const expected = new Set(); +if (fs.existsSync(manifestDir)) { + for (const name of fs.readdirSync(manifestDir)) { + if (!name.startsWith('parallel-') || !name.endsWith('.txt')) continue; + const text = fs.readFileSync(path.join(manifestDir, name), 'utf8'); + for (const line of text.split('\n')) { + const t = line.trim(); + if (t) expected.add(t); + } + } +} + +if (expected.size === 0) { + console.log('No VRT snapshot manifest entries; skipping orphan prune.'); + process.exit(0); +} + +const e2eRoot = path.join(pkgRoot, 'e2e'); +let removed = 0; + +function considerRemove(absPath) { + const rel = path.relative(pkgRoot, absPath).split(path.sep).join('/'); + if (!expected.has(rel)) { + fs.unlinkSync(absPath); + console.log('Removed orphan snapshot:', rel); + removed += 1; + } +} + +function walkPngUnderSnapshotDir(dir) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walkPngUnderSnapshotDir(p); + else if (ent.name.endsWith('.png')) considerRemove(p); + } +} + +for (const ent of fs.readdirSync(e2eRoot, { withFileTypes: true })) { + if (ent.isDirectory() && ent.name.endsWith('-snapshots')) { + walkPngUnderSnapshotDir(path.join(e2eRoot, ent.name)); + } +} + +console.log( + `VRT orphan prune done (${removed} removed, ${expected.size} expected).`, +); diff --git a/packages/desktop-client/e2e/accounts.test.ts b/packages/desktop-client/e2e/accounts.test.ts index 85903c1c032..f78cc523830 100644 --- a/packages/desktop-client/e2e/accounts.test.ts +++ b/packages/desktop-client/e2e/accounts.test.ts @@ -2,6 +2,10 @@ import { join } from 'path'; import type { Page } from '@playwright/test'; +import { + currencyPrecisionTestData, + setDefaultCurrency, +} from './currency-precision'; import { expect, test } from './fixtures'; import type { AccountPage } from './page-models/account-page'; import { ConfigurationPage } from './page-models/configuration-page'; @@ -26,23 +30,52 @@ test.describe('Accounts', () => { await page?.close(); }); - test('creates a new account and views the initial balance transaction', async () => { - accountPage = await navigation.createAccount({ - name: 'New Account', - offBudget: false, - balance: 100, - }); + test.describe('Currency Precision', () => { + for (const { + code, + balance, + expectedDisplay, + } of currencyPrecisionTestData) { + const codeLabel = code === '' ? 'Default' : code; + test(`starting balance displayed correctly for ${codeLabel}`, async () => { + await setDefaultCurrency(page, navigation, code); + accountPage = await navigation.createAccount({ + name: `${codeLabel} Local Account`, + offBudget: false, + balance, + }); + await accountPage.waitFor(); + await expect(accountPage.accountBalance).toContainText(expectedDisplay); + + const transaction = accountPage.getNthTransaction(0); + await expect(transaction.payee).toHaveText('Starting Balance'); + await expect(transaction.notes).toHaveText(''); + await expect(transaction.category).toHaveText('Starting Balances'); + await expect(transaction.debit).toHaveText(''); + await expect(transaction.credit).toHaveText(expectedDisplay); + await expect(page).toMatchThemeScreenshots(); + }); - const transaction = accountPage.getNthTransaction(0); - await expect(transaction.payee).toHaveText('Starting Balance'); - await expect(transaction.notes).toHaveText(''); - await expect(transaction.category).toHaveText('Starting Balances'); - await expect(transaction.debit).toHaveText(''); - await expect(transaction.credit).toHaveText('100.00'); - await expect(page).toMatchThemeScreenshots(); + test(`close account shows correct balance for ${codeLabel}`, async () => { + await setDefaultCurrency(page, navigation, code); + accountPage = await navigation.createAccount({ + name: `${codeLabel} Close Test`, + offBudget: false, + balance, + }); + await accountPage.waitFor(); + await expect(accountPage.accountBalance).toContainText(expectedDisplay); + + const modal = await accountPage.clickCloseAccount(); + await expect(modal.locator).toContainText('balance'); + await expect(modal.locator).toContainText(expectedDisplay); + await expect(modal.locator).toMatchThemeScreenshots(); + await page.reload(); + }); + } }); - test('closes an account', async () => { + test('closes an account with transfer', async () => { accountPage = await navigation.goToAccountPage('Roth IRA'); await expect(accountPage.accountName).toHaveText('Roth IRA'); diff --git a/packages/desktop-client/e2e/budget.test.ts b/packages/desktop-client/e2e/budget.test.ts index 1385f7a1555..d5a2bb80d4d 100644 --- a/packages/desktop-client/e2e/budget.test.ts +++ b/packages/desktop-client/e2e/budget.test.ts @@ -1,8 +1,13 @@ import type { Page } from '@playwright/test'; +import { + currencyPrecisionTestData, + setDefaultCurrency, +} from './currency-precision'; import { expect, test } from './fixtures'; import type { BudgetPage } from './page-models/budget-page'; import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; test.describe('Budget', () => { let page: Page; @@ -66,4 +71,24 @@ test.describe('Budget', () => { expect(await accountPage.accountName.textContent()).toMatch('All Accounts'); await page.getByRole('button', { name: 'Back' }).click(); }); + + test.describe('Currency Precision', () => { + for (const { code } of currencyPrecisionTestData) { + const codeLabel = code === '' ? 'Default' : code; + test(`budget page displays for ${codeLabel}`, async () => { + const navigation = new Navigation(page); + await setDefaultCurrency(page, navigation, code); + + await page.goto('/budget', { waitUntil: 'load' }); + await page.waitForLoadState('networkidle'); + await expect(budgetPage.budgetSummary.first()).toBeVisible({ + timeout: 30000, + }); + await expect(budgetPage.budgetTable).toBeVisible(); + + await page.mouse.move(0, 0); + await expect(page).toMatchThemeScreenshots(); + }); + } + }); }); diff --git a/packages/desktop-client/e2e/currency-precision.ts b/packages/desktop-client/e2e/currency-precision.ts new file mode 100644 index 00000000000..8ecc2342a7b --- /dev/null +++ b/packages/desktop-client/e2e/currency-precision.ts @@ -0,0 +1,153 @@ +import type { Page } from '@playwright/test'; + +import type { Navigation } from './page-models/navigation'; + +export type CurrencyPrecisionDatum = { + code: string; + balance: number; + expectedDisplay: string; + /** Debit amount to type in transaction (e.g. '12.34', '1234', '12.345') */ + transactionDebit: string; + /** Expected debit display in transaction list (e.g. '12.34', '1,234', '12.345') */ + expectedDebitDisplay: string; + /** Split-transaction test: payee and amounts for each split row */ + split: { + payee: string; + debits: string[]; + expectedDisplays: string[]; + }; + /** Modified-amount-persists test: initial value, then change to new value */ + edit: { + initialDebit: string; + expectedInitial: string; + newDebit: string; + expectedAfter: string; + }; + /** Account-balance-header-updates test: debit amount and expected display */ + balanceCheck: { + debit: string; + expectedDebitDisplay: string; + expectedHeaderBalance: string; + }; +}; + +export const currencyPrecisionTestData: CurrencyPrecisionDatum[] = [ + { + code: '', + balance: 100, + expectedDisplay: '100.00', + transactionDebit: '12.34', + expectedDebitDisplay: '12.34', + split: { + payee: 'Split Default', + debits: ['33.33', '22.22', '11.11'], + expectedDisplays: ['33.33', '22.22', '11.11'], + }, + edit: { + initialDebit: '99.99', + expectedInitial: '99.99', + newDebit: '55.55', + expectedAfter: '55.55', + }, + balanceCheck: { + debit: '12.34', + expectedDebitDisplay: '12.34', + expectedHeaderBalance: '7,640.66', + }, + }, + { + code: 'JPY', + balance: 101, + expectedDisplay: '101', + transactionDebit: '1234', + expectedDebitDisplay: '1,234', + split: { + payee: 'Split JPY', + debits: ['1000', '600', '400'], + expectedDisplays: ['1,000', '600', '400'], + }, + edit: { + initialDebit: '5000', + expectedInitial: '5,000', + newDebit: '7500', + expectedAfter: '7,500', + }, + balanceCheck: { + debit: '500', + expectedDebitDisplay: '500', + expectedHeaderBalance: '‪¥‬764,800', + }, + }, + { + code: 'USD', + balance: 100.5, + expectedDisplay: '100.50', + transactionDebit: '12.34', + expectedDebitDisplay: '12.34', + split: { + payee: 'Split USD', + debits: ['333.33', '222.22', '111.11'], + expectedDisplays: ['333.33', '222.22', '111.11'], + }, + edit: { + initialDebit: '99.99', + expectedInitial: '99.99', + newDebit: '55.55', + expectedAfter: '55.55', + }, + balanceCheck: { + debit: '12.34', + expectedDebitDisplay: '12.34', + expectedHeaderBalance: '‪$‬7,640.66', + }, + }, + { + code: 'KWD', + balance: 100.5, + expectedDisplay: '100.500', + transactionDebit: '12.345', + expectedDebitDisplay: '12.345', + split: { + payee: 'Split KWD', + debits: ['10.500', '6.250', '4.250'], + expectedDisplays: ['10.500', '6.250', '4.250'], + }, + edit: { + initialDebit: '99.999', + expectedInitial: '99.999', + newDebit: '55.555', + expectedAfter: '55.555', + }, + balanceCheck: { + debit: '12.345', + expectedDebitDisplay: '12.345', + expectedHeaderBalance: '‪KD‬752.955', + }, + }, +]; + +/** + * Set the default currency in Settings. When code is '', ensures Currency + * support is disabled (Default / no-currency mode). Otherwise enables + * Currency support and selects the given currency. Returns the label for the + * currency (e.g. 'Default' or code). + */ +export async function setDefaultCurrency( + page: Page, + navigation: Navigation, + currencyCode: string, +): Promise { + const settingsPage = await navigation.goToSettingsPage(); + await settingsPage.waitFor(); + + if (currencyCode === '') { + await settingsPage.disableExperimentalFeature('Currency support'); + return 'Default'; + } + + await settingsPage.enableExperimentalFeature('Currency support'); + + await settingsPage.selectCurrency(currencyCode); + + return currencyCode; +} diff --git a/packages/desktop-client/e2e/fixtures.ts b/packages/desktop-client/e2e/fixtures.ts index 2e98f092f40..86370906239 100644 --- a/packages/desktop-client/e2e/fixtures.ts +++ b/packages/desktop-client/e2e/fixtures.ts @@ -1,8 +1,38 @@ +import './playwright-env'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { expect as baseExpect } from '@playwright/test'; -import type { Locator } from '@playwright/test'; +import type { Locator, TestInfo } from '@playwright/test'; + +const require = createRequire(import.meta.url); +const { currentTestInfo } = require('playwright/lib/common/globals') as { + currentTestInfo: () => TestInfo | null; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export { test } from '@playwright/test'; +function appendVrtSnapshotManifestLine( + testInfo: TestInfo, + absolutePath: string, +) { + if (!process.env.VRT) return; + const manifestDir = + process.env.VRT_SNAPSHOT_MANIFEST_DIR ?? + path.join(__dirname, '.vrt-manifest'); + fs.mkdirSync(manifestDir, { recursive: true }); + const rel = path + .relative(path.join(__dirname, '..'), absolutePath) + .split(path.sep) + .join('/'); + const file = path.join(manifestDir, `parallel-${testInfo.parallelIndex}.txt`); + fs.appendFileSync(file, `${rel}\n`, 'utf8'); +} + export const expect = baseExpect.extend({ async toMatchThemeScreenshots(locator: Locator) { // Disable screenshot assertions in regular e2e tests; @@ -14,6 +44,11 @@ export const expect = baseExpect.extend({ }; } + const testInfo = currentTestInfo(); + if (!testInfo) { + throw new Error('toMatchThemeScreenshots() must be called during a test'); + } + const config = { mask: [locator.locator('[data-vrt-mask="true"]')], maxDiffPixels: 5, @@ -30,11 +65,19 @@ export const expect = baseExpect.extend({ // Check lightmode await locator.evaluate(() => window.Actual.setTheme('auto')); await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto'); + appendVrtSnapshotManifestLine( + testInfo, + testInfo.snapshotPath('', { kind: 'screenshot' }), + ); await baseExpect(locator).toHaveScreenshot(config); // Switch to darkmode and check await locator.evaluate(() => window.Actual.setTheme('dark')); await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark'); + appendVrtSnapshotManifestLine( + testInfo, + testInfo.snapshotPath('', { kind: 'screenshot' }), + ); await baseExpect(locator).toHaveScreenshot(config); // Switch to midnight theme and check @@ -43,6 +86,10 @@ export const expect = baseExpect.extend({ 'data-theme', 'midnight', ); + appendVrtSnapshotManifestLine( + testInfo, + testInfo.snapshotPath('', { kind: 'screenshot' }), + ); await baseExpect(locator).toHaveScreenshot(config); // Switch back to lightmode diff --git a/packages/desktop-client/e2e/onboarding.test.ts b/packages/desktop-client/e2e/onboarding.test.ts index 8a4db3b06c4..971bb0f508e 100644 --- a/packages/desktop-client/e2e/onboarding.test.ts +++ b/packages/desktop-client/e2e/onboarding.test.ts @@ -60,6 +60,15 @@ test.describe('Onboarding', () => { await expect(budgetPage.budgetTable).toBeVisible({ timeout: 30000 }); const accountPage = await navigation.goToAccountPage('Checking'); + await expect(accountPage.accountBalance).toHaveText('‪$‬2,600.00'); + + await navigation.goToAccountPage('Saving'); + await expect(accountPage.accountBalance).toHaveText('‪$‬250.00'); + + const settingsPage = await navigation.goToSettingsPage(); + await settingsPage.disableExperimentalFeature('Currency support'); + + await navigation.goToAccountPage('Checking'); await expect(accountPage.accountBalance).toHaveText('2,600.00'); await navigation.goToAccountPage('Saving'); diff --git a/packages/desktop-client/e2e/page-models/settings-page.ts b/packages/desktop-client/e2e/page-models/settings-page.ts index 5ef6e56fc9c..0526cc8a42d 100644 --- a/packages/desktop-client/e2e/page-models/settings-page.ts +++ b/packages/desktop-client/e2e/page-models/settings-page.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import type { Locator, Page } from '@playwright/test'; export class SettingsPage { @@ -41,20 +42,87 @@ export class SettingsPage { } } - async enableExperimentalFeature(featureName: string) { + private async getExperimentalFeatureCheckbox( + featureName: string, + ): Promise { + const featureCheckbox = this.page.getByRole('checkbox', { + name: featureName, + }); + + // If the checkbox is already visible (sections already expanded), use it + const alreadyVisible = await featureCheckbox + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (alreadyVisible) { + return featureCheckbox; + } + + // Expand sections only when collapsed (expand buttons are visible when collapsed) if (await this.advancedSettingsButton.isVisible()) { await this.advancedSettingsButton.click(); + await this.advancedSettingsButton.waitFor({ + state: 'hidden', + timeout: 5000, + }); } - if (await this.experimentalSettingsButton.isVisible()) { await this.experimentalSettingsButton.click(); + await this.experimentalSettingsButton.waitFor({ + state: 'hidden', + timeout: 5000, + }); } - const featureCheckbox = this.page.getByRole('checkbox', { - name: featureName, - }); + await featureCheckbox.waitFor({ state: 'visible', timeout: 15000 }); + return featureCheckbox; + } + + async enableExperimentalFeature(featureName: string) { + const featureCheckbox = + await this.getExperimentalFeatureCheckbox(featureName); if (!(await featureCheckbox.isChecked())) { await featureCheckbox.click(); + // Synced prefs update after async save; wait for checkbox to reflect state + await expect(featureCheckbox).toBeChecked({ timeout: 15000 }); + } + } + + async disableExperimentalFeature(featureName: string) { + const featureCheckbox = + await this.getExperimentalFeatureCheckbox(featureName); + if (await featureCheckbox.isChecked()) { + await featureCheckbox.click(); + // Synced prefs update after async save; wait for checkbox to reflect state + await expect(featureCheckbox).not.toBeChecked({ timeout: 15000 }); } } + + /** + * Select the default currency from the Settings currency dropdown. + * Call after enabling the "Currency support" experimental feature. + */ + async selectCurrency(currencyCode: string): Promise { + const trimmed = currencyCode.trim(); + if (!trimmed) { + throw new Error( + 'selectCurrency requires a non-empty ISO currency code (e.g. "USD").', + ); + } + + const dropdownTrigger = this.settings + .getByRole('button', { name: /^(None|[A-Z]{3} - )/ }) + .first(); + await dropdownTrigger.scrollIntoViewIfNeeded(); + await dropdownTrigger.waitFor({ state: 'visible', timeout: 15000 }); + await dropdownTrigger.click(); + + const currencyMenu = this.page.locator('[data-popover]').last(); + await currencyMenu.waitFor({ state: 'visible', timeout: 15000 }); + const currencyOption = currencyMenu.getByRole('button', { + name: `${trimmed} -`, + }); + await currencyOption.waitFor({ state: 'visible', timeout: 15000 }); + await currencyOption.click(); + } } diff --git a/packages/desktop-client/e2e/playwright-env.ts b/packages/desktop-client/e2e/playwright-env.ts new file mode 100644 index 00000000000..4de025df79c --- /dev/null +++ b/packages/desktop-client/e2e/playwright-env.ts @@ -0,0 +1,10 @@ +export {}; + +declare global { + // oxlint-disable-next-line typescript/consistent-type-definitions -- interface merges with lib.dom Window + interface Window { + Actual: { + setTheme(theme: string): void; + }; + } +} diff --git a/packages/desktop-client/e2e/transactions.test.ts b/packages/desktop-client/e2e/transactions.test.ts index 120e2f15473..8b3ed883333 100644 --- a/packages/desktop-client/e2e/transactions.test.ts +++ b/packages/desktop-client/e2e/transactions.test.ts @@ -1,5 +1,9 @@ import type { Page } from '@playwright/test'; +import { + currencyPrecisionTestData, + setDefaultCurrency, +} from './currency-precision'; import { expect, test } from './fixtures'; import type { AccountPage } from './page-models/account-page'; import { ConfigurationPage } from './page-models/configuration-page'; @@ -26,6 +30,111 @@ test.describe('Transactions', () => { await page?.close(); }); + test.describe('Currency Precision', () => { + for (const { + code, + transactionDebit, + expectedDebitDisplay, + split, + edit, + balanceCheck, + } of currencyPrecisionTestData) { + const codeLabel = code === '' ? 'Default' : code; + test(`entered amount matches displayed amount for ${codeLabel}`, async () => { + await setDefaultCurrency(page, navigation, code); + accountPage = await navigation.goToAccountPage('Ally Savings'); + await accountPage.waitFor(); + + await accountPage.createSingleTransaction({ + payee: `${codeLabel} Test`, + debit: transactionDebit, + category: 'Food', + }); + + const transaction = accountPage.getNthTransaction(0); + await expect(transaction.debit).toHaveText(expectedDebitDisplay); + await expect(transaction.payee).toHaveText(`${codeLabel} Test`); + + await expect(page).toMatchThemeScreenshots(); + }); + + test(`split transaction shows correct amounts for ${codeLabel}`, async () => { + await setDefaultCurrency(page, navigation, code); + accountPage = await navigation.goToAccountPage('Ally Savings'); + await accountPage.waitFor(); + + const [firstDebit, ...restDebits] = split.debits; + await accountPage.createSplitTransaction([ + { payee: split.payee, debit: firstDebit, category: 'split' }, + ...restDebits.map((debit, i) => ({ + debit, + category: (i === 0 ? 'Food' : 'General') as string, + })), + ]); + + for (let i = 0; i < split.expectedDisplays.length; i++) { + const expected = split.expectedDisplays[i]; + const row = accountPage.getNthTransaction(i); + if (i === 0) { + await expect(row.payee).toHaveText(split.payee); + } + await expect(row.debit).toHaveText(expected); + } + + await expect(page).toMatchThemeScreenshots(); + }); + + test(`modified amount persists correctly for ${codeLabel}`, async () => { + await setDefaultCurrency(page, navigation, code); + accountPage = await navigation.goToAccountPage('Ally Savings'); + await accountPage.waitFor(); + + await accountPage.createSingleTransaction({ + payee: `Edit ${codeLabel}`, + debit: edit.initialDebit, + category: 'Food', + }); + + let transaction = accountPage.getNthTransaction(0); + await expect(transaction.debit).toHaveText(edit.expectedInitial); + + await transaction.debit.click(); + const debitInput = transaction.debit.getByRole('textbox'); + await debitInput.selectText(); + await debitInput.pressSequentially(edit.newDebit); + await page.keyboard.press('Tab'); + + transaction = accountPage.getNthTransaction(0); + await expect(transaction.debit).toHaveText(edit.expectedAfter); + }); + + test(`account balance header updates after transaction for ${codeLabel}`, async () => { + await setDefaultCurrency(page, navigation, code); + accountPage = await navigation.goToAccountPage('Ally Savings'); + await accountPage.waitFor(); + + const balanceBefore = await accountPage.accountBalance.textContent(); + + await accountPage.createSingleTransaction({ + payee: 'Balance Check', + debit: balanceCheck.debit, + category: 'Food', + }); + + const balanceAfter = await accountPage.accountBalance.textContent(); + expect(balanceAfter).not.toBe(balanceBefore); + await expect(accountPage.accountBalance).toHaveText( + balanceCheck.expectedHeaderBalance, + ); + + const transaction = accountPage.getNthTransaction(0); + await expect(transaction.debit).toHaveText( + balanceCheck.expectedDebitDisplay, + ); + }); + } + }); + test('checks the page visuals', async () => { await expect(page).toMatchThemeScreenshots(); }); diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index f5ca95833a2..4816b012306 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -15,6 +15,7 @@ "test": "vitest --run", "e2e": "npx playwright test --browser=chromium", "vrt": "cross-env VRT=true npx playwright test --browser=chromium", + "prune-vrt-snapshots": "node ./bin/prune-vrt-snapshots.mjs", "playwright": "playwright", "typecheck": "tsgo -b && tsc-strict" }, diff --git a/packages/desktop-client/src/accounts/mutations.ts b/packages/desktop-client/src/accounts/mutations.ts index 7f3634e7b45..ba04ae260a8 100644 --- a/packages/desktop-client/src/accounts/mutations.ts +++ b/packages/desktop-client/src/accounts/mutations.ts @@ -58,6 +58,7 @@ type CreateAccountPayload = { name: string; balance: number; offBudget: boolean; + decimalPlaces: number; }; export function useCreateAccountMutation() { @@ -66,11 +67,17 @@ export function useCreateAccountMutation() { const { t } = useTranslation(); return useMutation({ - mutationFn: async ({ name, balance, offBudget }: CreateAccountPayload) => { + mutationFn: async ({ + name, + balance, + offBudget, + decimalPlaces, + }: CreateAccountPayload) => { const id = await send('account-create', { name, balance, offBudget, + decimalPlaces, }); return id; }, diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 327b5b8c970..c5db551457f 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -57,6 +57,7 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules'; import { useCategories } from '@desktop-client/hooks/useCategories'; import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { usePayees } from '@desktop-client/hooks/usePayees'; import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules'; @@ -243,6 +244,7 @@ type AccountInternalProps = { payees: PayeeEntity[]; categoryGroups: CategoryGroupEntity[]; hideFraction: boolean; + decimalPlaces: number; accountsSyncing: string[]; dispatch: AppDispatch; onSetTransfer: ReturnType['onSetTransfer']; @@ -399,6 +401,22 @@ class AccountInternal extends PureComponent< this.setState({ isAdding: false }); } + // When decimalPlaces changes with an active search, re-run search so results use new precision + if ( + prevProps.decimalPlaces !== this.props.decimalPlaces && + this.state.search !== '' + ) { + this.updateQuery( + queries.transactionsSearch( + this.currentQuery, + this.state.search, + this.props.dateFormat, + this.props.decimalPlaces, + ), + true, + ); + } + // If the user was on a different screen and is now coming back to // the transactions, automatically refresh the transaction to make // sure we have updated state @@ -564,6 +582,7 @@ class AccountInternal extends PureComponent< this.currentQuery, this.state.search, this.props.dateFormat, + this.props.decimalPlaces, ), true, ); @@ -1986,6 +2005,7 @@ export function Account() { const { data: payees = [] } = usePayees(); const failedAccounts = useFailedAccounts(); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + const decimalPlaces = useFormat().currency.decimalPlaces; const [hideFraction] = useSyncedPref('hideFraction'); const [expandSplits] = useLocalPref('expand-splits'); const [showBalances, setShowBalances] = useSyncedPref( @@ -2044,6 +2064,7 @@ export function Account() { failedAccounts={failedAccounts} dateFormat={dateFormat} hideFraction={String(hideFraction) === 'true'} + decimalPlaces={decimalPlaces} expandSplits={expandSplits} showBalances={String(showBalances) === 'true'} setShowBalances={showBalances => diff --git a/packages/desktop-client/src/components/accounts/BalanceHistoryGraph.tsx b/packages/desktop-client/src/components/accounts/BalanceHistoryGraph.tsx index c43396f4f11..fe36eae42b7 100644 --- a/packages/desktop-client/src/components/accounts/BalanceHistoryGraph.tsx +++ b/packages/desktop-client/src/components/accounts/BalanceHistoryGraph.tsx @@ -11,11 +11,12 @@ import { eachMonthOfInterval, format, subMonths } from 'date-fns'; import { Area, AreaChart, Tooltip as RechartsTooltip, YAxis } from 'recharts'; import * as monthUtils from 'loot-core/shared/months'; -import { integerToCurrency } from 'loot-core/shared/util'; +import { FinancialText } from '@desktop-client/components/FinancialText'; import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter'; import { useRechartsAnimation } from '@desktop-client/components/reports/chart-theme'; import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useLocale } from '@desktop-client/hooks/useLocale'; import * as query from '@desktop-client/queries'; import { liveQuery } from '@desktop-client/queries/liveQuery'; @@ -34,6 +35,7 @@ export function BalanceHistoryGraph({ ref, }: BalanceHistoryGraphProps) { const locale = useLocale(); + const formatCurrency = useFormat(); const animationProps = useRechartsAnimation({ isAnimationActive: false }); const [balanceData, setBalanceData] = useState< Array<{ date: string; balance: number }> @@ -316,7 +318,9 @@ export function BalanceHistoryGraph({ {hoveredValue.date} !isHovered]}> - {integerToCurrency(hoveredValue.balance)} + + {formatCurrency(hoveredValue.balance, 'financial')} + )} diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index 19d3f404370..890f7551a8b 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -33,6 +33,7 @@ import { useEnvelopeSheetValue } from '@desktop-client/components/budget/envelop import { makeAmountFullStyle } from '@desktop-client/components/budget/util'; import { FinancialText } from '@desktop-client/components/FinancialText'; import { useCategories } from '@desktop-client/hooks/useCategories'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useSheetValue } from '@desktop-client/hooks/useSheetValue'; import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref'; import { @@ -77,6 +78,9 @@ function CategoryList({ showBalances, }: CategoryListProps) { const { t } = useTranslation(); + const { + currency: { decimalPlaces }, + } = useFormat(); const { splitTransaction, groupedCategories } = useMemo(() => { return items.reduce( (acc, item, index) => { @@ -177,6 +181,7 @@ function CategoryList({ }), }, showBalances, + decimalPlaces, })} ))} @@ -426,6 +431,7 @@ type CategoryItemProps = { highlighted?: boolean; embedded?: boolean; showBalances?: boolean; + decimalPlaces: number; }; function CategoryItem({ @@ -435,6 +441,7 @@ function CategoryItem({ highlighted, embedded, showBalances, + decimalPlaces, ...props }: CategoryItemProps) { const { t } = useTranslation(); @@ -507,7 +514,7 @@ function CategoryItem({ <> {' '} - {integerToCurrency(toBudget || 0)} + {integerToCurrency(toBudget || 0, decimalPlaces)} ) @@ -515,7 +522,7 @@ function CategoryItem({ <> {' '} - {integerToCurrency(balance || 0)} + {integerToCurrency(balance || 0, decimalPlaces)} )} diff --git a/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx b/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx index 37efa00397e..61ad4d5b5fe 100644 --- a/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx +++ b/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx @@ -121,16 +121,24 @@ export function BalanceWithCarryover({ const budgetedValue = useSheetValue(budgeted); const longGoalValue = useSheetValue(longGoal); const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const format = useFormat(); + const decimalPlaces = format.currency.decimalPlaces; const getBalanceAmountStyle = useCallback( (balanceValue: number) => makeBalanceAmountStyle( balanceValue, + decimalPlaces, isGoalTemplatesEnabled ? goalValue : null, longGoalValue === 1 ? balanceValue : budgetedValue, ), - [budgetedValue, goalValue, isGoalTemplatesEnabled, longGoalValue], + [ + budgetedValue, + decimalPlaces, + goalValue, + isGoalTemplatesEnabled, + longGoalValue, + ], ); - const format = useFormat(); const getDifferenceToGoal = useCallback( (balanceValue: number) => diff --git a/packages/desktop-client/src/components/budget/envelope/CoverMenu.tsx b/packages/desktop-client/src/components/budget/envelope/CoverMenu.tsx index d3656473762..97c2c347ceb 100644 --- a/packages/desktop-client/src/components/budget/envelope/CoverMenu.tsx +++ b/packages/desktop-client/src/components/budget/envelope/CoverMenu.tsx @@ -18,6 +18,7 @@ import { removeCategoriesFromGroups, } from '@desktop-client/components/budget/util'; import { useCategories } from '@desktop-client/hooks/useCategories'; +import { useFormat } from '@desktop-client/hooks/useFormat'; type CoverMenuProps = { showToBeBudgeted?: boolean; @@ -35,6 +36,7 @@ export function CoverMenu({ onClose, }: CoverMenuProps) { const { t } = useTranslation(); + const decimalPlaces = useFormat().currency.decimalPlaces; const { data: { grouped: originalCategoryGroups } = { grouped: [] } } = useCategories(); @@ -51,13 +53,16 @@ export function CoverMenu({ : categoryGroups; }, [categoryId, showToBeBudgeted, originalCategoryGroups]); - const _initialAmount = integerToCurrency(Math.abs(initialAmount ?? 0)); + const _initialAmount = integerToCurrency( + Math.abs(initialAmount ?? 0), + decimalPlaces, + ); const [amount, setAmount] = useState(_initialAmount); function _onSubmit() { const parsedAmount = evalArithmetic(amount || ''); if (parsedAmount && fromCategoryId) { - onSubmit(amountToInteger(parsedAmount), fromCategoryId); + onSubmit(amountToInteger(parsedAmount, decimalPlaces), fromCategoryId); } onClose(); } diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts index 8aa9471e41c..24372873791 100644 --- a/packages/desktop-client/src/components/budget/util.ts +++ b/packages/desktop-client/src/components/budget/util.ts @@ -6,7 +6,7 @@ import { t } from 'i18next'; import { send } from 'loot-core/platform/client/connection'; import * as monthUtils from 'loot-core/shared/months'; -import { currencyToAmount, integerToCurrency } from 'loot-core/shared/util'; +import { integerToAmount } from 'loot-core/shared/util'; import type { Handlers } from 'loot-core/types/handlers'; import type { CategoryEntity, @@ -68,15 +68,14 @@ export function makeAmountGrey(value: number | string | null): CSSProperties { export function makeBalanceAmountStyle( value: number, + decimalPlaces: number, goalValue?: number | null, budgetedValue?: number | null, ) { - // Converts an integer currency value to a normalized decimal amount. - // First converts the integer to currency format, then to a decimal amount. - // Uses integerToCurrency to display the value correctly according to user prefs. - + // Converts an integer currency value to a decimal amount using actual + // decimal precision (no display rounding) for exact numeric comparisons. const normalizeIntegerValue = (val: number | null | undefined) => - typeof val === 'number' ? currencyToAmount(integerToCurrency(val)) : 0; + typeof val === 'number' ? integerToAmount(val, decimalPlaces) : 0; const currencyValue = normalizeIntegerValue(value); diff --git a/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx index 03ce946d65c..ae4bd5e4b5e 100644 --- a/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx @@ -1,10 +1,11 @@ -import React, { memo, useEffect, useRef, useState } from 'react'; +import React, { memo, useEffect, useId, useRef, useState } from 'react'; import type { ComponentPropsWithRef, CSSProperties, HTMLProps, Ref, } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '@actual-app/components/button'; import type { CSSProperties as EmotionCSSProperties } from '@actual-app/components/styles'; @@ -14,13 +15,16 @@ import { View } from '@actual-app/components/view'; import { css } from '@emotion/css'; import { - amountToCurrency, + amountToInteger, appendDecimals, currencyToAmount, + getFractionDigitCount, reapplyThousandSeparators, } from 'loot-core/shared/util'; import { makeAmountFullStyle } from '@desktop-client/components/budget/util'; +import { FinancialText } from '@desktop-client/components/FinancialText'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs'; import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref'; @@ -44,11 +48,20 @@ const AmountInput = memo(function AmountInput({ textStyle, ...props }: AmountInputProps) { + const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [text, setText] = useState(''); const [value, setValue] = useState(0); + const [showFractionError, setShowFractionError] = useState(false); + const [fractionErrorMessage, setFractionErrorMessage] = useState(''); + const fractionErrorClearRef = useRef | null>( + null, + ); + const fractionErrorId = useId(); const inputRef = useRef(null); const [hideFraction] = useSyncedPref('hideFraction'); + const format = useFormat(); + const decimalPlaces = format.currency.decimalPlaces; const mergedInputRef = useMergedRefs( props.inputRef, @@ -57,6 +70,15 @@ const AmountInput = memo(function AmountInput({ const initialValue = Math.abs(props.value); + function clearFractionErrorState() { + if (fractionErrorClearRef.current != null) { + clearTimeout(fractionErrorClearRef.current); + fractionErrorClearRef.current = null; + } + setShowFractionError(false); + setFractionErrorMessage(''); + } + useEffect(() => { if (focused) { inputRef.current?.focus(); @@ -67,8 +89,29 @@ const AmountInput = memo(function AmountInput({ setEditing(false); setText(''); setValue(initialValue); + clearFractionErrorState(); }, [initialValue]); + useEffect(() => { + return () => { + if (fractionErrorClearRef.current != null) { + clearTimeout(fractionErrorClearRef.current); + } + }; + }, []); + + function flashFractionError() { + const message = t('This currency does not allow that many decimal places'); + setFractionErrorMessage(message); + setShowFractionError(true); + if (fractionErrorClearRef.current != null) { + clearTimeout(fractionErrorClearRef.current); + } + fractionErrorClearRef.current = setTimeout(() => { + clearFractionErrorState(); + }, 800); + } + const onKeyUp: HTMLProps['onKeyUp'] = e => { if (e.key === 'Backspace' && text === '') { setEditing(true); @@ -81,6 +124,11 @@ const AmountInput = memo(function AmountInput({ }; const applyText = () => { + if (getFractionDigitCount(text) > decimalPlaces) { + flashFractionError(); + return Math.abs(props.value); + } + clearFractionErrorState(); const parsed = currencyToAmount(text) || 0; const newValue = editing ? parsed : value; @@ -113,7 +161,12 @@ const AmountInput = memo(function AmountInput({ const onChangeText = (text: string) => { text = reapplyThousandSeparators(text); - text = appendDecimals(text, String(hideFraction) === 'true'); + text = appendDecimals(text, String(hideFraction) === 'true', decimalPlaces); + if (getFractionDigitCount(text) > decimalPlaces) { + flashFractionError(); + return; + } + clearFractionErrorState(); setEditing(true); setText(text); props.onChangeValue?.(text); @@ -131,6 +184,10 @@ const AmountInput = memo(function AmountInput({ onBlur={onBlur} onKeyUp={onKeyUp} data-testid="amount-input" + aria-invalid={showFractionError || undefined} + aria-describedby={ + showFractionError && fractionErrorMessage ? fractionErrorId : undefined + } style={{ flex: 1, textAlign: 'center', position: 'absolute' }} /> ); @@ -139,8 +196,10 @@ const AmountInput = memo(function AmountInput({ {input} - - {editing ? text : amountToCurrency(value)} - + {editing ? text : format.forEdit(amountToInteger(value, decimalPlaces))} + + {showFractionError && fractionErrorMessage ? ( + + {fractionErrorMessage} + + ) : null} ); }); @@ -188,6 +262,8 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ onBlur, ...props }: FocusableAmountInputProps) { + const format = useFormat(); + const decimalPlaces = format.currency.decimalPlaces; const [isNegative, setIsNegative] = useState(true); const maybeApplyNegative = (amount: number, negative: boolean) => { @@ -277,7 +353,7 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ ...style, }} > - - {amountToCurrency(Math.abs(value))} - + {format.forEdit(amountToInteger(Math.abs(value), decimalPlaces))} + diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx index 52c72e9e1d2..2a9643f928e 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx @@ -80,6 +80,7 @@ import { AmountInput } from '@desktop-client/components/util/AmountInput'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useCategories } from '@desktop-client/hooks/useCategories'; import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useInitialMount } from '@desktop-client/hooks/useInitialMount'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useLocationPermission } from '@desktop-client/hooks/useLocationPermission'; @@ -106,12 +107,13 @@ function getFieldName(transactionId: TransactionEntity['id'], field: string) { function serializeTransaction( transaction: TransactionEntity, dateFormat: string, + decimalPlaces: number, ) { const { date, amount } = transaction; return { ...transaction, date: formatDate(parseISO(date), dateFormat), - amount: integerToAmount(amount || 0), + amount: integerToAmount(amount || 0, decimalPlaces), }; } @@ -119,6 +121,7 @@ function deserializeTransaction( transaction: TransactionEntity, originalTransaction: TransactionEntity | null, dateFormat: string, + decimalPlaces: number, ) { const { amount, date: originalDate, ...realTransaction } = transaction; @@ -152,7 +155,11 @@ function deserializeTransaction( monthUtils.currentDay(); } - return { ...realTransaction, date, amount: amountToInteger(amount || 0) }; + return { + ...realTransaction, + date, + amount: amountToInteger(amount || 0, decimalPlaces), + }; } export function lookupName(items: CategoryEntity[], id?: CategoryEntity['id']) { @@ -232,6 +239,7 @@ function Footer({ onEditField, }: FooterProps) { const [transaction, ...childTransactions] = transactions; + const decimalPlaces = useFormat().currency.decimalPlaces; const emptySplitTransaction = childTransactions.find(t => t.amount === 0); const onClickRemainingSplit = () => { if (childTransactions.length === 0) { @@ -280,6 +288,7 @@ function Footer({ transaction.amount > 0 ? transaction.error.difference : -transaction.error.difference, + decimalPlaces, ), }}{' '} left @@ -292,6 +301,7 @@ function Footer({ transaction.amount > 0 ? transaction.error.difference : -transaction.error.difference, + decimalPlaces, ), }} @@ -400,6 +410,7 @@ const ChildTransactionEdit = forwardRef< const { t } = useTranslation(); const { editingField, onRequestActiveEdit, onClearActiveEdit } = useSingleActiveEditForm()!; + const decimalPlaces = useFormat().currency.decimalPlaces; const [hideFraction, _] = useSyncedPref('hideFraction'); const prettyPayee = getPrettyPayee({ @@ -448,7 +459,7 @@ const ChildTransactionEdit = forwardRef< editingField !== getFieldName(transaction.id, 'amount') } focused={amountFocused} - value={amountToInteger(transaction.amount)} + value={amountToInteger(transaction.amount, decimalPlaces)} zeroSign={amountSign} style={{ marginRight: 8 }} inputStyle={{ @@ -460,7 +471,7 @@ const ChildTransactionEdit = forwardRef< onRequestActiveEdit(getFieldName(transaction.id, 'amount')) } onUpdate={value => { - const amount = integerToAmount(value); + const amount = integerToAmount(value, decimalPlaces); if (transaction.amount !== amount) { onUpdate(transaction, 'amount', amount); } else { @@ -586,6 +597,7 @@ const TransactionEditInner = memo( nearestPayee, }) { const { t } = useTranslation(); + const decimalPlaces = useFormat().currency.decimalPlaces; const navigate = useNavigate(); const dispatch = useDispatch(); const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories'); @@ -595,9 +607,9 @@ const TransactionEditInner = memo( const transactions = useMemo( () => unserializedTransactions.map(t => - serializeTransaction(t, dateFormat), + serializeTransaction(t, dateFormat, decimalPlaces), ) || [], - [unserializedTransactions, dateFormat], + [unserializedTransactions, dateFormat, decimalPlaces], ); const { data: { grouped: categoryGroups } = { grouped: [] } } = useCategories(); @@ -1373,6 +1385,7 @@ function TransactionEditUnconnected({ dateFormat, }: TransactionEditUnconnectedProps) { const { t } = useTranslation(); + const decimalPlaces = useFormat().currency.decimalPlaces; const { transactionId } = useParams(); const { state: locationState } = useLocation(); const [searchParams] = useSearchParams(); @@ -1468,6 +1481,7 @@ function TransactionEditUnconnected({ category: searchParamCategory || locationState?.categoryId || null, amount: -amountToInteger( parseFloat(searchParams.get('amount') || '') || 0, + decimalPlaces, ), cleared: searchParams.get('cleared') === 'true', notes: searchParams.get('notes') || '', @@ -1482,6 +1496,7 @@ function TransactionEditUnconnected({ searchParamCategory, searchParamPayee, searchParams, + decimalPlaces, ]); const onUpdate = useCallback( @@ -1493,6 +1508,7 @@ function TransactionEditUnconnected({ serializedTransaction, null, dateFormat, + decimalPlaces, ); // Run the rules to auto-fill in any data. Right now we only do @@ -1570,7 +1586,7 @@ function TransactionEditUnconnected({ } } }, - [dateFormat, transactions, locationAccess], + [dateFormat, transactions, locationAccess, decimalPlaces], ); const onSave = useCallback( @@ -1677,11 +1693,11 @@ function TransactionEditUnconnected({ } const updated = { - ...serializeTransaction(transaction, dateFormat), + ...serializeTransaction(transaction, dateFormat, decimalPlaces), payee: nearestPayee.id, }; void onUpdate(updated, 'payee'); - }, [transactions, nearestPayee, onUpdate, dateFormat]); + }, [transactions, nearestPayee, onUpdate, dateFormat, decimalPlaces]); if (accounts.length === 0) { return ( diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.tsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.tsx index 328c1d99196..d701e118916 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionList.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.tsx @@ -41,6 +41,7 @@ import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem'; import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useCategoriesById } from '@desktop-client/hooks/useCategories'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useLocale } from '@desktop-client/hooks/useLocale'; import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { usePayees } from '@desktop-client/hooks/usePayees'; @@ -280,6 +281,7 @@ function SelectedTransactionsFloatingActionBar({ showMakeTransfer, }: SelectedTransactionsFloatingActionBarProps) { const { t } = useTranslation(); + const decimalPlaces = useFormat().currency.decimalPlaces; const editMenuTriggerRef = useRef(null); const [isEditMenuOpen, setIsEditMenuOpen] = useState(false); const moreOptionsMenuTriggerRef = useRef(null); @@ -503,7 +505,7 @@ function SelectedTransactionsFloatingActionBar({ case 'amount': displayValue = Number.isNaN(Number(value)) ? value - : integerToCurrency(Number(value)); + : integerToCurrency(Number(value), decimalPlaces); break; case 'notes': displayValue = `${mode} with ${String(value)}`; diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx index 7b83c9db38c..b7f6a3d86fc 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx @@ -39,6 +39,7 @@ import { useAccount } from '@desktop-client/hooks/useAccount'; import { useCachedSchedules } from '@desktop-client/hooks/useCachedSchedules'; import { useCategories } from '@desktop-client/hooks/useCategories'; import { useDisplayPayee } from '@desktop-client/hooks/useDisplayPayee'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { usePayee } from '@desktop-client/hooks/usePayee'; import { NotesTagFormatter } from '@desktop-client/notes/NotesTagFormatter'; import { useSelector } from '@desktop-client/redux'; @@ -88,6 +89,7 @@ export function TransactionListItem({ const { data: payee } = usePayee(transaction?.payee); const displayPayee = useDisplayPayee({ transaction }); + const decimalPlaces = useFormat().currency.decimalPlaces; const account = useAccount(transaction?.account || ''); const transferAccount = useAccount(payee?.transfer_acct || ''); @@ -288,7 +290,7 @@ export function TransactionListItem({ ...textStyle, }} > - {integerToCurrency(amount)} + {integerToCurrency(amount, decimalPlaces)} {showRunningBalance && runningBalance !== undefined && ( - {integerToCurrency(runningBalance)} + {integerToCurrency(runningBalance, decimalPlaces)} )} diff --git a/packages/desktop-client/src/components/modals/CloseAccountModal.tsx b/packages/desktop-client/src/components/modals/CloseAccountModal.tsx index 3bfeebb92fb..807267518ea 100644 --- a/packages/desktop-client/src/components/modals/CloseAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CloseAccountModal.tsx @@ -13,7 +13,6 @@ import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; -import { integerToCurrency } from 'loot-core/shared/util'; import type { AccountEntity } from 'loot-core/types/models'; import type { TransObjectLiteral } from 'loot-core/types/util'; @@ -26,8 +25,10 @@ import { ModalCloseButton, ModalHeader, } from '@desktop-client/components/common/Modal'; +import { FinancialText } from '@desktop-client/components/FinancialText'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useCategories } from '@desktop-client/hooks/useCategories'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { pushModal } from '@desktop-client/modals/modalsSlice'; import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice'; import { useDispatch } from '@desktop-client/redux'; @@ -56,6 +57,7 @@ export function CloseAccountModal({ canDelete, }: CloseAccountModalProps) { const { t } = useTranslation(); // Initialize translation hook + const format = useFormat(); const { data: allAccounts = [] } = useAccounts(); const accounts = allAccounts.filter(a => a.closed === 0); const { @@ -174,11 +176,9 @@ export function CloseAccountModal({ This account has a balance of{' '} - { - { - balance: integerToCurrency(balance), - } as TransObjectLiteral - } + + {format(balance, 'financial')} + . To close this account, select a different account to transfer this balance to: diff --git a/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx b/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx index cfab6d88d74..73a3ccc8a29 100644 --- a/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx @@ -13,7 +13,7 @@ import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; -import { toRelaxedNumber } from 'loot-core/shared/util'; +import { currencyToAmount } from 'loot-core/shared/util'; import { useCreateAccountMutation } from '@desktop-client/accounts'; import { Link } from '@desktop-client/components/common/Link'; @@ -27,6 +27,7 @@ import { import { Checkbox } from '@desktop-client/components/forms'; import { validateAccountName } from '@desktop-client/components/util/accountValidation'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { closeModal } from '@desktop-client/modals/modalsSlice'; import { useDispatch } from '@desktop-client/redux'; @@ -35,6 +36,7 @@ export function CreateLocalAccountModal() { const { t } = useTranslation(); const navigate = useNavigate(); const dispatch = useDispatch(); + const format = useFormat(); const { data: accounts = [] } = useAccounts(); const [name, setName] = useState(''); const [offbudget, setOffbudget] = useState(false); @@ -66,11 +68,13 @@ export function CreateLocalAccountModal() { setBalanceError(balanceError); if (!nameError && !balanceError) { + const amount = currencyToAmount(balance); createAccount.mutate( { name, - balance: toRelaxedNumber(balance), + balance: amount != null ? amount : 0, offBudget: offbudget, + decimalPlaces: format.currency.decimalPlaces, }, { onSuccess: id => { diff --git a/packages/desktop-client/src/components/modals/EditFieldModal.tsx b/packages/desktop-client/src/components/modals/EditFieldModal.tsx index fa788105da7..c445bf53329 100644 --- a/packages/desktop-client/src/components/modals/EditFieldModal.tsx +++ b/packages/desktop-client/src/components/modals/EditFieldModal.tsx @@ -10,7 +10,11 @@ import { View } from '@actual-app/components/view'; import { format as formatDate, parse as parseDate, parseISO } from 'date-fns'; import { currentDay, dayFromDate } from 'loot-core/shared/months'; -import { amountToInteger, currencyToInteger } from 'loot-core/shared/util'; +import { + amountToInteger, + currencyToAmount, + getFractionDigitCount, +} from 'loot-core/shared/util'; import { Modal, @@ -21,6 +25,7 @@ import { SectionLabel } from '@desktop-client/components/forms'; import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox'; import { DateSelect } from '@desktop-client/components/select/DateSelect'; import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice'; const itemStyle: CSSProperties = { @@ -52,6 +57,7 @@ export function EditFieldModal({ onClose, }: EditFieldModalProps) { const { t } = useTranslation(); + const decimalPlaces = useFormat().currency.decimalPlaces; const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const noteInputRef = useRef(null); const noteReplaceInputRef = useRef(null); @@ -62,24 +68,30 @@ export function EditFieldModal({ } } - function onSelect(value: string | number) { - if (value != null) { - // Process the value if needed - if (name === 'amount') { - if (typeof value === 'string') { - const parsed = currencyToInteger(value); - if (parsed === null) { - alert(t('Invalid amount value')); - return; - } - value = parsed; - } else if (typeof value === 'number') { - value = amountToInteger(value); + function onSelect(value: string | number): boolean { + if (value == null) { + return false; + } + // Process the value if needed + if (name === 'amount') { + if (typeof value === 'string') { + const parsed = currencyToAmount(value); + if (parsed == null) { + alert(t('Invalid amount value')); + return false; } + if (getFractionDigitCount(value) > decimalPlaces) { + alert(t('Invalid amount value')); + return false; + } + value = amountToInteger(parsed, decimalPlaces); + } else if (typeof value === 'number') { + value = amountToInteger(value, decimalPlaces); } - - onSubmit(name, value); } + + onSubmit(name, value); + return true; } const { isNarrowWidth } = useResponsive(); @@ -111,8 +123,11 @@ export function EditFieldModal({ dateFormat={dateFormat} embedded onSelect={date => { - onSelect(dayFromDate(parseDate(date, 'yyyy-MM-dd', new Date()))); - close(); + if ( + onSelect(dayFromDate(parseDate(date, 'yyyy-MM-dd', new Date()))) + ) { + close(); + } }} /> ); @@ -245,8 +260,9 @@ export function EditFieldModal({ editor = ({ close }) => ( { - onSelect(value); - close(); + if (onSelect(value)) { + close(); + } }} style={inputStyle} /> diff --git a/packages/desktop-client/src/components/modals/EnvelopeBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/EnvelopeBudgetMenuModal.tsx index 323e825e8b3..f1437cd1f29 100644 --- a/packages/desktop-client/src/components/modals/EnvelopeBudgetMenuModal.tsx +++ b/packages/desktop-client/src/components/modals/EnvelopeBudgetMenuModal.tsx @@ -28,6 +28,7 @@ import { import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput'; import { Notes } from '@desktop-client/components/Notes'; import { useCategory } from '@desktop-client/hooks/useCategory'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useNotes } from '@desktop-client/hooks/useNotes'; import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice'; import { envelopeBudget } from '@desktop-client/spreadsheet/bindings'; @@ -54,6 +55,7 @@ export function EnvelopeBudgetMenuModal({ flexBasis: '100%', }; + const decimalPlaces = useFormat().currency.decimalPlaces; const defaultMenuItemStyle: CSSProperties = { ...styles.mobileMenuItem, color: theme.menuItemText, @@ -70,7 +72,7 @@ export function EnvelopeBudgetMenuModal({ const notesId = category ? `${category.id}-${month}` : ''; const originalNotes = useNotes(notesId) ?? ''; const _onUpdateBudget = (amount: number) => { - onUpdateBudget?.(amountToInteger(amount)); + onUpdateBudget?.(amountToInteger(amount, decimalPlaces)); }; const [showMore, setShowMore] = useState(false); @@ -120,7 +122,7 @@ export function EnvelopeBudgetMenuModal({ Budgeted setAmountFocused(true)} onBlur={() => setAmountFocused(false)} diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx index da0dc86f854..a7797d10c52 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx @@ -55,6 +55,7 @@ import { } from '@desktop-client/components/table'; import { useCategories } from '@desktop-client/hooks/useCategories'; import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useSyncedPrefs } from '@desktop-client/hooks/useSyncedPrefs'; import { payeeQueries } from '@desktop-client/payees'; @@ -189,6 +190,7 @@ export function ImportTransactionsModal({ const { t } = useTranslation(); const queryClient = useQueryClient(); const dateFormat = useDateFormat() || ('MM/dd/yyyy' as const); + const decimalPlaces = useFormat().currency.decimalPlaces; const [prefs, savePrefs] = useSyncedPrefs(); const { data: { list: categories } = { list: [] } } = useCategories(); @@ -341,14 +343,14 @@ export function ImportTransactionsModal({ previewTransactions.push({ ...finalTransaction, date, - amount: amountToInteger(amount), + amount: amountToInteger(amount, decimalPlaces), cleared: clearOnImport, }); } return previewTransactions; }, - [categories, clearOnImport], + [categories, clearOnImport, decimalPlaces], ); const parse = useCallback( @@ -655,7 +657,7 @@ export function ImportTransactionsModal({ finalTransactions.push({ ...finalTransaction, date, - amount: amountToInteger(amount), + amount: amountToInteger(amount, decimalPlaces), cleared: clearOnImport, notes: importNotes ? finalTransaction.notes : null, }); diff --git a/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx index 6e22d77c1ad..73638b8ed77 100644 --- a/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx +++ b/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx @@ -28,6 +28,7 @@ import { import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput'; import { Notes } from '@desktop-client/components/Notes'; import { useCategory } from '@desktop-client/hooks/useCategory'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useNotes } from '@desktop-client/hooks/useNotes'; import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice'; import { trackingBudget } from '@desktop-client/spreadsheet/bindings'; @@ -46,6 +47,7 @@ export function TrackingBudgetMenuModal({ onEditNotes, month, }: TrackingBudgetMenuModalProps) { + const decimalPlaces = useFormat().currency.decimalPlaces; const defaultMenuItemStyle: CSSProperties = { ...styles.mobileMenuItem, color: theme.menuItemText, @@ -70,7 +72,7 @@ export function TrackingBudgetMenuModal({ const [amountFocused, setAmountFocused] = useState(false); const _onUpdateBudget = (amount: number) => { - onUpdateBudget?.(amountToInteger(amount)); + onUpdateBudget?.(amountToInteger(amount, decimalPlaces)); }; const _onEditNotes = () => { @@ -120,7 +122,7 @@ export function TrackingBudgetMenuModal({ Budgeted setAmountFocused(true)} onBlur={() => setAmountFocused(false)} diff --git a/packages/desktop-client/src/components/settings/Currency.tsx b/packages/desktop-client/src/components/settings/Currency.tsx index 08e05e82fae..c94781b403a 100644 --- a/packages/desktop-client/src/components/settings/Currency.tsx +++ b/packages/desktop-client/src/components/settings/Currency.tsx @@ -45,6 +45,7 @@ export function CurrencySettings() { ['JMD', t('Jamaican Dollar')], ['JPY', t('Japanese Yen')], ['KRW', t('South Korean Won')], + ['KWD', t('Kuwaiti Dinar')], ['LKR', t('Sri Lankan Rupee')], ['MDL', t('Moldovan Leu')], ['MYR', t('Malaysian Ringgit')], diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx index 582a01b6ca6..ce7692ed44b 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx @@ -447,7 +447,7 @@ describe('Transactions', () => { ); if (transaction.amount < 0) { expect(queryField(container, 'debit', 'div', idx).textContent).toBe( - integerToCurrency(-transaction.amount), + integerToCurrency(-transaction.amount, 2), ); expect(queryField(container, 'credit', 'div', idx).textContent).toBe( '', @@ -455,7 +455,7 @@ describe('Transactions', () => { } else { expect(queryField(container, 'debit', 'div', idx).textContent).toBe(''); expect(queryField(container, 'credit', 'div', idx).textContent).toBe( - integerToCurrency(transaction.amount), + integerToCurrency(transaction.amount, 2), ); } }); diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.tsx b/packages/desktop-client/src/components/transactions/TransactionsTable.tsx index ddb7ca390ff..39d5b93b959 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.tsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.tsx @@ -63,7 +63,7 @@ import { } from 'loot-core/shared/transactions'; import { amountToCurrency, - currencyToAmount, + formatCurrencyInput, integerToCurrency, titleFirst, } from 'loot-core/shared/util'; @@ -137,6 +137,7 @@ import type { OnDragChangeCallback, OnDropCallback, } from '@desktop-client/hooks/useDragDrop'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs'; import { usePrevious } from '@desktop-client/hooks/usePrevious'; @@ -967,27 +968,36 @@ const Transaction = memo(function Transaction({ onDrop, }: TransactionProps) { const { t } = useTranslation(); + const format = useFormat(); + const decimalPlaces = format.currency.decimalPlaces; const dispatch = useDispatch(); const dispatchSelected = useSelectedDispatch(); const triggerRef = useRef(null); const [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit); + const [prevDecimalPlaces, setPrevDecimalPlaces] = useState(decimalPlaces); const [prevTransaction, setPrevTransaction] = useState(originalTransaction); const [transaction, setTransaction] = useState(() => - serializeTransaction(originalTransaction, showZeroInDeposit), + serializeTransaction(originalTransaction, decimalPlaces, showZeroInDeposit), ); const isPreview = isPreviewId(transaction.id); if ( originalTransaction !== prevTransaction || - showZeroInDeposit !== prevShowZero + showZeroInDeposit !== prevShowZero || + decimalPlaces !== prevDecimalPlaces ) { setTransaction( - serializeTransaction(originalTransaction, showZeroInDeposit), + serializeTransaction( + originalTransaction, + decimalPlaces, + showZeroInDeposit, + ), ); setPrevTransaction(originalTransaction); setPrevShowZero(showZeroInDeposit); + setPrevDecimalPlaces(decimalPlaces); } const [showReconciliationWarning, setShowReconciliationWarning] = @@ -1099,10 +1109,13 @@ const Transaction = memo(function Transaction({ const deserialized = deserializeTransaction( newTransaction, originalTransaction, + decimalPlaces, ); // Run the transaction through the formatting so that we know // it's always showing the formatted result - setTransaction(serializeTransaction(deserialized, showZeroInDeposit)); + setTransaction( + serializeTransaction(deserialized, decimalPlaces, showZeroInDeposit), + ); const deserializedName = ['credit', 'debit'].includes(name) ? 'amount' @@ -1754,11 +1767,12 @@ const Transaction = memo(function Transaction({ name="debit" exposed={focusedField === 'debit'} focused={focusedField === 'debit'} - value={debit === '' && credit === '' ? amountToCurrency(0) : debit} - formatter={value => - // reformat value so since we might have kept decimals - value ? amountToCurrency(currencyToAmount(value) || 0) : '' + value={ + debit === '' && credit === '' + ? formatCurrencyInput('0', decimalPlaces) + : debit } + formatter={value => formatCurrencyInput(value, decimalPlaces)} valueStyle={valueStyle} textAlign="right" title={debit} @@ -1769,7 +1783,10 @@ const Transaction = memo(function Transaction({ ...amountStyle, }} inputProps={{ - value: debit === '' && credit === '' ? amountToCurrency(0) : debit, + value: + debit === '' && credit === '' + ? formatCurrencyInput('0', decimalPlaces) + : debit, onUpdate: onUpdate.bind(null, 'debit'), 'data-1p-ignore': true, }} @@ -1786,10 +1803,7 @@ const Transaction = memo(function Transaction({ exposed={focusedField === 'credit'} focused={focusedField === 'credit'} value={credit} - formatter={value => - // reformat value so since we might have kept decimals - value ? amountToCurrency(currencyToAmount(value) || 0) : '' - } + formatter={value => formatCurrencyInput(value, decimalPlaces)} valueStyle={valueStyle} textAlign="right" title={credit} @@ -1816,7 +1830,7 @@ const Transaction = memo(function Transaction({ value={ runningBalance == null || isChild || isTemporaryId(id) ? '' - : integerToCurrency(runningBalance) + : integerToCurrency(runningBalance, decimalPlaces) } valueStyle={{ color: @@ -1914,7 +1928,7 @@ const Transaction = memo(function Transaction({ textAlign: 'right', }} > - {integerToCurrency(amount)} + {integerToCurrency(amount, decimalPlaces)} )} @@ -1926,6 +1940,7 @@ const Transaction = memo(function Transaction({ type TransactionErrorProps = { error: NonNullable; isDeposit: boolean; + decimalPlaces: number; onAddSplit: () => void; onDistributeRemainder: () => void; style?: CSSProperties; @@ -1935,6 +1950,7 @@ type TransactionErrorProps = { function TransactionError({ error, isDeposit, + decimalPlaces, onAddSplit, onDistributeRemainder, style, @@ -1955,9 +1971,10 @@ function TransactionError({ > Amount left:{' '} - + {integerToCurrency( isDeposit ? error.difference : -error.difference, + decimalPlaces, )} @@ -2056,6 +2073,7 @@ function NewTransaction({ balance, showHiddenCategories, }: NewTransactionProps) { + const decimalPlaces = useFormat().currency.decimalPlaces; const error = transactions[0].error; const isDeposit = transactions[0].amount > 0; @@ -2149,6 +2167,7 @@ function NewTransaction({ onAddSplit(transactions[0].id)} onDistributeRemainder={() => onDistributeRemainder(transactions[0].id) @@ -2265,6 +2284,7 @@ function TransactionTableInner({ showHiddenCategories, ...props }: TransactionTableInnerProps) { + const decimalPlaces = useFormat().currency.decimalPlaces; const containerRef = createRef(); const isAddingPrev = usePrevious(props.isAdding); const [scrollWidth, setScrollWidth] = useState(0); @@ -2446,6 +2466,7 @@ function TransactionTableInner({ props.onAddSplit(trans.id)} onDistributeRemainder={() => props.onDistributeRemainder(trans.id) diff --git a/packages/desktop-client/src/components/transactions/table/utils.test.ts b/packages/desktop-client/src/components/transactions/table/utils.test.ts new file mode 100644 index 00000000000..f4557b65319 --- /dev/null +++ b/packages/desktop-client/src/components/transactions/table/utils.test.ts @@ -0,0 +1,42 @@ +import type { TransactionEntity } from 'loot-core/types/models'; + +import { + deserializeTransaction, + serializeTransaction, +} from '@desktop-client/components/transactions/table/utils'; + +function makeTransaction(amount: number): TransactionEntity { + return { + id: 'tx-1', + account: 'acct-1', + amount, + date: '2026-02-08', + }; +} + +describe('transaction table utils decimal places', () => { + test.each([ + [0, 1000000, '1,000,000', ''] as const, + [2, 1000000, '10,000.00', ''] as const, + [3, 1000000, '1,000.000', ''] as const, + [3, -1000000, '', '1,000.000'] as const, + [2, -1000000, '', '10,000.00'] as const, + [0, -1000000, '', '1,000,000'] as const, + ])( + 'serializes and deserializes (decimals=%i, amount=%i)', + (decimals, amount, expectedCredit, expectedDebit) => { + const original = makeTransaction(amount); + + const serialized = serializeTransaction(original, decimals, false); + expect(serialized.credit).toBe(expectedCredit); + expect(serialized.debit).toBe(expectedDebit); + + const deserialized = deserializeTransaction( + serialized, + original, + decimals, + ); + expect(deserialized.amount).toBe(amount); + }, + ); +}); diff --git a/packages/desktop-client/src/components/transactions/table/utils.ts b/packages/desktop-client/src/components/transactions/table/utils.ts index 302004a3d3f..fe55e6e9608 100644 --- a/packages/desktop-client/src/components/transactions/table/utils.ts +++ b/packages/desktop-client/src/components/transactions/table/utils.ts @@ -31,6 +31,7 @@ export type TransactionUpdateFunction = ( export function serializeTransaction( transaction: TransactionEntity, + decimalPlaces: number, showZeroInDeposit?: boolean, ): SerializedTransaction { const { amount, date: originalDate } = transaction; @@ -62,17 +63,24 @@ export function serializeTransaction( return { ...transaction, date, - debit: debit != null ? integerToCurrencyWithDecimal(debit) : '', - credit: credit != null ? integerToCurrencyWithDecimal(credit) : '', + debit: + debit != null ? integerToCurrencyWithDecimal(debit, decimalPlaces) : '', + credit: + credit != null ? integerToCurrencyWithDecimal(credit, decimalPlaces) : '', }; } export function deserializeTransaction( transaction: SerializedTransaction, originalTransaction: TransactionEntity, + decimalPlaces: number, ) { const { debit, credit, date: originalDate, ...realTransaction } = transaction; + // Tolerance for float noise when checking if parsed amount round-trips at decimalPlaces + const ROUND_TRIP_TOLERANCE = 1e-9; + const scale = 10 ** decimalPlaces; + let amount: number | null; if (debit !== '') { const parsed = evalArithmetic(debit, null); @@ -81,8 +89,19 @@ export function deserializeTransaction( amount = evalArithmetic(credit, null); } - amount = - amount != null ? amountToInteger(amount) : originalTransaction.amount; + if (amount != null) { + const scaled = Math.abs(amount) * scale; + const canRoundTrip = + Math.abs(scaled - Math.round(scaled)) < ROUND_TRIP_TOLERANCE || + scaled === Math.round(scaled); + if (!canRoundTrip) { + amount = originalTransaction.amount; + } else { + amount = amountToInteger(amount, decimalPlaces); + } + } else { + amount = originalTransaction.amount; + } let date = originalDate; if (date == null) { date = originalTransaction.date || currentDay(); diff --git a/packages/desktop-client/src/hooks/useFormat.ts b/packages/desktop-client/src/hooks/useFormat.ts index d1b31cee445..c7704161dff 100644 --- a/packages/desktop-client/src/hooks/useFormat.ts +++ b/packages/desktop-client/src/hooks/useFormat.ts @@ -97,8 +97,8 @@ function format( numericValue: localValue, formattedString: integerToCurrency( localValue, - formatter, decimalPlaces, + formatter, ), }; } diff --git a/packages/desktop-client/src/hooks/useFormulaExecution.ts b/packages/desktop-client/src/hooks/useFormulaExecution.ts index b687365ba1e..302e0f2aaac 100644 --- a/packages/desktop-client/src/hooks/useFormulaExecution.ts +++ b/packages/desktop-client/src/hooks/useFormulaExecution.ts @@ -13,6 +13,7 @@ import type { TimeFrame, } from 'loot-core/types/models'; +import { useFormat } from './useFormat'; import { useLocale } from './useLocale'; import { getLiveRange } from '@desktop-client/components/reports/getLiveRange'; @@ -89,6 +90,7 @@ export function useFormulaExecution( namedExpressions?: Record, ) { const locale = useLocale(); + const decimalPlaces = useFormat().currency.decimalPlaces; const [result, setResult] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -197,7 +199,7 @@ export function useFormulaExecution( } const data = await fetchQuerySum(queryConfig); - queryData[queryName] = integerToAmount(data, 2); + queryData[queryName] = integerToAmount(data, decimalPlaces); } for (const queryName of queryCountNames) { @@ -275,6 +277,7 @@ export function useFormulaExecution( param1 as string[], param2 as string, param3 as string, + decimalPlaces, ); processedFormula = processedFormula.replace( @@ -396,7 +399,14 @@ export function useFormulaExecution( return () => { cancelled = true; }; - }, [formula, queriesVersion, locale, queries, namedExpressions]); + }, [ + formula, + queriesVersion, + locale, + queries, + namedExpressions, + decimalPlaces, + ]); return { result, isLoading, error }; } @@ -717,6 +727,7 @@ async function fetchBudgetDimensionValueDirect( categoryIds: string[], startMonth: string, endMonth: string, + decimalPlaces: number, ): Promise { const allowed = new Set([ 'budgeted', @@ -745,15 +756,18 @@ async function fetchBudgetDimensionValueDirect( }; if (dim === 'budgeted') { - return integerToAmount(await sumDimension('budget-{catId}'), 2); + return integerToAmount(await sumDimension('budget-{catId}'), decimalPlaces); } if (dim === 'spent') { - return integerToAmount(await sumDimension('sum-amount-{catId}'), 2); + return integerToAmount( + await sumDimension('sum-amount-{catId}'), + decimalPlaces, + ); } if (dim === 'goal') { - return integerToAmount(await sumDimension('goal-{catId}'), 2); + return integerToAmount(await sumDimension('goal-{catId}'), decimalPlaces); } // Handle balance dimensions: chain month-by-month with carryover logic @@ -812,11 +826,11 @@ async function fetchBudgetDimensionValueDirect( } if (dim === 'balance_start') { - return integerToAmount(balances[intervals[0]]?.start || 0, 2); + return integerToAmount(balances[intervals[0]]?.start || 0, decimalPlaces); } return integerToAmount( balances[intervals[intervals.length - 1]]?.end || 0, - 2, + decimalPlaces, ); } diff --git a/packages/desktop-client/src/hooks/useTransactionsSearch.ts b/packages/desktop-client/src/hooks/useTransactionsSearch.ts index 5d99cf6deae..68c7e605f2d 100644 --- a/packages/desktop-client/src/hooks/useTransactionsSearch.ts +++ b/packages/desktop-client/src/hooks/useTransactionsSearch.ts @@ -4,6 +4,7 @@ import debounce from 'lodash/debounce'; import type { Query } from 'loot-core/shared/query'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import * as queries from '@desktop-client/queries'; type UseTransactionsSearchProps = { @@ -24,6 +25,7 @@ export function useTransactionsSearch({ delayMs = 150, }: UseTransactionsSearchProps): UseTransactionsSearchResult { const [isSearching, setIsSearching] = useState(false); + const decimalPlaces = useFormat().currency.decimalPlaces; const updateQueryRef = useRef(updateQuery); updateQueryRef.current = updateQuery; @@ -40,12 +42,17 @@ export function useTransactionsSearch({ } else if (searchText) { resetQueryRef.current?.(); updateQueryRef.current(previousQuery => - queries.transactionsSearch(previousQuery, searchText, dateFormat), + queries.transactionsSearch( + previousQuery, + searchText, + dateFormat, + decimalPlaces, + ), ); setIsSearching(true); } }, delayMs), - [dateFormat, delayMs], + [dateFormat, delayMs, decimalPlaces], ); useEffect(() => { diff --git a/packages/desktop-client/src/queries/index.ts b/packages/desktop-client/src/queries/index.ts index dc2c7910699..9de34831b08 100644 --- a/packages/desktop-client/src/queries/index.ts +++ b/packages/desktop-client/src/queries/index.ts @@ -10,7 +10,11 @@ import { } from 'loot-core/shared/months'; import { q } from 'loot-core/shared/query'; import type { Query } from 'loot-core/shared/query'; -import { amountToInteger, currencyToAmount } from 'loot-core/shared/util'; +import { + amountToInteger, + currencyToAmount, + getFractionDigitCount, +} from 'loot-core/shared/util'; import type { AccountEntity } from 'loot-core/types/models'; import type { SyncedPrefs } from 'loot-core/types/prefs'; @@ -82,8 +86,12 @@ export function transactionsSearch( currentQuery: Query, search: string, dateFormat: SyncedPrefs['dateFormat'], + decimalPlaces: number, ) { const amount = currencyToAmount(search); + const fractionDigitsExceedPrecision = + amount != null && getFractionDigitCount(search) > decimalPlaces; + const divisor = Math.pow(10, decimalPlaces); // Support various date formats let parsedDate; @@ -104,13 +112,18 @@ export function transactionsSearch( 'account.name': { $like: `%${search}%` }, $or: [ isDateValid(parsedDate) && { date: dayFromDate(parsedDate) }, - amount != null && { - amount: { $transform: '$abs', $eq: amountToInteger(amount) }, - }, amount != null && + !fractionDigitsExceedPrecision && { + amount: { + $transform: '$abs', + $eq: amountToInteger(amount, decimalPlaces), + }, + }, + amount != null && + !fractionDigitsExceedPrecision && Number.isInteger(amount) && { amount: { - $transform: { $abs: { $idiv: ['$', 100] } }, + $transform: { $abs: { $idiv: ['$', divisor] } }, $eq: amount, }, }, diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts index 8bb6eaba4f1..6ae09ad07f9 100644 --- a/packages/loot-core/src/server/accounts/app.ts +++ b/packages/loot-core/src/server/accounts/app.ts @@ -363,12 +363,22 @@ async function createAccount({ balance = 0, offBudget = false, closed = false, + decimalPlaces, }: { name: string; balance?: number | undefined; offBudget?: boolean | undefined; closed?: boolean | undefined; + decimalPlaces?: number | undefined; }) { + const safeDecimalPlaces: number = + decimalPlaces != null && + Number.isFinite(decimalPlaces) && + Number.isInteger(decimalPlaces) && + decimalPlaces >= 0 + ? decimalPlaces + : 2; + const id: AccountEntity['id'] = await db.insertAccount({ name, offbudget: offBudget ? 1 : 0, @@ -385,7 +395,7 @@ async function createAccount({ await db.insertTransaction({ account: id, - amount: amountToInteger(balance), + amount: amountToInteger(balance, safeDecimalPlaces), category: offBudget ? null : payee.category, payee: payee.id, date: monthUtils.currentDay(), diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index 35659642dd7..495539720d8 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -459,7 +459,7 @@ async function normalizeBankSyncTransactions(transactions, acctId) { normalized.push({ payee_name: payeeName, trans: { - amount: amountToInteger(trans.amount), + amount: amountToInteger(trans.amount, 2), payee: trans.payee, account: trans.account, date, @@ -578,7 +578,7 @@ export async function reconcileTransactions( existingPayeeMap.set(existing.payee, payee?.name); } existing.payee_name = existingPayeeMap.get(existing.payee); - existing.amount = integerToAmount(existing.amount); + existing.amount = integerToAmount(existing.amount, 2); updatedPreview.push({ transaction: trans, existing }); } else { updatedPreview.push({ transaction: trans, ignored: true }); diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index fbb94dcbaba..9df9f1c9027 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -3,6 +3,7 @@ import { getClock } from '@actual-app/crdt'; import * as connection from '../platform/server/connection'; import { logger } from '../platform/server/log'; +import { getCurrency } from '../shared/currencies'; import { getBankSyncError, getDownloadError, @@ -580,13 +581,23 @@ handlers['api/account-create'] = withMutation(async function ({ initialBalance = null, }) { checkFileOpen(); + const currencyPref = await db.first>( + 'SELECT value FROM preferences WHERE id = ?', + ['defaultCurrencyCode'], + ); + const defaultCurrencyCode = currencyPref?.value ?? ''; + const decimalPlaces = getCurrency(defaultCurrencyCode).decimalPlaces; return handlers['account-create']({ name: account.name, offBudget: account.offbudget, closed: account.closed, + decimalPlaces, // Current the API expects an amount but it really should expect // an integer - balance: initialBalance != null ? integerToAmount(initialBalance) : null, + balance: + initialBalance != null + ? integerToAmount(initialBalance, decimalPlaces) + : null, }); }); diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index 01de568cc09..b0ef8dc6fd3 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -624,11 +624,7 @@ async function addMovementNotes({ currencyCode: string; }) { const currency = getCurrency(currencyCode); - const displayAmount = integerToCurrency( - amount, - undefined, - currency.decimalPlaces, - ); + const displayAmount = integerToCurrency(amount, currency.decimalPlaces); const monthBudgetNotesId = `budget-${month}`; const existingMonthBudgetNotes = addNewLine( diff --git a/packages/loot-core/src/server/budget/category-template-context.test.ts b/packages/loot-core/src/server/budget/category-template-context.test.ts index 0fd8ad8f134..7163cfb6865 100644 --- a/packages/loot-core/src/server/budget/category-template-context.test.ts +++ b/packages/loot-core/src/server/budget/category-template-context.test.ts @@ -86,7 +86,7 @@ describe('CategoryTemplateContext', () => { ); const result = CategoryTemplateContext.runSimple(template, instance); - expect(result).toBe(amountToInteger(100)); + expect(result).toBe(amountToInteger(100, 2)); }); it('should return limit when monthly is not provided', () => { @@ -112,7 +112,7 @@ describe('CategoryTemplateContext', () => { ); const result = CategoryTemplateContext.runSimple(template, instance); - expect(result).toBe(amountToInteger(500)); + expect(result).toBe(amountToInteger(500, 2)); }); it('should handle weekly limit', async () => { @@ -353,7 +353,7 @@ describe('CategoryTemplateContext', () => { }; const result = CategoryTemplateContext.runPeriodic(template, instance); - expect(result).toBe(amountToInteger(500)); + expect(result).toBe(amountToInteger(500, 2)); }); it('should calculate weekly amount for multiple weeks', () => { @@ -370,7 +370,7 @@ describe('CategoryTemplateContext', () => { }; const result = CategoryTemplateContext.runPeriodic(template, instance); - expect(result).toBe(amountToInteger(300)); + expect(result).toBe(amountToInteger(300, 2)); }); it('should handle weeks spanning multiple months', () => { @@ -387,7 +387,7 @@ describe('CategoryTemplateContext', () => { }; const result = CategoryTemplateContext.runPeriodic(template, instance); - expect(result).toBe(amountToInteger(100)); + expect(result).toBe(amountToInteger(100, 2)); }); it('should handle periodic days', () => { @@ -404,7 +404,7 @@ describe('CategoryTemplateContext', () => { }; const result = CategoryTemplateContext.runPeriodic(template, instance); - expect(result).toBe(amountToInteger(400)); // for the 1st, 11th, 21st, 31st + expect(result).toBe(amountToInteger(400, 2)); // for the 1st, 11th, 21st, 31st }); it('should handle periodic years', () => { @@ -421,7 +421,7 @@ describe('CategoryTemplateContext', () => { }; const result = CategoryTemplateContext.runPeriodic(template, instance); - expect(result).toBe(amountToInteger(100)); + expect(result).toBe(amountToInteger(100, 2)); }); it('should handle periodic months', () => { @@ -438,7 +438,7 @@ describe('CategoryTemplateContext', () => { }; const result = CategoryTemplateContext.runPeriodic(template, instance); - expect(result).toBe(amountToInteger(100)); + expect(result).toBe(amountToInteger(100, 2)); }); }); @@ -1151,7 +1151,7 @@ describe('CategoryTemplateContext', () => { // Mock the sheet values needed for init vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(false, 'USD'); // Initialize the template @@ -1216,7 +1216,7 @@ describe('CategoryTemplateContext', () => { // Mock the sheet values needed for init vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(false, 'USD'); // Initialize the template @@ -1271,7 +1271,7 @@ describe('CategoryTemplateContext', () => { // Mock the sheet values needed for init vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(false, 'USD'); // Initialize the template @@ -1331,7 +1331,7 @@ describe('CategoryTemplateContext', () => { // Mock the sheet values needed for init vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(false, 'USD'); // Initialize the template @@ -1374,7 +1374,7 @@ describe('CategoryTemplateContext', () => { // Mock the sheet values needed for init vi.mocked(actions.getSheetValue).mockResolvedValueOnce(10000); // lastMonthBalance vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(false, 'USD'); // Initialize the template @@ -1419,7 +1419,7 @@ describe('CategoryTemplateContext', () => { // Mock the sheet values needed for init vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'USD'); // Initialize the template @@ -1462,7 +1462,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'JPY'); const instance = await CategoryTemplateContext.init( @@ -1494,7 +1494,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); - vi.mocked(actions.isReflectBudget).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'JPY'); const instance = await CategoryTemplateContext.init( @@ -1526,6 +1526,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'JPY'); const instance = await CategoryTemplateContext.init( @@ -1562,6 +1563,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'JPY'); const instance = await CategoryTemplateContext.init( @@ -1595,6 +1597,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'JPY'); const instance = await CategoryTemplateContext.init( @@ -1627,6 +1630,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(true, 'JPY'); const instanceJPY = await CategoryTemplateContext.init( @@ -1640,6 +1644,7 @@ describe('CategoryTemplateContext', () => { vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); + vi.mocked(actions.isReflectBudget).mockReturnValueOnce(false); mockPreferences(false, 'USD'); const instanceUSD = await CategoryTemplateContext.init( diff --git a/packages/loot-core/src/server/budget/category-template-context.ts b/packages/loot-core/src/server/budget/category-template-context.ts index f715bc3bf01..7d1aa6d63bf 100644 --- a/packages/loot-core/src/server/budget/category-template-context.ts +++ b/packages/loot-core/src/server/budget/category-template-context.ts @@ -264,10 +264,10 @@ export class CategoryTemplateContext { if (this.hideDecimal) { // handle hideDecimal toBudget = this.removeFraction(toBudget); - smallest = 100; + smallest = Math.pow(10, this.currency.decimalPlaces); } - //check possible overbudget from rounding, 1cent leftover + // check possible overbudget from rounding if (toBudget > budgetAvail || budgetAvail - toBudget <= smallest) { toBudget = budgetAvail; } diff --git a/packages/loot-core/src/server/budget/schedule-template.ts b/packages/loot-core/src/server/budget/schedule-template.ts index 6c8c534c525..53ec6f5c94c 100644 --- a/packages/loot-core/src/server/budget/schedule-template.ts +++ b/packages/loot-core/src/server/budget/schedule-template.ts @@ -78,6 +78,7 @@ async function createScheduleList( amount: scheduleAmount, category: category.id, subtransactions: [], + _decimalPlaces: currency.decimalPlaces, }); const categorySubtransactions = subtransactions?.filter( t => t.category === category.id, diff --git a/packages/loot-core/src/server/importers/ynab4.ts b/packages/loot-core/src/server/importers/ynab4.ts index 540ad9dfb6b..7071313d357 100644 --- a/packages/loot-core/src/server/importers/ynab4.ts +++ b/packages/loot-core/src/server/importers/ynab4.ts @@ -4,6 +4,7 @@ import normalizePathSep from 'slash'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../../platform/server/log'; +import { getCurrency } from '../../shared/currencies'; import * as monthUtils from '../../shared/months'; import { amountToInteger, groupBy, sortByKey } from '../../shared/util'; import { send } from '../main-app'; @@ -176,6 +177,10 @@ async function importTransactions( } const transactionsGrouped = groupBy(data.transactions, 'accountId'); + // Always use the source budget currency precision; YNAB amounts are scaled to that precision. + const decimalPlaces = getCurrency( + data.budgetMetaData?.currencyISOSymbol || '', + ).decimalPlaces; await Promise.all( [...transactionsGrouped.keys()].map(async accountId => { @@ -214,7 +219,7 @@ async function importTransactions( const newTransaction = { id, - amount: amountToInteger(transaction.amount), + amount: amountToInteger(transaction.amount, decimalPlaces), category: isOffBudget(entityIdMap.get(accountId)) ? null : getCategory(transaction.categoryId), @@ -233,7 +238,7 @@ async function importTransactions( .map(t => { return { id: entityIdMap.get(t.entityId), - amount: amountToInteger(t.amount), + amount: amountToInteger(t.amount, decimalPlaces), category: getCategory(t.categoryId), notes: t.memo || null, ...transferProperties(t), @@ -290,6 +295,9 @@ async function importBudgets( entityIdMap: Map, ) { const budgets = sortByKey(data.monthlyBudgets, 'month'); + const decimalPlaces = getCurrency( + data.budgetMetaData?.currencyISOSymbol || '', + ).decimalPlaces; await send('api/batch-budget-start'); try { @@ -301,7 +309,7 @@ async function importBudgets( await Promise.all( filled.map(async catBudget => { - const amount = amountToInteger(catBudget.budgeted); + const amount = amountToInteger(catBudget.budgeted, decimalPlaces); const catId = entityIdMap.get(catBudget.categoryId); const month = monthUtils.monthFromDate(budget.month); if (!catId) { diff --git a/packages/loot-core/src/server/importers/ynab5.ts b/packages/loot-core/src/server/importers/ynab5.ts index 9fc7676b669..7c6a205e75e 100644 --- a/packages/loot-core/src/server/importers/ynab5.ts +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import { logger } from '../../platform/server/log'; +import { getCurrency } from '../../shared/currencies'; import * as monthUtils from '../../shared/months'; import { q } from '../../shared/query'; import { groupBy, sortByKey } from '../../shared/util'; @@ -68,10 +69,36 @@ function findIdByName( return findByNameIgnoreCase(categories, name)?.id; } -function amountFromYnab(amount: number) { - // YNAB multiplies amount by 1000 and Actual by 100 - // so, this function divides by 10 - return Math.round(amount / 10); +function amountFromYnab(amount: number, decimalPlaces: number) { + // YNAB API uses millicents (×1000); Actual uses integer = amount × 10^decimalPlaces + return Math.round((amount * Math.pow(10, decimalPlaces)) / 1000); +} + +function getYnab5DecimalPlaces(data: Budget): number { + const cf = data.currency_format; + // Prefer API decimal_digits (including 0 for zero-decimal currencies); fall back to ISO code. + const decimalPlaces = cf?.decimal_digits; + if ( + typeof decimalPlaces === 'number' && + Number.isInteger(decimalPlaces) && + decimalPlaces >= 0 && + decimalPlaces <= 10 + ) { + return decimalPlaces; + } + const code = cf?.iso_code?.trim() ?? ''; + if (!code) { + throw new Error( + 'currency_format is missing both valid decimal_digits and a non-empty iso_code; cannot determine decimal places.', + ); + } + const currency = getCurrency(code); + if (currency.code !== code) { + throw new Error( + `unknown currency ISO code "${code}". Add it to supported currencies or ensure decimal_digits is present in the YNAB export.`, + ); + } + return currency.decimalPlaces; } function getDayOfMonth(date: string) { @@ -570,6 +597,7 @@ async function importTransactions( data: Budget, entityIdMap: Map, flagNameConflicts: Set, + decimalPlaces: number, ) { const payees = await send('api/payees-get'); const categories = await send('api/categories-get', { @@ -760,7 +788,7 @@ async function importTransactions( id: entityIdMap.get(transaction.id), account: entityIdMap.get(transaction.account_id), date: transaction.date, - amount: amountFromYnab(transaction.amount), + amount: amountFromYnab(transaction.amount, decimalPlaces), category: entityIdMap.get(transaction.category_id) || null, cleared: ['cleared', 'reconciled'].includes(transaction.cleared), reconciled: transaction.cleared === 'reconciled', @@ -774,7 +802,7 @@ async function importTransactions( ? subtransactions.map(subtrans => { return { id: entityIdMap.get(subtrans.id), - amount: amountFromYnab(subtrans.amount), + amount: amountFromYnab(subtrans.amount, decimalPlaces), category: entityIdMap.get(subtrans.category_id) || null, notes: subtrans.memo, transfer_id: @@ -851,6 +879,7 @@ async function importScheduledTransactions( data: Budget, entityIdMap: Map, flagNameConflicts: Set, + decimalPlaces: number, ) { const scheduledTransactions = data.scheduled_transactions; const scheduledSubtransactionsGrouped = groupBy( @@ -956,7 +985,7 @@ async function importScheduledTransactions( posts_transaction: false, payee: mappedPayeeId, account: mappedAccountId, - amount: amountFromYnab(scheduled.amount), + amount: amountFromYnab(scheduled.amount, decimalPlaces), amountOp: 'is', date: scheduleDate, }); @@ -1027,7 +1056,7 @@ async function importScheduledTransactions( actions.push({ op: 'set-split-amount', - value: amountFromYnab(subtransaction.amount), + value: amountFromYnab(subtransaction.amount, decimalPlaces), options: { splitIndex, method: 'fixed-amount' }, }); @@ -1094,7 +1123,11 @@ async function importScheduledTransactions( } } -async function importBudgets(data: Budget, entityIdMap: Map) { +async function importBudgets( + data: Budget, + entityIdMap: Map, + decimalPlaces: number, +) { // There should be info in the docs to deal with // no credit card category and how YNAB and Actual // handle differently the amount To be Budgeted @@ -1122,7 +1155,7 @@ async function importBudgets(data: Budget, entityIdMap: Map) { await Promise.all( budget.categories.map(async catBudget => { const catId = entityIdMap.get(catBudget.id); - const amount = Math.round(catBudget.budgeted / 10); + const amount = amountFromYnab(catBudget.budgeted, decimalPlaces); if ( !catId || @@ -1165,6 +1198,16 @@ export async function doImport(data: Budget) { const entityIdMap = new Map(); const flagNameConflicts = getFlagNameConflicts(data); + let decimalPlaces: number; + try { + decimalPlaces = getYnab5DecimalPlaces(data); + } catch (e) { + logger.warn( + `YNAB import: ${normalizeError(e)} Defaulting to 2 decimal places.`, + ); + decimalPlaces = 2; + } + logger.log('Importing Accounts...'); await importAccounts(data, entityIdMap); @@ -1181,13 +1224,23 @@ export async function doImport(data: Budget) { await importFlagsAsTags(data, flagNameConflicts); logger.log('Importing Transactions...'); - await importTransactions(data, entityIdMap, flagNameConflicts); + await importTransactions(data, entityIdMap, flagNameConflicts, decimalPlaces); logger.log('Importing Scheduled Transactions...'); - await importScheduledTransactions(data, entityIdMap, flagNameConflicts); + await importScheduledTransactions( + data, + entityIdMap, + flagNameConflicts, + decimalPlaces, + ); logger.log('Importing Budgets...'); - await importBudgets(data, entityIdMap); + await importBudgets(data, entityIdMap, decimalPlaces); + + await send('preferences/save', { + id: 'defaultCurrencyCode', + value: data.currency_format?.iso_code ?? '', + }); logger.log('Setting up...'); } diff --git a/packages/loot-core/src/server/rules/action.ts b/packages/loot-core/src/server/rules/action.ts index e5ccdf6c57f..38b089dd354 100644 --- a/packages/loot-core/src/server/rules/action.ts +++ b/packages/loot-core/src/server/rules/action.ts @@ -349,7 +349,9 @@ export class Action { } if (typeof cellValue === 'number') { - return amountToInteger(Math.round(cellValue * 100) / 100); + // Uses budget default currency's decimal places. + // TODO: Use account-specific currency when DbAccount becomes currency-aware. + return amountToInteger(cellValue, transaction._decimalPlaces ?? 2); } return cellValue; diff --git a/packages/loot-core/src/server/rules/customFunctions.ts b/packages/loot-core/src/server/rules/customFunctions.ts index 7342ab413a9..2ff9d06da13 100644 --- a/packages/loot-core/src/server/rules/customFunctions.ts +++ b/packages/loot-core/src/server/rules/customFunctions.ts @@ -10,7 +10,7 @@ export class CustomFunctionsPlugin extends FunctionPlugin { ast.args, state, this.metadata('INTEGER_TO_AMOUNT'), - (integerAmount: number, decimalPlaces: number = 2) => { + (integerAmount: number, decimalPlaces: number) => { return integerToAmount(integerAmount, decimalPlaces); }, ); @@ -21,8 +21,8 @@ export class CustomFunctionsPlugin extends FunctionPlugin { ast.args, state, this.metadata('FIXED'), - (number: number, decimals: number = 0) => { - return Number(number).toFixed(decimals); + (number: number, decimalPlaces: number = 0) => { + return Number(number).toFixed(decimalPlaces); }, ); } diff --git a/packages/loot-core/src/server/rules/index.test.ts b/packages/loot-core/src/server/rules/index.test.ts index b9852995223..51d7350108a 100644 --- a/packages/loot-core/src/server/rules/index.test.ts +++ b/packages/loot-core/src/server/rules/index.test.ts @@ -908,7 +908,7 @@ describe('Rule', () => { options: { splitIndex: 1, method: 'formula', - formula: '=INTEGER_TO_AMOUNT(parent_amount) * 0.5', + formula: '=INTEGER_TO_AMOUNT(parent_amount, 2) * 0.5', }, }, { diff --git a/packages/loot-core/src/server/transactions/export/export-to-csv.ts b/packages/loot-core/src/server/transactions/export/export-to-csv.ts index 02120d388d5..4a8630ac42a 100644 --- a/packages/loot-core/src/server/transactions/export/export-to-csv.ts +++ b/packages/loot-core/src/server/transactions/export/export-to-csv.ts @@ -1,8 +1,19 @@ // @ts-strict-ignore import { stringify as csvStringify } from 'csv-stringify/sync'; +import { getCurrency } from '../../../shared/currencies'; import { integerToAmount } from '../../../shared/util'; import { aqlQuery } from '../../aql'; +import * as db from '../../db'; + +async function getDecimalPlaces(): Promise { + const currencyPref = await db.first>( + 'SELECT value FROM preferences WHERE id = ?', + ['defaultCurrencyCode'], + ); + const defaultCurrencyCode = currencyPref?.value ?? ''; + return getCurrency(defaultCurrencyCode).decimalPlaces; +} export async function exportToCSV( transactions, @@ -10,6 +21,7 @@ export async function exportToCSV( categoryGroups, payees, ) { + const decimalPlaces = await getDecimalPlaces(); const accountNamesById = accounts.reduce((reduced, { id, name }) => { reduced[id] = name; return reduced; @@ -47,7 +59,7 @@ export async function exportToCSV( Payee: payeeNamesById[payee], Notes: notes, Category: categoryNamesById[category], - Amount: amount == null ? 0 : integerToAmount(amount), + Amount: amount == null ? 0 : integerToAmount(amount, decimalPlaces), Cleared: cleared, Reconciled: reconciled, }), @@ -57,6 +69,7 @@ export async function exportToCSV( } export async function exportQueryToCSV(query) { + const decimalPlaces = await getDecimalPlaces(); const { data: transactions } = await aqlQuery( query .select([ @@ -117,8 +130,12 @@ export async function exportQueryToCSV(query) { ? 0 : trans.Amount == null ? 0 - : integerToAmount(trans.Amount), - Split_Amount: trans.IsParent ? integerToAmount(trans.Amount) : 0, + : integerToAmount(trans.Amount, decimalPlaces), + Split_Amount: trans.IsParent + ? trans.Amount == null + ? null + : integerToAmount(trans.Amount, decimalPlaces) + : 0, Cleared: trans.Reconciled === true ? 'Reconciled' diff --git a/packages/loot-core/src/server/transactions/import/parse-file.test.ts b/packages/loot-core/src/server/transactions/import/parse-file.test.ts index 9d6c0b00076..c7295600cb5 100644 --- a/packages/loot-core/src/server/transactions/import/parse-file.test.ts +++ b/packages/loot-core/src/server/transactions/import/parse-file.test.ts @@ -57,7 +57,7 @@ async function importFileWithRealTime( // oxlint-disable-next-line typescript/no-explicit-any transactions = (transactions as any[]).map(trans => ({ ...trans, - amount: amountToInteger(trans.amount), + amount: amountToInteger(trans.amount, 2), date: dateFormat ? d.format(d.parse(trans.date, dateFormat, new Date()), 'yyyy-MM-dd') : trans.date, diff --git a/packages/loot-core/src/server/transactions/transaction-rules.ts b/packages/loot-core/src/server/transactions/transaction-rules.ts index 68c860fc2f1..654059b5ce2 100644 --- a/packages/loot-core/src/server/transactions/transaction-rules.ts +++ b/packages/loot-core/src/server/transactions/transaction-rules.ts @@ -1,6 +1,7 @@ // @ts-strict-ignore import { logger } from '../../platform/server/log'; +import { getCurrency } from '../../shared/currencies'; import { addDays, currentDay, @@ -91,6 +92,14 @@ function toInternalField(obj: T): T { }; } +async function getDefaultCurrencyCode(): Promise { + const row = await db.first>( + 'SELECT value FROM preferences WHERE id = ?', + ['defaultCurrencyCode'], + ); + return row?.value ?? ''; +} + function parseArray(str) { let value; try { @@ -324,7 +333,12 @@ export async function runRules( accountsMap = accounts; } - let finalTrans = await prepareTransactionForRules({ ...trans }, accountsMap); + const defaultCurrencyCode = await getDefaultCurrencyCode(); + let finalTrans = await prepareTransactionForRules( + { ...trans }, + accountsMap, + defaultCurrencyCode, + ); let scheduleRuleID = ''; // Check if a schedule is attached to this transaction and if so get the rule ID attached to that schedule. @@ -698,9 +712,10 @@ export async function applyActions( const accounts: db.DbAccount[] = await db.getAccounts(); const accountsMap = new Map(accounts.map(account => [account.id, account])); + const defaultCurrencyCode = await getDefaultCurrencyCode(); const transactionsForRules = await Promise.all( - transactions.map(transactions => - prepareTransactionForRules(transactions, accountsMap), + transactions.map(trans => + prepareTransactionForRules(trans, accountsMap, defaultCurrencyCode), ), ); @@ -938,11 +953,16 @@ export type TransactionForRules = TransactionEntity & { _category_name?: string; _account_name?: string; parent_amount?: number; + /** Decimal places from budget default currency. + * TODO: Use account-specific currency when + * DbAccount becomes currency-aware. */ + _decimalPlaces?: number; }; export async function prepareTransactionForRules( trans: TransactionEntity, accounts: Map | null = null, + currencyCode?: string, ): Promise { const r: TransactionForRules = { ...trans }; if (trans.payee) { @@ -1008,6 +1028,9 @@ export async function prepareTransactionForRules( } } + const resolvedCurrencyCode = currencyCode ?? (await getDefaultCurrencyCode()); + r._decimalPlaces = getCurrency(resolvedCurrencyCode).decimalPlaces; + return r; } @@ -1039,6 +1062,10 @@ export async function finalizeTransactionForRules( delete trans.parent_amount; } + if ('_decimalPlaces' in trans) { + delete trans._decimalPlaces; + } + if (trans.subtransactions?.length) { trans.subtransactions.forEach(stx => { if ('balance' in stx) { @@ -1048,6 +1075,10 @@ export async function finalizeTransactionForRules( if ('parent_amount' in stx) { delete stx.parent_amount; } + + if ('_decimalPlaces' in stx) { + delete stx._decimalPlaces; + } }); } diff --git a/packages/loot-core/src/shared/currencies.ts b/packages/loot-core/src/shared/currencies.ts index ce2eef1fe81..ec7f0b0b624 100644 --- a/packages/loot-core/src/shared/currencies.ts +++ b/packages/loot-core/src/shared/currencies.ts @@ -45,6 +45,7 @@ export const currencies: Currency[] = [ { code: 'JMD', name: 'Jamaican Dollar', symbol: 'J$', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true }, { code: 'JPY', name: 'Japanese Yen', symbol: '¥', decimalPlaces: 0, numberFormat: 'comma-dot', symbolFirst: true }, { code: 'KRW', name: 'South Korean Won', symbol: '₩', decimalPlaces: 0, numberFormat: 'comma-dot', symbolFirst: true }, + { code: 'KWD', name: 'Kuwaiti Dinar', symbol: 'KD', decimalPlaces: 3, numberFormat: 'comma-dot', symbolFirst: true }, { code: 'LKR', name: 'Sri Lankan Rupee', symbol: 'Rs.', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true }, { code: 'MDL', name: 'Moldovan Leu', symbol: 'L', decimalPlaces: 2, numberFormat: 'dot-comma', symbolFirst: false }, { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true }, @@ -63,6 +64,7 @@ export const currencies: Currency[] = [ { code: 'UAH', name: 'Ukrainian Hryvnia', symbol: '₴', decimalPlaces: 2, numberFormat: 'space-comma', symbolFirst: false }, { code: 'USD', name: 'US Dollar', symbol: '$', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true }, { code: 'UZS', name: 'Uzbek Soum', symbol: 'UZS', decimalPlaces: 2, numberFormat: 'space-comma', symbolFirst: false }, + { code: 'VND', name: 'Vietnamese Dong', symbol: '₫', decimalPlaces: 0, numberFormat: 'dot-comma', symbolFirst: false }, ]; export function getCurrency(code: string): Currency { diff --git a/packages/loot-core/src/shared/util.test.ts b/packages/loot-core/src/shared/util.test.ts index db9406d0e26..5de7279761f 100644 --- a/packages/loot-core/src/shared/util.test.ts +++ b/packages/loot-core/src/shared/util.test.ts @@ -1,6 +1,11 @@ import { + amountToInteger, + appendDecimals, currencyToAmount, getNumberFormat, + integerToAmount, + integerToCurrency, + integerToCurrencyWithDecimal, looselyParseAmount, setNumberFormat, stringToInteger, @@ -224,6 +229,56 @@ describe('utility functions', () => { expect(currencyToAmount('3.000,')).toBe(3000); }); + test('integerToCurrencyWithDecimal respects decimalPlaces including three-decimal currencies', () => { + setNumberFormat({ format: 'comma-dot', hideFraction: false }); + + expect(integerToCurrencyWithDecimal(1000000, 0)).toBe('1,000,000'); + expect(integerToCurrencyWithDecimal(1000001, 0)).toBe('1,000,001'); + expect(integerToCurrencyWithDecimal(1234567, 3)).toBe('1,234.567'); + expect(integerToCurrencyWithDecimal(1000000, 3)).toBe('1,000.000'); + }); + + test('appendDecimals respects decimalPlaces including three-decimal currencies', () => { + setNumberFormat({ format: 'comma-dot', hideFraction: false }); + + expect(appendDecimals('1000000', false, 0)).toBe('1,000,000'); + expect(appendDecimals('1000000', false, 2)).toBe('10,000.00'); + expect(appendDecimals('1000000', false, 3)).toBe('1,000.000'); + }); + + test('amountToInteger and integerToAmount round-trip for 0, 2, and 3 decimal places', () => { + // Zero decimals (e.g. JPY): amount 1234 -> integer 1234 -> amount 1234 + expect(amountToInteger(1234, 0)).toBe(1234); + expect(integerToAmount(1234, 0)).toBe(1234); + + // Two decimals (e.g. USD): amount 12.34 -> integer 1234 -> amount 12.34 + expect(amountToInteger(12.34, 2)).toBe(1234); + expect(integerToAmount(1234, 2)).toBe(12.34); + + // Three decimals (e.g. KWD): amount 12.345 -> integer 12345 -> amount 12.345 + expect(amountToInteger(12.345, 3)).toBe(12345); + expect(integerToAmount(12345, 3)).toBe(12.345); + }); + + test('amountToInteger does not scale by wrong factor', () => { + // Regression: ensure 100 JPY stays 100, not 10000 or 1 + expect(amountToInteger(100, 0)).toBe(100); + expect(amountToInteger(1.5, 2)).toBe(150); + expect(amountToInteger(1.234, 3)).toBe(1234); + }); + + test('three-decimal amount is preserved exactly (e.g. KWD 1234.123)', () => { + setNumberFormat({ format: 'comma-dot', hideFraction: false }); + + const amount = 1234.123; + const integer = amountToInteger(amount, 3); + expect(integer).toBe(1234123); + expect(integerToAmount(integer, 3)).toBe(1234.123); + + const formatted = integerToCurrency(integer, 3); + expect(formatted).toBe('1,234.123'); + }); + test('titleFirst works with all inputs', () => { expect(titleFirst('')).toBe(''); expect(titleFirst(undefined)).toBe(''); diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts index 3d9b32cad09..0a06356ce6b 100644 --- a/packages/loot-core/src/shared/util.ts +++ b/packages/loot-core/src/shared/util.ts @@ -244,6 +244,7 @@ export function reapplyThousandSeparators(amountText: string) { export function appendDecimals( amountText: string, hideDecimals = false, + decimalPlaces: number, ): string { const { decimalSeparator: separator } = getNumberFormat(); let result = amountText; @@ -253,10 +254,19 @@ export function appendDecimals( if (!hideDecimals) { result = result.replaceAll(/[,.]/g, ''); result = result.replace(/^0+(?!$)/, ''); - result = result.padStart(3, '0'); - result = result.slice(0, -2) + separator + result.slice(-2); + result = result.padStart(decimalPlaces + 1, '0'); + if (decimalPlaces > 0) { + result = + result.slice(0, -decimalPlaces) + + separator + + result.slice(-decimalPlaces); + } } - return amountToCurrency(currencyToAmount(result)); + const amount = currencyToAmount(result) || 0; + const formatter = getNumberFormat({ + decimalPlaces: hideDecimals ? 0 : decimalPlaces, + }).formatter; + return formatter.format(amount); } const NUMBER_FORMATS = [ @@ -438,34 +448,66 @@ export function safeNumber(value: number) { return value; } -export function toRelaxedNumber(currencyAmount: CurrencyAmount): Amount { - return integerToAmount(currencyToInteger(currencyAmount) || 0); +export function toRelaxedNumber( + currencyAmount: CurrencyAmount, + decimalPlaces: number, +): Amount { + return integerToAmount( + currencyToInteger(currencyAmount, decimalPlaces) || 0, + decimalPlaces, + ); } export function integerToCurrency( integerAmount: IntegerAmount, - formatter = getNumberFormat().formatter, - decimalPlaces: number = 2, + decimalPlaces: number, + formatter?: { format: (value: number) => string }, ) { const divisor = Math.pow(10, decimalPlaces); const amount = safeNumber(integerAmount) / divisor; - - return formatter.format(amount); -} - -export function integerToCurrencyWithDecimal(integerAmount: IntegerAmount) { + const displayDecimalPlaces = numberFormatConfig.hideFraction + ? 0 + : decimalPlaces; + const effectiveFormatter = + formatter ?? + getNumberFormat({ + format: numberFormatConfig.format, + hideFraction: numberFormatConfig.hideFraction, + decimalPlaces: displayDecimalPlaces, + }).formatter; + return effectiveFormatter.format(amount); +} + +export function integerToCurrencyWithDecimal( + integerAmount: IntegerAmount, + decimalPlaces: number, +) { + const divisor = Math.pow(10, decimalPlaces); // If decimal digits exist, keep them. Otherwise format them as usual. - if (integerAmount % 100 !== 0) { + if (integerAmount % divisor !== 0) { return integerToCurrency( integerAmount, + decimalPlaces, getNumberFormat({ - ...numberFormatConfig, + format: numberFormatConfig.format, hideFraction: false, + decimalPlaces, }).formatter, ); } - return integerToCurrency(integerAmount); + const displayDecimalPlaces = numberFormatConfig.hideFraction + ? 0 + : decimalPlaces; + return integerToCurrency( + integerAmount, + decimalPlaces, + getNumberFormat({ + format: numberFormatConfig.format, + hideFraction: numberFormatConfig.hideFraction, + decimalPlaces: displayDecimalPlaces, + }).formatter, + ); } export function amountToCurrency(amount: Amount): CurrencyAmount { @@ -503,11 +545,54 @@ export function currencyToAmount(currencyAmount: string): Amount | null { return isNaN(amount) ? null : amount; } +/** + * Returns the number of digits after the decimal separator in a currency string, + * using the same separator logic as currencyToAmount. Returns 0 if there is no + * fractional part. + */ +export function getFractionDigitCount(currencyAmount: string): number { + currencyAmount = currencyAmount.replace(/\u2212/g, '-'); + const match = currencyAmount.match(/[,.](?=[^.,]*$)/); + if ( + !match || + (match[0] === getNumberFormat().thousandsSeparator && + match.index + 4 <= currencyAmount.length) + ) { + return 0; + } + const fraction = currencyAmount.slice(match.index + 1); + return fraction.replace(/\D/g, '').length; +} + export function currencyToInteger( currencyAmount: CurrencyAmount, + decimalPlaces: number, ): IntegerAmount | null { const amount = currencyToAmount(currencyAmount); - return amount == null ? null : amountToInteger(amount); + return amount == null ? null : amountToInteger(amount, decimalPlaces); +} + +/** + * Parse a currency string, round to the given decimal places, and format it. + * Use when reformatting user input (e.g. in formatters) with per-currency precision. + */ +export function formatCurrencyInput( + value: string | null | undefined, + decimalPlaces: number, +): string { + if (value == null || value === '') return ''; + const amount = currencyToAmount(value); + return amount == null + ? '' + : integerToCurrency( + amountToInteger(amount, decimalPlaces), + decimalPlaces, + getNumberFormat({ + format: numberFormatConfig.format, + hideFraction: false, + decimalPlaces, + }).formatter, + ); } export function stringToInteger(str: string): number | null { @@ -522,7 +607,7 @@ export function stringToInteger(str: string): number | null { export function amountToInteger( amount: Amount, - decimalPlaces: number = 2, + decimalPlaces: number, ): IntegerAmount { const multiplier = Math.pow(10, decimalPlaces); return Math.round(amount * multiplier); @@ -530,7 +615,7 @@ export function amountToInteger( export function integerToAmount( integerAmount: IntegerAmount, - decimalPlaces: number = 2, + decimalPlaces: number, ): Amount { const divisor = Math.pow(10, decimalPlaces); return integerAmount / divisor; diff --git a/packages/loot-core/src/types/models/bank-sync.ts b/packages/loot-core/src/types/models/bank-sync.ts index 6d27bfe8a8b..a047e71cdba 100644 --- a/packages/loot-core/src/types/models/bank-sync.ts +++ b/packages/loot-core/src/types/models/bank-sync.ts @@ -15,6 +15,10 @@ export type BankSyncResponse = { pending: BankSyncTransaction[]; }; balances: BankSyncBalance[]; + // Interface with sync-server: amounts are expected to be integers in currency + // minor units. Today synced accounts effectively assume 2 decimal places. + // TODO: Use BankSyncAmount in this response so loot-core can correctly handle + // sync payloads for currencies with non-2-decimal minor units. startingBalance: number; error_type: string; error_code: string; diff --git a/upcoming-release-notes/6902.md b/upcoming-release-notes/6902.md new file mode 100644 index 00000000000..18f4ca88092 --- /dev/null +++ b/upcoming-release-notes/6902.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [antran22] +--- + +Add Vietnamese Dong (VND) currency diff --git a/upcoming-release-notes/6954.md b/upcoming-release-notes/6954.md new file mode 100644 index 00000000000..8dd1a23c865 --- /dev/null +++ b/upcoming-release-notes/6954.md @@ -0,0 +1,6 @@ +--- +category: Bugfixes +authors: [StephenBrown2] +--- + +Fixed currency amount handling for currencies with different decimal formats, and added support for KWD.