mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
This commit is contained in:
@@ -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({})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) : {};
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user