From dbbbd07b1c48dd855b6ae005624cba3d20372176 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 15:04:32 +0200 Subject: [PATCH] fix(costs): amounts follow their currency's decimals and format, and payments count on the local day Several small things in the costs views, on desktop and phone alike: - The outstanding card no longer says "You're all settled up" while a payment is still open in Settle up. - The custom split shows its suggested shares as a faint placeholder in the currency's own form (30000 for won, not 30000.00), so it no longer reads like an amount already typed. - Amount fields take only the decimals the currency has: won and yen take none, KWD and the other three-decimal currencies three. A switch of currency cuts what was already typed. 1000.555 won was saved as typed, listed as 1,001 and reopened as 1001. - The expense sheet on the phone prints shares and sums with the same formatter as the list, with grouping and the currency's decimals. - A payment recorded with Settle or Settle all is dated on the viewer's own day, and one without a date of its own is grouped by its UTC record time read on the local calendar. In Seoul before 09:00 it landed on the day before. - "1 expenses need a payer" now uses the plural forms of each language. Refs #2604 --- .../Budget/CostsPanel.helpers.test.ts | 39 +++++- .../components/Budget/CostsPanel.helpers.ts | 39 +++++- .../Budget/CostsPanel.summary.test.tsx | 126 ++++++++++++++++++ client/src/components/Budget/CostsPanel.tsx | 20 +-- .../src/components/Budget/expenseFx.test.ts | 15 ++- client/src/components/Budget/expenseFx.ts | 3 +- .../components/Budget/useCostsLedger.test.ts | 19 ++- .../src/components/Budget/useCostsLedger.ts | 16 ++- .../components/Budget/useExpenseForm.test.ts | 39 +++++- .../src/components/Budget/useExpenseForm.ts | 28 +++- .../Budget/useSettlementForm.test.ts | 12 +- .../components/Budget/useSettlementForm.ts | 14 +- .../mobile/screens/trip/sheets/MCostSheet.tsx | 30 ++--- .../mobile/screens/trip/tabs/MCostsTab.tsx | 6 +- client/src/utils/formatters.test.ts | 11 +- client/src/utils/formatters.ts | 5 + .../unit/mobile/trip/MCostSheet.test.tsx | 38 ++++-- shared/src/i18n/ar/budget.ts | 5 + shared/src/i18n/az/budget.ts | 1 + shared/src/i18n/br/budget.ts | 1 + shared/src/i18n/ca/budget.ts | 1 + shared/src/i18n/cs/budget.ts | 2 + shared/src/i18n/de/budget.ts | 1 + shared/src/i18n/en/budget.ts | 1 + shared/src/i18n/es/budget.ts | 1 + shared/src/i18n/et/budget.ts | 1 + shared/src/i18n/fr/budget.ts | 1 + shared/src/i18n/gr/budget.ts | 1 + shared/src/i18n/hu/budget.ts | 1 + shared/src/i18n/it/budget.ts | 1 + shared/src/i18n/nl/budget.ts | 1 + shared/src/i18n/pl/budget.ts | 5 +- shared/src/i18n/ru/budget.ts | 5 +- shared/src/i18n/sk/budget.ts | 2 + shared/src/i18n/sv/budget.ts | 1 + shared/src/i18n/tr/budget.ts | 1 + shared/src/i18n/uk/budget.ts | 5 +- 37 files changed, 424 insertions(+), 74 deletions(-) create mode 100644 client/src/components/Budget/CostsPanel.summary.test.tsx diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 043b90fd0..97ed55c70 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { calculateTicketShares, expenseEditorFor, newExpenseSeed, receiptToPrefill, finalBudgetFor, finalBudgetSources, formerParticipantRows, hasTicketSplit, knownPersonNames, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { amountInCurrency, amountPattern, calculateTicketShares, clampAmountDecimals, expenseEditorFor, newExpenseSeed, receiptToPrefill, finalBudgetFor, finalBudgetSources, formerParticipantRows, hasTicketSplit, knownPersonNames, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' describe('splitCents', () => { it('splits evenly when it divides cleanly', () => { @@ -199,6 +199,43 @@ describe('settlementDate', () => { it('is empty when neither is set', () => { expect(settlementDate({})).toBe('') }) + + it("reads the recorded UTC time on the viewer's own calendar day (#2604)", () => { + const runnerTimeZone = process.env.TZ + process.env.TZ = 'Asia/Seoul' + try { + // 23:30 UTC is 08:30 the next morning in Seoul. + expect(settlementDate({ created_at: '2026-10-11 23:30:00' })).toBe('2026-10-12') + expect(settlementDate({ created_at: '2026-10-11T23:30:00Z' })).toBe('2026-10-12') + // A day the user picked is a calendar day already and stays as it is. + expect(settlementDate({ settled_at: '2026-10-11', created_at: '2026-10-11 23:30:00' })).toBe('2026-10-11') + } finally { + if (runnerTimeZone === undefined) delete process.env.TZ + else process.env.TZ = runnerTimeZone + } + }) +}) + +describe('amount decimals per currency (#2604)', () => { + it('cuts a typed amount to the decimals the currency has', () => { + expect(clampAmountDecimals('1000.555', 'KRW')).toBe('1000.') + expect(clampAmountDecimals('1000.', 'JPY')).toBe('1000.') + expect(amountInCurrency('12.34', 'KRW')).toBe('12') + expect(amountInCurrency('12.34', 'EUR')).toBe('12.34') + expect(clampAmountDecimals('10.555', 'EUR')).toBe('10.55') + expect(clampAmountDecimals('1.5555', 'KWD')).toBe('1.555') + expect(clampAmountDecimals('-12.5', 'EUR')).toBe('-12.5') + expect(clampAmountDecimals('', 'KRW')).toBe('') + }) + + it('takes no decimals for a currency without minor units, three for a three-decimal one', () => { + expect(amountPattern('KRW', false).test('1000')).toBe(true) + expect(amountPattern('KRW', false).test('1000.')).toBe(true) + expect(amountPattern('KRW', false).test('1000.5')).toBe(false) + expect(amountPattern('EUR', true).test('-10.55')).toBe(true) + expect(amountPattern('EUR', false).test('10.555')).toBe(false) + expect(amountPattern('KWD', false).test('1.555')).toBe(true) + }) }) // ── Receipt splits have to reconcile (#1382) ───────────────────────────────── diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index c8b6e290d..0a6ffbcae 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -14,16 +14,36 @@ import { splitEqualShares as splitEqualMinorShares, toMinor } from '@trek/shared' import type { BudgetParticipantFinal, ReceiptRead } from '@trek/shared' import { amountToInputString, currencyDecimals } from '../../utils/formatters' +import { localIsoDate } from '../../utils/localDate' import type { ExpensePrefill } from './CostsPanel' import type { BudgetItem } from '../../types' // The split and receipt fields guard their own precision on every keystroke, so the // guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three // places, and the old fixed two rejected every keystroke after that, leaving the field -// unusable until a digit was deleted (#2175). Never stricter than two places, so no -// value a field accepts today can become uneditable. +// unusable until a digit was deleted (#2175). A currency without minor units (KRW, +// JPY) takes no decimals at all, or a won amount could carry cents the list then +// rounds away (#2604). The point itself may stand, so a digit typed after it is +// dropped instead of joining the whole won. Callers cut the input with +// clampAmountDecimals first, so a longer value a field was seeded with stays editable. export const amountPattern = (currency: string, signed: boolean) => - new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`) + new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${currencyDecimals(currency)}}$`) + +/** + * A dot-normalized amount as typed, cut to the decimals the currency has (#2604): + * "1000.555" in won is "1000.", in euros "1000.55". Cut, not rounded, so the digits + * left are the ones the user typed, and a won amount keeps its point so a digit + * typed after it is dropped rather than read as one more whole won. + */ +export function clampAmountDecimals(value: string, currency: string): string { + const dot = value.indexOf('.') + return dot < 0 ? value : value.slice(0, dot + 1 + currencyDecimals(currency)) +} + +/** An amount already typed, in the currency it was switched to: cut, and without a point left bare. */ +export function amountInCurrency(value: string, currency: string): string { + return clampAmountDecimals(value, currency).replace(/\.$/, '') +} /** * Spread `amount` across `n` payers in whole cents so the parts sum back exactly. @@ -41,12 +61,17 @@ export function splitCents(amount: number, n: number): number[] { /** * The calendar day a settle-up payment counts on for grouping/filtering: its own * `settled_at` if the user set one, else the day it was recorded (`created_at`). - * Mirrors `expense_date` falling back nowhere on a budget item — a settlement's - * `created_at` doubled as its date before `settled_at` existed, so this keeps - * every pre-existing row grouped exactly where it already was. + * A settlement's `created_at` doubled as its date before `settled_at` existed. + * It is SQLite's UTC clock ("2026-10-11 23:30:00"), so it is read as UTC and + * placed on the viewer's own calendar: in Seoul that payment was made on the + * 12th, not the 11th (#2604). */ export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string { - return (s.settled_at || s.created_at || '').slice(0, 10) + if (s.settled_at) return s.settled_at.slice(0, 10) + if (!s.created_at) return '' + const iso = s.created_at.replace(' ', 'T') + const at = new Date(/(Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`) + return Number.isNaN(at.getTime()) ? s.created_at.slice(0, 10) : localIsoDate(at) } /** diff --git a/client/src/components/Budget/CostsPanel.summary.test.tsx b/client/src/components/Budget/CostsPanel.summary.test.tsx new file mode 100644 index 000000000..e42bb2d16 --- /dev/null +++ b/client/src/components/Budget/CostsPanel.summary.test.tsx @@ -0,0 +1,126 @@ +// FE-COSTS-2604-001 to -004: the summary footers and won amounts in the Costs panel (#2604) +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { render, screen } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; +import CostsPanel, { ExpenseModal } from './CostsPanel'; + +const tripMembers = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, +]; + +type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }; +type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }; +const expense = (over: Partial> & { members?: MemberFixture[] }): BudgetItem => + ({ ...buildBudgetItem({ trip_id: 1 }), payers: [], members: [], ...over }) as unknown as BudgetItem; + +function mount(items: BudgetItem[], settlement: { flows?: Flow[] } = {}) { + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ balances: [], flows: [], settlements: [], ...settlement }) + ) + ); + return render(); +} + +// Alice is the signed-in user; the euro display currency keeps FX an identity. +function seedAlice() { + resetAllStores(); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }); + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }); +} + +describe('CostsPanel: summary footers and won amounts (#2604)', () => { + beforeEach(seedAlice); + + const owedByBob = () => + expense({ + id: 601, + name: 'Museum', + category: 'activities', + total_price: 40, + expense_date: '2025-06-15', + payers: [{ user_id: 1, amount: 40 }], + members: [ + { user_id: 1, username: 'alice' }, + { user_id: 2, username: 'bob' }, + ], + }); + + it('FE-COSTS-2604-001: the outstanding card says settled up only once no payment is left to make', async () => { + mount([owedByBob()], { + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }], + }); + await screen.findByText('Museum'); + expect(screen.getByRole('region', { name: 'Outstanding amount' })).not.toHaveTextContent("You're all settled up"); + }); + + it('FE-COSTS-2604-002: with nothing left to settle the outstanding card still says so', async () => { + mount([owedByBob()]); + await screen.findByText('Museum'); + expect(screen.getByRole('region', { name: 'Outstanding amount' })).toHaveTextContent("You're all settled up"); + }); + + it('FE-COSTS-2604-003: a single expense without a payer reads in the singular', async () => { + mount([ + expense({ + id: 602, + name: 'Taxi', + category: 'transport', + total_price: 30, + members: [{ user_id: 1, username: 'alice' }], + }), + ]); + await screen.findByText('Taxi'); + expect(screen.getByRole('region', { name: 'Outstanding amount' })).toHaveTextContent('1 expense needs a payer'); + }); + + it('FE-COSTS-2604-004: a won expense shows whole won, and the custom split suggests its shares as a placeholder', async () => { + const user = userEvent.setup(); + render( + {}} + onSaved={vi.fn()} + editing={expense({ + id: 603, + name: 'BBQ', + category: 'food', + currency: 'KRW', + total_price: 60000, + payers: [{ user_id: 1, amount: 60000 }], + members: [ + { user_id: 1, username: 'alice' }, + { user_id: 2, username: 'bob' }, + ], + })} + /> + ); + + expect(await screen.findByDisplayValue('60000')).toBeInTheDocument(); + expect(screen.getByText('₩30,000 per person')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Custom' })); + const shares = screen.getAllByPlaceholderText('30000') as HTMLInputElement[]; + expect(shares).toHaveLength(2); + // A suggestion, not a value: empty, and drawn in the faint placeholder style. + expect(shares[0].value).toBe(''); + expect(shares[0].className).toContain('placeholder:text-content-faint'); + + // A digit after the point is dropped, not read as one more whole won. + await user.type(shares[0], '1000.5'); + expect(shares[0].value).toBe('1000.'); + }); +}); diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 67c12365a..b877e1c95 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -7,7 +7,7 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { splitShareLabel } from './expenseFx' import { useIsMobile } from '../../hooks/useIsMobile' -import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters' +import { formatMoney, amountToInputString, currencyDecimals, currencyLocale, localizeAmountInput, zeroAmountInput } from '../../utils/formatters' import { openFile } from '../../utils/fileDownload' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' @@ -283,8 +283,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } tone="unfinished" foot={totals.outstandingCount > 0 - ? {totals.outstandingCount} {t('costs.outstandingItems')} - : {t('costs.allSettled')}} /> + ? {totals.outstandingCount} {t('costs.outstandingItems', { count: totals.outstandingCount })} + : !settlement?.flows?.length && {t('costs.allSettled')}} /> } tone="total" foot={<> @@ -1134,7 +1134,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency,
{SYMBOLS[cur] || (cur + ' ')} {/* Typed and shown the way the expense total is, in the reader's own decimal mark. */} - setAmount(v.replace(',', '.'))} className="w-full flex-1 border-0 bg-transparent ps-1.5 font-semibold text-content outline-none dark:bg-transparent" style={fs(14, 'body')} />
@@ -1204,7 +1204,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const PERSON_LIST = 'divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card' const PERSON_ROW = 'grid min-h-[48px] grid-cols-[minmax(0,1fr)_130px] items-center gap-2.5 px-3 py-1.5' const AMOUNT_BOX = 'flex items-center gap-1 rounded-[8px] border border-edge bg-surface-input px-2.5' - const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent' + const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent placeholder:font-normal placeholder:text-content-faint' // Who is in, shown as a real box to tick instead of a row that only fades. const tick = (on: boolean) => ( @@ -1280,7 +1280,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {on ? (
{sym(currency)} - onPayerAmountChange(p.id, v)} className={AMOUNT_INPUT} style={fs(13.5, 'body')} /> @@ -1346,7 +1346,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {sym(currency)} handleUpdateItemPrice(item.id, v)} className={AMOUNT_INPUT} @@ -1427,7 +1427,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo ) : (
{sym(currency)} - handleCustomAmountChange(p.id, e.target.value)} className={AMOUNT_INPUT} style={fs(13.5, 'body')} />
@@ -1440,7 +1440,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {splitMode === 'equally' ? ( participants.size > 0 && <> {badge(<>{participants.size})} - {badge(t('costs.perPerson', { amount: splitShareLabel(each, currency, fx, participants.size, base, null, locale) }))} + {badge(t('costs.perPerson', { amount: splitShareLabel(each, currency, fx, participants.size, base, locale) }))} ) : customBalanced ? ( badge(<>{t('costs.splitBalanced')}, 'success') @@ -1557,7 +1557,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)} - { }) describe('splitShareLabel', () => { - const sym = (c: string) => (c === 'USD' ? '$' : c + ' ') - it('prints the entered share, then the share the row lends or borrows', () => { const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' }) - expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77') + expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77') // Read in the trip currency, the share is the booked euros. const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' }) - expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`) + expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`) }) it('prints the entered share alone when nothing is converted', () => { - expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00') - expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00') + expect(splitShareLabel(125, 'USD', null, 2, 'USD', 'en-US')).toBe('$125.00') + expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', 'en-US')).toBe('$0.00') + }) + + it('prints a share the way the list prints its currency, grouped and with its own decimals (#2604)', () => { + expect(splitShareLabel(15000, 'KRW', null, 4, 'KRW', 'en-US')).toBe('₩15,000') + expect(splitShareLabel(1.5, 'KWD', null, 2, 'KWD', 'en-US')).toBe(formatMoney(1.5, 'KWD', 'en-US')) }) }) diff --git a/client/src/components/Budget/expenseFx.ts b/client/src/components/Budget/expenseFx.ts index 87f21b2a7..3315dabd2 100644 --- a/client/src/components/Budget/expenseFx.ts +++ b/client/src/components/Budget/expenseFx.ts @@ -66,10 +66,9 @@ export function splitShareLabel( fx: ExpenseFx | null, participants: number, base: string, - sym: ((currency: string) => string) | null, locale: string, ): string { - const entered = sym ? sym(currency) + each.toFixed(2) : formatMoney(each, currency, locale) + const entered = formatMoney(each, currency, locale) const whole = fx ? (fx.shown ?? fx.inTrip) : null if (whole == null || participants <= 0) return entered return `${entered} → ${formatMoney(whole / participants, base, locale)}` diff --git a/client/src/components/Budget/useCostsLedger.test.ts b/client/src/components/Budget/useCostsLedger.test.ts index 56fe3cd19..880d114b2 100644 --- a/client/src/components/Budget/useCostsLedger.test.ts +++ b/client/src/components/Budget/useCostsLedger.test.ts @@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import type { BudgetItem, Trip } from '../../types'; import { downloadBlob } from '../../utils/fileDownload'; +import { localToday } from '../Planner/today'; import type { CostsSettlementResponse } from './costsModel'; import { useCostsLedger } from './useCostsLedger'; import { useFreezeMissingRates } from './useFreezeMissingRates'; @@ -268,13 +269,19 @@ describe('useCostsLedger: writes', () => { expect(toastError).toHaveBeenCalledWith('common.unknownError'); }); - it('FE-BUDGET-LEDGER-011: settling one flow or all of them records transfers in the display currency', async () => { + it('FE-BUDGET-LEDGER-011: settling one flow or all of them records transfers in the display currency, dated on the local day (#2604)', async () => { const { result } = setup(); await waitFor(() => expect(result.current.settlement).not.toBeNull()); await act(async () => { await result.current.settleFlow(1, 2, 15); }); - expect(createMock()).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 15, currency: 'EUR' }); + expect(createMock()).toHaveBeenCalledWith(7, { + from_user_id: 1, + to_user_id: 2, + amount: 15, + currency: 'EUR', + settled_at: localToday(), + }); expect(settlementMock()).toHaveBeenCalledTimes(2); createMock().mockClear(); @@ -284,7 +291,13 @@ describe('useCostsLedger: writes', () => { await result.current.settleAll(); }); expect(createMock()).toHaveBeenCalledTimes(2); - expect(createMock()).toHaveBeenNthCalledWith(2, 7, { from_user_id: 3, to_user_id: 1, amount: 5, currency: 'EUR' }); + expect(createMock()).toHaveBeenNthCalledWith(2, 7, { + from_user_id: 3, + to_user_id: 1, + amount: 5, + currency: 'EUR', + settled_at: localToday(), + }); expect(toastError).toHaveBeenCalledWith('common.unknownError'); expect(settlementMock()).toHaveBeenCalledTimes(3); }); diff --git a/client/src/components/Budget/useCostsLedger.ts b/client/src/components/Budget/useCostsLedger.ts index 7d703b7fc..cb4cc8d4a 100644 --- a/client/src/components/Budget/useCostsLedger.ts +++ b/client/src/components/Budget/useCostsLedger.ts @@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'; import type { TripStoreState } from '../../store/tripStore'; import type { BudgetItem, Trip } from '../../types'; import { downloadBlob } from '../../utils/fileDownload'; +import { localToday } from '../Planner/today'; import { buildCostsCsv, categoryBreakdown, @@ -185,11 +186,16 @@ export function useCostsLedger({ } }; + // A quick settle is dated on the viewer's own day, as the payment dialog dates + // it, so it is not left to the server's UTC clock (#2604). const settleFlow = async (fromId: number, toId: number, amount: number) => { try { await budgetApi.createSettlement( tripId, - withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency) + withFallbackFx( + { from_user_id: fromId, to_user_id: toId, amount, currency: base, settled_at: localToday() }, + tripCurrency + ) ); loadSettlement(); } catch { @@ -204,7 +210,13 @@ export function useCostsLedger({ await budgetApi.createSettlement( tripId, withFallbackFx( - { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, + { + from_user_id: f.from.user_id, + to_user_id: f.to.user_id, + amount: f.amount, + currency: base, + settled_at: localToday(), + }, tripCurrency ) ); diff --git a/client/src/components/Budget/useExpenseForm.test.ts b/client/src/components/Budget/useExpenseForm.test.ts index 7e8f984af..6de21e9bb 100644 --- a/client/src/components/Budget/useExpenseForm.test.ts +++ b/client/src/components/Budget/useExpenseForm.test.ts @@ -21,7 +21,7 @@ vi.mock('./receiptUploads', () => ({ ), })); -// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-021 +// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-023 const PEOPLE: TripMember[] = [ { id: 1, username: 'alice', avatar_url: null }, @@ -268,13 +268,13 @@ describe('useExpenseForm: what it adds up', () => { expect(result.current.valid).toBe(true); }); - it('FE-BUDGET-EXPFORM-008: amounts are refused beyond the currency decimals and ticket prices cannot be negative', () => { + it('FE-BUDGET-EXPFORM-008: amounts are cut to the currency decimals, text is refused and ticket prices cannot be negative', () => { const { result } = setup(); act(() => { result.current.handleCustomAmountChange(1, '1.234'); result.current.handleCustomAmountChange(2, 'abc'); }); - expect(result.current.customAmounts).toEqual({}); + expect(result.current.customAmounts).toEqual({ 1: '1.23' }); act(() => result.current.handleCustomAmountChange(1, '-2')); expect(result.current.customAmounts).toEqual({ 1: '-2' }); act(() => result.current.handleCustomAmountChange(1, '')); @@ -566,4 +566,37 @@ describe('useExpenseForm: saving', () => { total_price: 12, }); }); + + it('FE-BUDGET-EXPFORM-022: amounts take only the decimals the currency has, typed or switched to (#2604)', async () => { + const { result } = setup({ prefill: { currency: 'KRW' } }); + act(() => { + result.current.setName('Dinner'); + result.current.onTotalChange('1000.555'); + }); + // The point stays, so a digit typed after it is not taken as one more whole won. + expect(result.current.total).toBe('1000.'); + act(() => result.current.onTotalChange('1000.5')); + expect(result.current.total).toBe('1000.'); + act(() => { + result.current.setSplitMode('custom'); + result.current.handleCustomAmountChange(1, '600,5'); + result.current.enableMultiPayer(); + }); + expect(result.current.customAmounts[1]).toBe('600.'); + act(() => result.current.onPayerAmountChange(1, '400.75')); + expect(result.current.payerAmounts[1]).toBe('400.'); + + // Euros keep two places, and a switch to won drops the cents already typed. + act(() => result.current.setCurrency('EUR')); + act(() => result.current.onTotalChange('12.345')); + expect(result.current.total).toBe('12.34'); + act(() => result.current.setCurrency('KRW')); + expect(result.current.total).toBe('12'); + }); + + it('FE-BUDGET-EXPFORM-023: a three-decimal currency keeps its third place', () => { + const { result } = setup({ prefill: { currency: 'KWD' } }); + act(() => result.current.onTotalChange('1.5555')); + expect(result.current.total).toBe('1.555'); + }); }); diff --git a/client/src/components/Budget/useExpenseForm.ts b/client/src/components/Budget/useExpenseForm.ts index 2813c9561..282d2f14e 100644 --- a/client/src/components/Budget/useExpenseForm.ts +++ b/client/src/components/Budget/useExpenseForm.ts @@ -10,8 +10,10 @@ import { SYMBOLS } from './BudgetPanel.constants'; import type { TripMember } from './BudgetPanelMemberChips'; import type { ExpensePrefill } from './CostsPanel'; import { + amountInCurrency, amountPattern, calculateTicketShares, + clampAmountDecimals, hasTicketSplit, newExpenseSeed, payersBalanced, @@ -81,7 +83,7 @@ export function useExpenseForm({ localToday() ) ); - const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency); + const [currency, setCurrencyState] = useState(editing ? editingCurrency : seed.currency); const [day, setDay] = useState(editing ? editing.expense_date || localToday() : seed.day); const [note, setNote] = useState(() => readUserNote(editing)); // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB @@ -222,7 +224,23 @@ export function useExpenseForm({ ? ticketValid : totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)); - const onTotalChange = (v: string) => setTotal(v.replace(',', '.')); + // Every amount field takes only the decimals its currency has (#2604): a won + // amount typed as 1000.555 would otherwise be saved as typed, listed as ₩1,001 + // and reopened as 1001. + const asAmount = (raw: string) => clampAmountDecimals(raw.replace(',', '.'), currency); + + const onTotalChange = (v: string) => setTotal(asAmount(v)); + + // A switch to a currency with fewer decimals cuts what was already typed the same way. + const setCurrency = (next: string) => { + const cut = (m: Record) => + Object.fromEntries(Object.entries(m).map(([id, v]) => [id, amountInCurrency(v, next)])); + setCurrencyState(next); + setTotal((prev) => amountInCurrency(prev, next)); + setPayerAmounts(cut); + setCustomAmounts(cut); + setTicketItems((prev) => prev.map((item) => ({ ...item, price: amountInCurrency(item.price, next) }))); + }; // Keep the payer amounts summing to the total as it changes, including in ticket // mode, where the total is derived from the ticket items rather than typed. Only @@ -266,7 +284,7 @@ export function useExpenseForm({ }; const onPayerAmountChange = (id: number, v: string) => { - const val = v.replace(',', '.'); + const val = asAmount(v); const nextPinned = new Set(pinnedPayers); nextPinned.add(id); setPinnedPayers(nextPinned); @@ -274,7 +292,7 @@ export function useExpenseForm({ }; const handleCustomAmountChange = (id: number, raw: string) => { - const val = raw.replace(',', '.'); + const val = asAmount(raw); if (val === '' || amountPattern(currency, true).test(val)) { setCustomAmounts((prev) => ({ ...prev, [id]: val })); } @@ -292,7 +310,7 @@ export function useExpenseForm({ }; const handleUpdateItemPrice = (id: string, raw: string) => { - const price = raw.replace(',', '.'); + const price = asAmount(raw); if (price === '' || amountPattern(currency, false).test(price)) { setTicketItems((prev) => prev.map((item) => (item.id === id ? { ...item, price } : item))); } diff --git a/client/src/components/Budget/useSettlementForm.test.ts b/client/src/components/Budget/useSettlementForm.test.ts index 08d2e3d39..bd2f237ce 100644 --- a/client/src/components/Budget/useSettlementForm.test.ts +++ b/client/src/components/Budget/useSettlementForm.test.ts @@ -7,7 +7,7 @@ import { localToday } from '../Planner/today'; import type { CostsSettlement } from './costsModel'; import { useSettlementForm } from './useSettlementForm'; -// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-009 +// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-010 vi.mock('../../api/client', async (importOriginal) => ({ ...(await importOriginal()), @@ -218,4 +218,14 @@ describe('useSettlementForm: saving', () => { expect(await run(true)).toBe(1); expect(await run(false)).toBe(2); }); + + it('FE-BUDGET-SETTLEFORM-010: the amount takes only the decimals its currency has (#2604)', () => { + const { result } = setup({ editing: null }); + act(() => result.current.setAmount('12,345')); + expect(result.current.amount).toBe('12.34'); + act(() => result.current.setCurrency('JPY')); + expect(result.current.amount).toBe('12'); + act(() => result.current.setAmount('1000.5')); + expect(result.current.amount).toBe('1000.'); + }); }); diff --git a/client/src/components/Budget/useSettlementForm.ts b/client/src/components/Budget/useSettlementForm.ts index 648c0b20e..867997099 100644 --- a/client/src/components/Budget/useSettlementForm.ts +++ b/client/src/components/Budget/useSettlementForm.ts @@ -4,7 +4,7 @@ import { budgetApi } from '../../api/client'; import { withFallbackFx } from '../../hooks/useExchangeRates'; import { amountToInputString } from '../../utils/formatters'; import { localToday } from '../Planner/today'; -import { settlementDate } from './CostsPanel.helpers'; +import { amountInCurrency, clampAmountDecimals, settlementDate } from './CostsPanel.helpers'; import type { CostsSettlement } from './costsModel'; type Translate = (key: string, params?: Record) => string; @@ -96,15 +96,23 @@ export function useSettlementForm({ } }; + // Typed with either decimal mark and cut to the currency's decimals (#2604), on + // entry and when the currency changes under an amount already typed. + const changeAmount = (v: string) => setAmount(clampAmountDecimals(v.replace(',', '.'), currency)); + const changeCurrency = (next: string) => { + setCurrency(next); + setAmount((prev) => amountInCurrency(prev, next)); + }; + return { fromId, setFromId, toId, setToId, amount, - setAmount, + setAmount: changeAmount, currency, - setCurrency, + setCurrency: changeCurrency, day, setDay, note, diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index 2581df027..4d79772de 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -8,7 +8,7 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } import { useTranslation } from '../../../../i18n' import { useToast } from '../../../../components/shared/Toast' import { useTripStore } from '../../../../store/tripStore' -import { formatMoney, localizeAmountInput } from '../../../../utils/formatters' +import { amountToInputString, formatMoney, localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters' import { openFile } from '../../../../utils/fileDownload' import { splitShareLabel } from '../../../../components/Budget/expenseFx' import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants' @@ -150,11 +150,11 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
@@ -276,10 +276,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, onPayerAmountChange(p.id, v)} - className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none" + className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint" />
) : ( @@ -330,10 +330,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, {sym(currency)} handleUpdateItemPrice(item.id, v)} - className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none" + className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint" />