Files
TREK/client/tests/unit/mobile/trip/MCostsTab.test.tsx
T
Maurice aa1f76ad11 fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:26 +02:00

878 lines
44 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { budgetApi } from '../../../../src/api/client'
import { localToday } from '../../../../src/components/Planner/today'
import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates'
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
import { useAuthStore } from '../../../../src/store/authStore'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import type { BudgetItem, Day, Trip } from '../../../../src/types'
import { buildTrip, buildUser } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-COSTT-001 to FE-MOB-COSTT-049
// The add/edit expense sheet is the shared desktop-sized form; the panel only
// owns when it opens and what happens on save, so it is stubbed here.
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
default: ({ base, editing, onClose, onSaved }: {
base: string
editing: { id: number } | null
onClose: () => void
onSaved: () => void
}) => (
<div>
<span>{editing ? `sheet editing ${editing.id}` : `sheet new ${base}`}</span>
<button type="button" onClick={onSaved}>sheet save</button>
<button type="button" onClick={onClose}>sheet close</button>
</div>
),
}))
const TRIP = buildTrip({ id: 7, title: 'Tokyo Trip', currency: 'USD' }) as Trip
const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
] as unknown as Day[]
const MEMBERS = [
{ id: 1, username: 'Me', avatar_url: null },
{ id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' },
{ id: 3, username: 'Bob', avatar_url: null },
]
const RAMEN = {
id: 11, trip_id: 7, name: 'Ramen', category: 'food', total_price: 80, currency: 'USD',
expense_date: '2026-05-02', note: null,
members: [
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: '/uploads/avatars/ada.png' },
],
payers: [{ user_id: 1, amount: 80, username: 'Me' }],
} as unknown as BudgetItem
/** Foreign currency + explicit custom shares — exercises the conversion line. */
const MUSEUM = {
id: 12, trip_id: 7, name: 'Museum', category: 'activities', total_price: 30, currency: 'GBP',
expense_date: '2026-04-30', note: 'Tickets; adult',
members: [
{ user_id: 1, paid: 0, username: 'Me', amount: 10, avatar_url: null },
{ user_id: 3, paid: 0, username: 'Bob', amount: 20, avatar_url: null },
],
payers: [{ user_id: 3, amount: 30, username: 'Bob' }],
} as unknown as BudgetItem
/** No currency (falls back to the trip's), no members, no payer → the "unfinished" case. */
const SOUVENIR = {
id: 13, trip_id: 7, name: 'Souvenirs', category: 'shopping', total_price: 25, currency: null,
expense_date: null, note: null, payers: [],
} as unknown as BudgetItem
/** I paid exactly my own share — counts as "mine" but never as "owed". */
const TAXI = {
id: 14, trip_id: 7, name: 'Taxi', category: 'transport', total_price: 20, currency: 'USD',
expense_date: '2026-05-02', note: 'TICKETJSON:{"items":[]}',
members: [{ user_id: 1, paid: 1, username: 'Me', amount: 20, avatar_url: null }],
payers: [{ user_id: 1, amount: 20, username: 'Me' }],
} as unknown as BudgetItem
const ITEMS = [RAMEN, MUSEUM, SOUVENIR, TAXI]
const SETTLEMENT = {
balances: [
{ user_id: 1, username: 'Me', avatar_url: null, balance: 12.5 },
{ user_id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png', balance: -7.25 },
],
flows: [
{ from: { user_id: 1, username: 'Me' }, to: { user_id: 2, username: 'Ada' }, amount: 40 },
{ from: { user_id: 3, username: 'Bob' }, to: { user_id: 1, username: 'Me' }, amount: 15 },
{ from: { user_id: 99, username: 'Ghost' }, to: { user_id: 3, username: 'Bob' }, amount: 5 },
],
}
let settlementBases: string[] = []
function serveSettlement(body: unknown = SETTLEMENT, fail = false) {
server.use(
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
return fail ? HttpResponse.error() : HttpResponse.json(body)
}),
)
}
function planner(overrides: Partial<TripPlanner> = {}) {
return buildPlanner({
tripId: 7,
trip: TRIP,
days: DAYS,
budgetItems: ITEMS,
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
...overrides,
})
}
/** Renders and waits until the settlement response has landed in state. */
async function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = render(<MCostsTab planner={p} shell={shell} />)
await screen.findByText('+$12.50')
return { ...view, planner: p, shell }
}
/** Walks up `n` levels — the panel's blocks are plain divs without test ids. */
function up(el: HTMLElement, n: number): HTMLElement {
let node: HTMLElement = el
for (let i = 0; i < n; i++) node = node.parentElement as HTMLElement
return node
}
/** The expense card of a row, found from its name. */
function cardOf(name: string): HTMLElement {
let el = screen.getByText(name).parentElement
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
if (!el) throw new Error(`no card for ${name}`)
return el
}
/** Card plus the edit/delete stack that sits next to it. */
function rowOf(name: string): HTMLElement {
return up(cardOf(name), 1)
}
describe('MCostsTab', () => {
beforeEach(() => {
resetAllStores()
clearExchangeRateCache()
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'Me' }) })
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, GBP: 0.5 }, ts: Date.now() }))
localStorage.setItem('trek_fx_GBP', JSON.stringify({ rates: { GBP: 1, USD: 2 }, ts: Date.now() }))
settlementBases = []
serveSettlement()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-COSTT-001: loads the items and the settlement for the display currency', async () => {
const { planner: p } = await renderTab()
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledWith(7)
expect(settlementBases).toEqual(['USD'])
})
it('FE-MOB-COSTT-002: sums the hero totals across currencies', async () => {
await renderTab()
const hero = up(screen.getByText('costs.totalSpend'), 1)
expect(hero.textContent).toContain('$185.00')
expect(hero.textContent).toContain('costs.yourShare')
expect(hero.textContent).toContain('$80.00')
expect(hero.textContent).toContain('costs.youPaid')
expect(hero.textContent).toContain('$100.00')
})
it('FE-MOB-COSTT-003: derives owe / owed from the settlement flows', async () => {
await renderTab()
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$40.00')
expect(up(screen.getByText('costs.youreOwed'), 1).textContent).toContain('$15.00')
})
it('FE-MOB-COSTT-004: reports the unpaid expenses as outstanding', async () => {
await renderTab()
const block = up(screen.getByText('costs.outstanding'), 2)
expect(block.textContent).toContain('1')
expect(block.textContent).toContain('$25.00')
})
it('FE-MOB-COSTT-005: hides the outstanding block when everything is paid', async () => {
await renderTab(planner({ budgetItems: [RAMEN, TAXI] }))
expect(screen.queryByText('costs.outstanding')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-006: lists the settle-up flows and names the participants', async () => {
await renderTab()
const settle = up(screen.getByText('costs.settleUp'), 3)
expect(within(settle).getByText('$40.00')).toBeInTheDocument()
expect(within(settle).getByText('$15.00')).toBeInTheDocument()
expect(within(settle).getByText('$5.00')).toBeInTheDocument()
expect(within(settle).getByText('?')).toBeInTheDocument()
expect(within(settle).getAllByText('costs.you')).toHaveLength(3)
expect(within(settle).getAllByText('Ada')).toHaveLength(2)
})
it('FE-MOB-COSTT-007: shows each member balance with its sign, zero for unknown members', async () => {
await renderTab()
const settle = up(screen.getByText('costs.settleUp'), 3)
expect(within(settle).getByText('+$12.50')).toBeInTheDocument()
expect(within(settle).getByText('−$7.25')).toBeInTheDocument()
expect(within(settle).getByText('$0.00')).toBeInTheDocument()
expect(within(settle).getByAltText('')).toHaveAttribute('src', '/uploads/avatars/ada.png')
})
it('FE-MOB-COSTT-008: collapses and reopens the settle-up card', async () => {
await renderTab()
// The header is a real <button> now, so Enter/Space activation is the
// browser's job — no key handler of our own left to exercise.
const head = screen.getByRole('button', { name: /costs\.settleUp/ })
expect(head).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(head)
expect(screen.queryByText('costs.balances')).not.toBeInTheDocument()
fireEvent.click(head)
expect(screen.getByText('costs.balances')).toBeInTheDocument()
})
it('FE-MOB-COSTT-009: celebrates a settlement without open flows', async () => {
serveSettlement({ balances: SETTLEMENT.balances, flows: [] })
await renderTab()
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
expect(screen.getByText('costs.nothingOutstanding')).toBeInTheDocument()
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$0.00')
})
it('FE-MOB-COSTT-010: survives a failing settlement request', async () => {
serveSettlement(null, true)
render(<MCostsTab planner={planner()} shell={buildShell()} />)
await waitFor(() => expect(settlementBases).toEqual(['USD']))
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
const settle = up(screen.getByText('costs.settleUp'), 3)
expect(within(settle).getAllByText('$0.00')).toHaveLength(3)
})
it('FE-MOB-COSTT-011: breaks the spend down by category, largest bar first', async () => {
await renderTab()
const block = up(screen.getByText('costs.byCategory'), 1)
const labels = within(block).getAllByText(/^costs\.cat\./).map(n => n.textContent)
expect(labels).toEqual(['costs.cat.food', 'costs.cat.activities', 'costs.cat.shopping', 'costs.cat.transport'])
expect(within(block).getByText('$60.00')).toBeInTheDocument()
expect(within(block).getByText('$20.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-012: shows the empty panel without any expense', async () => {
await renderTab(planner({ budgetItems: [] }))
expect(screen.getByText('costs.noCategories')).toBeInTheDocument()
expect(screen.getByText('costs.emptyText')).toBeInTheDocument()
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$0.00')
})
it('FE-MOB-COSTT-013: groups the expenses by day with a per-day total', async () => {
await renderTab()
expect(screen.getByText('Sat, May 2')).toBeInTheDocument()
expect(screen.getByText('Thu, Apr 30')).toBeInTheDocument()
expect(screen.getByText('costs.noDate')).toBeInTheDocument()
expect(screen.getByText('costs.spent:$100.00')).toBeInTheDocument()
expect(screen.getByText('costs.spent:$60.00')).toBeInTheDocument()
expect(screen.getByText('costs.spent:$25.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-014: converts a foreign expense and shows both amounts', async () => {
await renderTab()
const card = cardOf('Museum')
expect(within(card).getByText('£30.00 → $60.00')).toBeInTheDocument()
expect(within(card).getByText('costs.cat.activities')).toBeInTheDocument()
expect(within(card).getByText('$40.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-015: marks an expense nobody paid as unfinished', async () => {
await renderTab()
expect(within(cardOf('Souvenirs')).getByText('costs.unfinished')).toBeInTheDocument()
expect(within(cardOf('Ramen')).queryByText('costs.unfinished')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-016: renders the member chips with their share and paid state', async () => {
await renderTab()
const card = cardOf('Ramen')
const chips = within(card).getAllByRole('button')
expect(chips).toHaveLength(2)
expect(chips[0]).toHaveAttribute('aria-pressed', 'true')
expect(chips[1]).toHaveAttribute('aria-pressed', 'false')
expect(chips.map(c => c.textContent)).toEqual(['costs.youShort$40.00', '$40.00'])
})
it('FE-MOB-COSTT-017: toggles a member chip to paid', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
await waitFor(() => expect(p.tripActions.toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 11, 2, true))
})
it('FE-MOB-COSTT-018: toasts when the paid toggle fails', async () => {
const p = planner()
vi.mocked(p.tripActions.toggleBudgetMemberPaid).mockRejectedValueOnce(new Error('offline'))
await renderTab(p)
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-COSTT-019: filters the list by the search field', async () => {
await renderTab()
const search = screen.getByLabelText('costs.searchPlaceholder')
fireEvent.change(search, { target: { value: 'muse' } })
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: 'zzz' } })
expect(screen.getByText('costs.noMatch')).toBeInTheDocument()
})
it('FE-MOB-COSTT-020: segments the list into mine and owed', async () => {
await renderTab()
const mine = screen.getByRole('button', { name: 'costs.filter.mine' })
fireEvent.click(mine)
expect(mine).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(screen.getByText('Taxi')).toBeInTheDocument()
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
expect(screen.getByText('Museum')).toBeInTheDocument()
})
it('FE-MOB-COSTT-021: filters by a category that is actually in use', async () => {
await renderTab()
const trigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
fireEvent.click(trigger)
const menu = up(trigger, 1).nextElementSibling as HTMLElement
const options = within(menu).getAllByRole('button').map(b => b.textContent)
expect(options).toEqual([
'costs.filter.allCategories', 'costs.cat.food', 'costs.cat.transport', 'costs.cat.activities', 'costs.cat.shopping',
])
fireEvent.click(within(menu).getByText('costs.cat.activities'))
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(trigger)
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allCategories'))
expect(screen.getByText('Ramen')).toBeInTheDocument()
})
it('FE-MOB-COSTT-022: filters by expense day, labelling known days with their number', async () => {
await renderTab()
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
fireEvent.click(trigger)
const menu = up(trigger, 1).nextElementSibling as HTMLElement
expect(within(menu).getAllByRole('button').map(b => b.textContent)).toEqual([
'costs.filter.allDays', 'Apr 30', 'dayplan.dayN:2 · May 2',
])
fireEvent.click(within(menu).getByText('dayplan.dayN:2 · May 2'))
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(screen.getByText('Taxi')).toBeInTheDocument()
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
expect(trigger.textContent).toBe('dayplan.dayN:2 · May 2')
})
it('FE-MOB-COSTT-023: only one filter menu is open at a time', async () => {
await renderTab()
const catTrigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
const dayTrigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
fireEvent.click(catTrigger)
fireEvent.click(dayTrigger)
expect(catTrigger).toHaveAttribute('aria-expanded', 'false')
expect(dayTrigger).toHaveAttribute('aria-expanded', 'true')
})
it('FE-MOB-COSTT-024: opens the edit sheet from a row and reloads on save', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.edit' }))
expect(screen.getByText('sheet editing 11')).toBeInTheDocument()
fireEvent.click(screen.getByText('sheet save'))
expect(screen.queryByText('sheet editing 11')).not.toBeInTheDocument()
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledTimes(2)
await waitFor(() => expect(settlementBases).toEqual(['USD', 'USD']))
})
it('FE-MOB-COSTT-025: deletes an expense after confirming and refreshes the settlement', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
const dialog = screen.getByRole('dialog', { name: 'costs.confirm.deleteTitle' })
expect(within(dialog).getByText('costs.confirm.deleteBody:Ramen')).toBeInTheDocument()
fireEvent.click(within(dialog).getByText('common.delete'))
await waitFor(() => expect(p.tripActions.deleteBudgetItem).toHaveBeenCalledWith(7, 11))
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
it('FE-MOB-COSTT-026: toasts when the delete fails', async () => {
const p = planner()
vi.mocked(p.tripActions.deleteBudgetItem).mockRejectedValueOnce(new Error('offline'))
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.delete'))
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-COSTT-027: opens a blank expense sheet when the header raises the add signal', async () => {
const p = planner()
const { rerender } = await renderTab(p)
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
rerender(<MCostsTab planner={p} shell={buildShell({ addExpenseSignal: 1 })} />)
expect(screen.getByText('sheet new USD')).toBeInTheDocument()
fireEvent.click(screen.getByText('sheet close'))
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-028: exports the CSV when the header raises the export signal', async () => {
const blobs: Blob[] = []
const createObjectURL = vi.fn((b: Blob) => { blobs.push(b); return 'blob:costs' })
const revokeObjectURL = vi.fn()
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: createObjectURL })
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL })
let downloadName = ''
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloadName = this.download
})
const p = planner()
const { rerender } = await renderTab(p)
expect(createObjectURL).not.toHaveBeenCalled()
rerender(<MCostsTab planner={p} shell={buildShell({ exportCostsCsvSignal: 1 })} />)
expect(downloadName).toBe('costs-Tokyo Trip.csv')
// The blob URL outlives the click by a beat so the download can start.
expect(revokeObjectURL).not.toHaveBeenCalled()
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:costs'))
const csv = await blobs[0].text()
const lines = csv.replace('', '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (USD);Note')
expect(lines[1]).toBe(';Souvenirs;costs.cat.shopping;25.00;USD;25.00;')
expect(lines[2]).toBe('04/30/2026;Museum;costs.cat.activities;30.00;GBP;60.00;"Tickets; adult"')
// The ticket JSON note never leaves the app as a "note".
expect(lines[4]).toBe('05/02/2026;Taxi;costs.cat.transport;20.00;USD;20.00;')
})
it('FE-MOB-COSTT-029: records a manual payment and refreshes the settlement', async () => {
const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({})
await renderTab()
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
expect(submit).toBeDisabled()
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
expect(submit).toBeEnabled()
fireEvent.click(submit)
expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD', settled_at: localToday() })
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
it('FE-MOB-COSTT-030: refuses a payment to yourself and toasts a failed one', async () => {
const create = vi.spyOn(budgetApi, 'createSettlement').mockRejectedValue(new Error('nope'))
const p = planner()
await renderTab(p)
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
const toGroup = within(dialog).getByText('costs.to').nextElementSibling as HTMLElement
fireEvent.click(within(toGroup).getByText('costs.you'))
expect(submit).toBeDisabled()
fireEvent.click(within(within(dialog).getByText('costs.to').nextElementSibling as HTMLElement).getByText('Ada'))
expect(submit).toBeEnabled()
fireEvent.click(submit)
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
expect(create).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COSTT-031: a solo trip can never send a payment', async () => {
await renderTab(planner({ tripMembers: [MEMBERS[0]] as unknown as TripPlanner['tripMembers'] }))
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
expect(within(dialog).getByRole('button', { name: 'costs.addPayment' })).toBeDisabled()
})
it('FE-MOB-COSTT-032: a read-only member gets no add, edit or delete affordances', async () => {
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
expect(screen.queryByRole('button', { name: 'costs.addPayment' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(within(cardOf('Ramen')).getByRole('button', { pressed: false })).toBeDisabled()
})
it('FE-MOB-COSTT-033: resets the day filter back to all days', async () => {
await renderTab()
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
fireEvent.click(trigger)
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('Apr 30'))
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allDays'))
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(trigger.textContent).toBe('costs.filter.allDays')
})
it('FE-MOB-COSTT-034: dismisses the payment sheet without recording anything', async () => {
const create = vi.spyOn(budgetApi, 'createSettlement')
await renderTab()
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const fromGroup = within(dialog).getByText('costs.from').nextElementSibling as HTMLElement
fireEvent.click(within(fromGroup).getByText('Bob'))
fireEvent.click(within(dialog).getByRole('button', { name: 'common.cancel' }))
expect(create).not.toHaveBeenCalled()
expect(settlementBases).toHaveLength(1)
})
it('FE-MOB-COSTT-035: cancelling the confirm keeps the expense', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
expect(p.tripActions.deleteBudgetItem).not.toHaveBeenCalled()
expect(screen.getByText('Ramen')).toBeInTheDocument()
})
it('FE-MOB-COSTT-036: falls back to EUR and a neutral avatar without a user or trip currency', async () => {
seedStore(useAuthStore, { user: null })
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1 }, ts: Date.now() }))
const solo = [{ id: 1, username: '', avatar_url: null }] as unknown as TripPlanner['tripMembers']
render(<MCostsTab planner={planner({ trip: null as unknown as TripPlanner['trip'], tripMembers: solo })} shell={buildShell()} />)
await screen.findByText('+12,50 €')
expect(settlementBases).toEqual(['EUR'])
// Nameless member → the avatar and the flow names both fall back to '?'.
expect(screen.getAllByText('?').length).toBeGreaterThan(1)
})
it('FE-MOB-COSTT-037: follows the user display currency instead of the trip currency', async () => {
seedStore(useSettingsStore, {
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
})
render(<MCostsTab planner={planner()} shell={buildShell()} />)
await screen.findByText('+£12.50')
expect(settlementBases).toEqual(['GBP'])
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('£92.50')
})
it('FE-MOB-COSTT-038: renders a recorded settlement payment inline with the day it was made on', async () => {
const payment = { id: 501, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', created_at: '2026-04-30T09:00:00Z' }
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
await renderTab()
const group = up(screen.getByText('Thu, Apr 30'), 2)
expect(within(group).getByText('costs.payment')).toBeInTheDocument()
expect(within(group).getByText('costs.you → Ada')).toBeInTheDocument()
expect(within(group).getByText('$25.00')).toBeInTheDocument()
// The per-day "spent" total only counts expenses, not payments.
expect(within(group).getByText('costs.spent:$60.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-039: a payment on a day with no expense still creates its own ledger group', async () => {
const payment = { id: 502, from_user_id: 3, to_user_id: 1, amount: 15, currency: 'USD', created_at: '2026-06-15T09:00:00Z' }
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
await renderTab()
expect(screen.getByText('costs.payment')).toBeInTheDocument()
const row = up(screen.getByText('Bob → costs.you'), 2)
expect(within(row).getByText('$15.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-043: shows what the trip costs each traveler and opens the arithmetic on tap', async () => {
// The rows come from the server in display cents. Bob's museum is a GBP expense
// the server booked at its frozen rate as $58.00; the live rate this test seeds
// would make it $60.00, and that figure must never reach the card.
serveSettlement({
...SETTLEMENT,
settlements: [{ id: 5, from_user_id: 3, to_user_id: 1, amount: 10, currency: 'USD', created_at: '2026-05-02 10:00:00' }],
finalBudgets: [
{
user_id: 1, username: 'Me', avatar_url: null, expenses: 100, reimbursed: 10, pending: -25, final: 115,
sources: {
fronted: [{ item_id: 11, cents: 8000 }, { item_id: 14, cents: 2000 }],
moved: [{ settlement_id: 5, from_user_id: 3, to_user_id: 1, cents: 1000 }],
outstanding: [{ from_user_id: 1, to_user_id: 2, cents: -4000 }, { from_user_id: 3, to_user_id: 1, cents: 1500 }],
},
},
{
user_id: 3, username: 'Bob', avatar_url: null, expenses: 58, reimbursed: -10, pending: -10, final: 78,
sources: {
fronted: [{ item_id: 12, cents: 5800 }],
moved: [{ settlement_id: 5, from_user_id: 3, to_user_id: 1, cents: -1000 }],
outstanding: [{ from_user_id: 3, to_user_id: 1, cents: -1500 }, { from_user_id: 99, to_user_id: 3, cents: 500 }],
},
},
],
})
await renderTab()
const card = up(screen.getByText('costs.finalBudget'), 1)
// One amount per traveler; Ada, whom the ledger left out, costs nothing.
expect(within(card).getByText('$115.00')).toBeInTheDocument()
expect(within(card).getByText('$78.00')).toBeInTheDocument()
expect(within(card).getByText('$0.00')).toBeInTheDocument()
expect(within(card).queryByText('costs.finalPending')).not.toBeInTheDocument()
const mine = within(card).getByRole('button', { name: /\$115\.00/ })
fireEvent.click(mine)
expect(mine).toHaveAttribute('aria-expanded', 'true')
// Each line signed by what it does to the final, and its rows signed the same
// way, so they add up to it on sight.
expect(within(card).getByText('+$100.00')).toBeInTheDocument()
expect(within(card).getByText('+$80.00')).toBeInTheDocument()
expect(within(card).getByText('+$20.00')).toBeInTheDocument()
expect(within(card).getAllByText('−$10.00')).toHaveLength(2)
expect(within(card).getByText('+$25.00')).toBeInTheDocument()
expect(within(card).getByText('+$40.00')).toBeInTheDocument()
expect(within(card).getByText('−$15.00')).toBeInTheDocument()
expect(within(card).getByText('Ramen')).toBeInTheDocument()
expect(within(card).getByText('Taxi')).toBeInTheDocument()
expect(within(card).queryByText('Museum')).not.toBeInTheDocument()
expect(within(card).getAllByText('Bob → costs.you')).toHaveLength(2)
expect(within(card).getByText('costs.you → Ada')).toBeInTheDocument()
// Opening Bob closes me. His museum row is the server's figure, not a live conversion.
const bob = within(card).getByRole('button', { name: /\$78\.00/ })
fireEvent.click(bob)
expect(mine).toHaveAttribute('aria-expanded', 'false')
expect(within(card).getAllByText('+$58.00')).toHaveLength(2)
expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument()
// Named by what Bob entered, as the list names it (#2525), never re-converted.
expect(within(card).getByText('Museum · £30.00')).toBeInTheDocument()
fireEvent.click(bob)
expect(bob).toHaveAttribute('aria-expanded', 'false')
expect(within(card).queryByText('+$58.00')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-044: a settlement read that fails says so instead of costing everyone nothing', async () => {
serveSettlement(SETTLEMENT, true)
render(<MCostsTab planner={planner()} shell={buildShell()} />)
const card = up(await screen.findByText('costs.finalBudget'), 1)
await waitFor(() => expect(within(card).getByText('common.unknownError')).toBeInTheDocument())
expect(within(card).queryByText('$0.00')).not.toBeInTheDocument()
expect(within(card).queryByRole('button')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-040: the expense filters apply to payments the same way they do on desktop', async () => {
const mine = { id: 503, from_user_id: 1, to_user_id: 2, amount: 8, currency: 'USD', created_at: '2026-05-02T09:00:00Z' }
const others = { id: 504, from_user_id: 2, to_user_id: 3, amount: 9, currency: 'USD', created_at: '2026-05-02T09:00:00Z' }
serveSettlement({ ...SETTLEMENT, settlements: [mine, others] })
await renderTab()
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
expect(screen.queryByText('costs.payment')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.mine' }))
expect(screen.getAllByText('costs.payment')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
fireEvent.change(screen.getByLabelText('costs.searchPlaceholder'), { target: { value: 'ramen' } })
expect(screen.queryByText('costs.payment')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-041: editing a recorded payment opens it pre-filled and saves the change', async () => {
const payment = { id: 501, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', settled_at: '2026-04-28', created_at: '2026-04-30T09:00:00Z' }
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
const update = vi.spyOn(budgetApi, 'updateSettlement').mockResolvedValue({})
await renderTab()
fireEvent.click(within(rowOf('costs.payment')).getByRole('button', { name: 'common.edit' }))
const dialog = screen.getByRole('dialog', { name: 'costs.editPayment' })
expect(within(dialog).getByDisplayValue('25.00')).toBeInTheDocument()
const submit = within(dialog).getByRole('button', { name: 'common.save' })
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '30' } })
fireEvent.click(submit)
expect(update).toHaveBeenCalledWith(7, 501, { from_user_id: 1, to_user_id: 2, amount: 30, currency: 'USD', settled_at: '2026-04-28' })
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
it('FE-MOB-COSTT-042: undoing a payment deletes it outright, no confirmation', async () => {
const payment = { id: 502, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', created_at: '2026-04-30T09:00:00Z' }
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
const del = vi.spyOn(budgetApi, 'deleteSettlement').mockResolvedValue({ success: true })
await renderTab()
fireEvent.click(within(rowOf('costs.payment')).getByRole('button', { name: 'costs.undo' }))
expect(del).toHaveBeenCalledWith(7, 502)
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
it('FE-MOB-COSTT-043: editing a payment keeps the currency it was recorded in, not the display currency', async () => {
// Recorded in pounds while the phone shows dollars: the row says so, the
// sheet reopens in GBP, and a date-only correction must not re-declare the
// amount as dollars (the server would freeze a fresh FX rate for it).
const payment = { id: 505, from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28', created_at: '2026-04-30T09:00:00Z' }
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
const update = vi.spyOn(budgetApi, 'updateSettlement').mockResolvedValue({})
await renderTab()
const row = rowOf('costs.payment')
expect(within(row).getByText('£10.00 → $20.00')).toBeInTheDocument()
expect(within(row).getByText('$20.00')).toBeInTheDocument()
fireEvent.click(within(row).getByRole('button', { name: 'common.edit' }))
const dialog = screen.getByRole('dialog', { name: 'costs.editPayment' })
expect(within(dialog).getByDisplayValue('10.00')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
// The pound rate the phone holds goes along; the server only uses it when the
// currency changes and it has no rate of its own, so the frozen one stays.
expect(update).toHaveBeenCalledWith(7, 505, {
from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28',
fallback_fx: { base: 'USD', rates: { GBP: 0.5 } },
})
})
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
// A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the
// euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55
// today, and that is what settle-up offers too; the row now says where the
// 801.76 that was entered went instead of hiding it. Converted with the euro's own
// quote, not the dollar's separately rounded one.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
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 }, ts: Date.now() }))
const hotel = {
id: 21, trip_id: 7, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
expense_date: '2026-05-01', note: null,
members: [
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
],
payers: [{ user_id: 1, amount: 801.76, username: 'Me' }],
} as unknown as BudgetItem
serveSettlement({
...SETTLEMENT,
settlements: [
{ id: 601, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, settled_at: '2026-05-02', created_at: '2026-05-02T09:00:00Z' },
// A transfer from before they carried a currency was entered in the display currency.
{ id: 602, from_user_id: 3, to_user_id: 1, amount: 30, currency: null, settled_at: '2026-05-03', created_at: '2026-05-03T09:00:00Z' },
],
})
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] }))
const card = cardOf('Aparthotel Silver')
expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
expect(within(card).getByText('$791.55')).toBeInTheDocument()
// Each share is half of the booked euros, the figure the balances net.
expect(within(card).getAllByText('$395.77')).toHaveLength(2)
expect(within(card).queryByText('$400.88')).toBeNull()
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55')
expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument()
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
const paid = up(screen.getByText('Ada → costs.you'), 2)
expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-MOB-COSTT-046: a bill booked at today\'s rate reads exactly as typed (#2525)', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
const villa = {
id: 22, trip_id: 7, name: 'Villa', category: 'accommodation', total_price: 12345.67, currency: 'USD', exchange_rate: 1.1398,
expense_date: '2026-05-01', note: null,
members: [
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
],
payers: [{ user_id: 1, amount: 12345.67, username: 'Me' }],
} as unknown as BudgetItem
serveSettlement(SETTLEMENT)
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [villa] }))
const card = cardOf('Villa')
// Through the dollar's own quote it read $12,346.05.
expect(within(card).getByText('$12,345.67')).toBeInTheDocument()
expect(within(card).queryByText(/^\$12,345\.67 →/)).toBeNull()
expect(screen.getByText('costs.spent:$12,345.67')).toBeInTheDocument()
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67')
})
// The server could not fetch rates, so the museum's pounds never froze one and the
// settlement left them out. An editor's phone lends its own rate and reads again.
it('FE-MOB-COSTT-047: an editor heals a bill the server could not convert and reads the settlement again', async () => {
resetFreezeAttempts()
let healed = false
server.use(
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
return HttpResponse.json(healed
? { ...SETTLEMENT, unconverted: { item_ids: [], settlement_ids: [], currencies: [] } }
: { ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } })
}),
)
const freeze = vi.spyOn(budgetApi, 'freezeRates').mockImplementation(async () => {
healed = true
return { items: [{ ...MUSEUM, exchange_rate: 0.5 }], settlements: [], unresolved: [] }
})
await renderTab()
await waitFor(() => expect(freeze).toHaveBeenCalledWith(7, { fallback_fx: { base: 'USD', rates: { GBP: 0.5 } } }))
await waitFor(() => expect(settlementBases).toHaveLength(2))
expect(freeze).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COSTT-048: a read-only member\'s phone never freezes a rate', async () => {
resetFreezeAttempts()
serveSettlement({ ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } })
const freeze = vi.spyOn(budgetApi, 'freezeRates')
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
await new Promise(r => setTimeout(r, 50))
expect(freeze).not.toHaveBeenCalled()
expect(settlementBases).toHaveLength(1)
})
it('FE-MOB-COSTT-049: asks the settlement in the display currency with the phone\'s own rate for it', async () => {
seedStore(useSettingsStore, {
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
})
const rates: (string | null)[] = []
server.use(
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
const url = new URL(request.url)
settlementBases.push(url.searchParams.get('base') ?? '')
rates.push(url.searchParams.get('base_rate'))
return HttpResponse.json(SETTLEMENT)
}),
)
render(<MCostsTab planner={planner()} shell={buildShell()} />)
await screen.findByText('+£12.50')
// Pounds per dollar from the trip currency's own table, what the server labels with
// when it cannot fetch a quote itself.
expect(settlementBases).toEqual(['GBP'])
expect(rates).toEqual(['0.5'])
})
it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => {
// Cleared, the server would store NULL and the ledger would quietly fall
// back to the day the payment was recorded on.
const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({})
await renderTab()
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
expect(submit).toBeEnabled()
fireEvent.click(dialog.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
fireEvent.click(screen.getByRole('button', { name: 'Clear date' }))
expect(submit).toBeDisabled()
fireEvent.click(submit)
expect(create).not.toHaveBeenCalled()
})
})