mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
06bee95ae3
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked cost is labelled in the trip currency, a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
721 lines
33 KiB
TypeScript
721 lines
33 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import MCostSheet from '../../../../src/mobile/screens/trip/sheets/MCostSheet'
|
|
import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMemberChips'
|
|
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
|
import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore'
|
|
import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
|
|
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
|
|
import { localToday } from '../../../../src/components/Planner/today'
|
|
import { resetAllStores } from '../../../helpers/store'
|
|
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
|
|
|
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030
|
|
// The sheet reads its copy from useTranslation(), so assertions are English.
|
|
// EUR formats de-DE style: displayed amounts and inputs use a comma.
|
|
|
|
const PEOPLE = [
|
|
{ id: 1, username: 'alice', avatar_url: null },
|
|
{ id: 2, username: 'bob', avatar_url: null },
|
|
] as TripMember[]
|
|
|
|
// The traveller's own calendar day, not the UTC one — see components/Planner/today.ts.
|
|
const TODAY = localToday()
|
|
|
|
function member(user_id: number, amount: number | null): BudgetItemMember {
|
|
return { user_id, paid: 0, username: user_id === 1 ? 'alice' : 'bob', amount }
|
|
}
|
|
|
|
let addBudgetItem: ReturnType<typeof vi.fn>
|
|
let updateBudgetItem: ReturnType<typeof vi.fn>
|
|
let deleteBudgetItem: ReturnType<typeof vi.fn>
|
|
let addToast: ReturnType<typeof vi.fn>
|
|
|
|
interface SheetOverrides {
|
|
people?: TripMember[]
|
|
me?: number
|
|
base?: string
|
|
editing?: BudgetItem | null
|
|
prefill?: { name?: string; category?: string; amount?: number; reservationId?: number; placeId?: number }
|
|
}
|
|
|
|
function renderSheet(overrides: SheetOverrides = {}) {
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
const view = render(
|
|
<MCostSheet
|
|
tripId={1}
|
|
base={overrides.base ?? 'EUR'}
|
|
people={overrides.people ?? PEOPLE}
|
|
me={overrides.me ?? 1}
|
|
editing={overrides.editing ?? null}
|
|
prefill={overrides.prefill}
|
|
onClose={onClose}
|
|
onSaved={onSaved}
|
|
/>,
|
|
)
|
|
return { ...view, onClose, onSaved }
|
|
}
|
|
|
|
const nameField = () => screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…')
|
|
const totalField = () => screen.getAllByPlaceholderText('0,00')[0]
|
|
const submit = () => screen.getByRole('button', { name: 'Add' })
|
|
const saveBtn = () => screen.getByRole('button', { name: 'Save' })
|
|
|
|
function fillBasics(name: string, total: string) {
|
|
fireEvent.change(nameField(), { target: { value: name } })
|
|
fireEvent.change(totalField(), { target: { value: total } })
|
|
}
|
|
|
|
const CAT_LABELS = ['Accommodation', 'Food & drink', 'Groceries', 'Transport', 'Flights', 'Activities', 'Sightseeing', 'Shopping', 'Fees & tickets', 'Health', 'Tips', 'Other']
|
|
|
|
describe('MCostSheet', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
clearExchangeRateCache()
|
|
// Seeded FX cache, fresh enough that the hook never reaches the network.
|
|
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.25 }, ts: Date.now() }))
|
|
addBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
|
|
updateBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
|
|
deleteBudgetItem = vi.fn(async () => undefined)
|
|
useTripStore.setState(
|
|
{ addBudgetItem, updateBudgetItem, deleteBudgetItem } as unknown as Partial<TripStoreState>,
|
|
)
|
|
addToast = vi.fn()
|
|
;(window as unknown as { __addToast: unknown }).__addToast = addToast
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete (window as unknown as { __addToast?: unknown }).__addToast
|
|
})
|
|
|
|
// The category moved from a row of eleven pills to a dropdown: the trigger
|
|
// shows the current one, and the options only exist while it is open.
|
|
const catTrigger = () => screen.getByRole('button', { name: new RegExp(`^(${CAT_LABELS.join('|')})$`) })
|
|
const chooseCategory = (label: string) => {
|
|
fireEvent.click(catTrigger())
|
|
fireEvent.click(screen.getByRole('button', { name: label, pressed: false }))
|
|
}
|
|
|
|
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
|
|
renderSheet()
|
|
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
|
|
expect(nameField()).toHaveValue('')
|
|
expect(catTrigger()).toHaveTextContent('Food & drink')
|
|
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-002: splits the typed total equally and saves the expense', async () => {
|
|
const { onSaved } = renderSheet()
|
|
// A comma is normalised to a dot before it reaches the state.
|
|
fillBasics('Dinner', '85,50')
|
|
|
|
expect(screen.getAllByText('€42.75')).toHaveLength(2)
|
|
expect(screen.getByText('Split 2 ways · €42.75 each')).toBeInTheDocument()
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, {
|
|
name: 'Dinner',
|
|
category: 'food',
|
|
currency: 'EUR',
|
|
payers: [{ user_id: 1, amount: 85.5 }],
|
|
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
|
|
member_ids: [1, 2],
|
|
expense_date: TODAY,
|
|
total_price: 85.5,
|
|
note: null,
|
|
ticket_json: null,
|
|
// Always sent, empty when nothing was attached: the server treats the
|
|
// array as the full set of receipts for the expense.
|
|
receipt_file_ids: [],
|
|
})
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-020: the category dropdown opens, marks the current pick and closes on choose (#1658)', () => {
|
|
renderSheet()
|
|
expect(screen.queryByRole('button', { name: 'Groceries' })).toBeNull()
|
|
|
|
fireEvent.click(catTrigger())
|
|
expect(screen.getByRole('button', { name: 'Food & drink', pressed: true })).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Groceries', pressed: false }))
|
|
expect(catTrigger()).toHaveTextContent('Groceries')
|
|
expect(screen.queryByRole('button', { name: 'Sightseeing' })).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-021: a note typed on the phone is saved with the expense (#1658)', async () => {
|
|
renderSheet()
|
|
fillBasics('Pharmacy', '18')
|
|
fireEvent.change(screen.getByPlaceholderText(/what it covered/i), { target: { value: 'Ben was ill, he pays this back' } })
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
note: 'Ben was ill, he pays this back',
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-022: an existing note is loaded for editing, and a receipt blob never is (#1658)', () => {
|
|
const { unmount } = renderSheet({ editing: buildBudgetItem({ id: 5, name: 'Chemist', note: 'reimburse from the kitty' }) })
|
|
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('reimburse from the kitty')
|
|
unmount()
|
|
|
|
renderSheet({ editing: buildBudgetItem({ id: 6, name: 'Market', note: 'TICKETJSON:{"items":[]}' }) })
|
|
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
|
|
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
|
|
expect(nameField()).toHaveValue('Ryokan')
|
|
// Padded to the base currency's decimals since #2175, localized for EUR.
|
|
expect(totalField()).toHaveValue('240,00')
|
|
expect(catTrigger()).toHaveTextContent('Accommodation')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
name: 'Ryokan', category: 'accommodation', total_price: 240, reservation_id: 77,
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-003b: a place prefill carries the place link into the payload (#1298)', async () => {
|
|
renderSheet({ prefill: { name: 'Louvre', category: 'activities', placeId: 12 } })
|
|
expect(nameField()).toHaveValue('Louvre')
|
|
|
|
fireEvent.change(totalField(), { target: { value: '34' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
name: 'Louvre', category: 'activities', total_price: 34, place_id: 12,
|
|
}))
|
|
expect(addBudgetItem.mock.calls[0][1]).not.toHaveProperty('reservation_id')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-004: dropping a participant re-splits and shrinks the member list', async () => {
|
|
renderSheet()
|
|
fillBasics('Taxi', '30')
|
|
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
|
|
|
expect(screen.getByText('Split 1 ways · €30.00 each')).toBeInTheDocument()
|
|
expect(screen.getAllByText('Tap to include')).toHaveLength(1)
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
members: [{ user_id: 1, amount: null }],
|
|
member_ids: [1],
|
|
payers: [{ user_id: 1, amount: 30 }],
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-005: an expense nobody shares is saved without members but keeps its payer', async () => {
|
|
renderSheet()
|
|
fillBasics('Souvenir', '12')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
// The server derives total_price from the payer sum, so the payer has to go
|
|
// out even with an empty split — otherwise the entry comes back at 0.
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
payers: [{ user_id: 1, amount: 12 }], members: [], member_ids: [],
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
|
|
renderSheet()
|
|
fillBasics('Metro pass', '20')
|
|
chooseCategory('Transport')
|
|
expect(catTrigger()).toHaveTextContent('Transport')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-007: a foreign currency shows the live conversion into the base', () => {
|
|
renderSheet()
|
|
fillBasics('Hotel', '100')
|
|
expect(screen.queryByText(/live rate/)).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'EUR €' }))
|
|
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'USD' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'USD $' }))
|
|
|
|
expect(screen.getByText('$100.00')).toBeInTheDocument()
|
|
// 100 USD at 1.25 USD per EUR = 80 EUR.
|
|
expect(screen.getByRole('dialog').textContent).toContain('80,00')
|
|
expect(screen.getByText(/live rate/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-008: "No one paid yet" saves a planning entry without payers', async () => {
|
|
renderSheet()
|
|
fillBasics('Tickets', '60')
|
|
fireEvent.click(screen.getByRole('button', { name: 'You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'No one paid yet' }))
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ payers: [] })))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-009: multiple payers start out balanced on the current payer', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
// Total field plus the single active payer row.
|
|
expect(amounts).toHaveLength(2)
|
|
expect(amounts[1]).toHaveValue('100,00')
|
|
|
|
// The hint next to an inactive payer is a second way into the same toggle.
|
|
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
|
expect(screen.getAllByPlaceholderText('0,00')[2]).toHaveValue('50,00')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-010: adding a second payer spreads the total and saves both', async () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
expect(amounts[1]).toHaveValue('50,00')
|
|
expect(amounts[2]).toHaveValue('50,00')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
payers: [{ user_id: 1, amount: 50 }, { user_id: 2, amount: 50 }],
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-011: a typed payer amount is pinned and the rest absorbs the remainder', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
expect(amounts[1]).toHaveValue('30')
|
|
expect(amounts[2]).toHaveValue('70,00')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-012: payers that do not add up block the save and explain why', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[2], { target: { value: '30' } })
|
|
|
|
expect(screen.getByText(/Payer amounts must add up to/)).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-013: going back to a single payer keeps the remaining one selected', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Y You' })[0])
|
|
fireEvent.click(screen.getByRole('button', { name: 'One person paid' }))
|
|
|
|
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
|
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-014: a custom split must match the total before it can be saved', async () => {
|
|
renderSheet()
|
|
fillBasics('Dinner', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
|
|
|
const shares = screen.getAllByPlaceholderText('50,00')
|
|
expect(shares).toHaveLength(2)
|
|
fireEvent.change(shares[0], { target: { value: '30' } })
|
|
expect(screen.getByText('€30.00 / €100.00')).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
|
|
// The remaining share now suggests what is left of the total.
|
|
fireEvent.change(screen.getByPlaceholderText('70,00'), { target: { value: '70' } })
|
|
expect(screen.getByText('Split 2 ways · €50.00 each')).toBeInTheDocument()
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
members: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }],
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-015: a custom share rejects a third decimal and is cleared when the member drops out', () => {
|
|
renderSheet()
|
|
fillBasics('Dinner', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
|
|
|
const share = screen.getAllByPlaceholderText('50,00')[0]
|
|
fireEvent.change(share, { target: { value: '30.123' } })
|
|
expect(share).toHaveValue('')
|
|
fireEvent.change(share, { target: { value: '30,5' } })
|
|
expect(screen.getByDisplayValue('30,5')).toBe(share)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
|
expect(screen.getAllByPlaceholderText('50,00')[0]).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-016: ticket mode derives the total and the per-person shares from the items', async () => {
|
|
renderSheet()
|
|
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
|
expect(totalField()).toBeDisabled()
|
|
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
|
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
|
|
|
expect(totalField()).toHaveValue('20,00')
|
|
expect(screen.getAllByText('€10.00')).toHaveLength(2)
|
|
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[1])
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
total_price: 20,
|
|
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
|
|
ticket_json: '{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-017: a ticket item without a name or a participant blocks the save', () => {
|
|
renderSheet()
|
|
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
|
|
|
const footerSubmit = () => screen.getAllByRole('button', { name: 'Add' })[1]
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
|
expect(footerSubmit()).toBeDisabled()
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
|
expect(footerSubmit()).toBeEnabled()
|
|
|
|
// Un-ticking everyone makes the item unsplittable again.
|
|
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
|
expect(footerSubmit()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-018: removing the last ticket item drops the share breakdown', () => {
|
|
renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
|
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
|
expect(screen.getAllByText('Split')).toHaveLength(2)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
|
|
expect(screen.getAllByText('Split')).toHaveLength(1)
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-019: editing loads name, currency, legacy category and the custom shares', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 5, name: 'Flight to Osaka', category: 'Flight', currency: 'usd', total_price: 60,
|
|
expense_date: '2026-03-04',
|
|
members: [member(1, 20), member(2, 40)],
|
|
payers: [{ user_id: 2, amount: 60 }],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
|
|
expect(nameField()).toHaveValue('Flight to Osaka')
|
|
expect(catTrigger()).toHaveTextContent('Flights')
|
|
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
|
|
// USD renders with a dot, so the total and both shares share this placeholder.
|
|
// Seeded padded to two decimals since #2175.
|
|
const amounts = screen.getAllByPlaceholderText('0.00')
|
|
expect(amounts[0]).toHaveValue('60.00')
|
|
expect(amounts[1]).toHaveValue('20.00')
|
|
expect(amounts[2]).toHaveValue('40.00')
|
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-020: saving an edit goes through updateBudgetItem', async () => {
|
|
const editing = buildBudgetItem({
|
|
id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 60 }],
|
|
})
|
|
const { onSaved } = renderSheet({ editing })
|
|
fireEvent.change(nameField(), { target: { value: 'Dinner & drinks' } })
|
|
fireEvent.click(saveBtn())
|
|
|
|
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(updateBudgetItem).toHaveBeenCalledWith(1, 5, expect.objectContaining({
|
|
name: 'Dinner & drinks',
|
|
total_price: 60,
|
|
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
|
|
}))
|
|
expect(addBudgetItem).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-021: an expense with two payers reopens in multi-payer mode', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 6, name: 'Rental car', category: 'transport', currency: 'EUR', total_price: 100,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 40 }, { user_id: 2, amount: 60 }],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'One person paid' })).toBeInTheDocument()
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
expect(amounts[1]).toHaveValue('40,00')
|
|
expect(amounts[2]).toHaveValue('60,00')
|
|
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-022: an expense nobody paid reopens on the placeholder payer', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 7, name: 'Museum', category: 'sightseeing', currency: 'EUR', total_price: 0,
|
|
members: [], payers: [],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
|
|
expect(totalField()).toHaveValue('')
|
|
expect(saveBtn()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-023: a stored ticket note reopens as editable ticket rows', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 8, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
|
|
members: [member(1, 10), member(2, 10)],
|
|
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
|
|
expect(screen.getByPlaceholderText('What was it for?')).toHaveValue('Ramen')
|
|
expect(screen.getAllByPlaceholderText('0,00')[1]).toHaveValue('20')
|
|
expect(totalField()).toHaveValue('20,00')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-024: a corrupt ticket note still opens the sheet with an empty item list', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 9, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
|
|
members: [], note: 'TICKETJSON:{not json',
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
|
|
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
|
|
expect(saveBtn()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-025: deleting takes two taps and warns in between', async () => {
|
|
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
|
|
const { onSaved } = renderSheet({ editing })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
expect(addToast).toHaveBeenCalledWith('Tap again to delete', 'warning', undefined)
|
|
expect(deleteBudgetItem).not.toHaveBeenCalled()
|
|
expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('--m-st-danger')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
await waitFor(() => expect(deleteBudgetItem).toHaveBeenCalledWith(1, 5))
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-026: a failing delete disarms the button and reports the error', async () => {
|
|
deleteBudgetItem.mockRejectedValueOnce(new Error('locked'))
|
|
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
|
|
const { onSaved } = renderSheet({ editing })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
|
expect(onSaved).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' }).className).not.toContain('--m-st-danger'))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-027: a failing save reports the error and lets the user retry', async () => {
|
|
addBudgetItem.mockRejectedValueOnce(new Error('offline'))
|
|
const { onSaved } = renderSheet()
|
|
fillBasics('Dinner', '20')
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
|
expect(onSaved).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(submit()).toBeEnabled())
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-028: cancel reports the close only after the exit animation', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { onClose } = renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
act(() => { vi.advanceTimersByTime(280) })
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-029: renders member avatars and flags guests', () => {
|
|
const people = [
|
|
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
|
|
{ id: 2, username: 'bob', avatar_url: null, is_guest: true },
|
|
] as TripMember[]
|
|
renderSheet({ people })
|
|
|
|
// The sheet lives in a portal, so query from the document root.
|
|
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).not.toBeNull()
|
|
expect(screen.getByText('Guest')).toBeInTheDocument()
|
|
})
|
|
|
|
// #1567 all over again: an expense entered at 23:00 in Tokyo belongs on that
|
|
// evening's day, not on the UTC date that has not started there yet.
|
|
it('FE-MOB-COSTSH-031: the default expense date comes off the local clock, not UTC', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
try {
|
|
// East of Greenwich the two dates part just after midnight, west of it just
|
|
// before, so pick the edge that this runner's zone actually has.
|
|
const hour = new Date(2026, 7, 12).getTimezoneOffset() > 0 ? 23 : 0
|
|
const now = new Date(2026, 7, 12, hour, 30, 0)
|
|
vi.setSystemTime(now)
|
|
const { onSaved } = renderSheet()
|
|
fillBasics('Ramen', '12')
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem.mock.calls[0][1]).toMatchObject({ expense_date: '2026-08-12' })
|
|
// Guard the guard: in a UTC runner there is no drift to catch.
|
|
if (now.getTimezoneOffset() !== 0) {
|
|
expect(now.toISOString().slice(0, 10)).not.toBe('2026-08-12')
|
|
}
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-032: reopens 4,90 as "4,90" instead of "4,9" (#2175)', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 10, name: 'Coffee', category: 'food', currency: 'EUR', total_price: 4.9,
|
|
members: [], payers: [{ user_id: 1, amount: 4.9 }],
|
|
})
|
|
renderSheet({ editing })
|
|
expect(totalField()).toHaveValue('4,90')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-033: a negative amount can be typed and saves a refund (#2176)', async () => {
|
|
const { onSaved } = renderSheet()
|
|
// The '-' used to be stripped by the input sanitizer, making this untypeable.
|
|
fillBasics('Hotel refund', '-100')
|
|
|
|
expect(submit()).toBeEnabled()
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
total_price: -100,
|
|
payers: [{ user_id: 1, amount: -100 }],
|
|
}))
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-034: a negative payer survives the reopen and the save (#2176)', async () => {
|
|
const editing = buildBudgetItem({
|
|
id: 11, name: 'Refund', category: 'other', currency: 'EUR', total_price: -60,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: -60 }],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(totalField()).toHaveValue('-60,00')
|
|
// Alice is still the recorded (negative) payer — not reset to "No one paid yet".
|
|
expect(screen.getByRole('button', { name: 'You' })).toBeInTheDocument()
|
|
|
|
fireEvent.click(saveBtn())
|
|
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 11, expect.objectContaining({
|
|
total_price: -60,
|
|
payers: [{ user_id: 1, amount: -60 }],
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-035: an expense saved without a currency reopens in the trip currency (#2525)', async () => {
|
|
// No currency on the row means the trip's own. Seeding the display currency
|
|
// instead labelled 100 euro as 100 dollars, and a plain save stored it so.
|
|
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.8 }, ts: Date.now() }))
|
|
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
|
const editing = buildBudgetItem({
|
|
id: 12, name: 'Tram pass', category: 'transport', currency: null, total_price: 100,
|
|
members: [member(1, null)],
|
|
payers: [{ user_id: 1, amount: 100 }],
|
|
})
|
|
renderSheet({ base: 'USD', editing })
|
|
|
|
expect(totalField()).toHaveValue('100,00')
|
|
expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'USD $' })).toBeNull()
|
|
|
|
fireEvent.click(saveBtn())
|
|
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 12, expect.objectContaining({
|
|
total_price: 100,
|
|
currency: 'EUR',
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
|
|
// A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro.
|
|
// Same currency as the list, so the sheet said nothing, while the list showed the
|
|
// booked euros at today's rate beside the 801.76 typed here.
|
|
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() }))
|
|
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
|
const editing = buildBudgetItem({
|
|
id: 13, name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, total_price: 801.76,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 801.76 }],
|
|
})
|
|
renderSheet({ base: 'USD', editing })
|
|
|
|
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(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
|
|
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() }))
|
|
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
|
const editing = buildBudgetItem({
|
|
id: 14, name: 'Villa', category: 'accommodation', currency: 'USD', exchange_rate: 1.1398, total_price: 12345.67,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 12345.67 }],
|
|
})
|
|
renderSheet({ base: 'USD', editing })
|
|
|
|
expect(screen.queryByText(/live rate/)).toBeNull()
|
|
expect(screen.queryByText(/→/)).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => {
|
|
localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() }))
|
|
renderSheet({ base: 'XTS' })
|
|
fireEvent.change(nameField(), { target: { value: 'Test purchase' } })
|
|
fireEvent.change(screen.getByPlaceholderText('0.00'), { target: { value: '10' } })
|
|
|
|
expect(screen.getAllByText('XTS 5.00')).toHaveLength(2)
|
|
expect(screen.getByText('Split 2 ways · XTS 5.00 each')).toBeInTheDocument()
|
|
})
|
|
})
|