From 945ea9356046b00ee2e266b9abc108ca8a380a67 Mon Sep 17 00:00:00 2001 From: Stephen Brown II Date: Wed, 11 Feb 2026 13:32:40 -0500 Subject: [PATCH 1/2] [AI] feat(currency): honor per-currency decimal places across core and UI Propagate decimalPlaces through shared amount utilities (including KWD), desktop and mobile UI, transactions, budgeting, search, importers, rules, and server account APIs. Add shared e2e currency-precision helpers and refactor related tests. Record attribution in upcoming release notes. --- packages/desktop-client/e2e/accounts.test.ts | 61 +++++-- packages/desktop-client/e2e/budget.test.ts | 25 +++ .../desktop-client/e2e/currency-precision.ts | 153 ++++++++++++++++++ .../desktop-client/e2e/onboarding.test.ts | 8 + .../e2e/page-models/settings-page.ts | 78 ++++++++- .../desktop-client/e2e/transactions.test.ts | 109 +++++++++++++ .../desktop-client/src/accounts/mutations.ts | 9 +- .../src/components/accounts/Account.tsx | 21 +++ .../accounts/BalanceHistoryGraph.tsx | 8 +- .../autocomplete/CategoryAutocomplete.tsx | 11 +- .../budget/BalanceWithCarryover.tsx | 12 +- .../components/budget/envelope/CoverMenu.tsx | 9 +- .../src/components/budget/util.ts | 11 +- .../transactions/FocusableAmountInput.tsx | 98 +++++++++-- .../mobile/transactions/TransactionEdit.tsx | 34 ++-- .../mobile/transactions/TransactionList.tsx | 4 +- .../transactions/TransactionListItem.tsx | 6 +- .../components/modals/CloseAccountModal.tsx | 12 +- .../modals/CreateLocalAccountModal.tsx | 8 +- .../src/components/modals/EditFieldModal.tsx | 56 ++++--- .../modals/EnvelopeBudgetMenuModal.tsx | 6 +- .../ImportTransactionsModal.tsx | 8 +- .../modals/TrackingBudgetMenuModal.tsx | 6 +- .../src/components/settings/Currency.tsx | 1 + .../transactions/TransactionsTable.test.tsx | 4 +- .../transactions/TransactionsTable.tsx | 55 +++++-- .../transactions/table/utils.test.ts | 42 +++++ .../components/transactions/table/utils.ts | 27 +++- .../desktop-client/src/hooks/useFormat.ts | 2 +- .../src/hooks/useFormulaExecution.ts | 28 +++- .../src/hooks/useTransactionsSearch.ts | 11 +- packages/desktop-client/src/queries/index.ts | 23 ++- packages/loot-core/src/server/accounts/app.ts | 12 +- .../loot-core/src/server/accounts/sync.ts | 4 +- packages/loot-core/src/server/api.ts | 13 +- .../loot-core/src/server/budget/actions.ts | 6 +- .../budget/category-template-context.test.ts | 37 +++-- .../budget/category-template-context.ts | 4 +- .../src/server/budget/schedule-template.ts | 1 + .../loot-core/src/server/importers/ynab4.ts | 14 +- .../loot-core/src/server/importers/ynab5.ts | 79 +++++++-- packages/loot-core/src/server/rules/action.ts | 4 +- .../src/server/rules/customFunctions.ts | 6 +- .../loot-core/src/server/rules/index.test.ts | 2 +- .../transactions/export/export-to-csv.ts | 23 ++- .../transactions/import/parse-file.test.ts | 2 +- .../server/transactions/transaction-rules.ts | 37 ++++- packages/loot-core/src/shared/currencies.ts | 2 + packages/loot-core/src/shared/util.test.ts | 55 +++++++ packages/loot-core/src/shared/util.ts | 121 +++++++++++--- .../loot-core/src/types/models/bank-sync.ts | 4 + upcoming-release-notes/6902.md | 6 + upcoming-release-notes/6954.md | 6 + 53 files changed, 1181 insertions(+), 203 deletions(-) create mode 100644 packages/desktop-client/e2e/currency-precision.ts create mode 100644 packages/desktop-client/src/components/transactions/table/utils.test.ts create mode 100644 upcoming-release-notes/6902.md create mode 100644 upcoming-release-notes/6954.md 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/onboarding.test.ts b/packages/desktop-client/e2e/onboarding.test.ts index 8a4db3b06c4..f0e4f8c95ca 100644 --- a/packages/desktop-client/e2e/onboarding.test.ts +++ b/packages/desktop-client/e2e/onboarding.test.ts @@ -60,6 +60,14 @@ 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 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/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/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. From a74fe75114afa17f313bcf7287358abddcb588dc Mon Sep 17 00:00:00 2001 From: Stephen Brown II Date: Thu, 19 Mar 2026 15:46:31 -0400 Subject: [PATCH 2/2] Update VRT screenshots workflow --- .github/workflows/e2e-vrt-comment.yml | 65 ++++++++++++++++++- .github/workflows/vrt-update-generate.yml | 27 +++++++- .gitignore | 1 + package.json | 1 + packages/desktop-client/README.md | 3 + .../bin/prune-vrt-snapshots.mjs | 61 +++++++++++++++++ packages/desktop-client/e2e/fixtures.ts | 49 +++++++++++++- .../desktop-client/e2e/onboarding.test.ts | 1 + packages/desktop-client/e2e/playwright-env.ts | 10 +++ packages/desktop-client/package.json | 1 + 10 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 packages/desktop-client/bin/prune-vrt-snapshots.mjs create mode 100644 packages/desktop-client/e2e/playwright-env.ts 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/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 f0e4f8c95ca..971bb0f508e 100644 --- a/packages/desktop-client/e2e/onboarding.test.ts +++ b/packages/desktop-client/e2e/onboarding.test.ts @@ -68,6 +68,7 @@ test.describe('Onboarding', () => { 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/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/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" },