mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 } })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) ────────────────────────────────────
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user