Files
TREK/client/src/components/Budget/CostsPanel.test.tsx
T
Maurice 02ad6c3d16 test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
2026-07-29 11:55:31 +02:00

1614 lines
78 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
// FE-W5COSTS-001 to FE-W5COSTS-035: the rest of the Costs panel
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal, splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel'
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
]
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
})
describe('CostsPanel — settlements in the ledger', () => {
it('renders a settle-up payment as a ledger row with an undo action', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// The expense and the settlement (payment) both appear in the unified ledger.
await screen.findByText('Dinner')
await screen.findByText('Payment')
// The payment row exposes an inline undo (no need to open a separate History modal).
expect(screen.getByTitle('Undo')).toBeInTheDocument()
})
it('records a manual payment via the Add payment button', async () => {
let posted: Record<string, unknown> | 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/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1, ...posted } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
// The footer submit is the second "Add payment" control once the modal is open.
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
const submit = addButtons[addButtons.length - 1]
await user.click(submit)
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
})
it('hides payment rows while a text search is active', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Payment')
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
// Payment rows have no name, so a search hides them while the matching expense stays.
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
expect(screen.getByText('Dinner')).toBeInTheDocument()
})
it('supports custom split amounts on save', async () => {
let posted: Record<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0,00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total = 100
await user.click(screen.getByRole('button', { name: /Custom/i }))
const customInputs = screen.getAllByPlaceholderText('50,00')
await user.type(customInputs[0], '30')
await user.type(customInputs[1], '70')
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.payers).toEqual([
expect.objectContaining({ amount: 100 })
])
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }),
]))
})
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('sums only unfinished expenses in the Outstanding amount card', async () => {
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// Footer only shows the count once unfinished expenses have loaded.
const foot = await screen.findByText('expenses need a payer')
expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]')
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | 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<string, unknown>
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(<CostsPanel tripId={1} tripMembers={tripMembers} />)
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([])
})
it('keeps "no one paid yet" when reopening a payer-less expense (#1533)', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let put: Record<string, unknown> | null = null
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }),
id: 5,
total_price: 120,
payers: [],
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.put('/api/trips/1/budget/5', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Hotel')
await user.click(screen.getByTitle('Edit'))
// Nobody paid this expense — reopening it must not silently reselect "You".
expect(await screen.findByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
// …and saving an untouched edit must not assign the current user as payer.
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.payers).toEqual([])
})
it('still defaults a brand-new expense to "You" as the payer', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
})
// ── Multi-payer (#1426 regression) ─────────────────────────────────────────
// 3.2.0 collapsed payers[] to a single payer, so a bill fronted by two people
// credited all of it to one and skewed settle-up. The ledger always supported N
// payers; only the form could no longer send them.
it('records an expense paid by two people with their own amounts', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let posted: Record<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 11 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
// Alice (me) is seeded as the sole payer; including Bob rebalances to 45/45.
await user.click(screen.getAllByTestId('payer-toggle')[1])
expect(screen.getAllByTestId('payer-amount').map(i => (i as HTMLInputElement).value))
.toEqual(['45,00', '45,00'])
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(90)
expect(posted!.payers).toEqual(expect.arrayContaining([
{ user_id: 1, amount: 45 },
{ user_id: 2, amount: 45 },
]))
expect(posted!.payers).toHaveLength(2)
})
it('blocks saving when the payer amounts do not add up to the total', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let posted: Record<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: 'Dinner' }) })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
await user.click(screen.getAllByTestId('payer-toggle')[1])
// Pin both payers at 20 of a 90 bill, so nobody is left to absorb the rest.
const amounts = () => screen.getAllByTestId('payer-amount') as HTMLInputElement[]
await user.clear(amounts()[0])
await user.type(amounts()[0], '20')
await user.clear(amounts()[1])
await user.type(amounts()[1], '20')
// An unbalanced payer list would make the server re-derive total_price as 40.
expect(screen.getByText(/must add up to/i)).toBeInTheDocument()
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
expect(addBtns[addBtns.length - 1]).toBeDisabled()
expect(posted).toBeNull()
})
it('reopens a two-payer expense with both payers intact', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let put: Record<string, unknown> | null = null
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
id: 7,
total_price: 90,
payers: [{ user_id: 1, amount: 45, username: 'alice' }, { user_id: 2, amount: 45, username: 'bob' }],
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.put('/api/trips/1/budget/7', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Dinner')
await user.click(screen.getByTitle('Edit'))
// Loading used to be payers.find(...), which silently dropped the second payer.
const amounts = await screen.findAllByTestId('payer-amount')
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45'])
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.payers).toHaveLength(2)
})
it('exports the expenses as a CSV download (#1500)', async () => {
// Display in the trip's own currency so FX conversion is an identity.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
let exported: Blob | null = null
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner; tapas' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Dinner; tapas')
await user.click(screen.getByTitle('Export CSV'))
expect(exported).toBeTruthy()
const text = await exported!.text()
expect(text).toContain('Date;Name;Category;Amount;Currency;Amount (EUR);Note')
expect(text).toContain('"Dinner; tapas"') // separator inside the name gets quoted
expect(text).toContain('Food & drink') // category label, not the raw key
expect(text).toContain('90.00;EUR')
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('supports itemized receipt ticket manual entry and split assignment', async () => {
let posted: Record<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 10 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.click(screen.getByRole('button', { name: 'Ticket' }))
const addBtn = screen.getByRole('button', { name: /Add item/i })
await user.click(addBtn)
await user.click(addBtn)
await user.click(addBtn)
const itemNames = screen.getAllByPlaceholderText('Item name')
const itemPrices = screen.getAllByPlaceholderText('0,00')
await user.type(itemNames[0], 'Apples')
await user.type(itemPrices[1], '10')
await user.type(itemNames[1], 'chocolate cake')
await user.type(itemPrices[2], '50')
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
await user.click(bobButtons[1])
await user.type(itemNames[2], 'Milk')
await user.type(itemPrices[3], '40')
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 75 }),
expect.objectContaining({ user_id: 2, amount: 25 }),
]))
expect(posted!.note).toContain('TICKETJSON:')
})
// ── Display currency ───────────────────────────────────────────────────────
it('shows amounts in the trip currency when the user has no display currency set', async () => {
// No personal preference → the trip's own currency wins, instead of a hardcoded one.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'JPY' }) })
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Sushi' }), total_price: 3000, currency: 'JPY', payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Sushi')
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
// Yen, unconverted and with JPY's zero decimals — not a euro/dollar default.
expect(card).toHaveTextContent('¥3,000')
})
// ── Payment currency ───────────────────────────────────────────────────────
// A transfer settling a shared bill can be made in any currency, so it carries its
// own rather than being assumed to be in the display one.
it('records a payment in the display currency by default', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
let posted: Record<string, unknown> | 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/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1, ...posted } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(addButtons[addButtons.length - 1])
await waitFor(() => expect(posted).toMatchObject({ amount: 25, currency: 'EUR' }))
})
it('records a payment made in another currency', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
let posted: Record<string, unknown> | 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/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1, ...posted } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
// Bob paid me back in dollars — the server freezes the USD rate on write.
await user.click(screen.getByText(/^EUR/))
await user.click(await screen.findByText(/^USD/))
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(addButtons[addButtons.length - 1])
await waitFor(() => expect(posted).toMatchObject({ amount: 25, currency: 'USD' }))
})
it('reopens a foreign-currency payment with its own currency', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, currency: 'USD', exchange_rate: 1.1, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
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')
expect(screen.getByText(/^USD/)).toBeInTheDocument()
})
})
// ── The rest of the panel ────────────────────────────────────────────────────
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string }
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: MemberFixture[] }): BudgetItem =>
({ ...buildBudgetItem({ trip_id: 1 }), payers: [], members: [], ...over }) as unknown as BudgetItem
function mount(
items: BudgetItem[],
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {},
entries?: string[],
) {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({ balances: [], flows: [], settlements: [], ...settlement })),
)
return render(<CostsPanel tripId={1} tripMembers={tripMembers} />, entries ? { initialEntries: entries } : undefined)
}
/** CustomSelect renders its options into a body portal. */
function pickOption(label: string | RegExp) {
const dropdown = document.querySelector('div[style*="z-index: 99999"]') as HTMLElement
fireEvent.click(within(dropdown).getByRole('button', { name: label }))
}
// Alice is the signed-in user throughout; the euro display currency keeps FX an identity.
function seedAlice() {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
}
const dinner = () => expense({
id: 101, name: 'Dinner', category: 'food', total_price: 90, expense_date: '2025-06-15',
payers: [{ user_id: 1, amount: 90 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})
const taxi = () => expense({
id: 102, name: 'Taxi', category: 'transport', total_price: 30, expense_date: '2025-06-16',
payers: [{ user_id: 2, amount: 30 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})
describe('CostsPanel — overview', () => {
beforeEach(seedAlice)
it('FE-W5COSTS-001: heads the panel with the trip span and the traveler chips', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', start_date: '2025-06-01', end_date: '2025-06-05' }) })
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={[{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/a.png' }, { id: 2, username: 'bob', avatar_url: null }]} />)
const span = await screen.findByText('5 days')
expect(span.parentElement).toHaveTextContent('Jun 1 – Jun 5')
expect(screen.getByText('2 travelers')).toBeInTheDocument()
// Alice has an uploaded avatar, Bob falls back to his initial.
expect(document.querySelectorAll('img[src="/uploads/avatars/a.png"]').length).toBeGreaterThan(0)
expect(screen.getAllByText('B').length).toBeGreaterThan(0)
})
it('FE-W5COSTS-002: a trip without dates gets no span chip', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', start_date: null, end_date: null } as never) })
mount([])
expect(await screen.findByText('2 travelers')).toBeInTheDocument()
expect(screen.queryByText(/^\d+ days$/)).toBeNull()
})
it('FE-W5COSTS-003: balances render credit, debt and a settled zero', async () => {
mount([], {
balances: [
{ user_id: 1, username: 'alice', avatar_url: null, balance: 45 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: -45 },
],
})
expect(await screen.findByText('+45,00 €')).toBeInTheDocument()
expect(screen.getByText('−45,00 €')).toBeInTheDocument()
})
it('FE-W5COSTS-004: a member with no balance row is shown as square', async () => {
mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] })
// Both travellers appear; neither has a signed amount.
await screen.findByText('Balances')
expect(screen.getAllByText('0,00 €')).toHaveLength(2)
})
it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => {
mount([dinner(), taxi()])
await screen.findByText('Dinner')
const breakdown = screen.getByText('By category').parentElement as HTMLElement
const labels = within(breakdown).getAllByText(/Food & drink|Transport/).map(el => el.textContent)
// Bars are ordered by spend, so the 90 € food row comes before the 30 € taxi.
expect(labels).toEqual(['Food & drink', 'Transport'])
expect(within(breakdown).getByText('90 €')).toBeInTheDocument()
expect(within(breakdown).getByText('30 €')).toBeInTheDocument()
})
it('FE-W5COSTS-006: an empty trip shows the empty ledger and the empty breakdown', async () => {
mount([])
expect(await screen.findByText('No expenses yet. Add your first one.')).toBeInTheDocument()
expect(screen.getByText('No expenses yet.')).toBeInTheDocument()
expect(screen.getByText("Everyone's square")).toBeInTheDocument()
})
it('FE-W5COSTS-007: an unknown currency falls back to a plainly formatted amount', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XX' }) })
mount([expense({ id: 110, name: 'Mystery', category: 'other', total_price: 90 })])
await screen.findByText('Mystery')
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
expect(card).toHaveTextContent('90.00 XX')
})
})
describe('CostsPanel — settle up', () => {
beforeEach(seedAlice)
const flows: Flow[] = [
{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 45 },
{ from: { user_id: 3, username: 'cara' }, to: { user_id: 1, username: 'alice' }, amount: 15 },
]
it('FE-W5COSTS-008: outstanding flows are listed and settling one records the transfer', async () => {
const posted: Record<string, unknown>[] = []
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ settlement: { id: 1 } })
}))
mount([], { flows: [flows[0]] })
await screen.findByText('45,00 €')
// The "you're owed" card names who still owes me.
expect(screen.getByText("You're owed")).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
await waitFor(() => expect(posted).toHaveLength(1))
expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 45, currency: 'EUR' })
})
it('FE-W5COSTS-009: Settle up clears every outstanding flow at once', async () => {
const posted: Record<string, unknown>[] = []
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ settlement: { id: 1 } })
}))
mount([], { flows })
const settleAll = await screen.findByRole('button', { name: 'Settle up' })
await waitFor(() => expect(settleAll).not.toBeDisabled())
fireEvent.click(settleAll)
await waitFor(() => expect(posted).toHaveLength(2))
expect(posted.map(p => p.amount)).toEqual([45, 15])
})
it('FE-W5COSTS-010: Settle up is disabled while nothing is outstanding', async () => {
mount([])
expect(await screen.findByRole('button', { name: 'Settle up' })).toBeDisabled()
})
it('FE-W5COSTS-011: a failing settle surfaces an error toast', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
server.use(http.post('/api/trips/1/budget/settlements', () => HttpResponse.json({ error: 'no' }, { status: 500 })))
mount([], { flows: [flows[0]] })
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
addToast.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Settle up' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
delete window.__addToast
})
it('FE-W5COSTS-012: undoing a payment deletes it, and a failure is reported', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
let deleted = 0
server.use(http.delete('/api/trips/1/budget/settlements/7', () => {
deleted += 1
return deleted === 1 ? HttpResponse.json({ success: true }) : HttpResponse.json({ error: 'no' }, { status: 500 })
}))
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
fireEvent.click(await screen.findByTitle('Undo'))
await waitFor(() => expect(deleted).toBe(1))
fireEvent.click(screen.getByTitle('Undo'))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
delete window.__addToast
})
it('FE-W5COSTS-013: the "you owe" card lists who I still have to pay', async () => {
mount([], { flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 20 }] })
await screen.findByText('You owe')
const card = screen.getByText('You owe').closest('div[style*="border-radius: 22"]') as HTMLElement
expect(within(card).getByText('To')).toBeInTheDocument()
expect(within(card).getByText('bob')).toBeInTheDocument()
expect(screen.getByText('Nothing owed to you')).toBeInTheDocument()
})
})
describe('CostsPanel — filtering the ledger', () => {
beforeEach(seedAlice)
const payment: Payment = { id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }
it('FE-W5COSTS-014: "Paid by me" keeps only the expenses I fronted', async () => {
mount([dinner(), taxi()], { settlements: [payment] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: 'Paid by me' }))
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
// A transfer I'm part of stays visible under "mine".
expect(screen.getByText('Payment')).toBeInTheDocument()
})
it('FE-W5COSTS-015: "I\'m owed" keeps the expenses I am net positive on and drops payments', async () => {
mount([dinner(), taxi()], { settlements: [payment] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: "I'm owed" }))
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
})
it('FE-W5COSTS-016: the category filter narrows the ledger and hides payments', async () => {
mount([dinner(), taxi()], { settlements: [payment] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: /All categories/ }))
pickOption('Transport')
expect(screen.getByText('Taxi')).toBeInTheDocument()
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
})
it('FE-W5COSTS-017: picking a single day banners it with that day’s total', async () => {
mount([dinner(), taxi()], { settlements: [payment] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
pickOption('Sun, Jun 15')
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
expect(screen.getByText('1 expenses')).toBeInTheDocument()
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
// The 16th carries the payment, so it is filtered out with the day too.
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
})
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
expect(await screen.findByText('No date')).toBeInTheDocument()
})
it('FE-W5COSTS-019: a search with no hits shows the no-match copy', async () => {
const user = userEvent.setup()
mount([dinner()])
await screen.findByText('Dinner')
await user.type(screen.getByPlaceholderText('Search expenses…'), 'zzz')
expect(screen.getByText('No expenses match your search.')).toBeInTheDocument()
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
})
})
describe('CostsPanel — expense rows', () => {
beforeEach(() => {
seedAlice()
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
it('FE-W5COSTS-020: each expense row shows who fronted it and the day subtotal', async () => {
mount([dinner(), taxi()])
await screen.findByText('Dinner')
// Each row carries a payer chip plus the row total, both in the base currency.
expect(screen.getAllByText('90,00 €')).toHaveLength(2)
expect(screen.getAllByText('30,00 €')).toHaveLength(2)
// Days are grouped, each with its own spent line.
expect(screen.getByText('90,00 € spent')).toBeInTheDocument()
expect(screen.getByText('30,00 € spent')).toBeInTheDocument()
// Same-currency rows show the category alone, with no conversion suffix.
expect(screen.getAllByText('Food & drink')).toHaveLength(2)
})
it('FE-W5COSTS-021: a foreign-currency expense shows the original and the converted amount', async () => {
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 2 }, ts: Date.now() }))
mount([expense({
id: 130, name: 'Diner', category: 'food', total_price: 100, currency: 'USD', expense_date: '2025-06-15',
payers: [{ user_id: 1, amount: 100 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})], { settlements: [{ id: 8, from_user_id: 2, to_user_id: 1, amount: 20, currency: 'USD', created_at: '2025-06-15 09:00:00' }] })
await screen.findByText('Diner')
expect(screen.getByText(/\$100\.00 → 50,00 €/)).toBeInTheDocument()
// The payer chip and the settlement row are converted the same way.
expect(screen.getByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument()
})
it('FE-W5COSTS-022: deleting an expense removes it, and a failure is reported', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
server.use(http.delete('/api/trips/1/budget/101', () => HttpResponse.json({ error: 'no' }, { status: 500 })))
mount([dinner()])
await screen.findByText('Dinner')
fireEvent.click(screen.getByTitle('Delete'))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
// The optimistic removal is rolled back by the store.
expect(await screen.findByText('Dinner')).toBeInTheDocument()
delete window.__addToast
})
it('FE-W5COSTS-023: ?create=expense opens the add modal straight away', async () => {
mount([], {}, ['/trips/1?create=expense'])
expect(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…')).toBeInTheDocument()
})
it('FE-W5COSTS-024: a viewer without edit rights gets no write affordances', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'admin' } })
mount([dinner()], { flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 45 }], settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
await screen.findByText('Dinner')
expect(screen.queryByRole('button', { name: 'Add expense' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Settle up' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add payment' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Settle' })).not.toBeInTheDocument()
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument()
expect(screen.queryByTitle('Undo')).not.toBeInTheDocument()
})
})
describe('CostsPanel — mobile layout', () => {
const desktopMatchMedia = window.matchMedia
beforeEach(() => {
seedAlice()
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes('1023'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
})
afterEach(() => { window.matchMedia = desktopMatchMedia })
it('FE-W5COSTS-025: the mobile column stacks the totals, owe/owed and outstanding cards', async () => {
mount([dinner(), expense({ id: 140, name: 'Hotel', category: 'accommodation', total_price: 60, payers: [], members: [{ user_id: 1, username: 'alice' }] })], {
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 45 }],
})
await screen.findByText('Hotel')
// The desktop summary grid is replaced by the mobile stack.
expect(document.querySelector('.costs-summary')).toBeNull()
expect(screen.getByText('Total trip spend')).toBeInTheDocument()
expect(screen.getByText('You owe')).toBeInTheDocument()
expect(screen.getByText("You're owed")).toBeInTheDocument()
expect(screen.getByText('Outstanding amount')).toBeInTheDocument()
// The unfinished expense is flagged with a badge on its icon instead of a pill.
expect(screen.getByTitle('Total only — not settled yet')).toHaveTextContent('!')
expect(screen.queryByText('Unfinished')).not.toBeInTheDocument()
})
it('FE-W5COSTS-026: the mobile ledger keeps search, filters and the empty text', async () => {
const user = userEvent.setup()
mount([dinner()])
await screen.findByText('Dinner')
await user.type(screen.getByPlaceholderText('Search expenses…'), 'zzz')
expect(screen.getByText('No expenses match your search.')).toBeInTheDocument()
await user.clear(screen.getByPlaceholderText('Search expenses…'))
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
pickOption('Sun, Jun 15')
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
})
it('FE-W5COSTS-027: the mobile total card opens the add-expense modal', async () => {
mount([])
// isMobile settles in an effect, which swaps the whole body out.
await waitFor(() => expect(document.querySelector('.costs-summary')).toBeNull())
fireEvent.click(screen.getByRole('button', { name: 'Add expense' }))
expect(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…')).toBeInTheDocument()
})
})
describe('CostsPanel — payment modal', () => {
beforeEach(seedAlice)
it('FE-W5COSTS-028: editing a payment updates it in place', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 7 } })
}))
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
await user.click(await screen.findByTitle('Edit'))
const amount = await screen.findByPlaceholderText('0.00')
await user.clear(amount)
await user.type(amount, '12,50')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 12.5 })
})
it('FE-W5COSTS-029: a transfer to yourself cannot be saved', async () => {
const user = userEvent.setup()
mount([])
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '10')
// Default is You → bob; pointing "to" back at me makes it a no-op transfer.
await user.click(screen.getByRole('button', { name: 'bob' }))
pickOption('You')
const submits = screen.getAllByRole('button', { name: 'Add payment' })
expect(submits[submits.length - 1]).toBeDisabled()
})
it('FE-W5COSTS-030: a failing payment save is reported', async () => {
const user = userEvent.setup()
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
server.use(http.post('/api/trips/1/budget/settlements', () => HttpResponse.json({ error: 'no' }, { status: 500 })))
mount([])
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '10')
const submits = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
delete window.__addToast
})
})
describe('CostsPanel — expense modal', () => {
beforeEach(() => {
seedAlice()
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
const openAdd = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…')
}
it('FE-W5COSTS-031: reopening a ticket expense restores its items and lets them be edited', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
const note = 'TICKETJSON:' + JSON.stringify({ items: [
{ name: 'Apples', price: '10', parts: [1, 2] },
{ name: 'Cake', price: '20', parts: [2] },
] })
server.use(http.put('/api/trips/1/budget/150', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: dinner() })
}))
mount([expense({ id: 150, name: 'Market run', category: 'groceries', total_price: 30, note, payers: [{ user_id: 1, amount: 30 }], members: [{ user_id: 1, username: 'alice', amount: 15 }, { user_id: 2, username: 'bob', amount: 15 }] })])
await screen.findByText('Market run')
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByDisplayValue('Apples')).toBeInTheDocument()
expect(screen.getByDisplayValue('Cake')).toBeInTheDocument()
// Drop the second item and take Bob off the first one.
const rows = screen.getAllByPlaceholderText('Item name')
await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0])
expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument()
await user.click(screen.getAllByRole('button', { name: /bob/i })[0])
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.total_price).toBe(10)
expect(put!.members).toEqual(expect.arrayContaining([expect.objectContaining({ user_id: 1, amount: 10 })]))
})
it('FE-W5COSTS-032: an unparsable ticket note opens with an empty item list', async () => {
const user = userEvent.setup()
mount([expense({ id: 151, name: 'Market run', category: 'groceries', total_price: 30, note: 'TICKETJSON:{oops', payers: [{ user_id: 1, amount: 30 }], members: [{ user_id: 1, username: 'alice' }] })])
await screen.findByText('Market run')
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByRole('button', { name: /Add item/i })).toBeInTheDocument()
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('FE-W5COSTS-033: reopening a custom split restores the per-member amounts', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/160', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: dinner() })
}))
mount([expense({
id: 160, name: 'Dinner', category: 'food', total_price: 100,
payers: [{ user_id: 1, amount: 100 }],
members: [{ user_id: 1, username: 'alice', amount: 70 }, { user_id: 2, username: 'bob', amount: 30 }],
})])
await screen.findByText('Dinner')
await user.click(screen.getByTitle('Edit'))
expect((await screen.findByDisplayValue('70')).tagName).toBe('INPUT')
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
// Excluding Bob drops his amount; the split no longer matches the total.
await user.click(screen.getByRole('button', { name: /bob/i }))
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
// Bringing him back in via the excluded-state hint restores an empty field,
// pre-filled as a placeholder with what is still missing from the total.
await user.click(screen.getByRole('button', { name: 'Tap to include' }))
await user.type(screen.getByPlaceholderText('30,00'), '30')
expect(screen.getByText('Split matches total')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.members).toEqual([{ user_id: 1, amount: 70 }, { user_id: 2, amount: 30 }])
})
it('FE-W5COSTS-034: changing the currency previews the converted total', async () => {
const user = userEvent.setup()
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 2 }, ts: Date.now() }))
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: dinner() })
}))
mount([])
await openAdd(user)
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Diner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '100')
await user.click(screen.getByText(/^EUR/))
await user.click(await screen.findByText(/^USD/))
expect(screen.getByText('live rate', { exact: false })).toBeInTheDocument()
expect(screen.getByText('$100.00')).toBeInTheDocument()
expect(screen.getByText('50,00 €')).toBeInTheDocument()
// A different category than the default is carried into the payload.
await user.click(screen.getByRole('button', { name: 'Sightseeing' }))
const submits = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted).toMatchObject({ currency: 'USD', category: 'sightseeing', total_price: 100 })
})
it('FE-W5COSTS-035: collapsing multi-payer mode keeps the first payer for the whole bill', 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: dinner() })
}))
mount([])
await openAdd(user)
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
await user.click(screen.getAllByTestId('payer-toggle')[1])
// Dropping Bob again leaves Alice absorbing the whole amount.
await user.click(screen.getAllByTestId('payer-toggle')[1])
expect(screen.getByRole('button', { name: 'Tap to include' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'One person paid' }))
const submits = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.payers).toEqual([{ user_id: 1, amount: 90 }])
})
it('FE-W5COSTS-036: a nobody-paid expense can be recorded from the payer dropdown', 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: dinner() })
}))
mount([])
await openAdd(user)
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '120')
await user.click(screen.getByRole('button', { name: 'You' }))
pickOption('No one paid yet')
const submits = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.payers).toEqual([])
expect(posted!.member_ids).toEqual([1, 2])
})
it('FE-W5COSTS-037: a failing expense save is reported and the modal stays open', async () => {
const user = userEvent.setup()
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
server.use(http.post('/api/trips/1/budget', () => HttpResponse.json({ error: 'no' }, { status: 500 })))
mount([])
await openAdd(user)
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
const submits = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
expect(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…')).toBeInTheDocument()
delete window.__addToast
})
it('FE-W5COSTS-038: a prefilled expense opens with the booking’s name, amount and category', 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: dinner() })
}))
const onSaved = vi.fn()
render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
prefill={{ name: 'Hotel Astoria', category: 'accommodation', amount: 240, reservationId: 12 }}
onClose={() => {}} onSaved={onSaved} />
)
expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument()
expect(screen.getByDisplayValue('240')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add expense' }))
await waitFor(() => expect(posted).toBeTruthy())
expect(posted).toMatchObject({ name: 'Hotel Astoria', category: 'accommodation', total_price: 240, reservation_id: 12 })
expect(onSaved).toHaveBeenCalled()
})
})
describe('CostsPanel — remaining paths', () => {
beforeEach(seedAlice)
it('FE-W5COSTS-042: the CSV export escapes, orders and strips ticket notes', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', title: '///' }) })
let exported: Blob | null = null
let downloadName = ''
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function (this: HTMLAnchorElement) { downloadName = this.download })
mount([
expense({ id: 201, name: 'Dinner "deluxe"', category: 'food', total_price: 90, expense_date: '2025-06-15', note: 'with;semicolon' }),
expense({ id: 202, name: 'Tickets', category: 'activities', total_price: 20, expense_date: '2025-06-14', note: 'TICKETJSON:{"items":[]}' }),
expense({ id: 203, name: 'Tip', category: 'tips', total_price: 5, expense_date: null, note: null }),
])
await screen.findByText('Tip')
fireEvent.click(screen.getByTitle('Export CSV'))
const lines = (await exported!.text()).replace(/^/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note')
// Oldest first, dateless rows leading.
expect(lines[1]).toBe(';Tip;Tips;5.00;EUR;5.00;')
// The ticket payload is machine data, so it never reaches the note column.
expect(lines[2]).toBe('06/14/2025;Tickets;Activities;20.00;EUR;20.00;')
expect(lines[3]).toBe('06/15/2025;"Dinner ""deluxe""";Food & drink;90.00;EUR;90.00;"with;semicolon"')
// A title made only of illegal characters still yields a usable file name.
expect(downloadName).toBe('costs-.csv')
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-043: deleting an expense drops it from the ledger', async () => {
let deleted = false
server.use(http.delete('/api/trips/1/budget/101', () => { deleted = true; return HttpResponse.json({ success: true }) }))
mount([dinner()])
await screen.findByText('Dinner')
fireEvent.click(screen.getByTitle('Delete'))
await waitFor(() => expect(deleted).toBe(true))
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
})
it('FE-W5COSTS-044: both modals can be dismissed with Cancel', async () => {
const user = userEvent.setup()
mount([])
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByPlaceholderText('e.g. Dinner, souvenirs, gas…')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add payment' }))
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument()
})
it('FE-W5COSTS-045: an explicit member amount is used as my share instead of an equal split', async () => {
mount([expense({
id: 210, name: 'Dinner', category: 'food', total_price: 100,
payers: [{ user_id: 2, amount: 100 }],
members: [{ user_id: 1, username: 'alice', amount: 80 }, { user_id: 2, username: 'bob', amount: 20 }],
})])
await screen.findByText('Dinner')
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]') as HTMLElement
// Not the 50/50 an equal split would have produced.
expect(within(card).getByText('80 €')).toBeInTheDocument()
expect(within(card).getByText('0 €')).toBeInTheDocument()
})
it('FE-W5COSTS-046: an expense I am not part of contributes nothing to my share', async () => {
mount([expense({
id: 211, name: 'Bob solo', category: 'food', total_price: 40,
payers: [{ user_id: 2, amount: 40 }],
members: [{ user_id: 2, username: 'bob' }],
})])
await screen.findByText('Bob solo')
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]') as HTMLElement
expect(within(card).getAllByText('0 €')).toHaveLength(2)
})
it('FE-W5COSTS-047: with no display and no trip currency the panel falls back to euro', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: '' }) })
mount([expense({ id: 220, name: 'Dinner', category: 'food', total_price: 90, currency: null })])
await screen.findByText('Dinner')
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
expect(card).toHaveTextContent('90,00 €')
})
it('FE-W5COSTS-048: a payment in a currency with no symbol keeps its code, and the sender can be swapped', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/settlements/9', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 9 } })
}))
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({
balances: [], flows: [],
settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 30, currency: 'XBT', created_at: '2025-06-16 10:00:00' }],
})),
)
render(<CostsPanel tripId={1} tripMembers={[...tripMembers, { id: 3, username: 'cara', avatar_url: null }]} />)
await user.click(await screen.findByTitle('Edit'))
// No symbol is known, so the code itself prefixes the amount and labels the option.
await screen.findByPlaceholderText('0.00')
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
await user.click(screen.getByRole('button', { name: 'bob' }))
pickOption('cara')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put).toMatchObject({ from_user_id: 3, to_user_id: 1, amount: 30, currency: 'XBT' })
})
it('FE-W5COSTS-055: an expense in a currency with no symbol prefixes the code', async () => {
const user = userEvent.setup()
mount([expense({ id: 240, name: 'Mining rig', category: 'other', total_price: 2, currency: 'XBT', payers: [{ user_id: 1, amount: 2 }], members: [{ user_id: 1, username: 'alice' }] })])
await screen.findByText('Mining rig')
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByDisplayValue('2')).toBeInTheDocument()
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
})
it('FE-W5COSTS-049: a solo trip defaults the payment counterpart to myself', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={[{ id: 1, username: 'alice', avatar_url: null }]} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '10')
// From and To both resolve to me, so the transfer stays unsavable.
const submits = screen.getAllByRole('button', { name: 'Add payment' })
expect(submits[submits.length - 1]).toBeDisabled()
})
it('FE-W5COSTS-050: the expense modal shows uploaded avatars and rejects over-precise input', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={[
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/a.png' },
{ id: 2, username: 'bob', avatar_url: '/uploads/avatars/b.png' },
]} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '100')
// Payer rows and split rows both fall back to the uploaded picture.
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
expect(document.querySelectorAll('img[src="/uploads/avatars/b.png"]').length).toBeGreaterThanOrEqual(2)
// Excluding Bob leaves the include hint; tapping it puts him back on the bill.
await user.click(screen.getAllByTestId('payer-toggle')[1])
await user.click(screen.getAllByTestId('payer-toggle')[1])
await user.click(screen.getByRole('button', { name: 'Tap to include' }))
expect(screen.getAllByTestId('payer-amount')).toHaveLength(2)
// Three decimals are not a valid money amount, so the field ignores them.
await user.click(screen.getByRole('button', { name: 'Custom' }))
const splitInput = screen.getAllByPlaceholderText('50,00')[0] as HTMLInputElement
await user.type(splitInput, '12,345')
expect(splitInput.value).toBe('12,34')
})
it('FE-W5COSTS-051: ticket prices ignore an over-precise entry and participants toggle back on', async () => {
const user = userEvent.setup()
mount([])
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Groceries')
await user.click(screen.getByRole('button', { name: 'Ticket' }))
await user.click(screen.getByRole('button', { name: /Add item/i }))
await user.type(screen.getByPlaceholderText('Item name'), 'Apples')
const price = screen.getAllByPlaceholderText('0,00')[1] as HTMLInputElement
await user.type(price, '10,999')
expect(price.value).toBe('10,99')
// Toggling a participant off and on again leaves the shares unchanged.
const bob = screen.getAllByRole('button', { name: /bob/i })[0]
await user.click(bob)
await user.click(bob)
// 10.99 across two people leaves the odd cent with the lower user id.
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('€5.50')).toBeInTheDocument()
expect(screen.getByText('€5.49')).toBeInTheDocument()
})
})
describe('CostsPanel — split modes and guests', () => {
beforeEach(seedAlice)
it('FE-W5COSTS-056: switching back to an equal split restores the per-head amounts', 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: dinner() })
}))
mount([])
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Custom' }))
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Equally' }))
expect(screen.getByText('Split 2 ways · €45.00 each')).toBeInTheDocument()
const submits = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.members).toEqual([{ user_id: 1, amount: null }, { user_id: 2, amount: null }])
})
it('FE-W5COSTS-057: multi-payer on a payer-less expense seeds and collapses back to me', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/250', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: dinner() })
}))
mount([expense({
id: 250, name: 'Hotel', category: 'accommodation', total_price: 120,
payers: [], members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})])
await screen.findByText('Hotel')
await user.click(screen.getByTitle('Edit'))
// Nobody paid, so switching to multi-payer seeds me as the only payer.
await user.click(await screen.findByRole('button', { name: 'Multiple people paid' }))
expect(screen.getAllByTestId('payer-amount')).toHaveLength(1)
// Dropping myself leaves the list empty; collapsing then falls back to me.
await user.click(screen.getAllByTestId('payer-toggle')[0])
expect(screen.queryAllByTestId('payer-amount')).toHaveLength(0)
await user.click(screen.getByRole('button', { name: 'One person paid' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.payers).toEqual([{ user_id: 1, amount: 120 }])
})
it('FE-W5COSTS-058: a guest traveler is badged in the split list', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={[
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'gus', avatar_url: '/uploads/avatars/g.png', is_guest: true },
]} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.click(screen.getByRole('button', { name: 'Ticket' }))
await user.click(screen.getByRole('button', { name: /Add item/i }))
// The uploaded picture is reused for the per-item participant chips.
expect(document.querySelectorAll('img[src="/uploads/avatars/g.png"]').length).toBeGreaterThanOrEqual(2)
await user.click(screen.getByRole('button', { name: 'Equally' }))
expect(screen.getByText('Guest')).toBeInTheDocument()
})
})
describe('CostsPanel — mobile extras', () => {
const desktopMatchMedia = window.matchMedia
beforeEach(() => {
seedAlice()
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes('1023'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
})
afterEach(() => { window.matchMedia = desktopMatchMedia })
it('FE-W5COSTS-052: the mobile ledger filters by owner and by category', async () => {
mount([dinner(), taxi()])
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: 'Paid by me' }))
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'All' }))
fireEvent.click(screen.getByRole('button', { name: /All categories/ }))
pickOption('Transport')
expect(screen.getByText('Taxi')).toBeInTheDocument()
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
})
it('FE-W5COSTS-053: the mobile settle-up card can record a manual payment', async () => {
mount([])
await waitFor(() => expect(document.querySelector('.costs-summary')).toBeNull())
fireEvent.click(screen.getByRole('button', { name: 'Add payment' }))
expect(await screen.findByPlaceholderText('0.00')).toBeInTheDocument()
})
it('FE-W5COSTS-054: an unknown currency degrades to a plain amount on mobile too', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XX' }) })
mount([expense({ id: 230, name: 'Mystery', category: 'other', total_price: 90 })])
await screen.findByText('Mystery')
expect(screen.getByText('Total trip spend').parentElement).toHaveTextContent('90.00 XX')
})
})
describe('CostsPanel — split maths', () => {
it('FE-W5COSTS-039: an equal split with nobody to split with is empty', () => {
expect(splitEqualShares(90, [], 1)).toEqual({})
})
it('FE-W5COSTS-040: the equal split spreads the remainder cents by item id', () => {
// 10.00 over 3 people = 3.34 / 3.33 / 3.33, with the extra cent rotating per item.
expect(splitEqualShares(10, [{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }], 0)).toEqual({ 1: 3.34, 2: 3.33, 3: 3.33 })
expect(splitEqualShares(10, [{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }], 1)).toEqual({ 1: 3.33, 2: 3.34, 3: 3.33 })
})
it('FE-W5COSTS-041: ticket items nobody is assigned to still count toward the total', () => {
const items: TicketItem[] = [
{ id: 'a', name: 'Apples', price: '10', participants: new Set([1, 2]) },
{ id: 'b', name: 'Service', price: '5', participants: new Set() },
{ id: 'c', name: 'Cake', price: 'x', participants: new Set([2]) },
]
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 5, 2: 5 }, total: 15 })
})
})