mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
104f0eef38
Under the payers, a Split line lists every member of the expense with their share and a tick once they marked it paid, so checking that the right people are in no longer means opening each expense. More than six fold into a +N chip with the rest in its tooltip. (#1763)
2753 lines
138 KiB
TypeScript
2753 lines
138 KiB
TypeScript
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
||
// FE-W5COSTS-001 to FE-W5COSTS-096: 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, buildSettings } from '../../../tests/helpers/factories'
|
||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||
import type { BudgetItem } from '../../types'
|
||
import CostsPanel, { ExpenseModal } from './CostsPanel'
|
||
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
|
||
import { resetFreezeAttempts } from './useFreezeMissingRates'
|
||
|
||
const tripMembers = [
|
||
{ id: 1, username: 'alice', avatar_url: null },
|
||
{ id: 2, username: 'bob', avatar_url: null },
|
||
]
|
||
|
||
/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */
|
||
const bobInForm = () => screen.getAllByRole('checkbox', { name: /bob/i })
|
||
|
||
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.getByRole('button', { name: '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()
|
||
})
|
||
|
||
// ── Notes on an expense (#1658) ────────────────────────────────────────────
|
||
|
||
it('shows a note on the row and expands it on click', async () => {
|
||
const long = 'Bought the whole week of breakfasts here, Ben pays back his half once the card statement lands'
|
||
const item = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Supermarket' }),
|
||
total_price: 60,
|
||
note: long,
|
||
payers: [{ user_id: 1, amount: 60, username: 'alice' }],
|
||
}
|
||
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 user = userEvent.setup()
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
// Re-queried after each click: the row is re-created on every render of the
|
||
// panel, so a handle taken before the click points at a detached node.
|
||
const noteToggle = () => screen.getByRole('button', { name: new RegExp(long.slice(0, 20)) })
|
||
await screen.findByText('Supermarket')
|
||
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
|
||
await user.click(noteToggle())
|
||
expect(noteToggle()).toHaveAttribute('aria-expanded', 'true')
|
||
await user.click(noteToggle())
|
||
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
|
||
})
|
||
|
||
it('never renders a receipt blob as a note', async () => {
|
||
const item = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Market' }),
|
||
total_price: 20,
|
||
// How the receipt was stored before it got its own column.
|
||
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"20","parts":[1]}]}',
|
||
payers: [{ user_id: 1, amount: 20, username: 'alice' }],
|
||
}
|
||
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('Market')
|
||
expect(screen.queryByText(/TICKETJSON/)).toBeNull()
|
||
})
|
||
|
||
it('keeps an existing note when the expense is saved again', async () => {
|
||
const item = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Lunch' }),
|
||
total_price: 30,
|
||
note: 'split with the neighbours',
|
||
payers: [{ user_id: 1, amount: 30, username: 'alice' }],
|
||
}
|
||
let patched: Record<string, unknown> | null = null
|
||
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/:id', async ({ request }) => {
|
||
patched = (await request.json()) as Record<string, unknown>
|
||
return HttpResponse.json({ item: { ...item, ...patched } })
|
||
}),
|
||
)
|
||
const user = userEvent.setup()
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
await screen.findByText('Lunch')
|
||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[0])
|
||
await user.click(await screen.findByRole('button', { name: 'Save' }))
|
||
|
||
await waitFor(() => expect(patched).toBeTruthy())
|
||
expect(patched!.note).toBe('split with the neighbours')
|
||
})
|
||
|
||
it('shows the net hint on a settled expense row and hides it while unfinished', async () => {
|
||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||
// alice fronted the whole 90 for a two-way split, so she is 45 up.
|
||
const lent = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
|
||
total_price: 90,
|
||
payers: [{ user_id: 1, amount: 90, username: 'alice' }],
|
||
members: [{ user_id: 1, username: 'alice', paid: 1 }, { user_id: 2, username: 'bob', paid: 0 }],
|
||
}
|
||
const unpaid = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }),
|
||
total_price: 90,
|
||
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: [lent, unpaid] })),
|
||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
const dinnerRow = (await screen.findByText('Dinner')).closest('.exp-row') as HTMLElement
|
||
expect(within(dinnerRow).getByText(/you lent/)).toHaveTextContent('45')
|
||
|
||
const hotelRow = (await screen.findByText('Hotel')).closest('.exp-row') as HTMLElement
|
||
expect(within(hotelRow).queryByText(/you lent|you borrowed/)).toBeNull()
|
||
})
|
||
|
||
it('lists who an expense is split between, with each part, in the row (#1763)', async () => {
|
||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||
const custom = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
|
||
total_price: 90,
|
||
payers: [{ user_id: 1, amount: 90, username: 'alice' }],
|
||
members: [{ user_id: 1, username: 'alice', paid: 1, amount: 60 }, { user_id: 2, username: 'bob', paid: 0, amount: 30 }],
|
||
}
|
||
const crowd = {
|
||
...buildBudgetItem({ trip_id: 1, category: 'activity', name: 'Boat' }),
|
||
total_price: 80,
|
||
payers: [{ user_id: 1, amount: 80, username: 'alice' }],
|
||
members: Array.from({ length: 8 }, (_, i) => ({ user_id: i + 1, username: `p${i + 1}`, paid: 0 })),
|
||
}
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [custom, crowd] })),
|
||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
const dinnerRow = (await screen.findByText('Dinner')).closest('.exp-row') as HTMLElement
|
||
const chips = within(dinnerRow).getAllByTestId('split-chip')
|
||
expect(chips).toHaveLength(2)
|
||
expect(chips[0]).toHaveTextContent('60')
|
||
expect(chips[1]).toHaveTextContent('30')
|
||
// alice marked her part as paid; bob has not.
|
||
expect(within(chips[0]).getByLabelText('paid')).toBeInTheDocument()
|
||
expect(within(chips[1]).queryByLabelText('paid')).toBeNull()
|
||
|
||
// Eight people fold into five chips and a "+3".
|
||
const boatRow = (await screen.findByText('Boat')).closest('.exp-row') as HTMLElement
|
||
expect(within(boatRow).getAllByTestId('split-chip')).toHaveLength(5)
|
||
expect(within(boatRow).getByText('+3')).toBeInTheDocument()
|
||
expect(within(boatRow).getAllByTestId('split-chip')[0]).toHaveTextContent('10')
|
||
})
|
||
|
||
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.getByRole('region', { name: 'Outstanding amount' })
|
||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||
})
|
||
|
||
// #2225 follow-up — the ledger stopped charging payer-less expenses, but this
|
||
// tile kept counting them, so it contradicted the balances right beside it.
|
||
it('keeps a payer-less expense out of Your share, like the balances do', async () => {
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||
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 noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })),
|
||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
await screen.findByText('Taxi')
|
||
// "Your share" is the first of the two bold figures in the Total spend card's
|
||
// footer; the second is "You paid". 60 from the paid expense only — the 40
|
||
// nobody has paid for is not a share, though it still counts as spend.
|
||
const card = screen.getByRole('region', { name: 'Total trip spend' })!
|
||
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
|
||
expect(figures[0]).toContain('60')
|
||
expect(figures[0]).not.toContain('100')
|
||
})
|
||
|
||
it('records a recorded-total expense with nobody to split with (#1286)', 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: '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 so the cost carries no split, and mark it as unpaid: a picked
|
||
// payer now always goes out (#1766), so "nobody paid" must be said explicitly.
|
||
await user.click(screen.getByRole('checkbox', { name: 'You' }))
|
||
await user.click(screen.getByRole('checkbox', { name: 'bob' }))
|
||
await user.click(screen.getByRole('radio', { name: 'No one paid yet' }))
|
||
|
||
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 a picked payer when nobody splits the expense (#1766)', 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: 'Flight' }), 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…'), 'Flight')
|
||
await user.type(screen.getAllByPlaceholderText('0,00')[0], '100')
|
||
|
||
// A personal expense: alice (the default "You" payer) fronted it, but nobody shares
|
||
// the split. The web used to drop the payer once no participants remained.
|
||
await user.click(screen.getByRole('checkbox', { name: 'You' }))
|
||
await user.click(screen.getByRole('checkbox', { name: 'bob' }))
|
||
|
||
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!.member_ids).toEqual([])
|
||
expect(posted!.payers).toEqual([{ user_id: 1, amount: 100 }])
|
||
})
|
||
|
||
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.getByRole('button', { name: 'Edit' }))
|
||
|
||
// Nobody paid this expense — reopening it must not silently reselect "You".
|
||
expect(await screen.findByRole('radio', { name: 'No one paid yet' })).toBeChecked()
|
||
|
||
// …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('radio', { name: 'You' })).toBeChecked()
|
||
})
|
||
|
||
// ── 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.getByRole('button', { name: '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,00', '45,00'])
|
||
|
||
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.getByRole('button', { name: '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 = bobInForm()
|
||
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')).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 }),
|
||
]))
|
||
// The receipt has its own column since #1658; `note` is the user's text.
|
||
expect(JSON.parse(posted!.ticket_json as string).items).toHaveLength(3)
|
||
expect(posted!.note).toBeNull()
|
||
})
|
||
|
||
// ── 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.getByRole('region', { name: 'Total trip spend' })
|
||
// 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.getByRole('button', { name: 'Edit' }))
|
||
|
||
// 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()
|
||
})
|
||
})
|
||
|
||
// ── 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; exchange_rate?: number; created_at?: string; settled_at?: string | null }
|
||
|
||
// `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[]; finalBudgets?: BudgetParticipantFinal[] } = {},
|
||
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.
|
||
const balances = (await screen.findByRole('region', { name: 'Balances' })) as HTMLElement
|
||
expect(within(balances).getAllByText('0,00 €')).toHaveLength(2)
|
||
})
|
||
|
||
it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => {
|
||
// Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen
|
||
// on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each
|
||
// of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate
|
||
// is loaded here, so a client converting the dinner itself would print 100 €.
|
||
const user = userEvent.setup()
|
||
mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], {
|
||
balances: [
|
||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 16 },
|
||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -16 },
|
||
],
|
||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }],
|
||
settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }],
|
||
finalBudgets: [
|
||
{
|
||
user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61,
|
||
sources: {
|
||
fronted: [{ item_id: 101, cents: 9200 }],
|
||
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }],
|
||
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }],
|
||
},
|
||
},
|
||
{
|
||
user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61,
|
||
sources: {
|
||
fronted: [{ item_id: 102, cents: 3000 }],
|
||
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }],
|
||
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }],
|
||
},
|
||
},
|
||
],
|
||
})
|
||
|
||
const card = (await screen.findByRole('region', { name: 'Final budget' })) as HTMLElement
|
||
await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2))
|
||
// The main view stays one figure per person until someone asks for more.
|
||
expect(within(card).queryByText('Expenses paid')).toBeNull()
|
||
|
||
const alice = within(card).getByRole('button', { name: /You/ })
|
||
await user.click(alice)
|
||
expect(alice).toHaveAttribute('aria-expanded', 'true')
|
||
// The dinner is the server's 92 €, once as the line and once as its only row; the
|
||
// 100 USD is never converted again here. What was entered labels the row (#2525).
|
||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||
expect(within(card).getByText('$100.00')).toBeInTheDocument()
|
||
// Received and still pending both lower her cost, each line with its one row.
|
||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||
expect(within(card).getByText('Dinner')).toBeInTheDocument()
|
||
expect(within(card).queryByText('Taxi')).toBeNull()
|
||
expect(within(card).getAllByText(/^bob → /)).toHaveLength(2)
|
||
|
||
// Opening Bob closes Alice: what he sent back and what he still owes raise his.
|
||
await user.click(within(card).getByRole('button', { name: /bob/ }))
|
||
expect(alice).toHaveAttribute('aria-expanded', 'false')
|
||
expect(within(card).getAllByText('+30,00 €')).toHaveLength(2)
|
||
expect(within(card).getAllByText('+15,00 €')).toHaveLength(2)
|
||
expect(within(card).getAllByText('+16,00 €')).toHaveLength(2)
|
||
expect(within(card).getByText('Taxi')).toBeInTheDocument()
|
||
expect(within(card).queryByText('Dinner')).toBeNull()
|
||
})
|
||
|
||
it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => {
|
||
mount([dinner(), taxi()])
|
||
|
||
await screen.findByText('Dinner')
|
||
const breakdown = screen.getByRole('region', { name: 'By category' }) 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.getByRole('region', { name: 'Total trip spend' })
|
||
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.findByRole('button', { name: 'Undo' }))
|
||
await waitFor(() => expect(deleted).toBe(1))
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||
delete window.__addToast
|
||
})
|
||
|
||
it('FE-W5COSTS-059: a settle-up that fails midway still reloads the transfers that went through', async () => {
|
||
const addToast = vi.fn()
|
||
window.__addToast = addToast as unknown as typeof window.__addToast
|
||
let posted = 0
|
||
let reads = 0
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||
http.get('/api/trips/1/budget/settlement', () => {
|
||
reads += 1
|
||
return HttpResponse.json({ balances: [], flows: posted > 0 ? [flows[1]] : flows, settlements: [] })
|
||
}),
|
||
http.post('/api/trips/1/budget/settlements', () => {
|
||
posted += 1
|
||
return posted === 1 ? HttpResponse.json({ settlement: { id: 1 } }) : HttpResponse.json({ error: 'no' }, { status: 500 })
|
||
}),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
const settleAll = await screen.findByRole('button', { name: 'Settle up' })
|
||
await waitFor(() => expect(settleAll).not.toBeDisabled())
|
||
const readsBefore = reads
|
||
fireEvent.click(settleAll)
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||
// The failed second transfer must not swallow the refresh: bob's 45 € was
|
||
// recorded, and leaving it listed invites a second, doubled settle-up.
|
||
await waitFor(() => expect(reads).toBeGreaterThan(readsBefore))
|
||
expect(posted).toBe(2)
|
||
delete window.__addToast
|
||
})
|
||
|
||
it('FE-W5COSTS-060: a settlement read that fails says so instead of claiming everyone is square', async () => {
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ error: 'no' }, { status: 500 })),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
await screen.findByText('Balances')
|
||
// Settle up, Balances and Final budget all hang off the one failed request.
|
||
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3))
|
||
expect(screen.queryByText("Everyone's square")).toBeNull()
|
||
})
|
||
|
||
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.getByRole('region', { name: 'You owe' }) 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: 'Filter' }))
|
||
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: 'Filter' }))
|
||
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: 'Filter' }))
|
||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: '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: 'Filter' }))
|
||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: '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-076: a payment groups by its own settled day, not the day it was recorded', async () => {
|
||
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
|
||
// must follow settled_at, the same way it already follows expense_date over
|
||
// an expense's own created_at.
|
||
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
|
||
|
||
await screen.findByText('Taxi')
|
||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||
|
||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||
expect(screen.getByText('Payment')).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-078: a booked rate outlives the live one, for the expense and for the transfer', async () => {
|
||
// What a tester hit after settling up: the euro moved, and so did a bill that had
|
||
// already been paid. A cost is money that changed hands at a rate that was true that
|
||
// day, so the frozen rate wins over whatever the market says this morning.
|
||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 2 }, ts: Date.now() }))
|
||
mount([expense({
|
||
id: 131, name: 'Diner', category: 'food', total_price: 120, currency: 'USD', exchange_rate: 1.2,
|
||
expense_date: '2025-06-15',
|
||
payers: [{ user_id: 1, amount: 120 }],
|
||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||
})], { settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 24, currency: 'USD', exchange_rate: 1.2, created_at: '2025-06-15 09:00:00' }] })
|
||
|
||
await screen.findByText('Diner')
|
||
// 120 USD at the booked 1.2 per euro is 100 euro. At today's 2 it would read 60,00 euro.
|
||
expect(screen.getByText(/\$120\.00 → 100,00 €/)).toBeInTheDocument()
|
||
expect(screen.queryByText(/60,00 €/)).toBeNull()
|
||
// The settled transfer is read back the same way: 24 USD booked at 1.2 is 20 euro.
|
||
expect(screen.getByText(/\$24\.00 → 20,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.getByRole('button', { name: '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.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '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.getByRole('img', { name: '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.findByRole('button', { name: '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
|
||
})
|
||
|
||
it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => {
|
||
vi.useFakeTimers({ toFake: ['Date'] })
|
||
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
|
||
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
|
||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||
let posted: Record<string, unknown> | null = null
|
||
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||
posted = await request.json() as Record<string, unknown>
|
||
return HttpResponse.json({ settlement: { id: 9 } })
|
||
}))
|
||
mount([])
|
||
|
||
try {
|
||
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(posted).toBeTruthy())
|
||
expect(posted!.settled_at).toBe('2026-08-12')
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', 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, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||
|
||
await waitFor(() => expect(put).toBeTruthy())
|
||
expect(put!.settled_at).toBe('2025-06-10')
|
||
})
|
||
|
||
it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => {
|
||
// Cleared, the server would store NULL and the ledger would quietly move
|
||
// the payment back to the day it was recorded on.
|
||
const user = userEvent.setup()
|
||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||
const save = screen.getByRole('button', { name: 'Save' })
|
||
expect(save).toBeEnabled()
|
||
|
||
await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
|
||
await user.click(screen.getByRole('button', { name: 'Clear date' }))
|
||
expect(save).toBeDisabled()
|
||
})
|
||
})
|
||
|
||
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.getByRole('button', { name: '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(bobInForm()[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.getByRole('button', { name: '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.getByRole('button', { name: 'Edit' }))
|
||
|
||
// 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.
|
||
expect(bobInForm()).toHaveLength(1)
|
||
await user.click(bobInForm()[0])
|
||
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(bobInForm()[0])
|
||
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: /^Category:/ }))
|
||
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.getAllByTestId('payer-toggle')[1]).toHaveAttribute('aria-checked', 'false')
|
||
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('radio', { name: '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()
|
||
// 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())
|
||
expect(posted).toMatchObject({ name: 'Hotel Astoria', category: 'accommodation', total_price: 240, reservation_id: 12 })
|
||
expect(onSaved).toHaveBeenCalled()
|
||
})
|
||
|
||
it('FE-W5COSTS-096: offers Scan receipt beside Add expense only when the AI model reads images', async () => {
|
||
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: true })))
|
||
const { unmount } = render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
fireEvent.click((await screen.findAllByRole('button', { name: 'Scan receipt' }))[0])
|
||
expect(screen.getByRole('dialog', { name: 'Scan a receipt' })).toBeInTheDocument()
|
||
unmount()
|
||
|
||
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: false })))
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
await screen.findAllByRole('button', { name: 'Add expense' })
|
||
expect(screen.queryByRole('button', { name: 'Scan receipt' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-W5COSTS-095: a scanned receipt opens in its own currency and day, with its photo waiting to be attached', () => {
|
||
const photo = new File(['x'], 'bill.jpg', { type: 'image/jpeg' })
|
||
render(
|
||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||
prefill={{ name: 'Sushi Dai', amount: 4200, currency: 'JPY', date: '2026-09-20', lines: [{ name: 'Omakase', price: 4200 }], receiptFiles: [photo] }}
|
||
onClose={() => {}} onSaved={() => {}} />
|
||
)
|
||
|
||
expect(screen.getByDisplayValue('Sushi Dai')).toBeInTheDocument()
|
||
// Yen has no minor unit, so the amount is not padded.
|
||
expect(screen.getByDisplayValue('4200')).toBeInTheDocument()
|
||
expect(screen.getByText('bill.jpg')).toBeInTheDocument()
|
||
})
|
||
|
||
// The mobile sheet already dates a new expense by the traveller's own clock;
|
||
// the desktop modal filed it under the UTC day, so the same expense entered
|
||
// late in Tokyo or early in Los Angeles landed on a different day per surface.
|
||
it('FE-W5COSTS-065: a new expense is dated by the local calendar day, not the UTC one', async () => {
|
||
vi.useFakeTimers({ toFake: ['Date'] })
|
||
// A wall-clock time on whichever side of midnight puts the runner's UTC date
|
||
// on a different day. On UTC itself the two spellings agree and there is
|
||
// nothing here to catch.
|
||
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
|
||
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
|
||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||
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() })
|
||
}))
|
||
render(
|
||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||
prefill={{ name: 'Ramen', category: 'food', amount: 18 }}
|
||
onClose={() => {}} onSaved={vi.fn()} />
|
||
)
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Add expense' }))
|
||
|
||
try {
|
||
await waitFor(() => expect(posted).toBeTruthy())
|
||
expect(posted!.expense_date).toBe('2026-08-12')
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', 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() })
|
||
}))
|
||
render(
|
||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||
prefill={{ name: 'Louvre', category: 'activities', amount: 34, placeId: 7 }}
|
||
onClose={() => {}} onSaved={vi.fn()} />
|
||
)
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Add expense' }))
|
||
|
||
await waitFor(() => expect(posted).toBeTruthy());
|
||
expect(posted).toMatchObject({ name: 'Louvre', category: 'activities', total_price: 34, place_id: 7 })
|
||
expect(posted).not.toHaveProperty('reservation_id')
|
||
})
|
||
})
|
||
|
||
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.getByRole('button', { name: 'Export CSV' }))
|
||
|
||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').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-061: the CSV export neutralises names a spreadsheet would run as a formula', async () => {
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', title: 'Rome' }) })
|
||
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(() => {})
|
||
mount([
|
||
expense({ id: 204, name: '=HYPERLINK("http://evil","click")', category: 'food', total_price: 12, expense_date: '2025-06-15', note: '@SUM(A1:A9)' }),
|
||
expense({ id: 205, name: '-5 refund', category: 'misc', total_price: 5, expense_date: '2025-06-16', note: null }),
|
||
])
|
||
|
||
await screen.findByText('-5 refund')
|
||
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
|
||
|
||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||
expect(lines[1]).toBe('06/15/2025;"\'=HYPERLINK(""http://evil"",""click"")";Food & drink;12.00;EUR;12.00;\'@SUM(A1:A9)')
|
||
expect(lines[2]).toBe('06/16/2025;\'-5 refund;Other;5.00;EUR;5.00;')
|
||
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.getByRole('button', { name: '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.getByRole('region', { name: 'Total trip spend' }) 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.getByRole('region', { name: 'Total trip spend' }) 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.getByRole('region', { name: 'Total trip spend' })
|
||
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.findByRole('button', { name: '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.getByRole('button', { name: 'Edit' }))
|
||
|
||
// 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)
|
||
})
|
||
|
||
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.getAllByTestId('payer-toggle')[1])
|
||
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('checkbox', { 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')).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('45,00 € per person')).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.getByRole('button', { name: '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-062: the mobile search box keeps the caret across keystrokes', async () => {
|
||
const user = userEvent.setup()
|
||
mount([dinner(), taxi()])
|
||
|
||
await screen.findByText('Taxi')
|
||
const box = screen.getByPlaceholderText('Search expenses…')
|
||
await user.type(box, 'din')
|
||
|
||
// The whole word has to land: if the body is remounted per keystroke the input
|
||
// loses focus and everything after the first character goes nowhere.
|
||
expect((box as HTMLInputElement).value).toBe('din')
|
||
expect(document.activeElement).toBe(box)
|
||
expect(screen.queryByText('Taxi')).not.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')
|
||
})
|
||
})
|
||
|
||
// ── #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.getAllByRole('button', { name: 'Edit' })[0])
|
||
expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument()
|
||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||
|
||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[1])
|
||
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ExpenseModal tripId={1} base="KWD" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||
editing={expense({
|
||
id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3,
|
||
payers: [{ user_id: 1, amount: 3 }],
|
||
members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }],
|
||
})} />)
|
||
|
||
expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument()
|
||
const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[]
|
||
|
||
// The seed is three decimals, so a two-decimal guard would reject every further
|
||
// keystroke, including one typed in front of the decimal point.
|
||
await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 })
|
||
expect(shares[0].value).toBe('21.500')
|
||
|
||
// A fourth decimal is still not a fils.
|
||
await user.clear(shares[1])
|
||
await user.type(shares[1], '1.5005')
|
||
expect(shares[1].value).toBe('1.500')
|
||
})
|
||
})
|
||
|
||
// ── #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.getByRole('button', { name: 'Edit' }))
|
||
// The negative payer reopens selected instead of resetting to "Nobody",
|
||
// and the total is seeded signed and padded.
|
||
expect(await screen.findByRole('radio', { name: 'You' })).toBeChecked()
|
||
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.getByRole('region', { name: 'By category' }) 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()
|
||
})
|
||
|
||
it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||
editing={expense({
|
||
id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100,
|
||
payers: [{ user_id: 1, amount: -100 }],
|
||
members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }],
|
||
})} />)
|
||
|
||
// -70 of -100: 30 still to hand out, not 30 too much.
|
||
expect(await screen.findByText('Sum of splits: -70,00 € of -100,00 € (under by 30,00 €)')).toBeInTheDocument()
|
||
|
||
await user.type(screen.getByPlaceholderText('-30,00'), '-40')
|
||
expect(screen.getByText('Sum of splits: -110,00 € of -100,00 € (over by 10,00 €)')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||
onClose={() => {}} onSaved={vi.fn()} />)
|
||
|
||
const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement
|
||
await user.type(total, '100')
|
||
|
||
// The iOS decimal pad has no minus key, so the sign has to be reachable without one.
|
||
const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' })
|
||
await user.click(toggle)
|
||
|
||
expect(total.value).toBe('-100')
|
||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||
})
|
||
})
|
||
|
||
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: a receipt line nobody is assigned to is carried by everyone on the receipt', () => {
|
||
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]) },
|
||
]
|
||
|
||
// The service line used to count toward the total without landing on anyone,
|
||
// so the shares stayed a permanent 5.00 short of it (#1382).
|
||
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
|
||
})
|
||
})
|
||
|
||
// The reworked expense modal shipped with English written straight into the JSX,
|
||
// so the receipt panel and the split summary stayed English on a German phone
|
||
// while everything around them translated.
|
||
describe('CostsPanel — expense modal in another language', () => {
|
||
beforeEach(() => {
|
||
seedAlice()
|
||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de', default_currency: 'EUR' }) })
|
||
clearExchangeRateCache()
|
||
})
|
||
afterEach(clearExchangeRateCache)
|
||
|
||
const openModal = () => render(
|
||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||
onClose={() => {}} onSaved={vi.fn()} />
|
||
)
|
||
|
||
it('FE-W5COSTS-063: the receipt panel is translated', async () => {
|
||
const user = userEvent.setup()
|
||
openModal()
|
||
|
||
// The locale bundle is fetched, so the first paint is still English.
|
||
await user.click(await screen.findByRole('button', { name: 'Beleg' }))
|
||
await user.click(screen.getByRole('button', { name: /Artikel hinzufügen/ }))
|
||
|
||
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument()
|
||
expect(screen.getByText('Aufteilen auf:')).toBeInTheDocument()
|
||
expect(screen.getByText('Anteil pro Person')).toBeInTheDocument()
|
||
expect(screen.queryByText('Individual shares')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-W5COSTS-064: the split summary and the excluded marker are translated', async () => {
|
||
const user = userEvent.setup()
|
||
openModal()
|
||
|
||
await user.type(await screen.findByPlaceholderText('0,00'), '90')
|
||
await user.click(screen.getByRole('button', { name: 'Individuell' }))
|
||
|
||
// Nothing entered yet, so the whole 90 is still unaccounted for.
|
||
expect(screen.getByText('Summe der Anteile: 0,00 € von 90,00 € (es fehlen 90,00 €)')).toBeInTheDocument()
|
||
|
||
const shares = screen.getAllByPlaceholderText('45,00')
|
||
await user.type(shares[0], '90')
|
||
expect(screen.getByText('Aufteilung passt zur Summe')).toBeInTheDocument()
|
||
|
||
await user.type(shares[1], '10')
|
||
expect(screen.getByText('Summe der Anteile: 100,00 € von 90,00 € (10,00 € zu viel)')).toBeInTheDocument()
|
||
|
||
// Back to the equal split: a member taken out of it is marked, not dropped.
|
||
await user.click(screen.getByRole('button', { name: 'Gleichmäßig' }))
|
||
// Taken out, the box is simply unticked; no English word leaks into the German dialog.
|
||
await user.click(screen.getByRole('checkbox', { name: /bob/i }))
|
||
expect(screen.getByRole('checkbox', { name: /bob/i })).not.toBeChecked()
|
||
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
// ── #2525: a bill entered in the display currency on a trip kept in another ───
|
||
describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||
// The reporter's setup: a euro trip, read in dollars, with the bill entered in
|
||
// dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what
|
||
// the balances and settle-up net. Today the euro buys 1.1551 dollars, so those
|
||
// euros are $791.55. The dollar's own quote is rounded separately (0.865706 per
|
||
// dollar is 1.155126 per euro), which is why the panel converts with the euro's.
|
||
beforeEach(() => {
|
||
seedAlice()
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||
clearExchangeRateCache()
|
||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() }))
|
||
})
|
||
afterEach(clearExchangeRateCache)
|
||
|
||
const hotel = (over: Partial<BudgetItem> = {}) => expense({
|
||
id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||
expense_date: '2026-08-06',
|
||
payers: [{ user_id: 1, amount: 801.76 }],
|
||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||
...over,
|
||
})
|
||
|
||
it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => {
|
||
mount([hotel()])
|
||
|
||
await screen.findByText('Aparthotel Silver')
|
||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||
// had Bob pay back more than he owed and reopened the trip.
|
||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||
})
|
||
|
||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||
mount([
|
||
hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }),
|
||
hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }),
|
||
])
|
||
|
||
await screen.findByText('Villa')
|
||
// Pill and payer chip; converting back with the dollar's own quote gave $12,346.05.
|
||
expect(screen.getAllByText('$12,345.67')).toHaveLength(2)
|
||
expect(screen.queryByText(/12,346\.0[56]/)).toBeNull()
|
||
expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument()
|
||
const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||
expect(total.textContent).toContain('$12,595.67')
|
||
const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement!
|
||
expect(outstanding.textContent).toContain('$250.00')
|
||
// Nothing to explain under either row.
|
||
expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||
expect(screen.queryByText(/^\$250\.00 →/)).toBeNull()
|
||
})
|
||
|
||
it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => {
|
||
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(() => {})
|
||
try {
|
||
mount([hotel()])
|
||
await screen.findByText('Aparthotel Silver')
|
||
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
|
||
await waitFor(() => expect(exported).toBeTruthy())
|
||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||
} finally {
|
||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => {
|
||
mount([
|
||
hotel(),
|
||
hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }),
|
||
])
|
||
|
||
await screen.findByText('Aparthotel Silver')
|
||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||
// $910.02, on the day header and on the total spend card.
|
||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||
// Label, its text block, the header row, then the card itself.
|
||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||
expect(card.textContent).toContain('$910.02')
|
||
})
|
||
|
||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||
// Nothing cached: the rates land after the expenses and the settlement, and the
|
||
// totals used to keep the figure they were first added up with.
|
||
clearExchangeRateCache()
|
||
server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => {
|
||
await new Promise(r => setTimeout(r, 150))
|
||
return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||
}))
|
||
mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||
|
||
// 100 euro at 0.8 euro to the dollar.
|
||
expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument()
|
||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||
await waitFor(() => expect(card.textContent).toContain('$125.00'))
|
||
})
|
||
|
||
it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => {
|
||
mount([
|
||
hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }),
|
||
hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }),
|
||
// Written before the freeze existed: nothing was booked, so dollars stay dollars.
|
||
hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }),
|
||
])
|
||
|
||
await screen.findByText('Museum')
|
||
// The trip currency has no frozen rate of its own and goes to dollars live.
|
||
expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument()
|
||
// 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill.
|
||
expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument()
|
||
expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2)
|
||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||
})
|
||
|
||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||
mount([hotel()], {
|
||
settlements: [
|
||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||
// Rows from before transfers had a currency were entered in the display
|
||
// currency, which is how the server settles them.
|
||
{ id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' },
|
||
],
|
||
})
|
||
|
||
await screen.findByText('Aparthotel Silver')
|
||
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||
expect(outsideChips('$30.00')).toHaveLength(1)
|
||
expect(screen.queryByText('$34.65')).toBeNull()
|
||
})
|
||
|
||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||
const user = userEvent.setup()
|
||
mount([hotel()])
|
||
|
||
await screen.findByText('Aparthotel Silver')
|
||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||
// $791.57 in the list looked like two different expenses.
|
||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||
const user = userEvent.setup()
|
||
mount([hotel()], {
|
||
balances: [
|
||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 },
|
||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 },
|
||
],
|
||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }],
|
||
finalBudgets: [
|
||
{
|
||
user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77,
|
||
sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] },
|
||
},
|
||
],
|
||
})
|
||
|
||
const card = (await screen.findByRole('region', { name: 'Final budget' })) as HTMLElement
|
||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||
expect(within(card).getAllByText('+$791.55')).toHaveLength(2)
|
||
expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument()
|
||
expect(within(card).getByText('$801.76')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => {
|
||
const user = userEvent.setup()
|
||
mount([hotel({ exchange_rate: 1.1551 })], {
|
||
balances: [
|
||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 },
|
||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 },
|
||
],
|
||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }],
|
||
finalBudgets: [
|
||
{
|
||
user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88,
|
||
sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] },
|
||
},
|
||
],
|
||
})
|
||
|
||
const card = (await screen.findByRole('region', { name: 'Final budget' })) as HTMLElement
|
||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||
expect(within(card).getAllByText('+$801.76')).toHaveLength(2)
|
||
expect(within(card).queryByText('$801.76')).toBeNull()
|
||
})
|
||
|
||
it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => {
|
||
const user = userEvent.setup()
|
||
let put: Record<string, unknown> | null = null
|
||
server.use(http.put('/api/trips/1/budget/405', async ({ request }) => {
|
||
put = await request.json() as Record<string, unknown>
|
||
return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) })
|
||
}))
|
||
mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||
|
||
await screen.findByText('Tram pass')
|
||
// The list reads the missing currency as the trip's own.
|
||
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
|
||
// Seeding the display currency here would label 100 euro as 100 dollars, and a
|
||
// plain save would then store it that way.
|
||
expect(screen.getByText(/^EUR/)).toBeInTheDocument()
|
||
expect(screen.queryByText(/^USD/)).toBeNull()
|
||
|
||
// Saving without touching anything keeps the 100 euro.
|
||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||
await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' }))
|
||
})
|
||
})
|
||
|
||
// ── A bill the server cannot convert: VND on an AUD trip ────────────────────────
|
||
describe('CostsPanel: a bill the server cannot convert', () => {
|
||
// The reporter's setup: an AUD trip whose server cannot fetch rates while the browser
|
||
// can. An 8,920,000 VND dinner that never froze a rate was counted as 8,920,000 AUD.
|
||
// The settlement now leaves it out and lists it, and an editor's browser lends its AUD
|
||
// table to freeze a rate on it.
|
||
const pho = () => expense({
|
||
id: 501, name: 'Pho', category: 'food', total_price: 8920000, currency: 'VND', exchange_rate: 1,
|
||
expense_date: '2026-09-20',
|
||
payers: [{ user_id: 1, amount: 8920000 }],
|
||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||
})
|
||
const leftOut = { balances: [], flows: [], settlements: [], currency: 'AUD', unconverted: { item_ids: [501], settlement_ids: [], currencies: ['VND'] } }
|
||
|
||
beforeEach(() => {
|
||
seedAlice()
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } })
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) })
|
||
clearExchangeRateCache()
|
||
resetFreezeAttempts()
|
||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
|
||
})
|
||
afterEach(clearExchangeRateCache)
|
||
|
||
it('FE-W5COSTS-090: VND/AUD regression: an editor heals the bill with the cached quote and the healed balances load', async () => {
|
||
let healed = false
|
||
const frozen: unknown[] = []
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [pho()] })),
|
||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json(healed
|
||
? {
|
||
// 8,920,000 VND at 18,241.3 is 489.00 AUD, half of it Bob's.
|
||
balances: [
|
||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 244.5 },
|
||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -244.5 },
|
||
],
|
||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 244.5 }],
|
||
settlements: [],
|
||
currency: 'AUD',
|
||
unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||
}
|
||
: leftOut)),
|
||
http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => {
|
||
frozen.push(await request.json())
|
||
healed = true
|
||
return HttpResponse.json({ items: [{ ...pho(), exchange_rate: 18241.3 }], settlements: [], unresolved: [] })
|
||
}),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
// Only the currency the settlement is missing, at the rate the browser holds.
|
||
await waitFor(() => expect(frozen).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }]))
|
||
// The settlement is read again and Bob's half can be settled.
|
||
expect(await screen.findByRole('button', { name: 'Settle' })).toBeInTheDocument()
|
||
expect(screen.getAllByText(/244\.50/).length).toBeGreaterThan(0)
|
||
// Nothing on the page counts the bill as 8,920,000 dollars any more.
|
||
expect(screen.queryByText(/\$8,920,000/)).toBeNull()
|
||
expect(frozen).toHaveLength(1)
|
||
})
|
||
|
||
it('FE-W5COSTS-091: a viewer\'s browser never freezes anything', async () => {
|
||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'admin' } })
|
||
let frozen = 0
|
||
server.use(http.post('/api/trips/1/budget/freeze-rates', () => {
|
||
frozen += 1
|
||
return HttpResponse.json({ items: [], settlements: [], unresolved: [] })
|
||
}))
|
||
mount([pho()], leftOut)
|
||
|
||
await screen.findByText('Pho')
|
||
await new Promise(r => setTimeout(r, 50))
|
||
expect(frozen).toBe(0)
|
||
})
|
||
|
||
it('FE-W5COSTS-092: settle-up in the display currency lends its rate and asks the settlement in it', async () => {
|
||
// Read in euros: the browser has the AUD table, the server has no quote at all.
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||
const asked: { base: string | null; baseRate: string | null }[] = []
|
||
const posted: Record<string, unknown>[] = []
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||
http.get('/api/trips/1/budget/settlement', ({ request }) => {
|
||
const url = new URL(request.url)
|
||
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
|
||
return HttpResponse.json({
|
||
balances: [], settlements: [], currency: 'EUR', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||
})
|
||
}),
|
||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||
posted.push(await request.json() as Record<string, unknown>)
|
||
return HttpResponse.json({ settlement: { id: 1 } })
|
||
}),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
|
||
await waitFor(() => expect(posted).toHaveLength(1))
|
||
// The transfer freezes at the rate the settlement was labelled with, so it cancels its
|
||
// flow to the cent.
|
||
expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||
expect(asked.length).toBeGreaterThan(0)
|
||
expect(asked.every(a => a.base === 'EUR' && a.baseRate === '0.61')).toBe(true)
|
||
|
||
const settleAll = screen.getByRole('button', { name: 'Settle up' })
|
||
fireEvent.click(settleAll)
|
||
await waitFor(() => expect(posted).toHaveLength(2))
|
||
expect(posted[1]).toMatchObject({ amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||
})
|
||
|
||
it('FE-W5COSTS-093: a payment added by hand lends the rate for its currency too', async () => {
|
||
const user = userEvent.setup()
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||
let posted: Record<string, unknown> | null = null
|
||
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||
posted = await request.json() as Record<string, unknown>
|
||
return HttpResponse.json({ settlement: { id: 1 } })
|
||
}))
|
||
mount([])
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||
await user.type(await screen.findByPlaceholderText('0,00'), '25')
|
||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||
await user.click(submits[submits.length - 1])
|
||
|
||
await waitFor(() => expect(posted).toBeTruthy())
|
||
expect(posted).toMatchObject({ amount: 25, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||
})
|
||
|
||
it('FE-W5COSTS-094: a display rate the settlement query would refuse is not sent', async () => {
|
||
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the query
|
||
// takes, and sent as `base_rate` it would fail the whole settlement with a 400.
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XAU' }) })
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'IRR' } })
|
||
localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() }))
|
||
localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() }))
|
||
const asked: { base: string | null; baseRate: string | null }[] = []
|
||
server.use(
|
||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||
http.get('/api/trips/1/budget/settlement', ({ request }) => {
|
||
const url = new URL(request.url)
|
||
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
|
||
return HttpResponse.json({
|
||
balances: [], flows: [], settlements: [], currency: 'XAU', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||
})
|
||
}),
|
||
)
|
||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||
|
||
await waitFor(() => expect(asked.length).toBeGreaterThan(0))
|
||
// Still asked in rials, only without the figure; the server answers in the trip
|
||
// currency when it has no quote of its own.
|
||
expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true)
|
||
})
|
||
})
|