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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios' import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw' 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: '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: '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: '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: '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' }, { 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] }) 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 () => { 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 })) const rec = await traceOne(() => daysApi.create(1, { dated: true }))
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { 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 PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest, type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery,
type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse,
type TodoCategoryAssigneesRequest, type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, 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), 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), 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), 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), // `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server
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), // uses it only in place of a display quote it cannot fetch itself, which also reads legacy
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), // 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), 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), 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), 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 type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel' import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers' import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
import { resetFreezeAttempts } from './useFreezeMissingRates'
const tripMembers = [ const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null }, { 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' })) 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 { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client' import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads' 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 { splitShareLabel, useExpenseFx } from './expenseFx'
import { useFreezeMissingRates } from './useFreezeMissingRates'
import { useIsMobile } from '../../hooks/useIsMobile' import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob, openFile } from '../../utils/fileDownload' 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 { 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 { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import { ReceiptPreviewModal } from './ReceiptPreviewModal' 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 { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips' import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge' import GuestBadge from '../shared/GuestBadge'
@@ -60,6 +61,11 @@ interface SettlementData {
// What the trip ends up costing each participant. Computed server-side off the // 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. // same ledger as the balances, so the breakdown can't contradict them.
finalBudgets: BudgetParticipantFinal[] 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, // 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". // Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
const tripCurrency = (trip?.currency || base).toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase()
// Anchored on the trip currency's quote, the one the server books with (#2525). // 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 curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null) const [settlement, setSettlement] = useState<SettlementData | null>(null)
// A failed settlement read leaves `settlement` null, which the empty views would // 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 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]) 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(() => { const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base) budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then(s => { setSettlement(s); setSettlementError(false) }) .then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true)) .catch(() => setSettlementError(true))
}, [tripId, base]) }, [tripId, base, tripCurrency, displayPerTrip])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) 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. // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -254,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── settle actions ────────────────────────────────────────────────────── // ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => { const settleFlow = async (fromId: number, toId: number, amount: number) => {
try { 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() loadSettlement()
} catch { toast.error(t('common.unknownError')) } } catch { toast.error(t('common.unknownError')) }
} }
@@ -265,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const flows = settlement?.flows || [] const flows = settlement?.flows || []
if (!flows.length) return if (!flows.length) return
try { 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')) } } catch { toast.error(t('common.unknownError')) }
// Refresh even when one transfer failed: the ones created before it are real, // 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. // 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) && ( {(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) }} onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> 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 — // 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 // 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. // FX rate on write, the same way an expense's is frozen.
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -1195,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const save = async () => { const save = async () => {
if (!valid) return if (!valid) return
setSaving(true) 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 { try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, 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 { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server' 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' const FX_URL = 'https://api.frankfurter.dev/v2/rates'
@@ -45,6 +45,94 @@ describe('fetchExchangeRates (#1561)', () => {
server.use(http.get(FX_URL, () => HttpResponse.error())) server.use(http.get(FX_URL, () => HttpResponse.error()))
expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 }) 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 // 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')) const { result } = renderHook(() => useExchangeRates('EUR', 'EUR'))
await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1)) await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1))
expect(calls).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 { useCallback, useEffect, useState } from 'react'
import type { BudgetFallbackFx } from '@trek/shared'
import { currencyDecimals } from '../utils/formatters' import { currencyDecimals } from '../utils/formatters'
/** /**
@@ -10,6 +11,9 @@ import { currencyDecimals } from '../utils/formatters'
*/ */
const TTL_MS = 6 * 60 * 60 * 1000 // 6h 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 }>() const mem = new Map<string, { rates: Record<string, number>; ts: number }>()
function readCache(base: string): { rates: Record<string, number>; ts: number } | null { 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 upper = (base || 'EUR').toUpperCase()
const cached = readCache(upper) const cached = readCache(upper)
if (cached && Date.now() - cached.ts < TTL_MS) return cached.rates 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 { 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 }> .then(r => r.json()) as Array<{ quote?: string; rate?: number }>
if (!Array.isArray(d)) return cached?.rates ?? null if (!Array.isArray(d)) return cached?.rates ?? null
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`. // 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 return rates
} catch { } catch {
return cached?.rates ?? null // offline → stale beats nothing 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 * 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 * 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], [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 MDancingTrek from '../../../components/MDancingTrek'
import { useAuthStore } from '../../../../store/authStore' import { useAuthStore } from '../../../../store/authStore'
import { useSettingsStore } from '../../../../store/settingsStore' import { useSettingsStore } from '../../../../store/settingsStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates' import { useExchangeRates, withFallbackFx } from '../../../../hooks/useExchangeRates'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import { amountToInputString, formatMoney } from '../../../../utils/formatters' import { amountToInputString, formatMoney } from '../../../../utils/formatters'
import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { downloadBlob, openFile } from '../../../../utils/fileDownload'
import { budgetApi } from '../../../../api/client' import { budgetApi } from '../../../../api/client'
import MCostSheet from '../sheets/MCostSheet' import MCostSheet from '../sheets/MCostSheet'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import { useFreezeMissingRates } from '../../../../components/Budget/useFreezeMissingRates'
import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import CustomSelect from '../../../../components/shared/CustomSelect' 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 base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const tripCurrency = (trip?.currency || base).toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase()
// Anchored on the trip currency's quote, the one the server books with (#2525). // 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 ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert])
const [settlement, setSettlement] = useState<CostsSettlementResponse | null>(null) const [settlement, setSettlement] = useState<CostsSettlementResponse | null>(null)
// A failed settlement read leaves `settlement` null, and the final budget would // 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. // read that as "the trip cost nobody anything", a claim we cannot make.
const [settlementError, setSettlementError] = useState(false) const [settlementError, setSettlementError] = useState(false)
// Sends the browser's own figure for the display currency, as CostsPanel.tsx does.
const loadSettlement = useCallback(() => { const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base) budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then(s => { setSettlement(s); setSettlementError(false) }) .then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true)) .catch(() => setSettlementError(true))
}, [tripId, base]) }, [tripId, base, tripCurrency, displayPerTrip])
// Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on // Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on
// trip/base change; further refreshes are explicit after each mutation below // trip/base change; further refreshes are explicit after each mutation below
@@ -79,6 +81,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
useEffect(() => { useEffect(() => {
loadSettlement() loadSettlement()
}, [loadSettlement]) }, [loadSettlement])
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [segment, setSegment] = useState<CostsSegment>('all') const [segment, setSegment] = useState<CostsSegment>('all')
@@ -571,6 +574,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
}} }}
tripId={tripId} tripId={tripId}
base={base} base={base}
tripCurrency={tripCurrency}
people={tripMembers} people={tripMembers}
me={me} me={me}
toast={toast} 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 * (editable like an expense's, unlike the legacy created_at-only date, see
* `settlementDate` in CostsPanel.helpers.ts). * `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 open: boolean
editing: CostsSettlement | null editing: CostsSettlement | null
onClose: () => void onClose: () => void
tripId: number tripId: number
base: string base: string
tripCurrency: string
people: TripMember[] people: TripMember[]
me: number me: number
toast: { error: (message: string) => void } toast: { error: (message: string) => void }
@@ -917,7 +922,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa
const save = async () => { const save = async () => {
if (!valid || saving) return if (!valid || saving) return
setSaving(true) 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 { try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, 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 { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates' import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates'
@@ -116,6 +116,10 @@ export interface CostsSettlementResponse {
settlements: CostsSettlement[] settlements: CostsSettlement[]
/** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */ /** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */
finalBudgets: BudgetParticipantFinal[] 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) ──────────────────────────────────── // ── 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 { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; 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'; import { useTripStore } from '../tripStore';
let addToast: ReturnType<typeof vi.fn>; let addToast: ReturnType<typeof vi.fn>;
@@ -284,3 +285,75 @@ describe('budgetSlice', () => {
expect(addToast).toHaveBeenCalledTimes(2); 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 { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { BudgetItem, BudgetItemMember } from '../../types' 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 { getApiErrorMessage } from '../../types'
import { withFallbackFx } from '../../hooks/useExchangeRates'
import { notify } from '../notify' import { notify } from '../notify'
type SetState = StoreApi<TripStoreState>['setState'] type SetState = StoreApi<TripStoreState>['setState']
@@ -19,6 +20,16 @@ export interface BudgetSlice {
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void> toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void> reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => 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 => ({ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
@@ -33,7 +44,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => { addBudgetItem: async (tripId, data) => {
try { 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] })) set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item return result.item
} catch (err: unknown) { } catch (err: unknown) {
@@ -43,7 +56,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => { updateBudgetItem: async (tripId, id, data) => {
try { try {
const result = await budgetApi.update(tripId, id, data) const result = await budgetApi.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId)))
set(state => ({ set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) 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') 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 type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { budgetApi } from '../../../../src/api/client' import { budgetApi } from '../../../../src/api/client'
import { localToday } from '../../../../src/components/Planner/today' import { localToday } from '../../../../src/components/Planner/today'
import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates'
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
import { useAuthStore } from '../../../../src/store/authStore' import { useAuthStore } from '../../../../src/store/authStore'
import { useSettingsStore } from '../../../../src/store/settingsStore' import { useSettingsStore } from '../../../../src/store/settingsStore'
@@ -14,7 +15,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server' import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' 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 // 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. // 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() expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' })) 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 () => { 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') 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 () => { 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 // Cleared, the server would store NULL and the ledger would quietly fall
// back to the day the payment was recorded on. // back to the day the payment was recorded on.
+42 -5
View File
@@ -4,6 +4,7 @@ import {
Delete, Delete,
Get, Get,
Headers, Headers,
HttpCode,
HttpException, HttpException,
Param, Param,
Post, Post,
@@ -28,6 +29,8 @@ import {
BudgetReorderCategoriesDto, BudgetReorderCategoriesDto,
BudgetCreateSettlementDto, BudgetCreateSettlementDto,
BudgetUpdateSettlementDto, BudgetUpdateSettlementDto,
BudgetFreezeRatesDto,
BudgetSettlementQueryDto,
} from './budget.dto'; } from './budget.dto';
/** /**
@@ -37,8 +40,9 @@ import {
* every handler verifies trip access (404); mutations check 'budget_edit' (403); * every handler verifies trip access (404); mutations check 'budget_edit' (403);
* create is 201, the rest 200; bespoke 404 bodies reproduced; mutations * create is 201, the rest 200; bespoke 404 bodies reproduced; mutations
* broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes * broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes
* (summary, settlement, reorder/*) are declared before /:id so they win over the * (summary, settlement, freeze-rates, reorder/*) are declared before /:id so they
* param. Updating total_price on a reservation-linked item syncs the price back. * win over the param. Updating total_price on a reservation-linked item syncs the
* price back.
* *
* Bodies are validated against the @trek/shared budget schemas via budget.dto.ts * Bodies are validated against the @trek/shared budget schemas via budget.dto.ts
* (global ZodValidationPipe). This replaced the legacy bespoke 400s ('Name is * (global ZodValidationPipe). This replaced the legacy bespoke 400s ('Name is
@@ -66,14 +70,16 @@ export class BudgetController {
return { summary: await this.budget.perPersonSummary(tripId) }; return { summary: await this.budget.perPersonSummary(tripId) };
} }
// `base_rate` is the caller's own quote for the display currency (units per 1 trip
// currency), used only when the server has none; a malformed one is a 400.
@Get('settlement') @Get('settlement')
settlement( settlement(
@CurrentUser() user: User, @CurrentUser() user: User,
@Trip() trip: TripAccess, @Trip() trip: TripAccess,
@Param('tripId') tripId: string, @Param('tripId') tripId: string,
@Query('base') base?: string, @Query() query: BudgetSettlementQueryDto,
) { ) {
return this.budget.settlement(tripId, base, trip.currency || 'EUR'); return this.budget.settlement(tripId, query.base, trip.currency || 'EUR', query.base_rate);
} }
@Get('settlements') @Get('settlements')
@@ -91,7 +97,10 @@ export class BudgetController {
) { ) {
const settlement = await this.budget.createSettlement( const settlement = await this.budget.createSettlement(
tripId, tripId,
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency, settled_at: body.settled_at }, {
from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount,
currency: body.currency, settled_at: body.settled_at, fallback_fx: body.fallback_fx,
},
user.id, user.id,
); );
// A party who is not on this trip gets the same answer as a settlement that // A party who is not on this trip gets the same answer as a settlement that
@@ -118,6 +127,7 @@ export class BudgetController {
amount: body.amount, amount: body.amount,
currency: body.currency, currency: body.currency,
settled_at: body.settled_at, settled_at: body.settled_at,
fallback_fx: body.fallback_fx,
}); });
if (!settlement) { if (!settlement) {
throw new HttpException({ error: 'Settlement not found' }, 404); throw new HttpException({ error: 'Settlement not found' }, 404);
@@ -141,6 +151,33 @@ export class BudgetController {
return { success: true }; return { success: true };
} }
/**
* Freeze a rate onto every foreign row that has none frozen yet, the ones the
* settlement lists as `unconverted` and the ones it still converts at today's rate:
* the server's own rate first, `fallback_fx` (the browser's) for a currency it cannot
* quote. A frozen row, a row in the trip currency and a row without a currency are
* never touched. The same service call as the freeze_budget_rates MCP tool, which
* lends no rates. 409 when the trip currency changed while the rates were fetched;
* nothing is written then.
*/
@RequirePermission('budget_edit')
@Post('freeze-rates')
@HttpCode(200)
async freezeRates(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: BudgetFreezeRatesDto,
@Headers('x-socket-id') socketId?: string,
) {
const healed = await this.budget.freezeMissingRates(tripId, body.fallback_fx);
if (!healed) {
throw new HttpException({ error: 'The trip currency changed. Reload and try again.' }, 409);
}
for (const item of healed.items) this.budget.broadcast(tripId, 'budget:updated', { item }, socketId);
for (const settlement of healed.settlements) this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
return healed;
}
@RequirePermission('budget_edit') @RequirePermission('budget_edit')
@Post() @Post()
async create( async create(
+5
View File
@@ -9,6 +9,8 @@ import {
budgetReorderCategoriesRequestSchema, budgetReorderCategoriesRequestSchema,
budgetCreateSettlementRequestSchema, budgetCreateSettlementRequestSchema,
budgetUpdateSettlementRequestSchema, budgetUpdateSettlementRequestSchema,
budgetFreezeRatesRequestSchema,
budgetSettlementQuerySchema,
} from '@trek/shared'; } from '@trek/shared';
/** /**
@@ -26,3 +28,6 @@ export class BudgetReorderItemsDto extends createZodDto(budgetReorderItemsReques
export class BudgetReorderCategoriesDto extends createZodDto(budgetReorderCategoriesRequestSchema) {} export class BudgetReorderCategoriesDto extends createZodDto(budgetReorderCategoriesRequestSchema) {}
export class BudgetCreateSettlementDto extends createZodDto(budgetCreateSettlementRequestSchema) {} export class BudgetCreateSettlementDto extends createZodDto(budgetCreateSettlementRequestSchema) {}
export class BudgetUpdateSettlementDto extends createZodDto(budgetUpdateSettlementRequestSchema) {} export class BudgetUpdateSettlementDto extends createZodDto(budgetUpdateSettlementRequestSchema) {}
export class BudgetFreezeRatesDto extends createZodDto(budgetFreezeRatesRequestSchema) {}
/** GET …/settlement query (`base`, and `base_rate` coerced from the query string). */
export class BudgetSettlementQueryDto extends createZodDto(budgetSettlementQuerySchema) {}
+32 -6
View File
@@ -72,7 +72,8 @@ function formatCents(cents: number): string {
* through the freeze-then-write composites so REST and MCP can't diverge on * through the freeze-then-write composites so REST and MCP can't diverge on
* the #1445 FX freeze, and the settlement resource resolves the trip currency * the #1445 FX freeze, and the settlement resource resolves the trip currency
* and live rates like get_settlement_summary instead of silently defaulting * and live rates like get_settlement_summary instead of silently defaulting
* to an unconverted EUR base. * to an unconverted EUR base. freeze_budget_rates has no legacy counterpart; it
* is the REST freeze-rates route without the rate table a browser may lend.
*/ */
@McpController() @McpController()
export class BudgetMcp { export class BudgetMcp {
@@ -177,7 +178,7 @@ export class BudgetMcp {
@Tool({ @Tool({
name: 'create_budget_item', name: 'create_budget_item',
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing.', description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@@ -249,7 +250,7 @@ export class BudgetMcp {
@Tool({ @Tool({
name: 'update_budget_item', name: 'update_budget_item',
description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.', description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
itemId: z.number().int().positive(), itemId: z.number().int().positive(),
@@ -390,7 +391,7 @@ export class BudgetMcp {
@Tool({ @Tool({
name: 'get_settlement_summary', name: 'get_settlement_summary',
description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.", description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in `summary.currency`: the `base` asked for when the server can quote it, otherwise the trip's base currency. An expense or payment in a foreign currency with no exchange rate is left out of every figure and listed under `summary.unconverted` (item_ids, settlement_ids, currencies); freeze_budget_rates pins today's rate on those. Call this before recording a settlement so you know who should pay whom and how much.",
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'), base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
@@ -425,7 +426,7 @@ export class BudgetMcp {
@Tool({ @Tool({
name: 'create_settlement', name: 'create_settlement',
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much.", description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.",
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
from_user_id: z.number().int().positive().describe('User ID of the member who paid'), from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
@@ -455,7 +456,7 @@ export class BudgetMcp {
@Tool({ @Tool({
name: 'update_settlement', name: 'update_settlement',
description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same.', description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
settlementId: z.number().int().positive(), settlementId: z.number().int().positive(),
@@ -505,6 +506,31 @@ export class BudgetMcp {
return ok({ success: true }); return ok({ success: true });
} }
// --- EXCHANGE RATES ---
@Tool({
name: 'freeze_budget_rates',
description: "Pin today's server exchange rate on every expense and settle-up payment of a trip that is in a foreign currency and has no rate frozen yet. That takes in the rows get_settlement_summary lists under `unconverted`, which count in no balance or total until then, and also foreign rows it still converts at today's live rate. Rows that already carry a frozen rate, rows in the trip currency and rows without a currency are never touched, and no rate is taken from the caller. Returns the rows it froze (`items`, `settlements`) and the currencies the server could not quote (`unresolved`), whose rows stay unfrozen.",
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: budgetAddonOn,
access: { group: 'budget', mode: 'write' },
})
async freezeBudgetRates({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
// The same service call as POST …/budget/freeze-rates, without a lent rate table:
// a rate from an MCP caller is never frozen, like everywhere else on this surface.
const healed = await this.budget.freezeMissingRates(tripId);
if (!healed) return errorResult('The trip currency changed. Reload and try again.');
for (const item of healed.items) this.guards.safeBroadcast(tripId, 'budget:updated', { item });
for (const settlement of healed.settlements) this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
return ok(healed);
}
// --- RESOURCES --- // --- RESOURCES ---
@ResourceTemplate({ @ResourceTemplate({
+226 -79
View File
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DatabaseService, type TripAccess } from '../database/database.service'; import { DatabaseService, type TripAccess } from '../database/database.service';
import type { BudgetParticipantFinal, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl'; import { avatarUrl } from '../common/avatarUrl';
import { byCodeUnit } from '../common/compare';
import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types'; import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types';
import { ExchangeRatesService } from './exchange-rates.service'; import { ExchangeRatesService } from './exchange-rates.service';
@@ -89,41 +90,58 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun
} }
/** /**
* An amount of a row, in the row's own currency, in the trip currency. The rate frozen * What `exchange_rate` holds on a row whose rate was never frozen. The column is
* when the row was entered wins (#1335), so a booked expense keeps the value it was * `REAL NOT NULL DEFAULT 1`, so without a migration 1 is the only way to store "no
* booked at. A row written before the freeze existed (rate 1 or none) converts through * rate". It is read in exactly two places, isFrozenRate and UNFROZEN_SQL; nothing
* today's `rates`, units of each currency per 1 of their base, and without them stays * else in this file compares a rate with 1.
* as it is. Pre-rework rows store currency = NULL, which means the trip's own. */
const NOT_FROZEN_RATE = 1;
/** Whether a stored rate is one that was frozen, rather than the "not frozen" sentinel. */
function isFrozenRate(rate: number | null | undefined): rate is number {
return rate != null && Number.isFinite(rate) && rate > 0 && rate !== NOT_FROZEN_RATE;
}
/** The SQL twin of `!isFrozenRate(exchange_rate)`, for the row tables' own column. */
const UNFROZEN_SQL = '(exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)';
/**
* How an amount of a row, in the row's own currency, becomes trip currency, or null when
* nothing can convert it. The rate frozen when the row was entered wins (#1335), so a
* booked expense keeps the value it was booked at. A row written before the freeze
* existed (no frozen rate) converts through today's `rates`, units of each currency per
* 1 of their base. Pre-rework rows store currency = NULL, which means the trip's own.
*
* A foreign row with neither is left out by every caller and reported, rather than read
* as if it were already in the trip currency: 8,920,000 VND counted as that many dollars
* swamps every balance on the trip.
* *
* The settlement nets with it and every trip total adds up with it, so the two can never * The settlement nets with it and every trip total adds up with it, so the two can never
* read the same rows differently. * read the same rows differently.
*/ */
function toTripAmount( function tripConverter(
amount: number,
itemCurrency: string | null | undefined, itemCurrency: string | null | undefined,
itemRate: number | null | undefined, itemRate: number | null | undefined,
tripCurrency: string, tripCurrency: string,
rates: Record<string, number> | null, rates: Record<string, number> | null,
): number { ): ((amount: number) => number) | null {
const cur = (itemCurrency || tripCurrency).toUpperCase(); const cur = (itemCurrency || tripCurrency).toUpperCase();
if (cur === tripCurrency) return amount; if (cur === tripCurrency) return amount => amount;
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; if (isFrozenRate(itemRate)) return amount => amount / itemRate;
if (!rates) return amount; const rCur = rates?.[cur];
const rCur = rates[cur]; const rTrip = rates?.[tripCurrency];
const rTrip = rates[tripCurrency]; if (rCur && rCur > 0 && rTrip && rTrip > 0) return amount => (amount / rCur) * rTrip;
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; return null;
return amount;
} }
/** /**
* Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is * Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is
* units of that currency per 1 base). 1 when either quote is missing, which leaves an * units of that currency per 1 base). Null when either quote is missing.
* amount as it is, the way every conversion here degrades without rates.
*/ */
function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number { function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number | null {
const rTo = rates?.[to]; const rTo = rates?.[to];
const rFrom = rates?.[from]; const rFrom = rates?.[from];
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : 1; return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null;
} }
/** /**
@@ -375,18 +393,25 @@ export class BudgetService {
* "units of the item/display currency per 1 trip currency" — the settlement * "units of the item/display currency per 1 trip currency" — the settlement
* converts with it via `amount / rate`. * converts with it via `amount / rate`.
* *
* Only freezes for a foreign currency with no explicit rate; degrades to live * Only freezes for a foreign currency with no explicit rate. The server's own rate
* rates if the fetch fails. On update it (re)freezes only when the currency * comes first; `fallback_fx`, rates the caller lent for this write, only fills a
* changes (checked against `budget_items`), so an unrelated edit never moves * currency the server cannot quote (resolveRate). With neither, a new row is
* money. Callers must invoke this *before* the (synchronous) DB write — the raw * stored unfrozen and left out of every figure until a rate turns up. On update it
* create/update stay sync because better-sqlite3 transactions can't await. * (re)freezes only when the currency changes (checked against `budget_items`), so
* an unrelated edit never moves money, and a change with no rate at all resets the
* row to unfrozen instead of keeping the old currency's rate. `fallback_fx` is taken
* off `data` either way, so it never reaches the write. Callers must invoke this
* *before* the (synchronous) DB write: the raw create/update stay sync because
* better-sqlite3 transactions can't await.
*/ */
async freezeForeignRate( async freezeForeignRate(
tripId: string | number, tripId: string | number,
data: { currency?: string | null; exchange_rate?: number }, data: { currency?: string | null; exchange_rate?: number; fallback_fx?: BudgetFallbackFx },
existingItemId?: string | number, existingItemId?: string | number,
existingCurrency?: string | null, existingCurrency?: string | null,
): Promise<void> { ): Promise<void> {
const fallback = data.fallback_fx;
delete data.fallback_fx;
if (data.exchange_rate != null) return; // an explicit rate from the caller wins if (data.exchange_rate != null) return; // an explicit rate from the caller wins
const cur = (data.currency || '').toUpperCase(); const cur = (data.currency || '').toUpperCase();
if (!cur) return; // currency not being set in this request if (!cur) return; // currency not being set in this request
@@ -397,16 +422,98 @@ export class BudgetService {
if (existingCurrency !== undefined) { if (existingCurrency !== undefined) {
prior = (existingCurrency || '').toUpperCase(); prior = (existingCurrency || '').toUpperCase();
} else if (existingItemId != null) { } else if (existingItemId != null) {
const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ?', existingItemId); const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ? AND trip_id = ?', existingItemId, tripId);
if (existing) prior = (existing.currency || '').toUpperCase(); if (existing) prior = (existing.currency || '').toUpperCase();
} }
if (prior !== undefined && prior === cur) return; // currency unchanged if (prior !== undefined && prior === cur) return; // currency unchanged
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId); const tripCur = this.tripCurrency(tripId);
const tripCur = (trip?.currency || 'EUR').toUpperCase();
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
const rates = await this.exchangeRates.getRates(tripCur); const rate = this.resolveRate(tripCur, cur, await this.exchangeRates.getRates(tripCur), fallback);
const r = rates?.[cur]; if (rate !== null) data.exchange_rate = rate;
if (r && r > 0) data.exchange_rate = r; // The update SQL keeps the stored rate when none is sent, which left the old
// currency's rate on the row (a USD 1.08 on a VND bill) and counted it as frozen.
else if (prior !== undefined) data.exchange_rate = NOT_FROZEN_RATE;
}
/** The trip's currency, upper case, EUR when it has none (the default the app reads). */
private tripCurrency(tripId: string | number): string {
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
return (trip?.currency || 'EUR').toUpperCase();
}
/**
* The rate to freeze `cur` at on a `tripCur` trip: the server's own quote, else the one
* the caller lent, but only while the lent table is quoted against the trip currency
* (a table against the old currency after a switch would freeze the wrong figure).
* A result that is not a usable frozen rate, exactly 1 among them, counts as none.
*/
private resolveRate(
tripCur: string,
cur: string,
serverRates: Record<string, number> | null,
fallback: BudgetFallbackFx | undefined,
): number | null {
const own = serverRates?.[cur];
if (isFrozenRate(own)) return own;
const lent = fallback && fallback.base.toUpperCase() === tripCur ? fallback.rates[cur] : undefined;
return isFrozenRate(lent) ? lent : null;
}
/**
* Freeze a rate onto every expense and transfer in a foreign currency that never
* froze one: the rows the settlement lists as `unconverted`, and also those it still
* converts at today's live rate. Today's server rate first, `fallback` (rates the
* caller holds) for what the server cannot quote. A row that is frozen, in the trip
* currency or without a currency is never touched, so a second call heals nothing.
*
* The write is a compare-and-set in one transaction: only rows that are still
* unfrozen are updated, and nothing is written when the trip currency changed while
* the rates were fetched (null, so the caller can say so). A healthy trip with
* nothing pending returns before any fetch.
*/
async freezeMissingRates(
tripId: string | number,
fallback?: BudgetFallbackFx,
): Promise<{ items: BudgetItem[]; settlements: NonNullable<ReturnType<BudgetService['getSettlement']>>[]; unresolved: string[] } | null> {
const tripCur = this.tripCurrency(tripId);
const pendingWhere = `trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND ${UNFROZEN_SQL}`;
const pending = this.db.all<{ cur: string }>(`
SELECT UPPER(currency) AS cur FROM budget_items WHERE ${pendingWhere}
UNION
SELECT UPPER(currency) AS cur FROM budget_settlements WHERE ${pendingWhere}
ORDER BY cur
`, tripId, tripCur, tripId, tripCur).map(r => r.cur);
if (pending.length === 0) return { items: [], settlements: [], unresolved: [] };
const serverRates = await this.exchangeRates.getRates(tripCur);
const resolved: [string, number][] = [];
const unresolved: string[] = [];
for (const cur of pending) {
const rate = this.resolveRate(tripCur, cur, serverRates, fallback);
if (rate === null) unresolved.push(cur);
else resolved.push([cur, rate]);
}
if (resolved.length === 0) return { items: [], settlements: [], unresolved };
const written = this.db.transaction(() => {
if (this.tripCurrency(tripId) !== tripCur) return null;
const ids = { budget_items: [] as number[], budget_settlements: [] as number[] };
for (const table of ['budget_items', 'budget_settlements'] as const) {
const where = `trip_id = ? AND UPPER(currency) = ? AND ${UNFROZEN_SQL}`;
for (const [cur, rate] of resolved) {
ids[table].push(...this.db.all<{ id: number }>(`SELECT id FROM ${table} WHERE ${where}`, tripId, cur).map(r => r.id));
this.db.run(`UPDATE ${table} SET exchange_rate = ? WHERE ${where}`, rate, tripId, cur);
}
}
return ids;
});
if (!written) return null;
return {
items: written.budget_items.sort((a, b) => a - b).map(id => this.getBudgetItem(id, tripId)).filter(i => i !== null),
settlements: written.budget_settlements.sort((a, b) => a - b).map(id => this.getSettlement(id, tripId)).filter(s => s !== null),
unresolved,
};
} }
/** /**
@@ -875,18 +982,19 @@ export class BudgetService {
JOIN users u ON bm.user_id = u.id JOIN users u ON bm.user_id = u.id
WHERE bi.trip_id = ? WHERE bi.trip_id = ?
`, tripId); `, tripId);
const toTripCents = (amount: number, item: { currency: string | null; exchange_rate: number | null }) =>
Math.round(toTripAmount(amount, item.currency, item.exchange_rate, tripCurrency, rates) * 100);
const people = new Map<number, { user_id: number; username: string; avatar: string | null; assigned: number; paid: number; items_count: number }>(); const people = new Map<number, { user_id: number; username: string; avatar: string | null; assigned: number; paid: number; items_count: number }>();
for (const item of items) { for (const item of items) {
const own = members.filter(m => m.budget_item_id === item.id); const own = members.filter(m => m.budget_item_id === item.id);
if (own.length === 0) continue; if (own.length === 0) continue;
// A foreign row nothing can convert counts for nobody, as in the settlement.
const convert = tripConverter(item.currency, item.exchange_rate, tripCurrency, rates);
if (!convert) continue;
const toTripCents = (amount: number) => Math.round(convert(amount) * 100);
// A member with an amount of their own owes exactly that; the rest split the // A member with an amount of their own owes exactly that; the rest split the
// total evenly, as the query this replaces did. // total evenly, as the query this replaces did.
const equal = this.splitEqualShares(toTripCents(item.total_price || 0, item), own, item.id); const equal = this.splitEqualShares(toTripCents(item.total_price || 0), own, item.id);
for (const m of own) { for (const m of own) {
const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount, item) : (equal[m.user_id] || 0); const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount) : (equal[m.user_id] || 0);
let p = people.get(m.user_id); let p = people.get(m.user_id);
if (!p) { if (!p) {
p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 }; p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 };
@@ -912,17 +1020,24 @@ export class BudgetService {
* What the trip's expenses add up to in the trip currency, overall and per category * What the trip's expenses add up to in the trip currency, overall and per category
* (#2525). Every row is converted once, the way the settlement converts it, and * (#2525). Every row is converted once, the way the settlement converts it, and
* rounded to a whole cent before anything is added. Summing total_price as stored adds * rounded to a whole cent before anything is added. Summing total_price as stored adds
* dollars to euros and labels the result with the trip currency. * dollars to euros and labels the result with the trip currency. A foreign row nothing
* can convert is left out and its id listed under `unconverted`.
*/ */
tripTotals(tripId: string | number, tripCurrency: string, rates: Record<string, number> | null = null): { total: number; byCategory: Record<string, number> } { tripTotals(tripId: string | number, tripCurrency: string, rates: Record<string, number> | null = null): { total: number; byCategory: Record<string, number>; unconverted: number[] } {
const trip = (tripCurrency || 'EUR').toUpperCase(); const trip = (tripCurrency || 'EUR').toUpperCase();
const rows = this.db.all<{ category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>( const rows = this.db.all<{ id: number; category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
'SELECT category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId, 'SELECT id, category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId,
); );
let total = 0; let total = 0;
const byCategory: Record<string, number> = {}; const byCategory: Record<string, number> = {};
const unconverted: number[] = [];
for (const r of rows) { for (const r of rows) {
const cents = Math.round(toTripAmount(r.total_price || 0, r.currency, r.exchange_rate, trip, rates) * 100); const convert = tripConverter(r.currency, r.exchange_rate, trip, rates);
if (!convert) {
unconverted.push(r.id);
continue;
}
const cents = Math.round(convert(r.total_price || 0) * 100);
total += cents; total += cents;
const cat = r.category || ''; const cat = r.category || '';
byCategory[cat] = (byCategory[cat] || 0) + cents; byCategory[cat] = (byCategory[cat] || 0) + cents;
@@ -930,6 +1045,7 @@ export class BudgetService {
return { return {
total: total / 100, total: total / 100,
byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])), byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])),
unconverted,
}; };
} }
@@ -943,7 +1059,7 @@ export class BudgetService {
const unbooked = this.db.get(` const unbooked = this.db.get(`
SELECT 1 FROM budget_items SELECT 1 FROM budget_items
WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ?
AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1) AND ${UNFROZEN_SQL}
LIMIT 1 LIMIT 1
`, tripId, trip); `, tripId, trip);
return unbooked ? this.exchangeRates.getRates(trip) : null; return unbooked ? this.exchangeRates.getRates(trip) : null;
@@ -998,13 +1114,19 @@ export class BudgetService {
* behind each figure (`sources`) travel with it, in the same display cents: an * behind each figure (`sources`) travel with it, in the same display cents: an
* expense list converted again on the client with today's rate would not add up * expense list converted again on the client with today's rate would not add up
* to a figure that was booked at the rate frozen on entry. * to a figure that was booked at the rate frozen on entry.
*
* A row nothing can convert (a foreign currency with no frozen rate and no live
* one) is left out whole and listed under `unconverted`, so Σ balances stays 0.
* `currency` says what the amounts are in: the display currency when there is a
* quote for it (the rates, else `baseRate`, the caller's own units of display
* currency per 1 trip currency), otherwise the trip currency.
*/ */
calculateSettlement( calculateSettlement(
tripId: string | number, tripId: string | number,
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {}, opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string; baseRate?: number } = {},
) { ) {
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase(); const requested = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
const tripCurrency = (opts.tripCurrency || base).toUpperCase(); const tripCurrency = (opts.tripCurrency || requested).toUpperCase();
const rates = opts.rates ?? null; const rates = opts.rates ?? null;
// Net the whole settlement in the trip's canonical currency and convert the final // Net the whole settlement in the trip's canonical currency and convert the final
// totals to the display currency once, instead of netting in the (moving) display // totals to the display currency once, instead of netting in the (moving) display
@@ -1017,8 +1139,8 @@ export class BudgetService {
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
// it was booked at, so a later live-rate drift doesn't re-open it with a residual. // it was booked at, so a later live-rate drift doesn't re-open it with a residual.
// Legacy rows without a frozen rate convert via base with live rates. // Legacy rows without a frozen rate convert via base with live rates.
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => const converterFor = (itemCurrency: string | null | undefined, itemRate?: number | null) =>
toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates); tripConverter(itemCurrency, itemRate, tripCurrency, rates);
// trip-currency → display currency, applied once to the final netted totals. // trip-currency → display currency, applied once to the final netted totals.
// Held as a plain factor so it is exactly linear: the balances are converted as // Held as a plain factor so it is exactly linear: the balances are converted as
// one set (allocateDisplayCents) rather than one at a time, which is what keeps // one set (allocateDisplayCents) rather than one at a time, which is what keeps
@@ -1030,29 +1152,32 @@ export class BudgetService {
// the display currency converts back to exactly what was typed (#2525). Taken the // the display currency converts back to exactly what was typed (#2525). Taken the
// other way round, from the display currency's quote, the two quotes are not exact // other way round, from the display currency's quote, the two quotes are not exact
// inverses and 12,345.67 USD came back as 12,346.06. // inverses and 12,345.67 USD came back as 12,346.06.
const displayFactor = base === tripCurrency ? 1 : quoteRatio(rates, base, tripCurrency); //
// Without a quote, the caller's own figure for the pair (`baseRate`) stands in for
// the missing display quote, and for nothing else: a row with a currency of its own
// is never converted with it. Like a live quote, it also reads a legacy transfer
// without a currency, which is taken to be in the display currency. With neither,
// the answer stays in the trip currency and says so, rather than printing trip cents
// as the display currency.
const baseRate = opts.baseRate != null && Number.isFinite(opts.baseRate) && opts.baseRate > 0 ? opts.baseRate : null;
const quoted = requested === tripCurrency ? 1 : (quoteRatio(rates, requested, tripCurrency) ?? baseRate);
const currency = quoted === null ? tripCurrency : requested;
const displayFactor = quoted ?? 1;
// A recorded settle-up amount is entered in whatever display currency the payer // A recorded settle-up amount is entered in whatever display currency the payer
// was viewing. New rows capture that currency and the rate frozen at settle time // was viewing. New rows capture that currency and the rate frozen at settle time
// (#1445), so a settled position stays balanced when live rates drift — mirroring // (#1445), so a settled position stays balanced when live rates drift — mirroring
// toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall // the expenses; a frozen currency without a usable rate falls back to live rates,
// back to the old behaviour: assume they were entered in the current display base // and without those the transfer is left out like an expense. Legacy rows
// and convert with live rates. // (currency = NULL) have no frozen rate, so fall back to the old behaviour: assume
const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => { // they were entered in the current display base and convert with live rates.
if (sCurrency) { const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) =>
const cur = sCurrency.toUpperCase(); sCurrency
if (cur === tripCurrency) return amount; ? tripConverter(sCurrency, sRate, tripCurrency, rates)
if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate;
// Frozen currency but no usable rate (fetch failed at settle time): live fallback.
if (rates) {
const rCur = rates[cur];
const rTrip = rates[tripCurrency];
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
}
return amount;
}
// The inverse of the display factor, so such a transfer reads back as entered. // The inverse of the display factor, so such a transfer reads back as entered.
return amount / displayFactor; : (amount: number) => amount / displayFactor;
}; const unconvertedItems: number[] = [];
const unconvertedSettlements: number[] = [];
const unconvertedCurrencies = new Set<string>();
const items = this.db.all<BudgetItem>('SELECT * FROM budget_items WHERE trip_id = ?', tripId); const items = this.db.all<BudgetItem>('SELECT * FROM budget_items WHERE trip_id = ?', tripId);
const allMembers = this.db.all<BudgetItemMember & { budget_item_id: number }>(` const allMembers = this.db.all<BudgetItemMember & { budget_item_id: number }>(`
@@ -1073,8 +1198,6 @@ export class BudgetService {
// and rounded to a cent once, at the boundary — from there on the ledger is // and rounded to a cent once, at the boundary — from there on the ledger is
// integer arithmetic, so Σ(balances) is exactly 0 and no sub-cent residual can // integer arithmetic, so Σ(balances) is exactly 0 and no sub-cent residual can
// build up behind the two-decimal figures the user sees (#1382). // build up behind the two-decimal figures the user sees (#1382).
const toTripCents = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number =>
Math.round(toTrip(amount, itemCurrency, itemRate) * 100);
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; cents: number }> = {}; const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; cents: number }> = {};
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => { const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 }; if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 };
@@ -1099,6 +1222,15 @@ export class BudgetService {
const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents); const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents);
for (const item of items) { for (const item of items) {
// Before anything else, planning-only and unpaid rows included: a row no rate can
// convert is reported whole and moves nothing, credits and shares alike.
const convert = converterFor(item.currency, item.exchange_rate);
if (!convert) {
unconvertedItems.push(item.id);
unconvertedCurrencies.add((item.currency || '').toUpperCase());
continue;
}
const toTripCents = (amount: number): number => Math.round(convert(amount) * 100);
const members = allMembers.filter(m => m.budget_item_id === item.id); const members = allMembers.filter(m => m.budget_item_id === item.id);
const payers = allPayers.filter(p => p.budget_item_id === item.id); const payers = allPayers.filter(p => p.budget_item_id === item.id);
if (members.length === 0) continue; // planning-only entry → doesn't affect balances if (members.length === 0) continue; // planning-only entry → doesn't affect balances
@@ -1126,7 +1258,7 @@ export class BudgetService {
// refund (#2176) — is debited by the same arithmetic: their credit is negative. // refund (#2176) — is debited by the same arithmetic: their credit is negative.
let creditCents = 0; let creditCents = 0;
for (const p of payers) { for (const p of payers) {
const exact = toTrip(p.amount, item.currency, item.exchange_rate) * 100; const exact = convert(p.amount) * 100;
const paid = Math.round(exact); const paid = Math.round(exact);
ensure(p.user_id, p).cents += paid; ensure(p.user_id, p).cents += paid;
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact); frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact);
@@ -1148,7 +1280,7 @@ export class BudgetService {
const equalShares = !hasCustomSplit ? this.splitEqualShares(creditCents, members, item.id) : {}; const equalShares = !hasCustomSplit ? this.splitEqualShares(creditCents, members, item.id) : {};
for (const m of members) { for (const m of members) {
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
? toTripCents(m.amount, item.currency, item.exchange_rate) ? toTripCents(m.amount)
: (equalShares[m.user_id] || 0); : (equalShares[m.user_id] || 0);
ensure(m.user_id, m).cents -= memberShare; ensure(m.user_id, m).cents -= memberShare;
} }
@@ -1165,9 +1297,15 @@ export class BudgetService {
return balances[id]; return balances[id];
}; };
for (const s of settlements) { for (const s of settlements) {
const convert = settleConverterFor(s.currency, s.exchange_rate);
if (!convert) {
unconvertedSettlements.push(s.id);
unconvertedCurrencies.add((s.currency || '').toUpperCase());
continue;
}
// Rounded to a trip cent per transfer, so recording one in a display currency // Rounded to a trip cent per transfer, so recording one in a display currency
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime. // can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
const exact = settleToTrip(s.amount, s.currency, s.exchange_rate) * 100; const exact = convert(s.amount) * 100;
const inTrip = Math.round(exact); const inTrip = Math.round(exact);
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip; ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip; ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip;
@@ -1259,6 +1397,12 @@ export class BudgetService {
}, },
}; };
}) satisfies BudgetParticipantFinal[], }) satisfies BudgetParticipantFinal[],
currency,
unconverted: {
item_ids: unconvertedItems,
settlement_ids: unconvertedSettlements,
currencies: [...unconvertedCurrencies].sort(byCodeUnit),
} satisfies BudgetUnconverted,
}; };
} }
@@ -1373,22 +1517,25 @@ export class BudgetService {
* trip currency's own quote, the one every entry rate was frozen from, so a same-day * trip currency's own quote, the one every entry rate was frozen from, so a same-day
* amount in the display currency round-trips to the cent and paying what settle-up * amount in the display currency round-trips to the cent and paying what settle-up
* offers closes the balance (#2525). The display currency's quote only stands in when * offers closes the balance (#2525). The display currency's quote only stands in when
* the trip's cannot be fetched. * the trip's cannot be fetched, and `baseRate` (the caller's own units of display
* currency per 1 trip currency) only when neither can. It only stands in for that
* missing display quote, which also reads a legacy transfer without a currency (taken
* to be in the display currency, as with a live quote).
*/ */
async settlement(tripId: string | number, base: string | undefined, tripCurrency: string) { async settlement(tripId: string | number, base: string | undefined, tripCurrency: string, baseRate?: number) {
const trip = (tripCurrency || 'EUR').toUpperCase(); const trip = (tripCurrency || 'EUR').toUpperCase();
const effectiveBase = (base || trip).toUpperCase(); const effectiveBase = (base || trip).toUpperCase();
const rates = (await this.exchangeRates.getRates(trip)) const rates = (await this.exchangeRates.getRates(trip))
?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase)); ?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase));
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip }); return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip, baseRate });
} }
async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1]) { async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1] & { fallback_fx?: BudgetFallbackFx }) {
await this.freezeForeignRate(tripId, data); await this.freezeForeignRate(tripId, data);
return this.createBudgetItem(tripId, data); return this.createBudgetItem(tripId, data);
} }
async update(id: string | number, tripId: string | number, data: Parameters<BudgetService['updateBudgetItem']>[2]) { async update(id: string | number, tripId: string | number, data: Parameters<BudgetService['updateBudgetItem']>[2] & { fallback_fx?: BudgetFallbackFx }) {
await this.freezeForeignRate(tripId, data, id); await this.freezeForeignRate(tripId, data, id);
return this.updateBudgetItem(id, tripId, data); return this.updateBudgetItem(id, tripId, data);
} }
@@ -1414,7 +1561,7 @@ export class BudgetService {
return roster.has(data.from_user_id) && roster.has(data.to_user_id); return roster.has(data.from_user_id) && roster.has(data.to_user_id);
} }
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, userId: number) { async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }, userId: number) {
if (!this.settlementPartiesOnTrip(tripId, data)) return null; if (!this.settlementPartiesOnTrip(tripId, data)) return null;
// Freeze the FX rate for the display currency the amount was entered in so the // Freeze the FX rate for the display currency the amount was entered in so the
// transfer keeps cancelling its expense when live rates drift (#1445). // transfer keeps cancelling its expense when live rates drift (#1445).
@@ -1422,7 +1569,7 @@ export class BudgetService {
return this.insertSettlement(tripId, data, userId); return this.insertSettlement(tripId, data, userId);
} }
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }) { async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }) {
// Pass the settlement's stored currency so an edit that doesn't change it keeps // Pass the settlement's stored currency so an edit that doesn't change it keeps
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a // the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
// settled position on an unrelated edit. // settled position on an unrelated edit.
@@ -8,14 +8,16 @@ import { Injectable } from '@nestjs/common';
* settlement request never hammers the upstream. Rates are "units of X per 1 * settlement request never hammers the upstream. Rates are "units of X per 1
* base", so an amount in currency C converts to base as `amount / rates[C]`. * base", so an amount in currency C converts to base as `amount / rates[C]`.
* *
* Everything degrades gracefully: if the fetch fails (offline, upstream down), * Everything degrades without throwing: if the fetch fails (offline, upstream
* callers get `null`/identity conversion and amounts are treated as already in * down), callers get `null`. Budget rows that already froze a rate still convert;
* the base currency rather than throwing. * a foreign row that did not is left out of the figures and reported rather than
* read as if it were already in the base currency. A failed fetch is not
* remembered: the next request for that base asks the upstream again.
* *
* Moved from the legacy `services/exchangeRateService.ts` (the budget-domain * Moved from the legacy `services/exchangeRateService.ts` (the budget-domain
* fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and * fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and
* null degradation. The cache and inflight maps are deliberately MODULE-scoped, * null degradation. The cache and inflight maps are deliberately MODULE-scoped,
* not instance fields — the DI singleton and the out-of-container instances * not instance fields: the DI singleton and the out-of-container instances
* (the trips/airtrail/auth bridges each construct their own) wrap the same * (the trips/airtrail/auth bridges each construct their own) wrap the same
* upstream feed, so they must share one cache (same reasoning as the * upstream feed, so they must share one cache (same reasoning as the
* permissions-cache.ts cache). * permissions-cache.ts cache).
@@ -73,6 +73,8 @@ export class TripReadModelService {
total: totals.total, total: totals.total,
by_category: totals.byCategory, by_category: totals.byCategory,
currency: trip.currency, currency: trip.currency,
// Rows in a foreign currency with no rate to convert them: in no total above.
unconverted_item_ids: totals.unconverted,
}; };
// Thread the viewer so another member's private/personal packing items (#858) // Thread the viewer so another member's private/personal packing items (#858)
+5 -1
View File
@@ -63,9 +63,13 @@ export class TripPromptsMcp {
.join('\n'); .join('\n');
const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length);
const perPerson = (total / memberCount).toFixed(2); const perPerson = (total / memberCount).toFixed(2);
// Rows no rate could convert are in none of the figures above; say so rather than
// let the total pass for the whole trip.
const uncounted = budget?.unconverted_item_ids?.length || 0;
const uncountedLine = uncounted > 0 ? `\n\n${uncounted} expense(s) not counted yet: no exchange rate.` : '';
return { return {
description: `Budget overview for "${trip?.title || tripId}"`, description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}${uncountedLine}` } }],
}; };
} }
+156
View File
@@ -294,4 +294,160 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Settlement not found' }); expect(res.body).toEqual({ error: 'Settlement not found' });
}); });
// The VND/AUD report. The rates override above answers null for every base, which is
// exactly that server: it never reached the rates provider, so the only rates there
// are the ones the browser lends.
describe('rows no rate can convert', () => {
/** A fresh AUD trip the owner shares with user 2, so these cases never meet the ledger above. */
function audTrip(): number {
const id = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'AUD Trip', 'AUD')").run().lastInsertRowid);
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(id);
canAccessTrip.mockReturnValue({ id, user_id: 1, currency: 'AUD' });
return id;
}
const balanceOf = (body: { balances: { user_id: number; balance: number }[] }, uid: number) =>
body.balances.find(b => b.user_id === uid)!.balance;
it('VND/AUD regression: unfrozen VND bill is listed as unconverted, freeze-rates heals it from the browser quote, settlement nets 489.00, second freeze heals nothing', async () => {
const trip = audTrip();
const bill = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Pho', currency: 'VND', payers: [{ user_id: 1, amount: 8920000 }], member_ids: [1, 2] });
expect(bill.status).toBe(201);
expect(bill.body.item.exchange_rate).toBe(1);
// Left out whole before anything is written: no balance in VND's order of magnitude.
const before = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1));
expect(before.status).toBe(200);
expect(before.body.currency).toBe('AUD');
expect(before.body.unconverted).toEqual({ item_ids: [bill.body.item.id], settlement_ids: [], currencies: ['VND'] });
expect(before.body.balances).toEqual([]);
const fallback_fx = { base: 'AUD', rates: { VND: 18241.3, EUR: 0.61 } };
const healed = await request(server)
.post(`/api/trips/${trip}/budget/freeze-rates`)
.set('Cookie', sessionCookie(1))
.send({ fallback_fx });
expect(healed.status).toBe(200);
expect(healed.body.items.map((i: { id: number; exchange_rate: number }) => [i.id, i.exchange_rate])).toEqual([[bill.body.item.id, 18241.3]]);
expect(healed.body).toMatchObject({ settlements: [], unresolved: [] });
const after = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1));
expect(after.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
// 8,920,000 VND at 18,241.3 per dollar is 489.00 AUD, half of it user 2's.
expect(after.body.finalBudgets.find((f: { user_id: number }) => f.user_id === 1).expenses).toBe(489);
expect(balanceOf(after.body, 1)).toBe(244.5);
expect(balanceOf(after.body, 2)).toBe(-244.5);
const again = await request(server)
.post(`/api/trips/${trip}/budget/freeze-rates`)
.set('Cookie', sessionCookie(1))
.send({ fallback_fx: { base: 'AUD', rates: { VND: 25000 } } });
expect(again.status).toBe(200);
expect(again.body).toEqual({ items: [], settlements: [], unresolved: [] });
expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(18241.3);
});
it('POST with fallback_fx freezes at entry', async () => {
const trip = audTrip();
const res = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
expect(res.status).toBe(201);
expect(res.body.item.exchange_rate).toBe(18241.3);
expect(res.body.item).not.toHaveProperty('fallback_fx');
});
it('POST /settlements in EUR with fallback_fx freezes the transfer', async () => {
const trip = audTrip();
const res = await request(server)
.post(`/api/trips/${trip}/budget/settlements`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 30.5, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } });
expect(res.status).toBe(201);
expect(res.body.settlement).toMatchObject({ currency: 'EUR', exchange_rate: 0.61 });
// Read back in euros at the same browser quote, the transfer is what was typed.
const s = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1));
expect(s.body.currency).toBe('EUR');
expect(s.body.unconverted.settlement_ids).toEqual([]);
expect(balanceOf(s.body, 2)).toBe(30.5);
expect(balanceOf(s.body, 1)).toBe(-30.5);
});
it('GET /settlement?base=EUR answers in AUD without base_rate and in EUR with it', async () => {
const trip = audTrip();
const dinner = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] });
expect(dinner.status).toBe(201);
const plain = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR`).set('Cookie', sessionCookie(1));
expect(plain.status).toBe(200);
// Trip dollars, labelled as such rather than printed as euros.
expect(plain.body.currency).toBe('AUD');
expect(balanceOf(plain.body, 2)).toBe(-50);
const quoted = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1));
expect(quoted.status).toBe(200);
expect(quoted.body.currency).toBe('EUR');
expect(balanceOf(quoted.body, 2)).toBe(-30.5);
});
it('PUT currency change to VND without a rate stores 1 instead of the USD rate', async () => {
const trip = audTrip();
const created = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 });
expect(created.body.item.exchange_rate).toBe(0.65);
const res = await request(server)
.put(`/api/trips/${trip}/budget/${created.body.item.id}`)
.set('Cookie', sessionCookie(1))
.send({ currency: 'VND' });
expect(res.status).toBe(200);
expect(res.body.item).toMatchObject({ currency: 'VND', exchange_rate: 1 });
expect(db.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(created.body.item.id))
.toEqual({ currency: 'VND', exchange_rate: 1 });
});
it('freeze-rates 403 without budget_edit, 400 on malformed fallback_fx, other base heals nothing (unresolved VND)', async () => {
const trip = audTrip();
const bill = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Pho', currency: 'VND', total_price: 100000 });
expect(bill.status).toBe(201);
const freeze = (body: object) => request(server).post(`/api/trips/${trip}/budget/freeze-rates`).set('Cookie', sessionCookie(1)).send(body);
checkPermission.mockReturnValue(false);
const denied = await freeze({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
expect(denied.status).toBe(403);
expect(denied.body).toEqual({ error: 'No permission' });
checkPermission.mockReturnValue(true);
for (const fallback_fx of [{ base: 'aud', rates: { VND: 18241.3 } }, { base: 'AUD', rates: { VND: 0 } }, { base: 'AUD', rates: {} }]) {
const malformed = await freeze({ fallback_fx });
expect(malformed.status).toBe(400);
expect(malformed.body.error).toContain('fallback_fx');
}
// A table against euros would freeze the bill against the wrong currency.
const otherBase = await freeze({ fallback_fx: { base: 'EUR', rates: { VND: 27000 } } });
expect(otherBase.status).toBe(200);
expect(otherBase.body).toEqual({ items: [], settlements: [], unresolved: ['VND'] });
expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(1);
});
it('400 on base_rate=abc', async () => {
const res = await request(server).get(`/api/trips/${tripId}/budget/settlement?base=EUR&base_rate=abc`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(400);
expect(res.body.error).toContain('base_rate');
});
});
}); });
+3
View File
@@ -394,6 +394,9 @@ describe('Budget summary and settlement', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.balances).toEqual([]); expect(res.body.balances).toEqual([]);
expect(res.body.flows).toEqual([]); expect(res.body.flows).toEqual([]);
// Additive since the VND/AUD report: what the amounts are in, and what was left out.
expect(res.body.currency).toBe('EUR');
expect(res.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
}); });
}); });
@@ -362,6 +362,7 @@ describe('Settlement tools', () => {
{ id: user.id } as User, { id: user.id } as User,
{ id: trip.id, user_id: user.id } as TripAccess, { id: trip.id, user_id: user.id } as TripAccess,
String(trip.id), String(trip.id),
{},
); );
await withHarness(user.id, async (h) => { await withHarness(user.id, async (h) => {
@@ -398,7 +399,7 @@ describe('Settlement tools', () => {
{ id: user.id } as User, { id: user.id } as User,
{ id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess, { id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess,
String(trip.id), String(trip.id),
'USD', { base: 'USD' },
); );
await withHarness(user.id, async (h) => { await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } }); const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } });
+134
View File
@@ -40,6 +40,8 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db'; import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem, createPlace, addTripMember } from '../../helpers/factories'; import { createUser, createTrip, createBudgetItem, createPlace, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
beforeAll(() => { beforeAll(() => {
createTables(testDb); createTables(testDb);
@@ -953,3 +955,135 @@ describe('Budget tools: a custom split cannot be certified against a total the r
}); });
}); });
}); });
// ---------------------------------------------------------------------------
// Foreign rows with no exchange rate (the VND/AUD report)
//
// The rate cache is module-scoped and outlives a case, so each case below uses a
// trip currency no other case in this file asks the rates for: AUD is quoted by
// its stub, NZD is left to the failing fetch every case starts with.
// ---------------------------------------------------------------------------
/** A trip in `currency` with one unfrozen VND bill and one unfrozen VND transfer. */
function tripWithVndRows(currency: string) {
const { user, other, trip } = tripWithTwo();
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run(currency, trip.id);
testDb.prepare('DELETE FROM budget_settlements WHERE trip_id = ?').run(trip.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Pho', total_price: 8920000 });
testDb.prepare("UPDATE budget_items SET currency = 'VND', exchange_rate = 1 WHERE id = ?").run(item.id);
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
.run(item.id, user.id, item.id, other.id);
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
.run(item.id, user.id, 8920000);
const settlementId = Number(testDb.prepare(
"INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate) VALUES (?, ?, ?, ?, 'VND', 1)",
).run(trip.id, other.id, user.id, 100000).lastInsertRowid);
return { user, other, trip, item, settlementId };
}
describe('Tool: freeze_budget_rates', () => {
it('refuses a demo user, a stranger and a member without budget_edit, writing nothing', async () => {
const { other, trip, item, settlementId } = tripWithVndRows('AUD');
const { user: stranger } = createUser(testDb);
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates');
try {
await withHarness(stranger.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
// budget_edit lowered to the owner, the way the admin permission panel does it.
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_budget_edit', 'trip_owner');
invalidatePermissionsCache();
await withHarness(other.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
invalidatePermissionsCache();
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@nomad.app' });
addTripMember(testDb, trip.id, demo.id);
await withHarness(demo.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
expect(spy).not.toHaveBeenCalled();
expect(itemRow(item.id).exchange_rate).toBe(1);
expect(settlementRow(settlementId).exchange_rate).toBe(1);
} finally {
spy.mockRestore();
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
invalidatePermissionsCache();
}
});
it('calls freezeMissingRates without a quote of its own, freezes the server rate and broadcasts', async () => {
const { user, trip, item, settlementId } = tripWithVndRows('AUD');
stubRates({ VND: 18241.3 });
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates');
try {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBeFalsy();
// No rate table travels with the MCP call: only the server's own rate is frozen.
expect(spy.mock.calls).toEqual([[trip.id]]);
const data = parseToolResult(result) as { items: { id: number }[]; settlements: { id: number }[]; unresolved: string[] };
expect(data.items.map(i => i.id)).toEqual([item.id]);
expect(data.settlements.map(s => s.id)).toEqual([settlementId]);
expect(data.unresolved).toEqual([]);
expect(itemRow(item.id).exchange_rate).toBe(18241.3);
expect(settlementRow(settlementId).exchange_rate).toBe(18241.3);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.objectContaining({ item: expect.objectContaining({ id: item.id }) }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-updated', expect.objectContaining({ settlement: expect.objectContaining({ id: settlementId }) }));
// Everything is frozen now, so a second call finds nothing to heal.
const again = parseToolResult(await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }));
expect(again).toEqual({ items: [], settlements: [], unresolved: [] });
});
} finally {
spy.mockRestore();
}
});
it('answers with an error and broadcasts nothing when the trip currency changed during the fetch', async () => {
const { user, trip, item, settlementId } = tripWithVndRows('AUD');
// The service's own answer for that race (BUDGET-SVC-DB-066): null, nothing written.
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates').mockResolvedValueOnce(null);
try {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('The trip currency changed');
});
expect(spy).toHaveBeenCalledTimes(1);
expect(broadcastMock).not.toHaveBeenCalled();
expect(itemRow(item.id).exchange_rate).toBe(1);
expect(settlementRow(settlementId).exchange_rate).toBe(1);
} finally {
spy.mockRestore();
}
});
});
describe('Tool: get_settlement_summary (unconverted rows)', () => {
it('returns the currency it answers in and the rows no rate could convert', async () => {
const { user, trip, item, settlementId } = tripWithVndRows('NZD');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
const { summary } = parseToolResult(result) as {
summary: {
currency: string; balances: unknown[]; flows: unknown[];
unconverted: { item_ids: number[]; settlement_ids: number[]; currencies: string[] };
};
};
expect(summary.currency).toBe('NZD');
expect(summary.unconverted).toEqual({ item_ids: [item.id], settlement_ids: [settlementId], currencies: ['VND'] });
// Left out whole: no balance in VND's order of magnitude, and nothing to settle.
expect(summary.balances).toEqual([]);
expect(summary.flows).toEqual([]);
});
});
});
@@ -172,6 +172,7 @@ beforeEach(() => {
total: totals.total, total: totals.total,
by_category: totals.byCategory, by_category: totals.byCategory,
currency: trip.currency, currency: trip.currency,
unconverted_item_ids: totals.unconverted,
}, },
packing: packingRows, // array shape; packing prompt tolerates it packing: packingRows, // array shape; packing prompt tolerates it
reservations: [], reservations: [],
@@ -535,6 +536,32 @@ describe('Prompt: budget-overview', () => {
expect(text).not.toContain('801.76'); expect(text).not.toContain('801.76');
}); });
it('says how many expenses no exchange rate could put into the total', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Dong Trip' });
// Stored with the "not frozen" rate 1, and no rates to convert it with.
promptBudget.createBudgetItem(trip.id, {
name: 'Pho', category: 'Food', currency: 'VND', total_price: 8920000, members: [{ user_id: user.id }],
});
createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 });
const client = await buildServer(user.id);
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
expect(text).toContain('Total: 100 EUR');
expect(text).toContain('1 expense(s) not counted yet: no exchange rate.');
expect(text).not.toContain('8920000');
});
it('adds no such line when every expense converts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Euro Trip' });
createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 });
const client = await buildServer(user.id);
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
expect(text).not.toContain('not counted yet');
});
it('renders "No expenses recorded." when budget array is empty', async () => { it('renders "No expenses recorded." when budget array is empty', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Empty Budget' }); const trip = createTrip(testDb, user.id, { title: 'Empty Budget' });
@@ -1,6 +1,8 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common'; import { HttpException } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import { BudgetController } from '../../../src/nest/budget/budget.controller'; import { BudgetController } from '../../../src/nest/budget/budget.controller';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import type { BudgetService } from '../../../src/nest/budget/budget.service'; import type { BudgetService } from '../../../src/nest/budget/budget.service';
import type { User } from '../../../src/types'; import type { User } from '../../../src/types';
@@ -57,8 +59,8 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
} as Partial<BudgetService>); } as Partial<BudgetService>);
expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
// A trip with no currency set falls back to EUR rather than passing undefined on. // A trip with no currency set falls back to EUR rather than passing undefined on.
expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5', {})).toEqual({ transfers: [] });
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR', undefined);
}); });
it('GET /settlement forwards the base query and the trip currency', () => { it('GET /settlement forwards the base query and the trip currency', () => {
@@ -67,8 +69,16 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }), verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
settlement, settlement,
} as Partial<BudgetService>); } as Partial<BudgetService>);
new BudgetController(svc).settlement(user, tripRow, '5', 'GBP'); new BudgetController(svc).settlement(user, tripRow, '5', { base: 'GBP' });
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD'); expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD', undefined);
});
it('GET /settlement forwards base and base_rate', () => {
const settlement = vi.fn().mockReturnValue({ transfers: [] });
const svc = makeService({ settlement } as Partial<BudgetService>);
// The Zod pipe has coerced base_rate to a number by the time the handler runs.
new BudgetController(svc).settlement(user, tripRow, '5', { base: 'EUR', base_rate: 0.61 });
expect(settlement).toHaveBeenCalledWith('5', 'EUR', 'USD', 0.61);
}); });
describe('settlements ledger', () => { describe('settlements ledger', () => {
@@ -92,6 +102,18 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
}); });
it('POST and PUT /settlements forward fallback_fx', async () => {
const fallback_fx = { base: 'AUD', rates: { EUR: 0.61 } };
const createSettlement = vi.fn().mockResolvedValue({ id: 3 });
const updateSettlement = vi.fn().mockResolvedValue({ id: 3 });
const svc = makeService({ createSettlement, updateSettlement } as Partial<BudgetService>);
const body = { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR', fallback_fx };
await new BudgetController(svc).createSettlement(user, '5', body);
expect(createSettlement).toHaveBeenCalledWith('5', expect.objectContaining({ currency: 'EUR', fallback_fx }), user.id);
await new BudgetController(svc).updateSettlement(user, '5', '3', body);
expect(updateSettlement).toHaveBeenCalledWith('3', '5', expect.objectContaining({ currency: 'EUR', fallback_fx }));
});
it('DELETE /settlements/:id 404 when missing', () => { it('DELETE /settlements/:id 404 when missing', () => {
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>); const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({ expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
@@ -125,6 +147,43 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
}); });
}); });
describe('POST /freeze-rates', () => {
const fallback_fx = { base: 'AUD', rates: { VND: 18241.3 } };
it('forwards fallback_fx and broadcasts per row', async () => {
const healed = {
items: [{ id: 7, exchange_rate: 18241.3 }, { id: 8, exchange_rate: 18241.3 }],
settlements: [{ id: 3, exchange_rate: 18241.3 }],
unresolved: ['XAF'],
};
const freezeMissingRates = vi.fn().mockResolvedValue(healed);
const broadcast = vi.fn();
const svc = makeService({ freezeMissingRates, broadcast } as Partial<BudgetService>);
expect(await new BudgetController(svc).freezeRates(user, '5', { fallback_fx }, 'sock')).toEqual(healed);
expect(freezeMissingRates).toHaveBeenCalledWith('5', fallback_fx);
expect(broadcast.mock.calls).toEqual([
['5', 'budget:updated', { item: healed.items[0] }, 'sock'],
['5', 'budget:updated', { item: healed.items[1] }, 'sock'],
['5', 'budget:settlement-updated', { settlement: healed.settlements[0] }, 'sock'],
]);
});
it('answers 409 and broadcasts nothing when the trip currency changed meanwhile', async () => {
const broadcast = vi.fn();
const svc = makeService({ freezeMissingRates: vi.fn().mockResolvedValue(null), broadcast } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).freezeRates(user, '5', {}))).toEqual({
status: 409, body: { error: 'The trip currency changed. Reload and try again.' },
});
expect(broadcast).not.toHaveBeenCalled();
});
it('requires budget_edit like freeze_budget_rates, and answers 200', () => {
const handler = BudgetController.prototype.freezeRates;
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('budget_edit');
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
});
});
describe('POST /', () => { describe('POST /', () => {
// The legacy 'Name is required' 400 is now produced by the global // The legacy 'Name is required' 400 is now produced by the global
@@ -955,6 +955,165 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => {
}); });
}); });
// ── Rows no rate can convert (the VND/AUD report) ───────────────────────────
// A foreign row with no frozen rate and no live one used to be read 1:1, as if it
// were already in the trip currency: 8,920,000 VND became 8,920,000 AUD of debt.
describe('calculateSettlement: unconverted rows', () => {
const four = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), makeMember(1, 4, 'dave')];
const vndBill = (exchange_rate: number) => ({ ...makeItem(1, 8920000), currency: 'VND', exchange_rate } as BudgetItem);
const none = { item_ids: [], settlement_ids: [], currencies: [] };
it('VND/AUD regression: an unfrozen 8,920,000 VND bill on an AUD trip with no rates is left out whole, Σ balances 0, no VND-scale balance', () => {
setupDb(
[vndBill(1), makeItem(2, 40)],
[...four(), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol')],
[makePayer(1, 1, 8920000, 'alice'), makePayer(2, 2, 40, 'bob')],
);
const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['VND'] });
expect(result.currency).toBe('AUD');
// Only the 40 AUD dinner settles; the bill moves nobody, its payer included.
expect(result.balances.map(b => [b.user_id, b.balance])).toEqual([[2, 20], [3, -20]]);
expect(centSum(result.balances.map(b => b.balance))).toBe(0);
expect(result.balances.every(b => Math.abs(b.balance) < 1000)).toBe(true);
expect(result.finalBudgets.flatMap(f => f.sources.fronted.map(r => r.item_id))).toEqual([2]);
});
it('VND/AUD regression: frozen at 18241.3 it books 489.00 AUD, split 4 ways +366.75 / -122.25 x3', () => {
setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]);
const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
expect(result.unconverted).toEqual(none);
expect(result.balances.map(b => b.balance)).toEqual([366.75, -122.25, -122.25, -122.25]);
expect(result.finalBudgets.find(f => f.user_id === 1)!.expenses).toBe(489);
});
it('a foreign row the rates do not quote is left out and listed while others convert live', () => {
setupDb(
[vndBill(1), { ...makeItem(2, 65), currency: 'USD', exchange_rate: 1 } as BudgetItem],
[...four(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
[makePayer(1, 1, 8920000, 'alice'), makePayer(2, 1, 65, 'alice')],
);
const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: { AUD: 1, USD: 0.65 } });
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['VND'] });
// 65 USD at today's 0.65 per dollar is 100 AUD, half of it Bob's.
expect(result.balances.map(b => [b.user_id, b.balance])).toEqual([[1, 50], [2, -50]]);
});
it('a frozen row converts without any rates (unchanged)', () => {
setupDb(
[{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 110, 'alice')],
);
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: null });
expect(result.unconverted).toEqual(none);
expect(result.balances.find(b => b.user_id === 2)!.balance).toBe(-50);
});
it('trip-currency and NULL-currency rows with rate 1 count as they are', () => {
setupDb(
[{ ...makeItem(1, 100), currency: 'aud', exchange_rate: 1 } as BudgetItem, { ...makeItem(2, 60), currency: null, exchange_rate: 1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
[makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 60, 'alice')],
);
const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
expect(result.unconverted).toEqual(none);
expect(result.balances.find(b => b.user_id === 2)!.balance).toBe(-80);
});
it('planning-only and unpaid unconvertible rows are listed but never touch balances', () => {
setupDb(
[
{ ...makeItem(1, 500000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // planning-only
{ ...makeItem(2, 700000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // nobody paid yet
makeItem(3, 30),
],
[makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'), makeMember(3, 1, 'alice'), makeMember(3, 2, 'bob')],
[makePayer(3, 1, 30, 'alice')],
);
const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
expect(result.unconverted).toEqual({ item_ids: [1, 2], settlement_ids: [], currencies: ['VND'] });
expect(result.balances.map(b => [b.user_id, b.balance])).toEqual([[1, 15], [2, -15]]);
});
it('an unfrozen foreign transfer without a quote is left out and listed in settlement_ids', () => {
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 100, 'alice')],
[makeSettlementRow(9, 2, 1, 1000000, 'VND', 1)],
);
const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
expect(result.unconverted).toEqual({ item_ids: [], settlement_ids: [9], currencies: ['VND'] });
// A million dong did not square a 50 dollar debt 20,000 times over.
expect(result.balances.find(b => b.user_id === 2)!.balance).toBe(-50);
expect(result.finalBudgets.every(f => f.sources.moved.length === 0)).toBe(true);
// Still on the ledger list, so it can be edited or undone.
expect(result.settlements.map(s => s.id)).toEqual([9]);
});
it('a NULL-currency transfer still reads in the display currency', () => {
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 100, 'alice')],
[makeSettlementRow(9, 2, 1, 62.5, null, 1)],
);
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.25 } });
expect(result.currency).toBe('USD');
expect(result.unconverted).toEqual(none);
expect(result.balances.map(b => b.balance)).toEqual([0, 0]);
});
it('base EUR on an AUD trip without quote or baseRate answers in AUD with currency \'AUD\'', () => {
setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')]);
for (const rates of [null, { AUD: 1, USD: 0.65 }]) {
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates });
// Trip cents, labelled as what they are rather than printed as euros.
expect(result.currency).toBe('AUD');
expect(result.balances.map(b => b.balance)).toEqual([50, -50]);
}
});
it('baseRate stands in for the missing display quote and a transfer frozen at it cancels its flow to the cent (#2525)', () => {
const bill = () => [makeItem(1, 123.45)];
const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')];
const paid = () => [makePayer(1, 1, 123.45, 'alice')];
setupDb(bill(), pair(), paid());
const before = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 });
expect(before.currency).toBe('USD');
// Bob's 61.73 EUR at the browser's 1.1429 dollars per euro.
expect(before.flows.map(f => f.amount)).toEqual([70.55]);
// Bob pays what settle-up offers, frozen at the same browser quote (fallback_fx).
setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]);
const after = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 });
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
expect(after.flows).toEqual([]);
});
it('a server quote wins over baseRate', () => {
setupDb([makeItem(1, 123.45)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 123.45, 'alice')]);
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1429 }, baseRate: 2 });
expect(result.currency).toBe('USD');
expect(result.flows.map(f => f.amount)).toEqual([70.55]);
});
it('baseRate never converts a row', () => {
setupDb(
[{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 100, 'alice')],
);
// 0.61 EUR per AUD would convert this bill arithmetically; it only relabels the result.
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates: null, baseRate: 0.61 });
expect(result.currency).toBe('EUR');
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['EUR'] });
expect(result.balances).toEqual([]);
});
});
// ── freezeForeignRate (write-path FX freeze, #1445) ─────────────────────────── // ── freezeForeignRate (write-path FX freeze, #1445) ───────────────────────────
describe('freezeForeignRate', () => { describe('freezeForeignRate', () => {
@@ -1187,6 +1187,7 @@ describe('trip totals read every row in the trip currency (#2525)', () => {
expect(budget.tripTotals(trip.id, 'EUR')).toEqual({ expect(budget.tripTotals(trip.id, 'EUR')).toEqual({
total: 785.26, total: 785.26,
byCategory: { accommodation: 685.26, food: 100 }, byCategory: { accommodation: 685.26, food: 100 },
unconverted: [],
}); });
// Both rows carry what they need, so nothing waits on today's rates. // Both rows carry what they need, so nothing waits on today's rates.
expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull(); expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull();
@@ -1313,3 +1314,245 @@ describe('the settlement converts with the quote the entry rate was frozen from
} }
}); });
}); });
// The VND/AUD report: the server reached no rates, so a VND bill was stored with the
// "not frozen" rate 1 and read as 8,920,000 AUD. RATES has no AUD quote, so every AUD
// trip below is that server; a rate can only come from what the caller lends.
describe('rows no rate can convert, and the rates that heal them', () => {
const VND_FX = { base: 'AUD', rates: { VND: 18241.3 } };
const rateOf = (table: 'budget_items' | 'budget_settlements', id: number) =>
(testDb.prepare(`SELECT exchange_rate FROM ${table} WHERE id = ?`).get(id) as { exchange_rate: number }).exchange_rate;
const exchangeRates = () => (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates;
function seedAudTrip() {
const { user: me } = createUser(testDb);
const { user: bob } = createUser(testDb);
const { user: carol } = createUser(testDb);
const { user: dave } = createUser(testDb);
const trip = createTrip(testDb, me.id);
for (const u of [bob, carol, dave]) addTripMember(testDb, trip.id, u.id);
testDb.prepare("UPDATE trips SET currency = 'AUD' WHERE id = ?").run(trip.id);
const members = [me, bob, carol, dave].map(u => ({ user_id: u.id }));
return { trip, me, bob, carol, members };
}
it('BUDGET-SVC-DB-056: freezes fallback_fx when getRates is null', async () => {
const { trip, me, members } = seedAudTrip();
const bill = await budget.create(String(trip.id), {
name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members, fallback_fx: VND_FX,
});
expect(bill.exchange_rate).toBe(18241.3);
const s = await budget.settlement(trip.id, 'AUD', 'AUD');
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(366.75);
});
it('BUDGET-SVC-DB-057: server quote wins', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bill = await budget.create(String(trip.id), {
name: 'Taxi', currency: 'USD', total_price: 20, fallback_fx: { base: 'EUR', rates: { USD: 2 } },
});
expect(bill.exchange_rate).toBe(RATES.EUR.USD);
});
it('BUDGET-SVC-DB-058: fallback_fx on another base is ignored', async () => {
const { trip } = seedAudTrip();
// Quoted against euros, the figure would freeze the bill against the wrong currency.
const bill = await budget.create(String(trip.id), {
name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'EUR', rates: { VND: 27000 } },
});
expect(bill.exchange_rate).toBe(1);
});
it('BUDGET-SVC-DB-059: explicit exchange_rate wins, fallback_fx never reaches the row', async () => {
const { trip } = seedAudTrip();
const data = { name: 'Pho', currency: 'VND', total_price: 100000, exchange_rate: 18000, fallback_fx: VND_FX };
const bill = await budget.create(String(trip.id), data);
expect(bill.exchange_rate).toBe(18000);
expect('fallback_fx' in data).toBe(false);
});
it('BUDGET-SVC-DB-060: currency change without any rate drops the old rate (items)', async () => {
const { trip } = seedAudTrip();
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 });
// The update SQL keeps the stored rate when none is sent: a USD rate on a VND bill.
expect(await budget.update(item.id, trip.id, { currency: 'VND' })).toMatchObject({ currency: 'VND', exchange_rate: 1 });
// With a lent rate the new currency freezes instead.
expect(await budget.update(item.id, trip.id, { currency: 'THB', fallback_fx: { base: 'AUD', rates: { THB: 23.5 } } }))
.toMatchObject({ currency: 'THB', exchange_rate: 23.5 });
});
it('BUDGET-SVC-DB-061: same for settlements', async () => {
const { trip, me, bob } = seedAudTrip();
const transfer = await budget.createSettlement(trip.id, {
from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'USD', fallback_fx: { base: 'AUD', rates: { USD: 0.65 } },
}, me.id);
expect(transfer).toMatchObject({ currency: 'USD', exchange_rate: 0.65 });
const moved = await budget.updateSettlement(transfer!.id, trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'VND' });
expect(moved).toMatchObject({ currency: 'VND', exchange_rate: 1 });
});
it('BUDGET-SVC-DB-062: unchanged currency never touches the rate', async () => {
const { trip } = seedAudTrip();
const frozen = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000 });
const open = budget.createBudgetItem(trip.id, { name: 'Bus', currency: 'VND', total_price: 50000 });
const fx = { base: 'AUD', rates: { VND: 20000 } };
expect(await budget.update(frozen.id, trip.id, { name: 'Pho bo', currency: 'vnd', fallback_fx: fx })).toMatchObject({ exchange_rate: 18241.3 });
// Not even an unfrozen row: an edit is no place to heal, freeze-rates is.
expect(await budget.update(open.id, trip.id, { name: 'Night bus', currency: 'VND', fallback_fx: fx })).toMatchObject({ exchange_rate: 1 });
});
it('BUDGET-SVC-DB-063: freezeMissingRates heals items and transfers from fallback_fx', async () => {
const { trip, me, bob, carol, members } = seedAudTrip();
const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
const transfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 2229963, currency: 'VND' }, bob.id)!;
// Carol pays in baht. The rows are healed currency by currency, THB before VND, so her
// later transfer is written first and the answer still lists the rows by id.
const bahtTransfer = budget.insertSettlement(trip.id, { from_user_id: carol.id, to_user_id: me.id, amount: 2872.88, currency: 'THB' }, carol.id)!;
const before = await budget.settlement(trip.id, 'AUD', 'AUD');
expect(before.unconverted).toEqual({ item_ids: [bill.id], settlement_ids: [bahtTransfer.id, transfer.id], currencies: ['THB', 'VND'] });
expect(before.balances).toEqual([]);
const healed = await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 18241.3, THB: 23.5 } });
expect(healed!.items.map(i => [i.id, i.exchange_rate])).toEqual([[bill.id, 18241.3]]);
expect(healed!.settlements.map(s => [s.id, s.exchange_rate])).toEqual([[transfer.id, 18241.3], [bahtTransfer.id, 23.5]]);
expect(healed!.unresolved).toEqual([]);
const after = await budget.settlement(trip.id, 'AUD', 'AUD');
expect(after.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
// 489.00 AUD four ways. Bob's 2,229,963 VND and Carol's 2,872.88 THB (122.25 AUD
// each) square their shares.
expect(after.balances.find(b => b.user_id === me.id)!.balance).toBe(122.25);
expect(after.balances.find(b => b.user_id === bob.id)!.balance).toBe(0);
expect(after.balances.find(b => b.user_id === carol.id)!.balance).toBe(0);
expect(after.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
});
it('BUDGET-SVC-DB-064: prefers the server quote, never touches frozen, trip-currency or NULL-currency rows', async () => {
const { user: me } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, me.id);
addTripMember(testDb, trip.id, bob.id);
const row = (currency: string | null, exchange_rate?: number) =>
budget.createBudgetItem(trip.id, { name: 'Row', currency, exchange_rate, total_price: 10 }).id;
const usd = row('USD');
const gbp = row('GBP');
const xaf = row('XAF');
const rub = row('RUB', 90);
const eur = row('EUR');
const implicit = row(null);
const lower = row('usd');
const usdTransfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5, currency: 'USD' }, bob.id)!;
const plainTransfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id)!;
const healed = await budget.freezeMissingRates(trip.id, { base: 'EUR', rates: { USD: 2, GBP: 0.85 } });
expect(healed!.items.map(i => i.id)).toEqual([usd, gbp, lower]);
expect(healed!.settlements.map(s => s.id)).toEqual([usdTransfer.id]);
expect(healed!.unresolved).toEqual(['XAF']);
expect(rateOf('budget_items', usd)).toBe(RATES.EUR.USD);
expect(rateOf('budget_items', lower)).toBe(RATES.EUR.USD);
expect(rateOf('budget_items', gbp)).toBe(0.85);
expect(rateOf('budget_items', xaf)).toBe(1);
expect(rateOf('budget_items', rub)).toBe(90);
expect(rateOf('budget_items', eur)).toBe(1);
expect(rateOf('budget_items', implicit)).toBe(1);
expect(rateOf('budget_settlements', usdTransfer.id)).toBe(RATES.EUR.USD);
expect(rateOf('budget_settlements', plainTransfer.id)).toBe(1);
});
it('BUDGET-SVC-DB-065: second call heals nothing even with another quote', async () => {
const { trip, me, members } = seedAudTrip();
const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
await budget.freezeMissingRates(trip.id, VND_FX);
expect(await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 25000 } }))
.toEqual({ items: [], settlements: [], unresolved: [] });
expect(rateOf('budget_items', bill.id)).toBe(18241.3);
});
it('BUDGET-SVC-DB-066: returns null and writes nothing when the trip currency changes during the fetch', async () => {
const { trip, me, members } = seedAudTrip();
const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
const spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async () => {
// Someone switches the trip to euros while the rates are on their way.
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
return null;
});
try {
expect(await budget.freezeMissingRates(trip.id, VND_FX)).toBeNull();
expect(rateOf('budget_items', bill.id)).toBe(1);
} finally {
spy.mockRestore();
}
});
it('BUDGET-SVC-DB-067: returns without fetching when nothing is pending', async () => {
const { trip, me, bob, members } = seedAudTrip();
budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'AUD', payers: [{ user_id: me.id, amount: 80 }], members });
budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 20, members });
budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000, members });
budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id);
const spy = vi.spyOn(exchangeRates(), 'getRates');
try {
expect(await budget.freezeMissingRates(trip.id)).toEqual({ items: [], settlements: [], unresolved: [] });
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
it('BUDGET-SVC-DB-068: tripTotals and per-person leave the VND row out and tripTotals reports its id', async () => {
const { trip, me, bob, members } = seedAudTrip();
const bill = budget.createBudgetItem(trip.id, {
name: 'Pho', category: 'food', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members,
});
budget.createBudgetItem(trip.id, {
name: 'Ferry', category: 'transport', currency: 'AUD', payers: [{ user_id: bob.id, amount: 40 }],
members: [{ user_id: me.id }, { user_id: bob.id }],
});
const rates = await budget.ratesForTripTotals(trip.id, 'AUD');
expect(rates).toBeNull();
expect(budget.tripTotals(trip.id, 'AUD', rates)).toEqual({ total: 40, byCategory: { transport: 40 }, unconverted: [bill.id] });
const summary = await budget.perPersonSummary(trip.id);
expect(summary.map(s => [s.user_id, s.total_assigned, s.items_count])).toEqual([[me.id, 20, 1], [bob.id, 20, 1]]);
});
it('BUDGET-SVC-DB-069: settlement() passes baseRate only as display fallback (fetch order of 050 unchanged)', async () => {
const { user: me } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, me.id);
addTripMember(testDb, trip.id, bob.id);
budget.createBudgetItem(trip.id, {
name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }],
members: [{ user_id: me.id }, { user_id: bob.id }],
});
const mine = (s: { balances: { user_id: number; balance: number }[] }) => s.balances.find(b => b.user_id === me.id)!.balance;
// Neither quote: the caller's own figure labels the answer...
let spy = vi.spyOn(exchangeRates(), 'getRates').mockResolvedValue(null);
try {
const s = await budget.settlement(trip.id, 'USD', 'EUR', 1.2);
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
expect(s.currency).toBe('USD');
expect(mine(s)).toBe(60);
// ...and without it the answer stays in euros and says so.
const plain = await budget.settlement(trip.id, 'USD', 'EUR');
expect(plain.currency).toBe('EUR');
expect(mine(plain)).toBe(50);
} finally {
spy.mockRestore();
}
// With the display currency's quote (050's setup) it changes nothing.
spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null));
try {
const s = await budget.settlement(trip.id, 'USD', 'EUR', 2);
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
expect(s.currency).toBe('USD');
expect(mine(s)).toBe(57.14);
} finally {
spy.mockRestore();
}
});
});
@@ -212,6 +212,8 @@ describe('getTripSummary shaping', () => {
expect(summary.budget.total).toBe(785.26); expect(summary.budget.total).toBe(785.26);
expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 }); expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 });
expect(summary.budget.currency).toBe('EUR'); expect(summary.budget.currency).toBe('EUR');
// Both rows convert, so none is reported as left out of the total.
expect(summary.budget.unconverted_item_ids).toEqual([]);
}); });
it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => { it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => {
+96
View File
@@ -1,5 +1,11 @@
import { import {
budgetCreateItemRequestSchema, budgetCreateItemRequestSchema,
budgetUpdateItemRequestSchema,
budgetCreateSettlementRequestSchema,
budgetUpdateSettlementRequestSchema,
budgetFreezeRatesRequestSchema,
budgetSettlementQuerySchema,
budgetUnconvertedSchema,
budgetUpdateMembersRequestSchema, budgetUpdateMembersRequestSchema,
budgetToggleMemberPaidRequestSchema, budgetToggleMemberPaidRequestSchema,
budgetReorderItemsRequestSchema, budgetReorderItemsRequestSchema,
@@ -61,3 +67,93 @@ describe('typeToCostCategory', () => {
expect(typeToCostCategory('taxi')).toBe('transport'); expect(typeToCostCategory('taxi')).toBe('transport');
}); });
}); });
describe('fallback_fx (rates lent for a write)', () => {
const fx = { base: 'AUD', rates: { VND: 18241.3 } };
it('is optional on all four write bodies and kept when well formed', () => {
const cases = [
[budgetCreateItemRequestSchema, { name: 'Pho' }],
[budgetUpdateItemRequestSchema, {}],
[budgetCreateSettlementRequestSchema, { from_user_id: 1, to_user_id: 2, amount: 10 }],
[budgetUpdateSettlementRequestSchema, { from_user_id: 1, to_user_id: 2, amount: 10 }],
] as const;
for (const [schema, body] of cases) {
expect(schema.safeParse(body).success).toBe(true);
const parsed = schema.safeParse({ ...body, fallback_fx: fx });
expect(parsed.success).toBe(true);
expect(parsed.data?.fallback_fx).toEqual(fx);
}
});
const accepted = (fallback_fx: unknown) =>
budgetCreateItemRequestSchema.safeParse({ name: 'Pho', fallback_fx }).success;
it('refuses a base or a currency that is not three upper-case letters', () => {
expect(accepted({ base: 'aud', rates: { VND: 1.5 } })).toBe(false);
expect(accepted({ base: 'AUDX', rates: { VND: 1.5 } })).toBe(false);
expect(accepted({ base: 'AUD', rates: { vnd: 1.5 } })).toBe(false);
expect(accepted({ base: 'AUD', rates: { VNDX: 1.5 } })).toBe(false);
});
it('refuses a rate that is zero, negative, NaN, infinite or above 1e9', () => {
for (const rate of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1e9 + 1]) {
expect(accepted({ base: 'AUD', rates: { VND: rate } })).toBe(false);
}
expect(accepted({ base: 'AUD', rates: { VND: 1e9 } })).toBe(true);
expect(accepted({ base: 'AUD', rates: { VND: 1e-9 } })).toBe(true);
});
it('refuses an empty table and one with more than 200 currencies', () => {
// AAA, AAB, … : n distinct three-letter codes.
const codes = (n: number) =>
Object.fromEntries(
Array.from({ length: n }, (_, i) => [
String.fromCharCode(65 + Math.floor(i / 676), 65 + (Math.floor(i / 26) % 26), 65 + (i % 26)),
1.5,
]),
);
expect(accepted({ base: 'AUD', rates: {} })).toBe(false);
expect(accepted({ base: 'AUD', rates: codes(200) })).toBe(true);
expect(accepted({ base: 'AUD', rates: codes(201) })).toBe(false);
});
});
describe('budgetFreezeRatesRequestSchema', () => {
it('accepts an empty body and a well-formed fallback table', () => {
expect(budgetFreezeRatesRequestSchema.safeParse({}).success).toBe(true);
expect(
budgetFreezeRatesRequestSchema.safeParse({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }).success,
).toBe(true);
expect(budgetFreezeRatesRequestSchema.safeParse({ fallback_fx: { base: 'AUD', rates: { VND: 0 } } }).success).toBe(
false,
);
});
});
describe('budgetSettlementQuerySchema', () => {
it('coerces base_rate from the query string and leaves base as it came', () => {
expect(budgetSettlementQuerySchema.parse({})).toEqual({});
expect(budgetSettlementQuerySchema.parse({ base: 'eur', base_rate: '0.61' })).toEqual({
base: 'eur',
base_rate: 0.61,
});
});
it('refuses a base_rate that is not a positive number in range', () => {
for (const base_rate of ['abc', '0', '-1', '1e10', '']) {
expect(budgetSettlementQuerySchema.safeParse({ base_rate }).success).toBe(false);
}
});
});
describe('budgetUnconvertedSchema', () => {
it('lists row ids and currencies', () => {
expect(budgetUnconvertedSchema.safeParse({ item_ids: [3], settlement_ids: [], currencies: ['VND'] }).success).toBe(
true,
);
expect(budgetUnconvertedSchema.safeParse({ item_ids: ['3'], settlement_ids: [], currencies: [] }).success).toBe(
false,
);
});
});
+87 -1
View File
@@ -1,3 +1,5 @@
import { idSchema } from '../common/primitives.schema';
import { z } from 'zod'; import { z } from 'zod';
/** /**
@@ -113,6 +115,9 @@ export type BudgetItemReceipt = z.infer<typeof budgetItemReceiptSchema>;
* plus the embedded `members` (equal-split participants), `payers` and `receipts` arrays. * plus the embedded `members` (equal-split participants), `payers` and `receipts` arrays.
* total_price is the sum of payer amounts in `currency`; `exchange_rate` converts * total_price is the sum of payer amounts in `currency`; `exchange_rate` converts
* that to the trip base currency (NULL currency + rate 1 = base currency). * that to the trip base currency (NULL currency + rate 1 = base currency).
* On a row in another currency, rate 1 means the rate was never frozen: the
* server converts it with today's rate, and without one leaves it out of every
* figure and lists it under the settlement's `unconverted`.
*/ */
export const budgetItemSchema = z.object({ export const budgetItemSchema = z.object({
id: z.number(), id: z.number(),
@@ -151,6 +156,29 @@ const memberInputSchema = z.object({
amount: z.number().nullable().optional(), amount: z.number().nullable().optional(),
}); });
const currencyCodeSchema = z.string().regex(/^[A-Z]{3}$/);
/**
* A rate table the caller already holds (the client's cached rates), lent to the
* server for a write. `rates` are units of each currency per 1 `base`, which is
* how a frozen `exchange_rate` reads when `base` is the trip currency.
*
* The server uses a rate from here only for a currency it cannot quote itself,
* and only while `base` is the trip's current currency; its own rate always wins,
* and a rate already frozen or sent as `exchange_rate` is never replaced.
*/
export const budgetFallbackFxSchema = z.object({
base: currencyCodeSchema,
rates: z.record(currencyCodeSchema, z.number().finite().min(1e-9).max(1e9)).refine(
(rates) => {
const n = Object.keys(rates).length;
return n >= 1 && n <= 200;
},
{ message: 'rates must hold between 1 and 200 currencies' },
),
});
export type BudgetFallbackFx = z.infer<typeof budgetFallbackFxSchema>;
export const budgetCreateItemRequestSchema = z.object({ export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
category: z.string().optional(), category: z.string().optional(),
@@ -176,6 +204,8 @@ export const budgetCreateItemRequestSchema = z.object({
place_id: z.number().optional(), place_id: z.number().optional(),
// Receipt files to link to this expense // Receipt files to link to this expense
receipt_file_ids: z.array(z.number()).optional(), receipt_file_ids: z.array(z.number()).optional(),
// Rates to freeze a foreign currency with when the server has none of its own.
fallback_fx: budgetFallbackFxSchema.optional(),
}); });
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>; export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
@@ -195,6 +225,7 @@ export const budgetUpdateItemRequestSchema = z.object({
ticket_json: z.string().nullable().optional(), ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(), expense_date: z.string().nullable().optional(),
receipt_file_ids: z.array(z.number()).optional(), receipt_file_ids: z.array(z.number()).optional(),
fallback_fx: budgetFallbackFxSchema.optional(),
}); });
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>; export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
@@ -209,7 +240,9 @@ export type BudgetUpdatePayersRequest = z.infer<typeof budgetUpdatePayersRequest
* given amount, entered in the payer's display `currency`. `exchange_rate` is the * given amount, entered in the payer's display `currency`. `exchange_rate` is the
* live rate frozen at settle time (units of that currency per 1 trip currency), so * live rate frozen at settle time (units of that currency per 1 trip currency), so
* a settled position stays balanced when live rates drift (#1445). Legacy rows * a settled position stays balanced when live rates drift (#1445). Legacy rows
* have currency = null / exchange_rate = 1 and convert with live rates. Creating * have currency = null / exchange_rate = 1 and convert with live rates. A row in
* another currency with rate 1 was never frozen: without a live rate it is left
* out of the balances and listed under the settlement's `unconverted`. Creating
* one marks a suggested flow as paid; deleting it (undo) brings the flow back. * one marks a suggested flow as paid; deleting it (undo) brings the flow back.
*/ */
export const budgetSettlementSchema = z.object({ export const budgetSettlementSchema = z.object({
@@ -243,6 +276,8 @@ export const budgetCreateSettlementRequestSchema = z.object({
// The day the transfer happened. Null when the caller sets none; the ledger then // The day the transfer happened. Null when the caller sets none; the ledger then
// uses the day it was recorded, which is where every older payment already sits. // uses the day it was recorded, which is where every older payment already sits.
settled_at: z.string().nullable().optional(), settled_at: z.string().nullable().optional(),
// Rates to freeze `currency` with when the server has none of its own.
fallback_fx: budgetFallbackFxSchema.optional(),
}); });
export type BudgetCreateSettlementRequest = z.infer<typeof budgetCreateSettlementRequestSchema>; export type BudgetCreateSettlementRequest = z.infer<typeof budgetCreateSettlementRequestSchema>;
@@ -253,9 +288,60 @@ export const budgetUpdateSettlementRequestSchema = z.object({
amount: z.number(), amount: z.number(),
currency: z.string().nullable().optional(), currency: z.string().nullable().optional(),
settled_at: z.string().nullable().optional(), settled_at: z.string().nullable().optional(),
fallback_fx: budgetFallbackFxSchema.optional(),
}); });
export type BudgetUpdateSettlementRequest = z.infer<typeof budgetUpdateSettlementRequestSchema>; export type BudgetUpdateSettlementRequest = z.infer<typeof budgetUpdateSettlementRequestSchema>;
/**
* POST …/budget/freeze-rates: pin a rate on every row in a foreign currency that
* has none frozen yet, not only the rows the settlement lists under `unconverted`
* but also those it still converts at today's rate. The server's own rate comes
* first; `fallback_fx` only fills a currency it cannot quote. A row that is frozen,
* in the trip currency or without a currency is never touched, so a second call
* never moves a rate the first one froze.
*/
export const budgetFreezeRatesRequestSchema = z.object({
fallback_fx: budgetFallbackFxSchema.optional(),
});
export type BudgetFreezeRatesRequest = z.infer<typeof budgetFreezeRatesRequestSchema>;
/** The rows the call froze, and the currencies no rate was found for. */
export const budgetFreezeRatesResponseSchema = z.object({
items: z.array(budgetItemSchema),
settlements: z.array(budgetSettlementSchema),
unresolved: z.array(z.string()),
});
export type BudgetFreezeRatesResponse = z.infer<typeof budgetFreezeRatesResponseSchema>;
/**
* GET …/budget/settlement query. `base` is the display currency. `base_rate` is
* units of it per 1 trip currency, from the caller's own rates; the server uses
* it only when it cannot quote that pair itself, and then only in place of the
* missing display quote. That quote also reads a legacy transfer without a
* currency, which is taken to be in the display currency, as with a live quote.
*/
export const budgetSettlementQuerySchema = z.object({
base: z.string().optional(),
base_rate: z.coerce.number().finite().min(1e-9).max(1e9).optional(),
});
export type BudgetSettlementQuery = z.infer<typeof budgetSettlementQuerySchema>;
/**
* What the settlement left out because no rate could convert it: expenses and
* transfers in a foreign currency with no frozen rate, while no live rate for
* that currency is to be had. Such a row counts in none of the figures, so the
* balances still add up to zero. `currencies` are upper case, deduplicated and
* sorted. The settlement also says which `currency` its amounts are in: the
* display currency asked for, or the trip currency when that one cannot be
* quoted.
*/
export const budgetUnconvertedSchema = z.object({
item_ids: z.array(idSchema),
settlement_ids: z.array(idSchema),
currencies: z.array(z.string()),
});
export type BudgetUnconverted = z.infer<typeof budgetUnconvertedSchema>;
/** /**
* What the trip actually costs one participant, as GET …/budget/settlement * What the trip actually costs one participant, as GET …/budget/settlement
* returns it alongside the balances and the suggested flows. * returns it alongside the balances and the suggested flows.
+2 -1
View File
@@ -157,11 +157,12 @@ Requires `budget:read` or `budget:write` scope. The Budget addon must be enabled
| `delete_budget_item` | Remove a budget item. | | `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which members are splitting a budget item (replaces current list). | | `set_budget_item_members` | Set which members are splitting a budget item (replaces current list). |
| `toggle_budget_member_paid` | Mark or unmark a member as having paid their share. | | `toggle_budget_member_paid` | Mark or unmark a member as having paid their share. |
| `get_settlement_summary` | Each member's net balance, the suggested payments to settle shared expenses, and each member's final budget (`finalBudgets`: expenses paid, net reimbursements, pending reimbursements, final cost, each figure with the rows it is made of under `sources`), in the trip's base currency. Call this before recording a settlement. | | `get_settlement_summary` | Each member's net balance, the suggested payments to settle shared expenses, and each member's final budget (`finalBudgets`: expenses paid, net reimbursements, pending reimbursements, final cost, each figure with the rows it is made of under `sources`). `currency` says what the amounts are in: the `base` asked for when the server can quote it, otherwise the trip's base currency. An expense or payment in a foreign currency with no frozen rate, while the server has no live rate for it either, is left out of every figure and listed under `unconverted` (`item_ids`, `settlement_ids`, `currencies`). Call this before recording a settlement. |
| `list_settlements` | List the recorded settle-up payments for a trip — who paid whom, how much, and when. | | `list_settlements` | List the recorded settle-up payments for a trip — who paid whom, how much, and when. |
| `create_settlement` | Record a settle-up payment: one member paid another the given amount, with the payment's currency and the day it happened. | | `create_settlement` | Record a settle-up payment: one member paid another the given amount, with the payment's currency and the day it happened. |
| `update_settlement` | Update a recorded settle-up payment (payer, recipient, amount, currency and the day it happened). | | `update_settlement` | Update a recorded settle-up payment (payer, recipient, amount, currency and the day it happened). |
| `delete_settlement` | Delete a recorded settle-up payment. This is the undo for `create_settlement` and restores the affected balances. | | `delete_settlement` | Delete a recorded settle-up payment. This is the undo for `create_settlement` and restores the affected balances. |
| `freeze_budget_rates` | Pin today's server exchange rate on every expense and settle-up payment in a foreign currency that has no rate frozen yet, the rows under `unconverted` included. Rows with a frozen rate, in the trip currency or without a currency are never touched, and no rate is taken from the caller. Returns the rows it froze (`items`, `settlements`) and the currencies the server could not quote (`unresolved`). Needs the budget edit permission; an error when the trip currency changed meanwhile, with nothing written. |
### Tags ### Tags