From 7a8a3ee4f24f612a46f9e8c74a03655d690d6b7f Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 22 Jun 2026 21:50:23 +0200 Subject: [PATCH] fix(costs): allow recording an expense with no split or payer (#1286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an expense required at least one participant, so a cost you only want to record — e.g. a booking paid on-site later — could not be saved without splitting it. Drop the participant requirement: with nobody selected the expense saves as a recorded total, counted in the trip total and shown as Unfinished, and kept out of settlements until who-paid is filled in. The shared schema and server already supported this case. --- .../src/components/Budget/CostsPanel.test.tsx | 35 +++++++++++++++++++ client/src/components/Budget/CostsPanel.tsx | 7 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 2fddc8d2..ef373abd 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -159,4 +159,39 @@ describe('CostsPanel — settlements in the ledger', () => { await screen.findByText('Hotel') expect(screen.getByText('Unfinished')).toBeInTheDocument() }) + + it('records a recorded-total expense with nobody to split with (#1286)', async () => { + let posted: Record | null = null + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add expense' })) + await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel') + await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later + + // Deselect everyone — the cost is recorded without a split (the bug: this was blocked). + // The participant toggles are buttons; the same names also appear as plain text in + // the Balances sidebar, so target the buttons specifically. + await user.click(screen.getByRole('button', { name: /alice/i })) + await user.click(screen.getByRole('button', { name: /bob/i })) + + const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) + const submit = addBtns[addBtns.length - 1] // footer submit + expect(submit).not.toBeDisabled() + await user.click(submit) + + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted!.total_price).toBe(120) + expect(posted!.member_ids).toEqual([]) + expect(posted!.payers).toEqual([]) + }) }) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 568ec14d..5111eb3b 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -818,7 +818,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const paidEntered = paidSum > 0 const balanced = Math.abs(paidSum - totalNum) < 0.01 const each = participants.size > 0 ? totalNum / participants.size : 0 - const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced) + // No participants = a recorded total with nobody to split with (e.g. a booking + // paid on-site later). It saves as an "unfinished" expense (#1286); selecting + // people only adds the who-owes-whom split on top. + const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced) // Spread `amount` across `n` people in whole cents so the parts sum back exactly. const splitCents = (amount: number, n: number): number[] => { @@ -978,7 +981,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
- {participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} + {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} {paidEntered ? {sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}