From 7c857173cf585787b6ffa9c02653fa2322f2b0ba Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 2 Sep 2026 19:00:49 +0200 Subject: [PATCH] fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176) The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total. --- .../components/Budget/CostsPanel.helpers.ts | 10 +++ .../src/components/Budget/CostsPanel.test.tsx | 55 +++++++++++++ client/src/components/Budget/CostsPanel.tsx | 13 +++- .../components/shared/NumericInput.test.tsx | 47 ++++++++++- client/src/components/shared/NumericInput.tsx | 77 +++++++++++++------ .../mobile/screens/trip/sheets/MCostSheet.tsx | 8 +- shared/src/i18n/ar/budget.ts | 1 + shared/src/i18n/br/budget.ts | 1 + shared/src/i18n/ca/budget.ts | 1 + shared/src/i18n/cs/budget.ts | 1 + shared/src/i18n/de/budget.ts | 1 + shared/src/i18n/en/budget.ts | 1 + shared/src/i18n/es/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/id/budget.ts | 1 + shared/src/i18n/it/budget.ts | 1 + shared/src/i18n/ja/budget.ts | 1 + shared/src/i18n/ko/budget.ts | 1 + shared/src/i18n/nl/budget.ts | 1 + shared/src/i18n/pl/budget.ts | 1 + shared/src/i18n/ru/budget.ts | 1 + shared/src/i18n/sv/budget.ts | 1 + shared/src/i18n/tr/budget.ts | 1 + shared/src/i18n/uk/budget.ts | 1 + shared/src/i18n/vi/budget.ts | 1 + shared/src/i18n/zh-TW/budget.ts | 1 + shared/src/i18n/zh/budget.ts | 1 + 29 files changed, 199 insertions(+), 34 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 15c3d15a6..ef2877b30 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -11,6 +11,16 @@ * Amounts are the raw input strings, parsed on use (same as customAmounts). */ +import { currencyDecimals } from '../../utils/formatters' + +// 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. +export const amountPattern = (currency: string, signed: boolean) => + new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`) + /** * Spread `amount` across `n` payers in whole cents so the parts sum back exactly. * Floor-based, so a negative total (a refund, #2176) splits just as exactly: diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index ebdbf34b2..aed5a1d41 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -1890,6 +1890,29 @@ describe('CostsPanel — decimal padding on edit (#2175)', () => { await user.click(screen.getAllByTitle('Edit')[1]) expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument() }) + + it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => { + const user = userEvent.setup() + render( {}} onSaved={vi.fn()} + editing={expense({ + id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3, + payers: [{ user_id: 1, amount: 3 }], + members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }], + })} />) + + expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument() + const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[] + + // The seed is three decimals, so a two-decimal guard would reject every further + // keystroke, including one typed in front of the decimal point. + await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 }) + expect(shares[0].value).toBe('21.500') + + // A fourth decimal is still not a fils. + await user.clear(shares[1]) + await user.type(shares[1], '1.5005') + expect(shares[1].value).toBe('1.500') + }) }) // ── #2176: negative amounts record partial reimbursements ─────────────────── @@ -1977,6 +2000,38 @@ describe('CostsPanel — negative amounts (#2176)', () => { expect(within(breakdown).getByText('60 €')).toBeInTheDocument() expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument() }) + + it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => { + const user = userEvent.setup() + render( {}} onSaved={vi.fn()} + editing={expense({ + id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100, + payers: [{ user_id: 1, amount: -100 }], + members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }], + })} />) + + // -70 of -100: 30 still to hand out, not 30 too much. + expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument() + + await user.type(screen.getByPlaceholderText('-30,00'), '-40') + expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument() + }) + + it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => { + const user = userEvent.setup() + render( {}} onSaved={vi.fn()} />) + + const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement + await user.type(total, '100') + + // The iOS decimal pad has no minus key, so the sign has to be reachable without one. + const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' }) + await user.click(toggle) + + expect(total.value).toBe('-100') + expect(toggle).toHaveAttribute('aria-pressed', 'true') + }) }) describe('CostsPanel — split maths', () => { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 268c30408..942460c48 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -17,7 +17,7 @@ import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { localToday } from '../Planner/today' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' -import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' @@ -1130,6 +1130,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) + // How much is still to be handed out, read on the total's own side: on a refund + // (#2176) the shares run negative, so a plain total minus sum flips under and + // over around and sends the user the wrong way. + const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum const each = participants.size > 0 ? totalNum / participants.size : 0 const equalShares = useMemo(() => { return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0) @@ -1212,7 +1216,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const handleCustomAmountChange = (id: number, val: string) => { val = val.replace(',', '.') - if (/^-?\d*\.?\d{0,2}$/.test(val) || val === '') { + if (val === '' || amountPattern(currency, true).test(val)) { setCustomAmounts(prev => ({ ...prev, [id]: val })) } } @@ -1235,7 +1239,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const handleUpdateItemPrice = (id: string, price: string) => { price = price.replace(',', '.') - if (/^\d*\.?\d{0,2}$/.test(price) || price === '') { + if (price === '' || amountPattern(currency, false).test(price)) { setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item)) } } @@ -1352,6 +1356,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {sym(currency)} @@ -1607,7 +1612,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {customBalanced ? t('costs.splitBalanced') - : t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', { + : t(splitShortfall > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', { sum: sym(currency) + splitSum.toFixed(2), total: sym(currency) + totalNum.toFixed(2), diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2), diff --git a/client/src/components/shared/NumericInput.test.tsx b/client/src/components/shared/NumericInput.test.tsx index 8dfa8da43..d0bb2bab1 100644 --- a/client/src/components/shared/NumericInput.test.tsx +++ b/client/src/components/shared/NumericInput.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-008 +// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-014 // // #1513: tapping a pre-populated numeric field put the caret at the end, so the first // digit typed was appended rather than replacing the value — quantity 1 + "6" = "16". @@ -16,9 +16,9 @@ beforeEach(() => { }) afterEach(() => vi.unstubAllGlobals()) -function Harness({ initial, mode }: { initial: string; mode?: NumericMode }) { +function Harness({ initial, mode, signToggleLabel }: { initial: string; mode?: NumericMode; signToggleLabel?: string }) { const [v, setV] = useState(initial) - return + return } describe('NumericInput', () => { @@ -115,6 +115,47 @@ describe('NumericInput', () => { expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal') }) + it('FE-COMP-NUMINPUT-012: the sign toggle flips the sign of what is already there (#2176)', () => { + // iOS renders inputMode="decimal" as a pad of digits, separator and backspace, with + // no minus key, so a refund is untypeable on a phone without this button. + render() + const input = screen.getByLabelText('field') as HTMLInputElement + const toggle = screen.getByRole('button', { name: 'Toggle sign' }) + + fireEvent.change(input, { target: { value: '12,50' } }) + fireEvent.click(toggle) + expect(input.value).toBe('-12,50') + expect(toggle).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(toggle) + expect(input.value).toBe('12,50') + expect(toggle).toHaveAttribute('aria-pressed', 'false') + + // The button must not take the focus, or the phone keypad closes mid-entry. + expect(fireEvent.mouseDown(toggle)).toBe(false) + }) + + it('FE-COMP-NUMINPUT-013: an empty field toggles to a bare minus so the digits follow', () => { + render() + const input = screen.getByLabelText('field') as HTMLInputElement + + fireEvent.click(screen.getByRole('button', { name: 'Toggle sign' })) + expect(input.value).toBe('-') + + // The caret stays in the field, so the next keystroke appends to the sign. + fireEvent.change(input, { target: { value: '-1' } }) + expect(input.value).toBe('-1') + }) + + it('FE-COMP-NUMINPUT-014: there is no toggle without a label, and none on an unsigned field', () => { + const { unmount } = render() + expect(screen.queryByRole('button')).toBeNull() + unmount() + + render() + expect(screen.queryByRole('button')).toBeNull() + }) + it('FE-COMP-NUMINPUT-009: typing inside the one-frame window is not swallowed by the deferred select', async () => { // The deferred select() exists for WebKit, but it must not fire *after* the user has // started typing: it would select the half-typed value and the next character would diff --git a/client/src/components/shared/NumericInput.tsx b/client/src/components/shared/NumericInput.tsx index 1e8bfdbf0..07b84e767 100644 --- a/client/src/components/shared/NumericInput.tsx +++ b/client/src/components/shared/NumericInput.tsx @@ -24,6 +24,8 @@ const SANITIZERS: Record string> = { }, } +const flipSign = (raw: string) => (raw.startsWith('-') ? raw.slice(1) : `-${raw}`) + type Props = Omit, 'type' | 'onChange' | 'value'> & { value: string | number | null | undefined /** Receives the sanitized raw string. Callers keep their own state and commit logic. */ @@ -31,6 +33,13 @@ type Props = Omit, 'type' | 'onChange' | ' mode?: NumericMode /** Escape hatch for a field that must not steal the caret (none today). */ selectOnFocus?: boolean + /** + * Accessible name for a sign toggle rendered beside the field. Set it on a signed + * money field: iOS draws inputMode="decimal" as a pad with digits, separator and + * backspace only, so a refund (#2176) has no minus key to type. Ignored on the + * unsigned modes, where there is no sign to flip. + */ + signToggleLabel?: string ref?: Ref } @@ -62,34 +71,54 @@ type Props = Omit, 'type' | 'onChange' | ' * others on blur. This owns only the part that was uniformly broken. */ export function NumericInput({ - value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ref, ...rest + value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, signToggleLabel, ref, ...rest }: Props) { // Set while a deferred select() is queued; any input in that window cancels it. const selectPending = useRef(false) + const raw = String(value ?? '') + const signable = mode === 'signed' || mode === 'signed-decimal' return ( - { - selectPending.current = false - onValueChange(SANITIZERS[mode](e.target.value)) - }} - onFocus={e => { - if (selectOnFocus) { - const el = e.currentTarget - el.select() - selectPending.current = true - requestAnimationFrame(() => { - if (selectPending.current && document.activeElement === el) el.select() - selectPending.current = false - }) - } - onFocus?.(e) - }} - /> + <> + { + selectPending.current = false + onValueChange(SANITIZERS[mode](e.target.value)) + }} + onFocus={e => { + if (selectOnFocus) { + const el = e.currentTarget + el.select() + selectPending.current = true + requestAnimationFrame(() => { + if (selectPending.current && document.activeElement === el) el.select() + selectPending.current = false + }) + } + onFocus?.(e) + }} + /> + {signToggleLabel && signable && ( + + )} + ) } diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index 30e6095b1..5cbba6c30 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -13,7 +13,7 @@ import { formatMoney, localizeAmountInput, amountToInputString } from '../../../ import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories' import { localToday } from '../../../../components/Planner/today' -import { calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers' +import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers' import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel' import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers' import GuestBadge from '../../../../components/shared/GuestBadge' @@ -203,7 +203,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const handleCustomAmountChange = (id: number, val: string) => { val = val.replace(',', '.') - if (/^-?\d*\.?\d{0,2}$/.test(val) || val === '') setCustomAmounts(prev => ({ ...prev, [id]: val })) + if (val === '' || amountPattern(currency, true).test(val)) setCustomAmounts(prev => ({ ...prev, [id]: val })) } const handleAddEmptyItem = () => { @@ -215,7 +215,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const handleUpdateItemName = (id: string, itemName: string) => setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name: itemName } : item)) const handleUpdateItemPrice = (id: string, price: string) => { price = price.replace(',', '.') - if (/^\d*\.?\d{0,2}$/.test(price) || price === '') setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item)) + if (price === '' || amountPattern(currency, false).test(price)) setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item)) } const handleRemoveItem = (id: string) => setTicketItems(prev => prev.filter(item => item.id !== id)) const handleToggleItemParticipant = (itemId: string, userId: number) => { @@ -346,6 +346,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, {sym(currency)} {sym(currency)} onPayerAmountChange(p.id, v)} diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index 2286740fa..04a87a911 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'التقسيم يطابق الإجمالي', 'costs.splitSumUnder': 'مجموع الحصص: {sum} من {total} (ناقص {diff})', 'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})', + 'costs.toggleSign': 'التبديل بين المصروف والاسترداد', }; export default budget; diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 11ddaaf31..13237225b 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'A divisão bate com o total', 'costs.splitSumUnder': 'Soma das partes: {sum} de {total} (faltam {diff})', 'costs.splitSumOver': 'Soma das partes: {sum} de {total} (sobram {diff})', + 'costs.toggleSign': 'Alternar entre despesa e reembolso', }; export default budget; diff --git a/shared/src/i18n/ca/budget.ts b/shared/src/i18n/ca/budget.ts index c5dc5d6e7..627b11ab0 100644 --- a/shared/src/i18n/ca/budget.ts +++ b/shared/src/i18n/ca/budget.ts @@ -146,6 +146,7 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'El repartiment quadra amb el total', 'costs.splitSumUnder': 'Suma de les parts: {sum} de {total} (en falten {diff})', 'costs.splitSumOver': 'Suma de les parts: {sum} de {total} ({diff} de més)', + 'costs.toggleSign': 'Alterna entre despesa i devolució', }; export default budget; diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index 54d91832c..7e905f021 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Rozdělení sedí s celkem', 'costs.splitSumUnder': 'Součet podílů: {sum} z {total} (chybí {diff})', 'costs.splitSumOver': 'Součet podílů: {sum} z {total} (o {diff} více)', + 'costs.toggleSign': 'Přepnout mezi výdajem a vratkou', }; export default budget; diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index 9638fdff8..f1014fdcd 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Aufteilung passt zur Summe', 'costs.splitSumUnder': 'Summe der Anteile: {sum} von {total} (es fehlen {diff})', 'costs.splitSumOver': 'Summe der Anteile: {sum} von {total} ({diff} zu viel)', + 'costs.toggleSign': 'Zwischen Ausgabe und Erstattung wechseln', }; export default budget; diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index 1941b03da..c2b4297d3 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -146,6 +146,7 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Split matches total', 'costs.splitSumUnder': 'Sum of splits: {sum} of {total} (under by {diff})', 'costs.splitSumOver': 'Sum of splits: {sum} of {total} (over by {diff})', + 'costs.toggleSign': 'Switch between expense and refund', }; export default budget; diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 66818794d..27e8c9d2b 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'El reparto cuadra con el total', 'costs.splitSumUnder': 'Suma de las partes: {sum} de {total} (faltan {diff})', 'costs.splitSumOver': 'Suma de las partes: {sum} de {total} (sobran {diff})', + 'costs.toggleSign': 'Cambiar entre gasto y reembolso', }; export default budget; diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index e5735a34b..053a3740b 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'La répartition correspond au total', 'costs.splitSumUnder': 'Somme des parts : {sum} sur {total} (il manque {diff})', 'costs.splitSumOver': 'Somme des parts : {sum} sur {total} ({diff} de trop)', + 'costs.toggleSign': 'Basculer entre dépense et remboursement', }; export default budget; diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index f9fb6261b..112a8c5d0 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -147,5 +147,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Ο διαμοιρασμός ταιριάζει με το σύνολο', 'costs.splitSumUnder': 'Άθροισμα μεριδίων: {sum} από {total} (λείπουν {diff})', 'costs.splitSumOver': 'Άθροισμα μεριδίων: {sum} από {total} ({diff} παραπάνω)', + 'costs.toggleSign': 'Εναλλαγή μεταξύ εξόδου και επιστροφής', }; export default budget; diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index 971a7f747..a6c3c3941 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'A felosztás megegyezik a végösszeggel', 'costs.splitSumUnder': 'A részek összege: {sum} / {total} (hiányzik {diff})', 'costs.splitSumOver': 'A részek összege: {sum} / {total} ({diff} a többlet)', + 'costs.toggleSign': 'Váltás kiadás és visszatérítés között', }; export default budget; diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index c2cbfe162..5839653b9 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Pembagian sudah pas dengan total', 'costs.splitSumUnder': 'Jumlah bagian: {sum} dari {total} (kurang {diff})', 'costs.splitSumOver': 'Jumlah bagian: {sum} dari {total} (lebih {diff})', + 'costs.toggleSign': 'Beralih antara pengeluaran dan pengembalian dana', }; export default budget; diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index cfc32d7fb..72c6f0b01 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'La divisione corrisponde al totale', 'costs.splitSumUnder': 'Somma delle quote: {sum} di {total} (mancano {diff})', 'costs.splitSumOver': 'Somma delle quote: {sum} di {total} ({diff} in più)', + 'costs.toggleSign': 'Passa da spesa a rimborso', }; export default budget; diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 105f56f7f..2cdcec980 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': '分割は合計と一致しています', 'costs.splitSumUnder': '分割の合計: {total} のうち {sum}({diff} 不足)', 'costs.splitSumOver': '分割の合計: {total} のうち {sum}({diff} 超過)', + 'costs.toggleSign': '支出と返金を切り替える', }; export default budget; diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index ec22a7047..9c9c3a2e5 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': '분할 금액이 합계와 일치합니다', 'costs.splitSumUnder': '분할 합계: {total} 중 {sum} ({diff} 부족)', 'costs.splitSumOver': '분할 합계: {total} 중 {sum} ({diff} 초과)', + 'costs.toggleSign': '지출과 환불 전환', }; export default budget; diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index e629c0616..4f912c182 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Verdeling klopt met het totaal', 'costs.splitSumUnder': 'Som van de delen: {sum} van {total} ({diff} te weinig)', 'costs.splitSumOver': 'Som van de delen: {sum} van {total} ({diff} te veel)', + 'costs.toggleSign': 'Wisselen tussen uitgave en terugbetaling', }; export default budget; diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index b7e6d77b2..301274eb2 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Podział zgadza się z sumą', 'costs.splitSumUnder': 'Suma podziałów: {sum} z {total} (brakuje {diff})', 'costs.splitSumOver': 'Suma podziałów: {sum} z {total} (o {diff} za dużo)', + 'costs.toggleSign': 'Przełącz między wydatkiem a zwrotem', }; export default budget; diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index 97bf367d2..b715b1a4e 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Разделение совпадает с итогом', 'costs.splitSumUnder': 'Сумма долей: {sum} из {total} (не хватает {diff})', 'costs.splitSumOver': 'Сумма долей: {sum} из {total} (больше на {diff})', + 'costs.toggleSign': 'Переключить между расходом и возвратом', }; export default budget; diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index 385245e97..fd3c1a8fe 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Fördelningen stämmer med totalen', 'costs.splitSumUnder': 'Summan av delarna: {sum} av {total} ({diff} saknas)', 'costs.splitSumOver': 'Summan av delarna: {sum} av {total} ({diff} för mycket)', + 'costs.toggleSign': 'Växla mellan utgift och återbetalning', }; export default budget; diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index 7faff9b2e..4434b721a 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Bölüşüm toplamla eşleşiyor', 'costs.splitSumUnder': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} eksik)', 'costs.splitSumOver': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} fazla)', + 'costs.toggleSign': 'Gider ve iade arasında geçiş yap', }; export default budget; diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 11951fe0b..0f1d28ef5 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -146,5 +146,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Розподіл збігається із сумою', 'costs.splitSumUnder': 'Сума часток: {sum} з {total} (бракує {diff})', 'costs.splitSumOver': 'Сума часток: {sum} з {total} (більше на {diff})', + 'costs.toggleSign': 'Перемкнути між витратою та поверненням', }; export default budget; diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index 196c87321..2bbed30da 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -146,6 +146,7 @@ const budget: TranslationStrings = { 'costs.splitBalanced': 'Phần chia khớp với tổng', 'costs.splitSumUnder': 'Tổng các phần: {sum} trên {total} (thiếu {diff})', 'costs.splitSumOver': 'Tổng các phần: {sum} trên {total} (thừa {diff})', + 'costs.toggleSign': 'Chuyển giữa khoản chi và khoản hoàn', }; export default budget; diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index bc6d0eecc..022ca52d1 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -145,5 +145,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': '分攤金額與總額相符', 'costs.splitSumUnder': '分攤合計:{total} 中的 {sum}(少 {diff})', 'costs.splitSumOver': '分攤合計:{total} 中的 {sum}(多 {diff})', + 'costs.toggleSign': '在支出與退款之間切換', }; export default budget; diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index 3a79916dc..e9f54991b 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -145,5 +145,6 @@ const budget: TranslationStrings = { 'costs.splitBalanced': '分摊金额与总额相符', 'costs.splitSumUnder': '分摊合计:{total} 中的 {sum}(少 {diff})', 'costs.splitSumOver': '分摊合计:{total} 中的 {sum}(多 {diff})', + 'costs.toggleSign': '在支出和退款之间切换', }; export default budget;