From d1aa573167b9262a2e7d696722e6074d051d07a6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 1 Sep 2026 15:07:26 +0200 Subject: [PATCH] fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) --- .../Budget/CostsPanel.helpers.test.ts | 61 +++++++- .../components/Budget/CostsPanel.helpers.ts | 17 ++- .../src/components/Budget/CostsPanel.test.tsx | 131 +++++++++++++++++- client/src/components/Budget/CostsPanel.tsx | 58 +++++--- client/src/components/PDF/TripPDF.tsx | 2 +- .../components/shared/NumericInput.test.tsx | 24 ++++ client/src/components/shared/NumericInput.tsx | 10 +- .../mobile/screens/trip/sheets/MCostSheet.tsx | 34 +++-- .../mobile/screens/trip/tabs/costsModel.ts | 16 ++- client/src/utils/formatters.test.ts | 28 +++- client/src/utils/formatters.ts | 13 ++ .../unit/mobile/trip/MCostSheet.test.tsx | 57 +++++++- .../tests/unit/mobile/trip/costsModel.test.ts | 28 ++++ server/src/nest/budget/budget.mcp.ts | 23 +-- server/src/nest/budget/budget.service.ts | 29 ++-- server/tests/e2e/budget.e2e.test.ts | 46 +++++- .../unit/nest/budget.service.calc.test.ts | 122 ++++++++++++++++ .../tests/unit/nest/budget.service.db.test.ts | 96 +++++++++++++ 18 files changed, 717 insertions(+), 78 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 31c97c102..195f6b1b8 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, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' describe('splitCents', () => { it('splits evenly when it divides cleanly', () => { @@ -16,8 +16,12 @@ describe('splitCents', () => { expect(splitCents(50, 0)).toEqual([]) }) - it('floors a negative amount at zero rather than inventing debt', () => { - expect(splitCents(-10, 2)).toEqual([0, 0]) + it('splits a negative amount exactly instead of clamping it to zero (#2176)', () => { + // A refund spread across payers has to sum back too — the old Math.max(0, …) + // clamp silently swallowed the sign and broke rebalancePayers for refunds. + expect(splitCents(-10, 2)).toEqual([-5, -5]) + const parts = splitCents(-100.01, 3) + expect(parts.reduce((a, b) => a + Math.round(b * 100), 0)).toBe(-10001) }) }) @@ -75,6 +79,57 @@ describe('rebalancePayers', () => { const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100) expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true) }) + + // ── Refunds: negative totals rebalance like any other (#2176) ────────────── + + it('spreads a negative total across payers so the parts stay typeable', () => { + const next = rebalancePayers({}, new Set(), new Set([1, 2]), -90) + expect(next).toEqual({ 1: '-45.00', 2: '-45.00' }) + expect(payersBalanced(next, new Set([1, 2]), -90)).toBe(true) + }) + + it('lets a free payer absorb the rest of a negative total past a pinned amount', () => { + const next = rebalancePayers({ 1: '-70' }, new Set([1]), new Set([1, 2]), -100) + expect(next[1]).toBe('-70') + expect(next[2]).toBe('-30.00') + }) + + it('handles a pinned positive payer against a negative total', () => { + // Alice fronted 50 but the entry nets to -100 — the free payer carries -150. + const next = rebalancePayers({ 1: '50' }, new Set([1]), new Set([1, 2]), -100) + expect(next[2]).toBe('-150.00') + expect(payersBalanced(next, new Set([1, 2]), -100)).toBe(true) + }) +}) + +// ── Client/server share parity (#2176) ─────────────────────────────────────── +// +// splitEqualShares exists twice: here (previewing the split in euros) and on the +// server (netting the settlement in cents — BudgetService.splitEqualShares in +// server/src/nest/budget/budget.service.ts). The fixture below is duplicated +// verbatim in server/tests/unit/nest/budget.service.calc.test.ts; if either +// implementation drifts — sign handling included — its copy of this table fails. +const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record }[] = [ + { totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } }, + { totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } }, + { totalCents: -10000, users: [1, 2, 3], itemId: 0, expected: { 1: -3333, 2: -3333, 3: -3334 } }, + { totalCents: -10000, users: [1, 2, 3], itemId: 1, expected: { 1: -3334, 2: -3333, 3: -3333 } }, + { totalCents: -101, users: [1, 2], itemId: 0, expected: { 1: -50, 2: -51 } }, + { totalCents: -101, users: [1, 2], itemId: 1, expected: { 1: -51, 2: -50 } }, + { totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } }, +] + +describe('splitEqualShares — server parity (#2176)', () => { + it.each(SHARE_PARITY_FIXTURE)( + 'splits $totalCents cents across $users.length members (item $itemId) exactly like the server', + ({ totalCents, users, itemId, expected }) => { + const shares = splitEqualShares(totalCents / 100, users.map(user_id => ({ user_id })), itemId) + const inCents = Object.fromEntries(Object.entries(shares).map(([id, v]) => [id, Math.round(v * 100)])) + expect(inCents).toEqual(expected) + // The invariant behind the fixture: the shares always sum back to the total. + expect(Object.values(inCents).reduce((a, b) => a + b, 0)).toBe(totalCents) + }, + ) }) // ── Notes vs. itemized receipts (#1658) ────────────────────────────────────── diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 6226d289c..15c3d15a6 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -11,10 +11,14 @@ * Amounts are the raw input strings, parsed on use (same as customAmounts). */ -/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */ +/** + * 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: + * the remainder is always in [0, n) and the parts still sum to the input. + */ export function splitCents(amount: number, n: number): number[] { if (n <= 0) return [] - const cents = Math.max(0, Math.round(amount * 100)) + const cents = Math.round(amount * 100) const base = Math.floor(cents / n) const rem = cents - base * n return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100) @@ -58,6 +62,13 @@ export function rebalancePayers( * The remainder cent rotates with the item id rather than always landing on the * first member, so across several expenses the rounding evens out instead of * always favouring the same person. + * + * Must stay share-for-share identical to the server's + * BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted + * there, this copy only previews it. The remainder is `totalCents - baseCents*n` + * rather than `%` so a negative total (a refund, #2176) still yields a remainder + * in [0, n) and the shares sum back to the total exactly, like the server's do. + * The parity fixture in CostsPanel.helpers.test.ts pins both sides. */ export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record { const n = members.length @@ -65,7 +76,7 @@ export function splitEqualShares(total: number, members: { user_id: number }[], const totalCents = Math.round(total * 100) const baseCents = Math.floor(totalCents / n) - const remainder = totalCents % n + const remainder = totalCents - baseCents * n const shares: Record = {} const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 3a5a8cff8..ebdbf34b2 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -518,8 +518,9 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(screen.getByTitle('Edit')) // Loading used to be payers.find(...), which silently dropped the second payer. + // Seeded with two decimals since #2175, localized for EUR. const amounts = await screen.findAllByTestId('payer-amount') - expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45']) + expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45,00', '45,00']) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(put).toBeTruthy()) @@ -705,8 +706,9 @@ describe('CostsPanel — settlements in the ledger', () => { await screen.findByText('Payment') await user.click(screen.getByTitle('Edit')) - // The stored USD amount comes back as-is, not silently reread as euros. - expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30') + // The stored USD amount comes back as-is (padded to two decimals, #2175), + // not silently reread as euros. + expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30.00') expect(screen.getByText(/^USD/)).toBeInTheDocument() }) }) @@ -1300,8 +1302,9 @@ describe('CostsPanel — expense modal', () => { await screen.findByText('Dinner') await user.click(screen.getByTitle('Edit')) - expect((await screen.findByDisplayValue('70')).tagName).toBe('INPUT') - expect(screen.getByDisplayValue('30')).toBeInTheDocument() + // Custom shares reopen padded to two decimals (#2175), localized for EUR. + expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT') + expect(screen.getByDisplayValue('30,00')).toBeInTheDocument() // Excluding Bob drops his amount; the split no longer matches the total. await user.click(screen.getByRole('button', { name: /bob/i })) @@ -1430,7 +1433,8 @@ describe('CostsPanel — expense modal', () => { ) expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument() - expect(screen.getByDisplayValue('240')).toBeInTheDocument() + // The prefilled amount is padded to the base currency's decimals (#2175). + expect(screen.getByDisplayValue('240,00')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Add expense' })) await waitFor(() => expect(posted).toBeTruthy()) @@ -1640,7 +1644,8 @@ describe('CostsPanel — remaining paths', () => { await screen.findByText('Mining rig') await user.click(screen.getByTitle('Edit')) - expect(await screen.findByDisplayValue('2')).toBeInTheDocument() + // XBT is unknown to the locale map → dot separator, two decimals (#2175). + expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument() expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2) }) @@ -1862,6 +1867,118 @@ describe('CostsPanel — mobile extras', () => { }) }) +// ── #2175: edit inputs are padded to the currency's decimals ──────────────── +describe('CostsPanel — decimal padding on edit (#2175)', () => { + beforeEach(() => { + seedAlice() + clearExchangeRateCache() + }) + afterEach(clearExchangeRateCache) + + it('FE-W5COSTS-066: reopens 4,90 as "4,90" and 5,00 as "5,00", not "4,9" and "5"', async () => { + const user = userEvent.setup() + mount([ + expense({ id: 301, name: 'Coffee', category: 'food', total_price: 4.9, payers: [{ user_id: 1, amount: 4.9 }], members: [{ user_id: 1, username: 'alice' }] }), + expense({ id: 302, name: 'Toll', category: 'transport', total_price: 5, payers: [{ user_id: 1, amount: 5 }], members: [{ user_id: 1, username: 'alice' }] }), + ]) + + await screen.findByText('Coffee') + await user.click(screen.getAllByTitle('Edit')[0]) + expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await user.click(screen.getAllByTitle('Edit')[1]) + expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument() + }) +}) + +// ── #2176: negative amounts record partial reimbursements ─────────────────── +describe('CostsPanel — negative amounts (#2176)', () => { + beforeEach(() => { + seedAlice() + clearExchangeRateCache() + }) + afterEach(clearExchangeRateCache) + + it('FE-W5COSTS-067: "-100" can be typed and saves a negative expense with its payer', async () => { + const user = userEvent.setup() + let posted: Record | null = null + server.use(http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel refund' }), id: 12 } }) + })) + mount([]) + + await user.click(await screen.findByRole('button', { name: 'Add expense' })) + await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel refund') + // The reported symptom: the '-' was stripped on input, so this was untypeable. + await user.type(screen.getAllByPlaceholderText('0,00')[0], '-100') + + const submits = screen.getAllByRole('button', { name: 'Add expense' }) + expect(submits[submits.length - 1]).not.toBeDisabled() + await user.click(submits[submits.length - 1]) + + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted!.total_price).toBe(-100) + expect(posted!.payers).toEqual([{ user_id: 1, amount: -100 }]) + expect(posted!.member_ids).toEqual([1, 2]) + }) + + it('FE-W5COSTS-068: a refund reopens with its negative payer intact and saves it back', async () => { + const user = userEvent.setup() + let put: Record | null = null + server.use(http.put('/api/trips/1/budget/310', async ({ request }) => { + put = await request.json() as Record + return HttpResponse.json({ item: dinner() }) + })) + mount([expense({ + id: 310, name: 'Hotel refund', category: 'lodging', total_price: -100, + payers: [{ user_id: 1, amount: -100 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + })]) + + // The row shows the amount twice — total pill and the payer chip, which used + // to be filtered to positive amounts and left the refund's recipient invisible. + const row = (await screen.findByText('Hotel refund')).closest('.exp-row') as HTMLElement + expect(within(row).getAllByText(/100,00/).length).toBeGreaterThanOrEqual(2) + // Not "Unfinished": the refund has its recipient recorded. + expect(within(row).queryByText('Unfinished')).toBeNull() + + await user.click(screen.getByTitle('Edit')) + // The negative payer reopens selected instead of resetting to "Nobody", + // and the total is seeded signed and padded. + expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument() + expect(screen.getByDisplayValue('-100,00')).toBeInTheDocument() + + // Saving untouched must not silently drop the negative payer. + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(put).toBeTruthy()) + expect(put!.total_price).toBe(-100) + expect(put!.payers).toEqual([{ user_id: 1, amount: -100 }]) + }) + + it('FE-W5COSTS-069: a payer-less refund is marked Unfinished like a payer-less bill', async () => { + mount([expense({ id: 320, name: 'Pending refund', category: 'misc', total_price: -50, payers: [], members: [{ user_id: 1, username: 'alice' }] })]) + + await screen.findByText('Pending refund') + expect(screen.getByText('Unfinished')).toBeInTheDocument() + }) + + it('FE-W5COSTS-070: the category breakdown nets refunds and keeps a net-negative category listed', async () => { + mount([ + expense({ id: 330, name: 'Dinner', category: 'food', total_price: 90, payers: [{ user_id: 1, amount: 90 }], members: [{ user_id: 1, username: 'alice' }] }), + expense({ id: 331, name: 'Meal refund', category: 'food', total_price: -30, payers: [{ user_id: 1, amount: -30 }], members: [{ user_id: 1, username: 'alice' }] }), + expense({ id: 332, name: 'Cancelled tour', category: 'activities', total_price: -20, payers: [{ user_id: 1, amount: -20 }], members: [{ user_id: 1, username: 'alice' }] }), + ]) + + await screen.findByText('Dinner') + const breakdown = screen.getByText('By category').parentElement as HTMLElement + // Food nets 90 − 30 = 60; the fully refunded tour keeps a row of its own. + expect(within(breakdown).getByText('60 €')).toBeInTheDocument() + expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument() + }) +}) + describe('CostsPanel — split maths', () => { it('FE-W5COSTS-039: an equal split with nobody to split with is empty', () => { expect(splitEqualShares(90, [], 1)).toEqual({}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index a4de9d132..268c30408 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, localizeAmountInput, cleanAmount } from '../../utils/formatters' +import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { downloadBlob } from '../../utils/fileDownload' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' @@ -134,8 +134,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return convert(myShare, curOf(e)) } // "Unfinished": a recorded total nobody has paid yet — counts toward the trip - // total but stays out of settlements until who-paid is filled in. - const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0 + // total but stays out of settlements until who-paid is filled in. A negative + // total (a refund, #2176) is just as unfinished until its recipient is named. + const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0 const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) @@ -717,7 +718,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const c = catMeta(e.category) const Icon = c.Icon const cur = curOf(e) - const payers = (e.payers || []).filter(p => p.amount > 0) + const payers = (e.payers || []).filter(p => p.amount !== 0) const net = round2(myPaidOf(e) - myShareOf(e)) const unfinished = isUnfinished(e) const note = readUserNote(e) @@ -878,9 +879,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } function CategoryBreakdown() { + // Categories net refunds against spend (#2176): a negative entry lowers its + // category's sum, and a category that nets negative keeps its own row — + // just without a bar, since the bars rank positive spend. const tot: Record = {} for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) } - const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) + const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) !== 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) if (rows.length === 0) return
{t('costs.noCategories')}
// Bars are scaled relative to the most expensive category (the top row fills the // bar), not to the trip grand total — makes the relative ranking readable. @@ -888,7 +892,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return (
{rows.map(c => { - const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0 + const v = tot[c.key]; const pct = maxCat > 0 && v > 0 ? v / maxCat * 100 : 0 return (
@@ -966,7 +970,9 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const otherDefault = people.find(p => p.id !== me)?.id ?? me const [fromId, setFromId] = useState(String(editing?.from_user_id ?? me)) const [toId, setToId] = useState(String(editing?.to_user_id ?? otherDefault)) - const [amount, setAmount] = useState(editing ? String(editing.amount) : '') + // Seeded with the transfer's own currency decimals, so a reopened 4,90 reads + // "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals. + const [amount, setAmount] = useState(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '') const [cur, setCur] = useState((editing?.currency || currency).toUpperCase()) const [saving, setSaving] = useState(false) @@ -1051,9 +1057,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [day, setDay] = useState(editing?.expense_date || localToday()) const [note, setNote] = useState(() => readUserNote(editing)) + // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB + // returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved + // 5,00 as "5". A prefill has no currency of its own — it is read as `base`, + // which is also what the currency field starts on. const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : '' - if (prefill?.amount != null) return String(prefill.amount) + if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' + if (prefill?.amount != null) return amountToInputString(prefill.amount, base) return '' }) const [participants, setParticipants] = useState>(() => @@ -1064,8 +1074,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo // next". The single-payer dropdown stays the default path; multiPayer swaps in a // per-person amount editor. 0 represents "Nobody (planning entry)"; on an // existing expense a missing payer is a deliberate choice, so only a brand-new - // one defaults to me. - const initialPayers = (editing?.payers || []).filter(p => p.amount > 0) + // one defaults to me. A negative payer (the recipient of a refund, #2176) is a + // real payer — filtering on > 0 here would silently drop them on save. + const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0) const [payerId, setPayerId] = useState(() => { const existingPayer = initialPayers[0] @@ -1076,7 +1087,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map(p => p.user_id))) const [payerAmounts, setPayerAmounts] = useState>(() => { const m: Record = {} - for (const p of initialPayers) m[p.user_id] = String(p.amount) + for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency) return m }) // Payers the user typed an amount for: rebalance leaves these alone and makes @@ -1101,7 +1112,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo if (editing && editing.members) { for (const member of editing.members) { if (member.amount !== null && member.amount !== undefined) { - m[member.user_id] = String(member.amount) + m[member.user_id] = amountToInputString(member.amount, currency) } } } @@ -1131,17 +1142,22 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const enteredSum = [...participants] .filter(id => customAmounts[id]) .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) - const remaining = Math.max(0, totalNum - enteredSum) + // Clamped toward zero on the total's own side, so an over-entered positive + // split never suggests negative leftovers — while a negative total (#2176) + // still previews its negative equal shares. + const rest = totalNum - enteredSum + const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest) return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) }, [totalNum, participants, customAmounts, editing]) - + const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0) const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) + // A negative total is a valid entry (a refund, #2176); only zero has nothing to say. const valid = name.trim().length > 0 && payersOk && ( isTicketMode ? ticketValid - : totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) + : totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) ) const onTotalChange = (v: string) => { @@ -1196,7 +1212,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 (/^-?\d*\.?\d{0,2}$/.test(val) || val === '') { setCustomAmounts(prev => ({ ...prev, [id]: val })) } } @@ -1264,7 +1280,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const payerList = multiPayer ? [...payerIds] .map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })) - .filter(p => p.amount > 0) + .filter(p => p.amount !== 0) : payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : [] // A receipt line can name somebody who is not ticked as a participant. Sending // only the ticked set would drop their share, leaving the member sum short of @@ -1334,7 +1350,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)} - @@ -1353,7 +1369,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
- {currency !== base && totalNum > 0 && ( + {currency !== base && totalNum !== 0 && (
{formatMoney(totalNum, currency, locale)} ≈ @@ -1422,7 +1438,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {on ? (
{sym(currency)} - onPayerAmountChange(p.id, v)} className="text-content" diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index d15de335f..053a86e0d 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -223,7 +223,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca const allCostEntries: MoneyEntry[] = Object.values(assignments) .flatMap(a => a) .map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || tripCur })) - const needsFx = allCostEntries.some(e => e.amount > 0 && e.currency.toUpperCase() !== tripCur) + const needsFx = allCostEntries.some(e => e.amount !== 0 && e.currency.toUpperCase() !== tripCur) const fxRates = needsFx ? await fetchExchangeRates(tripCur) : null const totalCostLabel = formatMoneySum(allCostEntries, tripCur, loc || 'en', fxRates) diff --git a/client/src/components/shared/NumericInput.test.tsx b/client/src/components/shared/NumericInput.test.tsx index cda0d5ff4..8dfa8da43 100644 --- a/client/src/components/shared/NumericInput.test.tsx +++ b/client/src/components/shared/NumericInput.test.tsx @@ -91,6 +91,30 @@ describe('NumericInput', () => { expect(input.value).toBe('36.72') }) + it('FE-COMP-NUMINPUT-010: signed-decimal mode keeps a leading minus and the comma (#2176)', () => { + render() + const input = screen.getByLabelText('field') as HTMLInputElement + + // A refund typed on a European keypad: sign and comma both survive. + fireEvent.change(input, { target: { value: '-12,50' } }) + expect(input.value).toBe('-12,50') + + fireEvent.change(input, { target: { value: '-100' } }) + expect(input.value).toBe('-100') + + // A minus anywhere but the front is not a sign. + fireEvent.change(input, { target: { value: '12-50' } }) + expect(input.value).toBe('1250') + + fireEvent.change(input, { target: { value: '-1x2.5eur' } }) + expect(input.value).toBe('-12.5') + }) + + it('FE-COMP-NUMINPUT-011: signed-decimal mode uses the decimal keypad', () => { + render() + expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal') + }) + 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 0866d48e4..1e8bfdbf0 100644 --- a/client/src/components/shared/NumericInput.tsx +++ b/client/src/components/shared/NumericInput.tsx @@ -1,6 +1,6 @@ import { useRef, type InputHTMLAttributes, type Ref } from 'react' -export type NumericMode = 'integer' | 'decimal' | 'signed' +export type NumericMode = 'integer' | 'decimal' | 'signed' | 'signed-decimal' const SANITIZERS: Record string> = { // Digits only — quantities, weights, day counts. @@ -14,6 +14,14 @@ const SANITIZERS: Record string> = { const digits = raw.replace(/[^0-9.]/g, '') return negative ? `-${digits}` : digits }, + // 'decimal' plus a leading '-' — money fields that accept a refund (#2176). + // Distinct from 'signed' on purpose: that one strips the comma, which a + // European keypad needs for amounts. + 'signed-decimal': raw => { + const negative = raw.trimStart().startsWith('-') + const digits = raw.replace(/[^0-9.,]/g, '') + return negative ? `-${digits}` : digits + }, } type Props = Omit, 'type' | 'onChange' | 'value'> & { diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index 5bf4cdfca..30e6095b1 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, localizeAmountInput, cleanAmount } from '../../../../utils/formatters' +import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters' 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' @@ -74,16 +74,20 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const [note, setNote] = useState(() => readUserNote(editing)) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [day, setDay] = useState(editing?.expense_date || localToday()) + // Edit and prefill seeds are padded to the currency's decimals (#2175), same + // as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9". A + // prefill has no currency of its own and is read as `base`. const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : '' - if (prefill?.amount != null) return String(prefill.amount) + if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' + if (prefill?.amount != null) return amountToInputString(prefill.amount, base) return '' }) const [participants, setParticipants] = useState>(() => editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) // Payer state — same model as the desktop modal. 0 = "Nobody (planning entry)". - const initialPayers = (editing?.payers || []).filter(p => p.amount > 0) + // A negative payer (the recipient of a refund, #2176) must survive the reopen. + const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0) const [payerId, setPayerId] = useState(() => { const existingPayer = initialPayers[0] if (existingPayer) return existingPayer.user_id @@ -93,7 +97,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map(p => p.user_id))) const [payerAmounts, setPayerAmounts] = useState>(() => { const m: Record = {} - for (const p of initialPayers) m[p.user_id] = String(p.amount) + for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency) return m }) const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map(p => p.user_id))) @@ -113,7 +117,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const m: Record = {} if (editing && editing.members) { for (const member of editing.members) { - if (member.amount !== null && member.amount !== undefined) m[member.user_id] = String(member.amount) + if (member.amount !== null && member.amount !== undefined) m[member.user_id] = amountToInputString(member.amount, currency) } } return m @@ -140,16 +144,20 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const enteredSum = [...participants] .filter(id => customAmounts[id]) .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) - const remaining = Math.max(0, totalNum - enteredSum) + // Clamped toward zero on the total's own side so a negative total (#2176) + // still previews its negative equal shares — same as the desktop modal. + const rest = totalNum - enteredSum + const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest) return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) }, [totalNum, participants, customAmounts, editing]) const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0) const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) + // A negative total is a valid entry (a refund, #2176); only zero has nothing to say. const valid = name.trim().length > 0 && payersOk && ( isTicketMode ? ticketValid - : totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) + : totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) ) const onTotalChange = (v: string) => setTotal(v.replace(',', '.')) @@ -195,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 (/^-?\d*\.?\d{0,2}$/.test(val) || val === '') setCustomAmounts(prev => ({ ...prev, [id]: val })) } const handleAddEmptyItem = () => { @@ -237,7 +245,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, // server re-derives total_price from the payer sum (CostsPanel.helpers), so // dropping the payer would store the entry with a total of 0. const payerList = multiPayer - ? [...payerIds].map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })).filter(p => p.amount > 0) + ? [...payerIds].map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })).filter(p => p.amount !== 0) : payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : [] const memberList = [...participants].map(id => ({ user_id: id, @@ -337,7 +345,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{sym(currency)} {/* CONVERSION HINT */} - {currency !== base && totalNum > 0 && ( + {currency !== base && totalNum !== 0 && (
{formatMoney(totalNum, currency, locale)} ≈ @@ -456,7 +464,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{sym(currency)} onPayerAmountChange(p.id, v)} diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index 085659659..5aa829d9c 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -54,9 +54,13 @@ export function myShareOf(e: BudgetItem, ctx: CostsCtx): number { return memberShareOf(e, ctx.me, ctx) } -/** A recorded total nobody has actually paid yet — counts toward the trip total but stays out of settlement. */ +/** + * A recorded total nobody has actually paid yet — counts toward the trip total + * but stays out of settlement. A negative total (a refund, #2176) is just as + * unfinished until its recipient is recorded as the (negative) payer. + */ export function isUnfinished(e: BudgetItem, ctx: CostsCtx): boolean { - return baseTotal(e, ctx) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0 + return baseTotal(e, ctx) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0 } // ── settlement (server-computed; these types describe what MCostsTab reads from it) ── @@ -241,6 +245,10 @@ export interface CostsCategoryBar { } export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCategoryBar[] { + // Categories net refunds against spend (#2176): a negative entry lowers its + // category's sum. A category that nets negative keeps its own row at the + // bottom, with widthPct 0 — the bars rank positive spend, and a negative + // CSS width would be dropped and render as a full bar. const totals = new Map() for (const e of items) { const key = catMeta(e.category).key @@ -248,10 +256,10 @@ export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCate } const rows = COST_CATEGORY_LIST .map(c => ({ key: c.key, amount: totals.get(c.key) || 0 })) - .filter(r => r.amount > 0) + .filter(r => r.amount !== 0) .sort((a, b) => b.amount - a.amount) const max = Math.max(0, ...rows.map(r => r.amount)) - return rows.map(r => ({ ...r, widthPct: max > 0 ? (r.amount / max) * 100 : 0 })) + return rows.map(r => ({ ...r, widthPct: max > 0 && r.amount > 0 ? (r.amount / max) * 100 : 0 })) } // ── presentation helpers ───────────────────────────────────────────────── diff --git a/client/src/utils/formatters.test.ts b/client/src/utils/formatters.test.ts index bac3c66aa..f97f10b87 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, localizeAmountInput } from './formatters' +import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals, localizeAmountInput, amountToInputString } from './formatters' import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants' import type { Day } from '../types' @@ -40,6 +40,32 @@ describe('localizeAmountInput (#1624)', () => { }) }) +describe('amountToInputString (#2175)', () => { + it('pads a stored number to the currency decimals so the edit form matches the list', () => { + expect(amountToInputString(4.9, 'EUR')).toBe('4.90') + expect(amountToInputString(5, 'EUR')).toBe('5.00') + expect(amountToInputString(163.2, 'USD')).toBe('163.20') + }) + it('gives zero-decimal currencies no fake decimals', () => { + expect(amountToInputString(500, 'JPY')).toBe('500') + expect(amountToInputString(500, 'HUF')).toBe('500') + }) + it('pads three-decimal currencies to three', () => { + expect(amountToInputString(1.5, 'KWD')).toBe('1.500') + }) + it('cent-rounds float noise before padding (#1964)', () => { + expect(amountToInputString(163.20999999999998, 'EUR')).toBe('163.21') + }) + it('keeps the sign of a refund (#2176)', () => { + expect(amountToInputString(-4.9, 'EUR')).toBe('-4.90') + }) + it('turns nullish and non-finite values into an empty field', () => { + expect(amountToInputString(null, 'EUR')).toBe('') + expect(amountToInputString(undefined, 'EUR')).toBe('') + expect(amountToInputString(Number.NaN, '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 047b83be5..a59ae8461 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -102,6 +102,19 @@ export function cleanAmount(value: number): number { return Math.round(value * 100) / 100 } +/** + * A stored amount as the edit form should seed it: cent-rounded and padded to + * the currency's decimal count (#2175). The DB hands back a number, so a saved + * 4,90 is 4.9 and a saved 5,00 is 5 — String() then renders "4,9"/"5" in the + * amount field while the list shows "4,90"/"5,00" via Intl. Dot-normalized like + * all amount-input state (see localizeAmountInput); zero-decimal currencies get + * no fake decimals ("500", not "500.00"). + */ +export function amountToInputString(value: number | null | undefined, currency: string): string { + if (value == null || !Number.isFinite(value)) return '' + return cleanAmount(value).toFixed(currencyDecimals(currency)) +} + /** * A price held as free text, tidied only when it really is a number. Import * writes whatever the confirmation said into this field, so anything that does diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx index f06e873aa..632ac4f12 100644 --- a/client/tests/unit/mobile/trip/MCostSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -167,7 +167,8 @@ describe('MCostSheet', () => { it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => { renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } }) expect(nameField()).toHaveValue('Ryokan') - expect(totalField()).toHaveValue('240') + // Padded to the base currency's decimals since #2175, localized for EUR. + expect(totalField()).toHaveValue('240,00') expect(catTrigger()).toHaveTextContent('Accommodation') fireEvent.click(submit()) @@ -431,10 +432,11 @@ describe('MCostSheet', () => { expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act') // USD renders with a dot, so the total and both shares share this placeholder. + // Seeded padded to two decimals since #2175. const amounts = screen.getAllByPlaceholderText('0.00') - expect(amounts[0]).toHaveValue('60') - expect(amounts[1]).toHaveValue('20') - expect(amounts[2]).toHaveValue('40') + expect(amounts[0]).toHaveValue('60.00') + expect(amounts[1]).toHaveValue('20.00') + expect(amounts[2]).toHaveValue('40.00') expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument() }) @@ -468,8 +470,8 @@ describe('MCostSheet', () => { expect(screen.getByRole('button', { name: 'One person paid' })).toBeInTheDocument() const amounts = screen.getAllByPlaceholderText('0,00') - expect(amounts[1]).toHaveValue('40') - expect(amounts[2]).toHaveValue('60') + expect(amounts[1]).toHaveValue('40,00') + expect(amounts[2]).toHaveValue('60,00') expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument() }) @@ -600,6 +602,49 @@ describe('MCostSheet', () => { } }) + it('FE-MOB-COSTSH-032: reopens 4,90 as "4,90" instead of "4,9" (#2175)', () => { + const editing = buildBudgetItem({ + id: 10, name: 'Coffee', category: 'food', currency: 'EUR', total_price: 4.9, + members: [], payers: [{ user_id: 1, amount: 4.9 }], + }) + renderSheet({ editing }) + expect(totalField()).toHaveValue('4,90') + }) + + it('FE-MOB-COSTSH-033: a negative amount can be typed and saves a refund (#2176)', async () => { + const { onSaved } = renderSheet() + // The '-' used to be stripped by the input sanitizer, making this untypeable. + fillBasics('Hotel refund', '-100') + + expect(submit()).toBeEnabled() + fireEvent.click(submit()) + await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1)) + expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ + total_price: -100, + payers: [{ user_id: 1, amount: -100 }], + })) + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)) + }) + + it('FE-MOB-COSTSH-034: a negative payer survives the reopen and the save (#2176)', async () => { + const editing = buildBudgetItem({ + id: 11, name: 'Refund', category: 'other', currency: 'EUR', total_price: -60, + members: [member(1, null), member(2, null)], + payers: [{ user_id: 1, amount: -60 }], + }) + renderSheet({ editing }) + + expect(totalField()).toHaveValue('-60,00') + // Alice is still the recorded (negative) payer — not reset to "No one paid yet". + expect(screen.getByRole('button', { name: 'You' })).toBeInTheDocument() + + fireEvent.click(saveBtn()) + await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 11, expect.objectContaining({ + total_price: -60, + payers: [{ user_id: 1, amount: -60 }], + }))) + }) + it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => { localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() })) renderSheet({ base: 'XTS' }) diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts index 74cb3e427..855f119b6 100644 --- a/client/tests/unit/mobile/trip/costsModel.test.ts +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -125,6 +125,12 @@ describe('costsModel — money maths', () => { expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 100)] }), ctx)).toBe(false); expect(isUnfinished(expense({ total_price: 0, payers: [] }), ctx)).toBe(false); }); + + it('FE-MOB-CMOD-030: isUnfinished flags a payer-less refund the same way (#2176)', () => { + expect(isUnfinished(expense({ total_price: -100, payers: [] }), ctx)).toBe(true); + // A negative payer is the refund's recipient — the entry is settled. + expect(isUnfinished(expense({ total_price: -100, payers: [payer(1, -100)] }), ctx)).toBe(false); + }); }); describe('costsModel — computeTotals', () => { @@ -352,6 +358,28 @@ describe('costsModel — breakdown and presentation', () => { expect(categoryBreakdown([expense({ category: 'food', total_price: 0 })], ctx)).toEqual([]); }); + it('FE-MOB-CMOD-031: categoryBreakdown nets refunds and keeps a net-negative category bar-less (#2176)', () => { + const items = [ + expense({ category: 'food', total_price: 90 }), + expense({ category: 'food', total_price: -30 }), + expense({ category: 'activities', total_price: -20 }), + ]; + const bars = categoryBreakdown(items, ctx); + + // Food nets 90 − 30 = 60 and carries the (full) bar; the fully refunded + // category keeps its own row at the bottom, without a bar — a negative CSS + // width would be dropped and paint a full one. + expect(bars).toEqual([ + { key: 'food', amount: 60, widthPct: 100 }, + { key: 'activities', amount: -20, widthPct: 0 }, + ]); + }); + + it('FE-MOB-CMOD-032: an all-negative breakdown renders rows but no bars (#2176)', () => { + const bars = categoryBreakdown([expense({ category: 'food', total_price: -20 })], ctx); + expect(bars).toEqual([{ key: 'food', amount: -20, widthPct: 0 }]); + }); + it('FE-MOB-CMOD-021: tint turns a hex colour into rgba, with or without the leading hash', () => { expect(tint('#8B5CF6', 0.3)).toBe('rgba(139, 92, 246, 0.3)'); expect(tint('000000', 1)).toBe('rgba(0, 0, 0, 1)'); diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 29dba2c13..630cc3e04 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -20,16 +20,20 @@ import { AddonsService } from '../addons/addons.service'; /** Legacy registrar gate: the whole budget surface rides the budget addon. */ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET); -/** Reusable Zod shape for the per-payer amounts on a budget item. */ +/** + * Reusable Zod shape for the per-payer amounts on a budget item. Amounts are + * signed like the REST contract's (#2176): a negative payer is the recipient + * of a refund recorded as a negative expense. + */ const payersSchema = z.array(z.object({ user_id: z.number().int().positive(), - amount: z.number().nonnegative(), + amount: z.number(), })).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.'); -/** Reusable Zod shape for an unequal split: what each participant owes. */ +/** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */ const splitMembersSchema = z.array(z.object({ user_id: z.number().int().positive(), - amount: z.number().nonnegative(), + amount: z.number(), })).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.'); function parseId(value: string | string[]): number | null { @@ -116,9 +120,10 @@ export class BudgetMcp { // Once payers are sent at all, the write path derives the total from them and // an empty list therefore means zero, not "no opinion". Reading total_price // here instead would certify a split against a figure the row never receives. + // Negative payers count with their sign (#2176) — the write path stores them. if (payers !== undefined) { const roster = this.db.rosterUserIds(tripId); - return sumCents(payers.filter(p => p.amount > 0 && roster.has(p.user_id)).map(p => p.amount)); + return sumCents(payers.filter(p => p.amount !== 0 && roster.has(p.user_id)).map(p => p.amount)); } if (total_price !== undefined) return toCents(total_price); return fallbackCents; @@ -151,7 +156,7 @@ export class BudgetMcp { // certified here would not be the split stored. Refuse rather than let the // difference surface on the balances screen. if (payers) { - const payerStrangers = payers.filter(p => p.amount > 0 && !roster.has(p.user_id)).map(p => p.user_id); + const payerStrangers = payers.filter(p => p.amount !== 0 && !roster.has(p.user_id)).map(p => p.user_id); if (payerStrangers.length > 0) { return `payers contains user IDs that are not on this trip: ${payerStrangers.join(', ')}. Resolve them with list_trip_members.`; } @@ -177,7 +182,7 @@ export class BudgetMcp { tripId: z.number().int().positive(), name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), - total_price: z.number().nonnegative(), + total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'), currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'), member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense equally. Omit to split across all trip members (owner + members); pass [] for no split.'), members: splitMembersSchema.optional().describe('Uneven split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Use this instead of member_ids, never alongside it.'), @@ -250,7 +255,7 @@ export class BudgetMcp { itemId: z.number().int().positive(), name: z.string().min(1).max(200).optional(), category: z.string().max(100).optional(), - total_price: z.number().nonnegative().optional(), + total_price: z.number().optional(), currency: z.string().max(10).nullable().optional().describe('ISO currency code the expense is in (e.g. "USD"); null puts it back in the trip currency. Changing it re-freezes the FX rate at today\'s rate.'), member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense equally; replaces the current split. Omit to leave unchanged, pass [] for no split.'), members: splitMembersSchema.optional().describe('Uneven split: what each participant owes, in the expense currency; replaces the current split. The amounts must add up to the expense total. Use this instead of member_ids, never alongside it.'), @@ -302,7 +307,7 @@ export class BudgetMcp { tripId: z.number().int().positive(), name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), - total_price: z.number().nonnegative(), + total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'), note: z.string().max(500).optional(), userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'), place_id: z.number().int().positive().optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'), diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 9bd4f95c4..37d8d592c 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -157,14 +157,18 @@ export class BudgetService { return new Set([...unique].filter(id => roster.has(id))); } - /** Replace the payer rows of an item and keep total_price = sum of payer amounts. */ + /** + * Replace the payer rows of an item and keep total_price = sum of payer amounts. + * A negative amount is a real payer — the recipient of a refund (#2176) — and + * is stored as such; only a zero (or NaN) amount says nothing and is dropped. + */ private writeItemPayers(itemId: number | string, tripId: string | number, payers: { user_id: number; amount: number }[]) { this.db.run('DELETE FROM budget_item_payers WHERE budget_item_id = ?', itemId); const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)'); const known = this.rosterMemberIds(tripId, payers.map(p => p.user_id)); const accepted: number[] = []; for (const p of payers) { - if (!(p.amount > 0) || !known.has(p.user_id)) continue; + if (!p.amount || !known.has(p.user_id)) continue; insert.run(itemId, p.user_id, p.amount); accepted.push(p.amount); } @@ -365,8 +369,9 @@ export class BudgetService { } // total_price is derived from explicit payers when given; otherwise the caller - // value (planning entries, or a bill no one has paid yet). - const payerTotal = sumMoney((data.payers || []).filter(p => p.amount > 0).map(p => p.amount)); + // value (planning entries, or a bill no one has paid yet). Negative payer + // amounts (a refund's recipient, #2176) count like any other. + const payerTotal = sumMoney((data.payers || []).filter(p => p.amount !== 0).map(p => p.amount)); const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0); const knownMembers = data.members ? this.rosterMemberIds(tripId, data.members.map(m => m.user_id)) : null; @@ -644,6 +649,12 @@ export class BudgetService { * The remainder cent rotates with the item id rather than always landing on the * first member, so across several expenses the rounding evens out instead of * always favouring the same person. + * + * Floor-based (`totalCents - baseCents * n`, never `%`), so a negative total — + * a refund split across its beneficiaries (#2176) — still yields a remainder + * in [0, n) and shares that sum back to the total exactly. The client mirror + * (CostsPanel.helpers.splitEqualShares) must stay share-for-share identical; + * the parity fixture in budget.service.calc.test.ts pins both sides. */ private splitEqualShares(totalCents: number, members: { user_id: number }[], itemId: number): Record { const n = members.length; @@ -755,10 +766,11 @@ export class BudgetService { if (members.length === 0) continue; // planning-only entry → doesn't affect balances // Payers are credited what they actually paid (converted to trip currency with - // the item's stored exchange rate)… + // the item's stored exchange rate). A negative payer — the recipient of a + // refund (#2176) — is debited by the same arithmetic: their credit is negative. let creditCents = 0; for (const p of payers) { - const paid = toTripCents(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate); + const paid = toTripCents(p.amount, item.currency, item.exchange_rate); ensure(p.user_id, p).cents += paid; creditCents += paid; } @@ -770,9 +782,10 @@ export class BudgetService { // from the payer sum), but converting the total separately would round to a // different cent on a foreign-currency expense and leave the item off by one. // With nobody down as a payer there is nothing to divide, so the recorded total - // stands in and an unpaid bill keeps reading as money owed. + // stands in and an unpaid bill keeps reading as money owed. Negative credits + // (a refund, #2176) divide the same way — only exactly zero falls back. const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined); - const splitCents = creditCents > 0 + const splitCents = creditCents !== 0 ? creditCents : toTripCents(item.total_price, item.currency, item.exchange_rate); const equalShares = !hasCustomSplit ? this.splitEqualShares(splitCents, members, item.id) : {}; diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 0bb965214..ba86fd587 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -43,6 +43,7 @@ let checkPermission: MockInstance; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { BudgetModule } from '../../src/nest/budget/budget.module'; +import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -52,7 +53,12 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule] }) + // The settlement read awaits live FX rates; the trip here is all-EUR, so a + // null result is the identity — and the test never touches the network. + .overrideProvider(ExchangeRatesService) + .useValue({ getRates: async () => null }) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -146,6 +152,44 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { expect(res.body.error.toLowerCase()).toContain('name'); }); + it('201 on a negative expense (partial reimbursement) and the settlement nets it out (#2176)', async () => { + // The dinner: user 1 fronts 90, split with user 2 → user 2 owes 45. + const dinner = await request(server) + .post(`/api/trips/${tripId}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Dinner', payers: [{ user_id: 1, amount: 90 }], member_ids: [1, 2] }); + expect(dinner.status).toBe(201); + + // The refund: -30 lands with user 1, shared with user 2 → user 2's debt + // drops by 15. The write path used to null this to a payer-less 0 € row. + const refund = await request(server) + .post(`/api/trips/${tripId}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Hotel partial refund', total_price: -30, payers: [{ user_id: 1, amount: -30 }], member_ids: [1, 2] }); + expect(refund.status).toBe(201); + expect(refund.body.item).toMatchObject({ total_price: -30 }); + expect(refund.body.item.payers).toEqual([expect.objectContaining({ user_id: 1, amount: -30 })]); + const row = db.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(refund.body.item.id) as { total_price: number }; + expect(row.total_price).toBe(-30); + + const settlement = await request(server) + .get(`/api/trips/${tripId}/budget/settlement`) + .set('Cookie', sessionCookie(1)); + expect(settlement.status).toBe(200); + const balances = settlement.body.balances as { user_id: number; balance: number }[]; + const balance = (uid: number) => balances.find(b => b.user_id === uid)!.balance; + // 45 owed from the dinner minus the 15 refund share = 30, and Σ balances = 0. + expect(balance(2)).toBe(-30); + expect(balance(1)).toBe(30); + expect(balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0); + + // Clean up so the ledger tests below start from an empty trip. + for (const id of [dinner.body.item.id, refund.body.item.id]) { + const del = await request(server).delete(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1)); + expect(del.status).toBe(200); + } + }); + it('200 on settlement update with permission, persisting the new amount', async () => { const created = await request(server) .post(`/api/trips/${tripId}/budget/settlements`) diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index 0f54ffe31..967833dc7 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -412,6 +412,128 @@ describe('calculateSettlement', () => { }); }); +// ── Refunds: negative amounts are first-class expenses (#2176) ─────────────── + +describe('calculateSettlement — negative amounts (#2176)', () => { + it('a refund credits the members and debits its recipient', () => { + // A 30 € refund lands with Alice; all three had shared the original cost. + // Alice received 30 but only 10 of it was hers, so she owes 20. + setupDb( + [makeItem(1, -30)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, -30, 'alice')], + ); + const result = budget.calculateSettlement(1); + const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; + + expect(balance(1)).toBe(-20); + expect(balance(2)).toBe(10); + expect(balance(3)).toBe(10); + expect(centSum(result.balances.map(b => b.balance))).toBe(0); + }); + + it('a refund reduces the debt its expense created', () => { + // 90 € dinner fronted by Alice, then a 30 € partial reimbursement she keeps — + // both split three ways. Bob's debt drops from 30 to 20. + setupDb( + [makeItem(1, 90), makeItem(2, -30)], + [ + makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), + makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol'), + ], + [makePayer(1, 1, 90, 'alice'), makePayer(2, 1, -30, 'alice')], + ); + const result = budget.calculateSettlement(1); + const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; + + expect(balance(1)).toBe(40); + expect(balance(2)).toBe(-20); + expect(balance(3)).toBe(-20); + expect(result.flows).toEqual(expect.arrayContaining([ + expect.objectContaining({ amount: 20, from: expect.objectContaining({ user_id: 2 }), to: expect.objectContaining({ user_id: 1 }) }), + ])); + }); + + it('an odd negative total still nets to exactly zero', () => { + setupDb( + [makeItem(1, -100.01)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, -100.01, 'alice')], + ); + const result = budget.calculateSettlement(1); + + expect(centSum(result.balances.map(b => b.balance))).toBe(0); + }); + + it('a negative custom split settles by the custom amounts', () => { + // 100 € refund to Alice; by agreement Bob is owed 70 of it, Carol 30. + setupDb( + [makeItem(1, -100)], + [ + { ...makeMember(1, 2, 'bob'), amount: -70 }, + { ...makeMember(1, 3, 'carol'), amount: -30 }, + ], + [makePayer(1, 1, -100, 'alice')], + ); + const result = budget.calculateSettlement(1); + const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; + + expect(balance(1)).toBe(-100); + expect(balance(2)).toBe(70); + expect(balance(3)).toBe(30); + expect(centSum(result.balances.map(b => b.balance))).toBe(0); + }); + + it('a payer-less negative total still reads as money owed back (fallback path)', () => { + // Nobody is recorded as the refund's recipient yet — the recorded total + // stands in, mirroring the positive unpaid-bill fallback. + setupDb( + [makeItem(1, -90)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [], + ); + const result = budget.calculateSettlement(1); + + expect(result.balances.map(b => b.balance)).toEqual([30, 30, 30]); + }); +}); + +// ── Client/server share parity (#2176) ─────────────────────────────────────── +// +// splitEqualShares exists twice: here (netting the settlement in cents) and on +// the client (previewing the split in euros — CostsPanel.helpers.ts). This +// fixture is duplicated verbatim in +// client/src/components/Budget/CostsPanel.helpers.test.ts; if either +// implementation drifts — sign handling included — its copy of the table fails. +const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record }[] = [ + { totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } }, + { totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } }, + { totalCents: -10000, users: [1, 2, 3], itemId: 0, expected: { 1: -3333, 2: -3333, 3: -3334 } }, + { totalCents: -10000, users: [1, 2, 3], itemId: 1, expected: { 1: -3334, 2: -3333, 3: -3333 } }, + { totalCents: -101, users: [1, 2], itemId: 0, expected: { 1: -50, 2: -51 } }, + { totalCents: -101, users: [1, 2], itemId: 1, expected: { 1: -51, 2: -50 } }, + { totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } }, +]; + +describe('splitEqualShares — client parity (#2176)', () => { + // Private on purpose (only the settlement calls it); the parity pin reaches + // through so the fixture exercises the real implementation, not a re-model. + const split = ( + budget as unknown as { + splitEqualShares(totalCents: number, members: { user_id: number }[], itemId: number): Record; + } + ).splitEqualShares.bind(budget); + + it.each(SHARE_PARITY_FIXTURE)( + 'splits $totalCents cents across $users.length members (item $itemId) exactly like the client', + ({ totalCents, users, itemId, expected }) => { + const shares = split(totalCents, users.map(user_id => ({ user_id })), itemId); + expect(shares).toEqual(expected); + expect((Object.values(shares) as number[]).reduce((a, b) => a + b, 0)).toBe(totalCents); + }, + ); +}); + // ── #1382: balances and flows have to tell the same story ──────────────────── // The ledger is netted in whole cents of the trip currency, so a balance is only // ever a whole number of cents and every one of them can be settled. What used to diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 7cb55d652..6232f68cb 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -581,6 +581,102 @@ describe('settlement parties are confined to the trip', () => { }); }); +// ── Refunds persist as negative expenses (#2176) ───────────────────────────── +// The write path used to filter payers on amount > 0 and re-derive total_price +// from the survivors, silently turning a negative entry into a 0 € one with no +// payer row. + +describe('negative amounts persist end-to-end (#2176)', () => { + it('BUDGET-SVC-DB-032: stores a negative payer row and the negative total', () => { + const { user: alice } = createUser(testDb, { username: 'alice' }); + const trip = createTrip(testDb, alice.id); + + const item = budget.createBudgetItem(trip.id, { + name: 'Hotel partial refund', + total_price: -100, + payers: [{ user_id: alice.id, amount: -100 }], + }) as { id: number; payers: { user_id: number; amount: number }[]; total_price: number }; + + expect(item.total_price).toBe(-100); + expect(item.payers).toEqual([expect.objectContaining({ user_id: alice.id, amount: -100 })]); + const row = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(item.id) as { total_price: number }; + expect(row.total_price).toBe(-100); + }); + + it('BUDGET-SVC-DB-033: an update keeps a negative payer instead of nulling the entry', () => { + const { user: alice } = createUser(testDb, { username: 'alice' }); + const { user: bob } = createUser(testDb, { username: 'bob' }); + const trip = createTrip(testDb, alice.id); + addTripMember(testDb, trip.id, bob.id); + const item = budget.createBudgetItem(trip.id, { + name: 'Refund', total_price: -60, payers: [{ user_id: alice.id, amount: -60 }], + }) as { id: number }; + + const updated = budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: alice.id, amount: -40 }, { user_id: bob.id, amount: -20 }], + }) as { payers: { user_id: number; amount: number }[]; total_price: number }; + + expect(updated.total_price).toBe(-60); + expect(updated.payers).toHaveLength(2); + expect(updated.payers.map(p => p.amount).sort((a, b) => a - b)).toEqual([-40, -20]); + }); + + it('BUDGET-SVC-DB-034: mixed-sign payers derive the netted total', () => { + // Alice fronted 100 but bob pocketed a 30 refund on the same receipt. + const { user: alice } = createUser(testDb, { username: 'alice' }); + const { user: bob } = createUser(testDb, { username: 'bob' }); + const trip = createTrip(testDb, alice.id); + addTripMember(testDb, trip.id, bob.id); + + const item = budget.createBudgetItem(trip.id, { + name: 'Tickets', payers: [{ user_id: alice.id, amount: 100 }, { user_id: bob.id, amount: -30 }], + }) as { payers: { user_id: number }[]; total_price: number }; + + expect(item.payers).toHaveLength(2); + expect(item.total_price).toBe(70); + }); + + it('BUDGET-SVC-DB-035: a zero-amount payer is still dropped', () => { + const { user: alice } = createUser(testDb, { username: 'alice' }); + const { user: bob } = createUser(testDb, { username: 'bob' }); + const trip = createTrip(testDb, alice.id); + addTripMember(testDb, trip.id, bob.id); + + const item = budget.createBudgetItem(trip.id, { + name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }, { user_id: bob.id, amount: 0 }], + }) as { payers: { user_id: number }[]; total_price: number }; + + expect(item.payers.map(p => p.user_id)).toEqual([alice.id]); + expect(item.total_price).toBe(90); + }); + + it('BUDGET-SVC-DB-036: a negative expense settles against real rows — Σ balances = 0', () => { + const { user: alice } = createUser(testDb, { username: 'alice' }); + const { user: bob } = createUser(testDb, { username: 'bob' }); + const trip = createTrip(testDb, alice.id); + addTripMember(testDb, trip.id, bob.id); + budget.createBudgetItem(trip.id, { + name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }], + member_ids: [alice.id, bob.id], + }); + budget.createBudgetItem(trip.id, { + name: 'Refund', payers: [{ user_id: alice.id, amount: -30 }], + member_ids: [alice.id, bob.id], + }); + + const result = budget.calculateSettlement(trip.id); + const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; + + // 90 out, 30 back, both split evenly: bob nets 45 - 15 = 30 owed to alice. + expect(balance(alice.id)).toBe(30); + expect(balance(bob.id)).toBe(-30); + expect(result.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0); + expect(result.flows).toEqual([ + expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: bob.id }), to: expect.objectContaining({ user_id: alice.id }) }), + ]); + }); +}); + describe('post-fold quirk fixes', () => { it('BUDGET-SVC-DB-019: settlements prefer display_name over username (quirk fix)', () => { const { user: alice } = createUser(testDb, { username: 'alice' });