fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176)

This commit is contained in:
Maurice
2026-09-01 15:07:26 +02:00
committed by Maurice
parent 49b0ce28d5
commit d1aa573167
18 changed files with 717 additions and 78 deletions
@@ -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<number, number> }[] = [
{ 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) ──────────────────────────────────────
@@ -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<number, number> {
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<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
@@ -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<string, unknown> | null = null
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
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<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/310', async ({ request }) => {
put = await request.json() as Record<string, unknown>
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({})
+37 -21
View File
@@ -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<string, number> = {}
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 <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div>
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{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 (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -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>(String(editing?.from_user_id ?? me))
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
const [amount, setAmount] = useState<string>(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<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
const [cur, setCur] = useState<string>((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<string>(() => {
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<Set<number>>(() =>
@@ -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<number>(() => {
const existingPayer = initialPayers[0]
@@ -1076,7 +1087,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
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
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
disabled={isTicketMode}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
@@ -1353,7 +1369,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
{currency !== base && totalNum > 0 && (
{currency !== base && totalNum !== 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-content-faint">≈</span>
@@ -1422,7 +1438,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
className="text-content"
+1 -1
View File
@@ -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)
@@ -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(<Harness initial="" mode="signed-decimal" />)
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(<Harness initial="" mode="signed-decimal" />)
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
@@ -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<NumericMode, (raw: string) => string> = {
// Digits only — quantities, weights, day counts.
@@ -14,6 +14,14 @@ const SANITIZERS: Record<NumericMode, (raw: string) => 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<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'value'> & {
@@ -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<string>(() => {
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<Set<number>>(() =>
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<number>(() => {
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<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
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<Set<number>>(() => 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<number, string> = {}
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,
<div className={`flex items-center gap-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px] ${isTicketMode ? 'opacity-60' : ''}`}>
<span className="text-[0.84375rem] font-medium text-m-faint">{sym(currency)}</span>
<NumericInput
mode="decimal"
mode="signed-decimal"
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
@@ -366,7 +374,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</div>
{/* CONVERSION HINT */}
{currency !== base && totalNum > 0 && (
{currency !== base && totalNum !== 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-[0.71875rem] text-m-muted">
<span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-m-faint">≈</span>
@@ -456,7 +464,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<div className={`${MINI_INPUT_WRAP} w-[120px] flex-none`}>
<span className="text-[0.75rem] text-m-faint">{sym(currency)}</span>
<NumericInput
mode="decimal"
mode="signed-decimal"
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
@@ -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<CostCategory, number>()
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 ─────────────────────────────────────────────────
+27 -1
View File
@@ -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')
+13
View File
@@ -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
@@ -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' })
@@ -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)');
+14 -9
View File
@@ -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'),
+21 -8
View File
@@ -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<number, number> {
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) : {};
+45 -1
View File
@@ -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`)
@@ -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<number, number> }[] = [
{ 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<number, number>;
}
).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
@@ -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' });