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.
This commit is contained in:
Maurice
2026-09-26 12:41:05 +02:00
parent c6a089107f
commit aa1f76ad11
32 changed files with 2071 additions and 137 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-058
// FE-APISURF-001 to FE-APISURF-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -455,6 +455,7 @@ describe('client > endpoint wiring', () => {
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
{ n: 'freezeRates', r: () => budgetApi.freezeRates(1, {}), e: 'POST /api/trips/1/budget/freeze-rates' },
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
])
@@ -572,6 +573,14 @@ describe('client > request payloads', () => {
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
})
it('FE-APISURF-059: the settlement carries the display rate only when the caller has one', async () => {
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', 0.61))).url).toBe('/api/trips/1/budget/settlement?base=EUR&base_rate=0.61')
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', null))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
const lent = { fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }
expect((await traceOne(() => budgetApi.freezeRates(1, lent))).body).toEqual(lent)
})
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
+9 -3
View File
@@ -40,6 +40,8 @@ import {
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery,
type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse,
type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
@@ -1352,9 +1354,13 @@ export const budgetApi = {
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
// `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server
// uses it only in place of a display quote it cannot fetch itself, which also reads legacy
// transfers without a currency (in the display currency, as with a live quote).
settlement: (tripId: number | string, base?: string, baseRate?: number | null) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base, ...(baseRate != null ? { base_rate: baseRate } : {}) } satisfies BudgetSettlementQuery } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: BudgetCreateSettlementRequest) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: BudgetUpdateSettlementRequest) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
freezeRates: (tripId: number | string, data: BudgetFreezeRatesRequest): Promise<BudgetFreezeRatesResponse> => apiClient.post(`/trips/${tripId}/budget/freeze-rates`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -15,6 +15,7 @@ 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 },
@@ -2525,3 +2526,159 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
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)
})
})
+24 -12
View File
@@ -9,8 +9,9 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads'
import { convertBooked, convertedLine, tripAmountOf, useExchangeRates } from '../../hooks/useExchangeRates'
import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'
import { splitShareLabel, useExpenseFx } from './expenseFx'
import { useFreezeMissingRates } from './useFreezeMissingRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob, openFile } from '../../utils/fileDownload'
@@ -22,7 +23,7 @@ import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
@@ -60,6 +61,11 @@ interface SettlementData {
// What the trip ends up costing each participant. Computed server-side off the
// same ledger as the balances, so the breakdown can't contradict them.
finalBudgets: BudgetParticipantFinal[]
// The currency the figures are in: the display currency, or the trip's own when
// neither the server nor `base_rate` could quote the pair.
currency?: string
// Rows no rate could convert, left out of every figure above.
unconverted?: BudgetUnconverted
}
// One row in the unified Costs ledger — either an expense or a settle-up payment,
@@ -87,7 +93,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
const tripCurrency = (trip?.currency || base).toUpperCase()
// Anchored on the trip currency's quote, the one the server books with (#2525).
const { convert } = useExchangeRates(base, tripCurrency)
const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency)
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null)
// A failed settlement read leaves `settlement` null, which the empty views would
@@ -121,14 +127,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
// The browser's own figure for the display currency goes along, for the server to
// answer in it when it cannot fetch a quote itself.
const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base)
budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true))
}, [tripId, base])
}, [tripId, base, tripCurrency, displayPerTrip])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement])
// Rows the server could not count get a rate frozen from the browser's, and the
// settlement is read again once they count.
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
const [searchParams, setSearchParams] = useSearchParams()
@@ -254,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
try {
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base })
await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency))
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
}
@@ -265,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const flows = settlement?.flows || []
if (!flows.length) return
try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency))
} catch { toast.error(t('common.unknownError')) }
// Refresh even when one transfer failed: the ones created before it are real,
// and leaving them in the flow list invites a second, doubled settle-up.
@@ -535,7 +547,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)}
{(editingSettlement || addingPayment) && (
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base} tripCurrency={tripCurrency}
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
@@ -1173,8 +1185,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
// A transfer can be made in any currency — paying a rouble debt in euros is normal —
// so it carries its own, defaulting to the display currency. The server freezes its
// FX rate on write, the same way an expense's is frozen.
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void
}) {
const { t } = useTranslation()
const toast = useToast()
@@ -1195,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency)
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -0,0 +1,123 @@
// FE-FREEZE-RATES-001 to FE-FREEZE-RATES-006
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import type { BudgetUnconverted } from '@trek/shared'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildBudgetItem } from '../../../tests/helpers/factories'
import { useTripStore } from '../../store/tripStore'
import { setForcedOffline } from '../../sync/networkMode'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetFreezeAttempts, useFreezeMissingRates } from './useFreezeMissingRates'
// The reporter's setup: an AUD trip whose server cannot fetch rates, and a VND bill that
// never froze one. The browser has today's AUD table.
const bill = () => buildBudgetItem({ id: 11, trip_id: 5, name: 'Pho', total_price: 8920000, currency: 'VND', exchange_rate: 1 })
const pending: BudgetUnconverted = { item_ids: [11], settlement_ids: [3], currencies: ['VND'] }
type Props = Parameters<typeof useFreezeMissingRates>[0]
function mountHook(over: Partial<Props> = {}) {
const onHealed = vi.fn()
const props: Props = { tripId: 5, tripCurrency: 'AUD', canEdit: true, unconverted: pending, onHealed, ...over }
const view = renderHook((p: Props) => useFreezeMissingRates(p), { initialProps: props })
return { ...view, onHealed, props }
}
let posted: unknown[] = []
function serveFreeze(reply: () => Response = () => HttpResponse.json({
items: [{ ...bill(), exchange_rate: 18241.3 }],
settlements: [{ id: 3, trip_id: 5, from_user_id: 2, to_user_id: 1, amount: 100000, currency: 'VND', exchange_rate: 18241.3 }],
unresolved: [],
})) {
server.use(http.post('/api/trips/5/budget/freeze-rates', async ({ request }) => {
posted.push(await request.json())
return reply()
}))
}
/** Lets a heal that was never going to send anything run to its end. */
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 30)) })
beforeEach(() => {
resetAllStores()
resetFreezeAttempts()
clearExchangeRateCache()
posted = []
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
seedStore(useTripStore, { budgetItems: [bill()] })
serveFreeze()
})
afterEach(() => {
setForcedOffline(false)
vi.restoreAllMocks()
})
describe('useFreezeMissingRates', () => {
it('FE-FREEZE-RATES-001: lends the fresh rates for the listed currencies and reloads once rows count', async () => {
const { onHealed } = mountHook()
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
// Only the currency the settlement is missing goes out, never the whole table.
expect(posted).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }])
expect(useTripStore.getState().budgetItems[0].exchange_rate).toBe(18241.3)
})
it('FE-FREEZE-RATES-002: asks once per set of rows, however often the settlement is read', async () => {
const { onHealed, props, rerender } = mountHook()
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
// The same rows in another order, then again after a reload: nothing new to ask.
rerender({ ...props, unconverted: { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } })
rerender({ ...props, unconverted: { settlement_ids: [3], item_ids: [11], currencies: ['VND'] } })
await settle()
expect(posted).toHaveLength(1)
// Another row is another attempt.
rerender({ ...props, unconverted: { item_ids: [11, 12], settlement_ids: [3], currencies: ['VND'] } })
await waitFor(() => expect(posted).toHaveLength(2))
})
it('FE-FREEZE-RATES-003: never asks for a viewer, offline, or with nothing left out', async () => {
mountHook({ canEdit: false })
mountHook({ unconverted: { item_ids: [], settlement_ids: [], currencies: [] } })
mountHook({ unconverted: undefined })
setForcedOffline(true)
mountHook({ unconverted: { item_ids: [21], settlement_ids: [], currencies: ['VND'] } })
await settle()
expect(posted).toEqual([])
})
it('FE-FREEZE-RATES-004: sends nothing when the browser has no rate for the currency either', async () => {
const { onHealed } = mountHook({ unconverted: { item_ids: [31], settlement_ids: [], currencies: ['JPY'] } })
await settle()
expect(posted).toEqual([])
expect(onHealed).not.toHaveBeenCalled()
})
it('FE-FREEZE-RATES-005: does not reload when the server froze nothing', async () => {
serveFreeze(() => HttpResponse.json({ items: [], settlements: [], unresolved: ['VND'] }))
const { onHealed } = mountHook()
await waitFor(() => expect(posted).toHaveLength(1))
await settle()
expect(onHealed).not.toHaveBeenCalled()
})
it('FE-FREEZE-RATES-006: a refused heal is logged, not thrown, and not retried in a loop', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
serveFreeze(() => HttpResponse.json({ error: 'The trip currency changed. Reload and try again.' }, { status: 409 }))
const { onHealed, props, rerender } = mountHook()
await waitFor(() => expect(warn).toHaveBeenCalled())
rerender({ ...props })
await settle()
expect(posted).toHaveLength(1)
expect(onHealed).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react'
import type { BudgetFallbackFx, BudgetUnconverted } from '@trek/shared'
import { fetchFreshRates, isSendableRate } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
/**
* Every set of rows already tried, by trip, trip currency and ids. The settlement lists a
* row it cannot count until a rate is frozen on it; one the browser has no rate for either
* stays listed, and without this every reload of the settlement would ask again.
*/
const tried = new Set<string>()
/** Test-only: the attempts outlive a vitest file's individual tests. */
export function resetFreezeAttempts(): void {
tried.clear()
}
/**
* Heals the rows the settlement leaves out (`unconverted`): expenses and transfers in a
* foreign currency that never froze a rate, while the server cannot fetch one. That is the
* setup where the server's own fetch fails and the browser's works, so an editor's browser
* lends its fresh rates to POST …/budget/freeze-rates. The server still prefers its own rate
* and never touches a row that is frozen already.
*
* Runs for an editor only, online, and once per set of rows. No request goes out when the
* browser has no rate for any of the listed currencies either. `onHealed` runs when the
* server froze something, so the caller can reload the settlement.
*
* Deliberately not in the mutation queue: the heal is derived and idempotent, and a replay
* hours later would freeze a rate from another day.
*/
export function useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted, onHealed }: {
tripId: number
tripCurrency: string
canEdit: boolean
unconverted: BudgetUnconverted | null | undefined
onHealed: () => void
}): void {
const onHealedRef = useRef(onHealed)
useEffect(() => { onHealedRef.current = onHealed })
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
const trip = (tripCurrency || '').toUpperCase()
const itemIds = [...(unconverted?.item_ids ?? [])].sort((a, b) => a - b).join(',')
const settlementIds = [...(unconverted?.settlement_ids ?? [])].sort((a, b) => a - b).join(',')
const currencies = (unconverted?.currencies ?? []).join(',')
useEffect(() => {
if (!canEdit || !trip || (!itemIds && !settlementIds) || isEffectivelyOffline()) return
const key = `${tripId}:${trip}:${itemIds}:${settlementIds}`
if (tried.has(key)) return
tried.add(key)
const heal = async (): Promise<void> => {
const rates = await fetchFreshRates(trip)
const lent: Record<string, number> = {}
for (const cur of currencies.split(',')) {
const rate = rates?.[cur]
if (isSendableRate(rate)) lent[cur] = rate
}
if (Object.keys(lent).length === 0) return
const fallback: BudgetFallbackFx = { base: trip, rates: lent }
const result = await useTripStore.getState().freezeMissingRates(tripId, fallback)
if (mounted.current && (result.items.length > 0 || result.settlements.length > 0)) onHealedRef.current()
}
heal().catch((err: unknown) => console.warn('Could not freeze the missing exchange rates:', err))
}, [canEdit, trip, tripId, itemIds, settlementIds, currencies])
}
+129 -2
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, clearExchangeRateCache, tripAmountOf, useExchangeRates } from './useExchangeRates'
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
@@ -45,6 +45,94 @@ describe('fetchExchangeRates (#1561)', () => {
server.use(http.get(FX_URL, () => HttpResponse.error()))
expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 })
})
// A request the network never answers held the Costs panel's heal for as long as the
// browser kept the socket open. It gives up after 10 s like any other failure.
it('gives up on a request that hangs after 10 s and serves the stale cache', async () => {
vi.useFakeTimers()
const fetchSpy = vi.spyOn(globalThis, 'fetch')
try {
localStorage.setItem('trek_fx_NOK', JSON.stringify({ rates: { NOK: 1, USD: 0.1 }, ts: Date.now() - 24 * 60 * 60 * 1000 }))
let signal: AbortSignal | undefined
fetchSpy.mockImplementation((_input, init) => new Promise((_resolve, reject) => {
signal = init?.signal ?? undefined
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
}))
const pending = fetchExchangeRates('NOK')
await vi.advanceTimersByTimeAsync(9_999)
expect(signal?.aborted).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(signal?.aborted).toBe(true)
expect(await pending).toEqual({ NOK: 1, USD: 0.1 })
} finally {
fetchSpy.mockRestore()
vi.useRealTimers()
}
})
})
// The rates the client lends the server to freeze a rate with. Only a fresh table counts:
// a stale one would pin a figure from another day on the row for good.
describe('freshRates and fetchFreshRates', () => {
beforeEach(() => {
clearExchangeRateCache()
})
it('reads a fresh cache and ignores a stale or missing one', () => {
expect(freshRates('AUD')).toBeNull()
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
expect(freshRates('aud')).toBeNull()
clearExchangeRateCache()
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() }))
expect(freshRates('aud')).toEqual({ AUD: 1, VND: 18241.3 })
})
it('fetches when the cache is stale and gives the new table', async () => {
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
server.use(http.get(FX_URL, () => HttpResponse.json([{ quote: 'VND', rate: 18241.3 }])))
expect(await fetchFreshRates('AUD')).toEqual({ AUD: 1, VND: 18241.3 })
})
it('is null when the fetch fails and only a stale table is left', async () => {
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
server.use(http.get(FX_URL, () => HttpResponse.error()))
expect(await fetchFreshRates('AUD')).toBeNull()
})
})
describe('withFallbackFx', () => {
beforeEach(() => {
clearExchangeRateCache()
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61, XAU: 1e-10 }, ts: Date.now() }))
})
it('lends the fresh trip-currency rate for a foreign currency', () => {
expect(withFallbackFx({ name: 'Pho', currency: 'vnd', total_price: 8920000 }, 'aud')).toEqual({
name: 'Pho', currency: 'vnd', total_price: 8920000,
fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } },
})
expect(withFallbackFx({ from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR' }, 'AUD').fallback_fx)
.toEqual({ base: 'AUD', rates: { EUR: 0.61 } })
})
it('leaves the data alone when there is nothing to lend', () => {
const inTrip = { name: 'Tram', currency: 'AUD' }
expect(withFallbackFx(inTrip, 'AUD')).toBe(inTrip)
// No currency at all is the trip's own.
expect(withFallbackFx({ name: 'Tram', currency: null }, 'AUD')).toEqual({ name: 'Tram', currency: null })
// An explicit rate is the caller's and wins on the server anyway.
expect(withFallbackFx({ currency: 'VND', exchange_rate: 17000 }, 'AUD')).toEqual({ currency: 'VND', exchange_rate: 17000 })
// No quote for the currency, no trip currency, or a rate the contract would refuse.
expect(withFallbackFx({ currency: 'JPY' }, 'AUD')).toEqual({ currency: 'JPY' })
expect(withFallbackFx({ currency: 'VND' }, null)).toEqual({ currency: 'VND' })
expect(withFallbackFx({ currency: 'XAU' }, 'AUD')).toEqual({ currency: 'XAU' })
})
it('lends nothing from a stale table', () => {
clearExchangeRateCache()
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
expect(withFallbackFx({ currency: 'VND' }, 'AUD')).toEqual({ currency: 'VND' })
})
})
// A cost entered in a foreign currency is money that was actually paid, at a rate that
@@ -171,6 +259,45 @@ describe('useExchangeRates anchored on the trip currency (#2525)', () => {
const { result } = renderHook(() => useExchangeRates('EUR', 'EUR'))
await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1))
expect(calls).toBe(1)
expect(result.current.displayPerTrip).toBe(1)
})
// What the settlement is asked to label its figures with when the server has no quote
// (`base_rate`): the same figure `convert` takes a trip amount to the display currency with.
it('gives the display currency per 1 trip currency from the trip\'s quote', async () => {
quotes()
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
await waitFor(() => expect(result.current.displayPerTrip).toBe(1.1398))
expect(result.current.convert(1, 'EUR')).toBe(result.current.displayPerTrip)
})
it('falls back to the display currency\'s quote for it, and is null without either', async () => {
server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD'
? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
: HttpResponse.error())))
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
await waitFor(() => expect(result.current.displayPerTrip).toBe(1.25))
clearExchangeRateCache()
let calls = 0
server.use(http.get(FX_URL, () => { calls++; return HttpResponse.error() }))
const none = renderHook(() => useExchangeRates('GBP', 'AUD'))
await waitFor(() => expect(calls).toBe(2))
expect(none.result.current.displayPerTrip).toBeNull()
})
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the
// settlement query takes, and sent as `base_rate` it would fail the whole read with a 400.
it('is null for a figure the settlement query would refuse, which convert still uses', () => {
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 high = renderHook(() => useExchangeRates('IRR', 'XAU'))
expect(high.result.current.convert(1, 'XAU')).toBe(2.6e9)
expect(high.result.current.displayPerTrip).toBeNull()
// The other way round the figure falls under 1e-9.
const low = renderHook(() => useExchangeRates('XAU', 'IRR'))
expect(low.result.current.convert(2.6e9, 'IRR')).toBeCloseTo(1, 10)
expect(low.result.current.displayPerTrip).toBeNull()
})
})
+70 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import type { BudgetFallbackFx } from '@trek/shared'
import { currencyDecimals } from '../utils/formatters'
/**
@@ -10,6 +11,9 @@ import { currencyDecimals } from '../utils/formatters'
*/
const TTL_MS = 6 * 60 * 60 * 1000 // 6h
// A hung request would otherwise hold every caller, the freeze-rates heal included,
// for as long as the browser keeps the socket open.
const FETCH_TIMEOUT_MS = 10_000
const mem = new Map<string, { rates: Record<string, number>; ts: number }>()
function readCache(base: string): { rates: Record<string, number>; ts: number } | null {
@@ -35,8 +39,12 @@ export async function fetchExchangeRates(base: string): Promise<Record<string, n
const upper = (base || 'EUR').toUpperCase()
const cached = readCache(upper)
if (cached && Date.now() - cached.ts < TTL_MS) return cached.rates
// A controller rather than AbortSignal.timeout, which Safari only has from 16 on;
// without it the call would throw and never fetch at all.
const stop = new AbortController()
const timer = setTimeout(() => stop.abort(), FETCH_TIMEOUT_MS)
try {
const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`, { signal: stop.signal })
.then(r => r.json()) as Array<{ quote?: string; rate?: number }>
if (!Array.isArray(d)) return cached?.rates ?? null
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
@@ -50,9 +58,58 @@ export async function fetchExchangeRates(base: string): Promise<Record<string, n
return rates
} catch {
return cached?.rates ?? null // offline → stale beats nothing
} finally {
clearTimeout(timer)
}
}
/**
* The cached rates for `base`, and only while they are fresh (younger than the TTL). This is
* the table the client lends the server to freeze a rate with (`fallback_fx`): a stale one
* would pin a figure from another day on the row for good.
*/
export function freshRates(base: string): Record<string, number> | null {
const cached = readCache((base || 'EUR').toUpperCase())
return cached && Date.now() - cached.ts < TTL_MS ? cached.rates : null
}
/** `freshRates`, fetching first when the cache is not fresh. Null when the fetch fails too. */
export async function fetchFreshRates(base: string): Promise<Record<string, number> | null> {
await fetchExchangeRates(base)
return freshRates(base)
}
const CURRENCY_CODE = /^[A-Z]{3}$/
/**
* Whether a rate is one the server's contract takes (budget.schema.ts): finite and from
* 1e-9 to 1e9, for a lent `fallback_fx` rate as for the settlement's `base_rate`. Anything
* else fails the whole request with a 400, so the client leaves it out instead.
*/
export function isSendableRate(rate: number | null | undefined): rate is number {
return rate != null && Number.isFinite(rate) && rate >= 1e-9 && rate <= 1e9
}
/**
* `data` with its currency's rate against the trip currency lent alongside, for a write the
* server freezes a rate on (an expense, a transfer). The server prefers its own rate and
* uses this one only when it has none, which is the setup where its fetch fails and the
* browser's does not. Sent only for a currency foreign to the trip with a fresh quote in
* the cache, and never beside an explicit `exchange_rate`; anything else comes back as it
* was. A rate outside what the contract accepts would fail the whole write, so it is left out.
*/
export function withFallbackFx<T extends { currency?: string | null; exchange_rate?: number | null }>(
data: T,
tripCurrency: string | null | undefined,
): T & { fallback_fx?: BudgetFallbackFx } {
const trip = (tripCurrency || '').toUpperCase()
const cur = (data.currency || '').toUpperCase()
if (!CURRENCY_CODE.test(trip) || !CURRENCY_CODE.test(cur) || cur === trip || data.exchange_rate != null) return data
const rate = freshRates(trip)?.[cur]
if (!isSendableRate(rate)) return data
return { ...data, fallback_fx: { base: trip, rates: { [cur]: rate } } }
}
/**
* Convert a booked amount the way the server's settlement does (#1335, #1445): the FX
* rate frozen when the expense or transfer was entered wins over today's rate, so a cost
@@ -229,5 +286,16 @@ export function useExchangeRates(base: string, tripCurrency?: string | null) {
[rates, anchorRates, upper],
)
return { rates, convert }
// Units of the display currency per 1 trip currency, from the same quotes `convert` uses.
// The settlement takes it as `base_rate` to answer in the display currency when the
// server has no quote of its own. Null while neither quote is known, and null for a
// figure the settlement query refuses (a trip in XAU shown in IRR runs past 1e9): sent
// along, it would fail the whole read with a 400, healthy server or not.
const perTrip = rates?.[anchor]
const quoted = anchor === upper
? 1
: crossRate(anchorRates, upper, anchor) ?? (perTrip && perTrip > 0 ? 1 / perTrip : null)
const displayPerTrip = isSendableRate(quoted) ? quoted : null
return { rates, convert, displayPerTrip }
}
@@ -7,13 +7,14 @@ import {
import MDancingTrek from '../../../components/MDancingTrek'
import { useAuthStore } from '../../../../store/authStore'
import { useSettingsStore } from '../../../../store/settingsStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { useExchangeRates, withFallbackFx } from '../../../../hooks/useExchangeRates'
import { useTranslation } from '../../../../i18n'
import { amountToInputString, formatMoney } from '../../../../utils/formatters'
import { downloadBlob, openFile } from '../../../../utils/fileDownload'
import { budgetApi } from '../../../../api/client'
import MCostSheet from '../sheets/MCostSheet'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import { useFreezeMissingRates } from '../../../../components/Budget/useFreezeMissingRates'
import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import CustomSelect from '../../../../components/shared/CustomSelect'
@@ -56,18 +57,19 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const tripCurrency = (trip?.currency || base).toUpperCase()
// Anchored on the trip currency's quote, the one the server books with (#2525).
const { convert } = useExchangeRates(base, tripCurrency)
const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency)
const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert])
const [settlement, setSettlement] = useState<CostsSettlementResponse | null>(null)
// A failed settlement read leaves `settlement` null, and the final budget would
// read that as "the trip cost nobody anything", a claim we cannot make.
const [settlementError, setSettlementError] = useState(false)
// Sends the browser's own figure for the display currency, as CostsPanel.tsx does.
const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base)
budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true))
}, [tripId, base])
}, [tripId, base, tripCurrency, displayPerTrip])
// Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on
// trip/base change; further refreshes are explicit after each mutation below
@@ -79,6 +81,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
useEffect(() => {
loadSettlement()
}, [loadSettlement])
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
const [search, setSearch] = useState('')
const [segment, setSegment] = useState<CostsSegment>('all')
@@ -571,6 +574,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
}}
tripId={tripId}
base={base}
tripCurrency={tripCurrency}
people={tripMembers}
me={me}
toast={toast}
@@ -881,12 +885,13 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
* (editable like an expense's, unlike the legacy created_at-only date, see
* `settlementDate` in CostsPanel.helpers.ts).
*/
function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: {
function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, people, me, toast, t, onSaved }: {
open: boolean
editing: CostsSettlement | null
onClose: () => void
tripId: number
base: string
tripCurrency: string
people: TripMember[]
me: number
toast: { error: (message: string) => void }
@@ -917,7 +922,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa
const save = async () => {
if (!valid || saving) return
setSaving(true)
const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }
const data = withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }, tripCurrency)
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -1,4 +1,4 @@
import type { BudgetParticipantFinal, CostCategory } from '@trek/shared'
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared'
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates'
@@ -116,6 +116,10 @@ export interface CostsSettlementResponse {
settlements: CostsSettlement[]
/** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */
finalBudgets: BudgetParticipantFinal[]
/** The currency the figures are in: the display currency, or the trip's own without a quote for it. */
currency?: string
/** Rows no rate could convert, left out of every figure above. */
unconverted?: BudgetUnconverted
}
// ── hero / tile totals (spec §3.1-§3.3) ────────────────────────────────────
+75 -2
View File
@@ -1,8 +1,9 @@
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-016
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-021
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
import { useTripStore } from '../tripStore';
let addToast: ReturnType<typeof vi.fn>;
@@ -284,3 +285,75 @@ describe('budgetSlice', () => {
expect(addToast).toHaveBeenCalledTimes(2);
});
});
// An AUD trip whose server cannot fetch rates while the browser can: a write in another
// currency lends the browser's rate, so the server can freeze one on entry.
describe('budgetSlice: lent rates', () => {
let sent: Record<string, unknown>[] = [];
beforeEach(() => {
clearExchangeRateCache();
sent = [];
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) });
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() }));
server.use(
http.post('/api/trips/:id/budget', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
sent.push(body);
return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: String(body.name) }) });
}),
http.put('/api/trips/1/budget/:itemId', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
sent.push(body);
return HttpResponse.json({ item: { ...buildBudgetItem({ id: 50, trip_id: 1 }), ...body } });
}),
);
});
afterEach(clearExchangeRateCache);
it('FE-STORE-BUDGET-018: addBudgetItem lends the fresh trip-currency rate for a foreign currency', async () => {
await useTripStore.getState().addBudgetItem(1, { name: 'Pho', total_price: 8920000, currency: 'VND' });
expect(sent[0]).toMatchObject({ name: 'Pho', currency: 'VND', fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
});
it('FE-STORE-BUDGET-019: nothing is lent for the trip currency, another trip or a stale table', async () => {
await useTripStore.getState().addBudgetItem(1, { name: 'Tram', currency: 'AUD' });
// The store only knows the open trip's currency, and a rate has to be quoted against it.
await useTripStore.getState().addBudgetItem(2, { name: 'Pho', currency: 'VND' });
clearExchangeRateCache();
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }));
await useTripStore.getState().addBudgetItem(1, { name: 'Banh mi', currency: 'VND' });
expect(sent).toHaveLength(3);
for (const body of sent) expect(body).not.toHaveProperty('fallback_fx');
});
it('FE-STORE-BUDGET-020: updateBudgetItem lends it too, for a change into a foreign currency', async () => {
seedStore(useTripStore, { budgetItems: [buildBudgetItem({ id: 50, trip_id: 1, currency: 'USD', exchange_rate: 0.66 })] });
await useTripStore.getState().updateBudgetItem(1, 50, { currency: 'VND', total_price: 8920000 });
expect(sent[0]).toEqual({ currency: 'VND', total_price: 8920000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
});
it('FE-STORE-BUDGET-021: freezeMissingRates sends the lent rates and takes the healed rows in', async () => {
const bill = buildBudgetItem({ id: 60, trip_id: 1, currency: 'VND', exchange_rate: 1 });
const other = buildBudgetItem({ id: 61, trip_id: 1, currency: 'AUD', exchange_rate: 1 });
seedStore(useTripStore, { budgetItems: [bill, other] });
const bodies: unknown[] = [];
server.use(http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ items: [{ ...bill, exchange_rate: 18241.3 }], settlements: [], unresolved: [] });
}));
const result = await useTripStore.getState().freezeMissingRates(1, { base: 'AUD', rates: { VND: 18241.3 } });
// Without a table of its own to lend, the call asks the server to use its own rates.
await useTripStore.getState().freezeMissingRates(1);
expect(bodies).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }, {}]);
expect(result.unresolved).toEqual([]);
expect(useTripStore.getState().budgetItems.map(i => [i.id, i.exchange_rate])).toEqual([[60, 18241.3], [61, 1]]);
});
});
+27 -3
View File
@@ -3,8 +3,9 @@ import { budgetRepo } from '../../repo/budgetRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { BudgetItem, BudgetItemMember } from '../../types'
import type { BudgetCreateItemRequest, BudgetUpdateItemRequest } from '@trek/shared'
import type { BudgetCreateItemRequest, BudgetFallbackFx, BudgetFreezeRatesResponse, BudgetUpdateItemRequest } from '@trek/shared'
import { getApiErrorMessage } from '../../types'
import { withFallbackFx } from '../../hooks/useExchangeRates'
import { notify } from '../notify'
type SetState = StoreApi<TripStoreState>['setState']
@@ -19,6 +20,16 @@ export interface BudgetSlice {
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
freezeMissingRates: (tripId: number | string, fallback?: BudgetFallbackFx) => Promise<BudgetFreezeRatesResponse>
}
/**
* The open trip's currency when `tripId` is that trip, which is what a lent rate has to be
* quoted against. Null for any other trip: the store only knows the one that is open.
*/
function openTripCurrency(get: GetState, tripId: number | string): string | null {
const trip = get().trip
return trip && String(trip.id) === String(tripId) ? trip.currency : null
}
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
@@ -33,7 +44,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
// A foreign currency goes out with the rate the browser holds for it, so the server
// can freeze one even when its own fetch fails.
const result = await budgetApi.create(tripId, withFallbackFx(data, openTripCurrency(get, tripId)))
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -43,7 +56,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
const result = await budgetApi.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId)))
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
@@ -151,4 +164,15 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
notify(getApiErrorMessage(err, 'Error reordering budget items'), 'error')
}
},
// Freezes a rate onto the rows the settlement could not count (see useFreezeMissingRates).
// Not optimistic: nothing changes locally until the server says which rows it froze.
freezeMissingRates: async (tripId, fallback) => {
const result = await budgetApi.freezeRates(tripId, fallback ? { fallback_fx: fallback } : {})
if (result.items.length > 0) {
const healed = new Map(result.items.map(item => [item.id, item]))
set(state => ({ budgetItems: state.budgetItems.map(item => healed.get(item.id) ?? item) }))
}
return result
},
})
@@ -4,6 +4,7 @@ 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'
@@ -14,7 +15,7 @@ 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-044
// 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.
@@ -723,7 +724,12 @@ describe('MCostsTab', () => {
expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' })
// 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 () => {
@@ -794,6 +800,63 @@ describe('MCostsTab', () => {
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.