diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx
index 9ebec4544..94869e274 100644
--- a/client/src/components/Budget/CostsPanel.test.tsx
+++ b/client/src/components/Budget/CostsPanel.test.tsx
@@ -108,12 +108,12 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
- const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
+ const nums = () => screen.getAllByPlaceholderText('0,00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total = 100
await user.click(screen.getByRole('button', { name: /Custom/i }))
- const customInputs = screen.getAllByPlaceholderText('50.00')
+ const customInputs = screen.getAllByPlaceholderText('50,00')
await user.type(customInputs[0], '30')
await user.type(customInputs[1], '70')
@@ -146,7 +146,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
- await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
+ await user.type(screen.getAllByPlaceholderText('0,00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
@@ -203,7 +203,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
- await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
+ await user.type(screen.getAllByPlaceholderText('0,00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
@@ -292,14 +292,14 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
- await user.type(screen.getAllByPlaceholderText('0.00')[0], '90')
+ await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
// Alice (me) is seeded as the sole payer; including Bob rebalances to 45/45.
await user.click(screen.getAllByTestId('payer-toggle')[1])
expect(screen.getAllByTestId('payer-amount').map(i => (i as HTMLInputElement).value))
- .toEqual(['45.00', '45.00'])
+ .toEqual(['45,00', '45,00'])
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
@@ -330,7 +330,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
- await user.type(screen.getAllByPlaceholderText('0.00')[0], '90')
+ await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
await user.click(screen.getAllByTestId('payer-toggle')[1])
@@ -435,7 +435,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(addBtn)
const itemNames = screen.getAllByPlaceholderText('Item name')
- const itemPrices = screen.getAllByPlaceholderText('0.00')
+ const itemPrices = screen.getAllByPlaceholderText('0,00')
await user.type(itemNames[0], 'Apples')
await user.type(itemPrices[1], '10')
@@ -448,7 +448,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.type(itemNames[2], 'Milk')
await user.type(itemPrices[3], '40')
- expect(screen.getByDisplayValue('100.00')).toBeDisabled()
+ expect(screen.getByDisplayValue('100,00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx
index 7bbaf6c93..edc2723b3 100644
--- a/client/src/components/Budget/CostsPanel.tsx
+++ b/client/src/components/Budget/CostsPanel.tsx
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
-import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
+import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -1295,7 +1295,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)}
-
@@ -1378,8 +1378,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
{sym(currency)}
-
onPayerAmountChange(p.id, v)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
@@ -1443,8 +1443,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)}
handleUpdateItemPrice(item.id, v)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
@@ -1526,7 +1526,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
on ? (
{sym(currency)}
- handleCustomAmountChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx
index a9a19dac6..57271b486 100644
--- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx
@@ -9,7 +9,7 @@ import { useTranslation } from '../../../../i18n'
import { useToast } from '../../../../components/shared/Toast'
import { useTripStore } from '../../../../store/tripStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
-import { formatMoney } from '../../../../utils/formatters'
+import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { calculateTicketShares, splitEqualShares, type TicketItem, type ExpensePrefill } from '../../../../components/Budget/CostsPanel'
@@ -350,8 +350,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{sym(currency)}
{sym(currency)}
onPayerAmountChange(p.id, v)}
className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none"
/>
@@ -497,8 +497,8 @@ 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-right text-[0.8125rem] font-semibold text-m-ink outline-none"
/>
@@ -575,8 +575,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
handleCustomAmountChange(p.id, e.target.value)}
className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
/>
diff --git a/client/src/utils/formatters.test.ts b/client/src/utils/formatters.test.ts
index 04a8a6eb9..bc94ef5b5 100644
--- a/client/src/utils/formatters.test.ts
+++ b/client/src/utils/formatters.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals } from './formatters'
+import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals, localizeAmountInput } from './formatters'
import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants'
import type { Day } from '../types'
@@ -26,6 +26,20 @@ describe('resolveDayId', () => {
})
})
+describe('localizeAmountInput (#1624)', () => {
+ it('shows the amount with the currency comma separator so the field matches the list', () => {
+ expect(localizeAmountInput('12.5', 'EUR')).toBe('12,5')
+ expect(localizeAmountInput('0.00', 'EUR')).toBe('0,00')
+ })
+ it('keeps the dot for dot-separator currencies', () => {
+ expect(localizeAmountInput('12.5', 'USD')).toBe('12.5')
+ })
+ it('passes an empty/nullish value through unchanged', () => {
+ expect(localizeAmountInput('', 'EUR')).toBe('')
+ expect(localizeAmountInput(null, 'EUR')).toBe('')
+ })
+})
+
describe('KGS currency (#1400)', () => {
it('is selectable everywhere the shared currency list feeds', () => {
expect(CURRENCIES).toContain('KGS')
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index a4b5b0237..91be2c9ee 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -71,6 +71,20 @@ export function currencyLocale(currency: string): string {
return CURRENCY_LOCALE[(currency || '').toUpperCase()] || 'en-US'
}
+// The decimal separator formatMoney renders a currency with (',' for EUR, '.' for
+// USD) — same home-locale convention, so an amount input can match the displayed list.
+export function currencyDecimalSeparator(currency: string): string {
+ return (0.1).toLocaleString(currencyLocale(currency)).replace(/\d/g, '') || '.'
+}
+
+// Show a dot-normalized amount string in an input using the currency's decimal
+// separator, so the field reads the same way as the list. State stays dot-normalized;
+// callers still normalize typed input back to a dot on change.
+export function localizeAmountInput(value: string | number | null | undefined, currency: string): string {
+ const s = String(value ?? '')
+ return currencyDecimalSeparator(currency) === ',' ? s.replace('.', ',') : s
+}
+
/**
* Locale- and currency-correct money formatting via Intl: the symbol position,
* thousands/decimal separators and decimal count all follow the user's locale