From aa1f76ad11a74084d7e98648ba6d9ee5c1147808 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 26 Sep 2026 12:41:05 +0200 Subject: [PATCH] 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. --- client/src/api/client.surface.test.ts | 11 +- client/src/api/client.ts | 12 +- .../src/components/Budget/CostsPanel.test.tsx | 157 +++++++++ client/src/components/Budget/CostsPanel.tsx | 36 +- .../Budget/useFreezeMissingRates.test.ts | 123 +++++++ .../Budget/useFreezeMissingRates.ts | 73 +++++ client/src/hooks/useExchangeRates.test.ts | 131 +++++++- client/src/hooks/useExchangeRates.ts | 72 +++- .../mobile/screens/trip/tabs/MCostsTab.tsx | 17 +- .../mobile/screens/trip/tabs/costsModel.ts | 6 +- client/src/store/slices/budgetSlice.test.ts | 77 ++++- client/src/store/slices/budgetSlice.ts | 30 +- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 67 +++- server/src/nest/budget/budget.controller.ts | 47 ++- server/src/nest/budget/budget.dto.ts | 5 + server/src/nest/budget/budget.mcp.ts | 38 ++- server/src/nest/budget/budget.service.ts | 307 +++++++++++++----- .../src/nest/budget/exchange-rates.service.ts | 10 +- .../trip-read-model.service.ts | 2 + server/src/nest/trips/trip-prompts.mcp.ts | 6 +- server/tests/e2e/budget.e2e.test.ts | 156 +++++++++ server/tests/integration/budget.test.ts | 3 + .../unit/mcp/tools-budget-advanced.test.ts | 3 +- server/tests/unit/mcp/tools-budget.test.ts | 134 ++++++++ server/tests/unit/mcp/tools-prompts.test.ts | 27 ++ .../tests/unit/nest/budget.controller.test.ts | 67 +++- .../unit/nest/budget.service.calc.test.ts | 159 +++++++++ .../tests/unit/nest/budget.service.db.test.ts | 243 ++++++++++++++ .../unit/nest/trip-read-model.service.test.ts | 2 + shared/src/budget/budget.schema.spec.ts | 96 ++++++ shared/src/budget/budget.schema.ts | 88 ++++- wiki/MCP-Tools-and-Resources.md | 3 +- 32 files changed, 2071 insertions(+), 137 deletions(-) create mode 100644 client/src/components/Budget/useFreezeMissingRates.test.ts create mode 100644 client/src/components/Budget/useFreezeMissingRates.ts diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index b5076504c..5776a2744 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -1,4 +1,4 @@ -// FE-APISURF-001 to FE-APISURF-058 +// FE-APISURF-001 to FE-APISURF-059 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { AxiosResponse } from 'axios' import { http, HttpResponse } from 'msw' @@ -455,6 +455,7 @@ describe('client > endpoint wiring', () => { { n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' }, { n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' }, { n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' }, + { n: 'freezeRates', r: () => budgetApi.freezeRates(1, {}), e: 'POST /api/trips/1/budget/freeze-rates' }, { n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' }, { n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' }, ]) @@ -572,6 +573,14 @@ describe('client > request payloads', () => { expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) }) + it('FE-APISURF-059: the settlement carries the display rate only when the caller has one', async () => { + expect((await traceOne(() => budgetApi.settlement(1, 'EUR', 0.61))).url).toBe('/api/trips/1/budget/settlement?base=EUR&base_rate=0.61') + expect((await traceOne(() => budgetApi.settlement(1, 'EUR', null))).url).toBe('/api/trips/1/budget/settlement?base=EUR') + expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement') + const lent = { fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } } + expect((await traceOne(() => budgetApi.freezeRates(1, lent))).body).toEqual(lent) + }) + it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => { const rec = await traceOne(() => daysApi.create(1, { dated: true })) expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } }) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fbf25b227..5974240e2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -40,6 +40,8 @@ import { type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest, type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, + type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery, + type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse, type TodoCategoryAssigneesRequest, type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, @@ -1352,9 +1354,13 @@ export const budgetApi = { togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), - settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), - createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), - updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + // `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server + // uses it only in place of a display quote it cannot fetch itself, which also reads legacy + // transfers without a currency (in the display currency, as with a live quote). + settlement: (tripId: number | string, base?: string, baseRate?: number | null) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base, ...(baseRate != null ? { base_rate: baseRate } : {}) } satisfies BudgetSettlementQuery } : undefined).then(r => r.data), + createSettlement: (tripId: number | string, data: BudgetCreateSettlementRequest) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + updateSettlement: (tripId: number | string, settlementId: number, data: BudgetUpdateSettlementRequest) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + freezeRates: (tripId: number | string, data: BudgetFreezeRatesRequest): Promise => apiClient.post(`/trips/${tripId}/budget/freeze-rates`, data).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 9105a0301..d970c160a 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -15,6 +15,7 @@ import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem } from '../../types' import CostsPanel, { ExpenseModal } from './CostsPanel' import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers' +import { resetFreezeAttempts } from './useFreezeMissingRates' const tripMembers = [ { id: 1, username: 'alice', avatar_url: null }, @@ -2525,3 +2526,159 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => { await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' })) }) }) + +// ── A bill the server cannot convert: VND on an AUD trip ──────────────────────── +describe('CostsPanel: a bill the server cannot convert', () => { + // The reporter's setup: an AUD trip whose server cannot fetch rates while the browser + // can. An 8,920,000 VND dinner that never froze a rate was counted as 8,920,000 AUD. + // The settlement now leaves it out and lists it, and an editor's browser lends its AUD + // table to freeze a rate on it. + const pho = () => expense({ + id: 501, name: 'Pho', category: 'food', total_price: 8920000, currency: 'VND', exchange_rate: 1, + expense_date: '2026-09-20', + payers: [{ user_id: 1, amount: 8920000 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + }) + const leftOut = { balances: [], flows: [], settlements: [], currency: 'AUD', unconverted: { item_ids: [501], settlement_ids: [], currencies: ['VND'] } } + + beforeEach(() => { + seedAlice() + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } }) + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) }) + clearExchangeRateCache() + resetFreezeAttempts() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() })) + }) + afterEach(clearExchangeRateCache) + + it('FE-W5COSTS-090: VND/AUD regression: an editor heals the bill with the cached quote and the healed balances load', async () => { + let healed = false + const frozen: unknown[] = [] + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [pho()] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json(healed + ? { + // 8,920,000 VND at 18,241.3 is 489.00 AUD, half of it Bob's. + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 244.5 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -244.5 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 244.5 }], + settlements: [], + currency: 'AUD', + unconverted: { item_ids: [], settlement_ids: [], currencies: [] }, + } + : leftOut)), + http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => { + frozen.push(await request.json()) + healed = true + return HttpResponse.json({ items: [{ ...pho(), exchange_rate: 18241.3 }], settlements: [], unresolved: [] }) + }), + ) + render() + + // 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[] = [] + 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) + return HttpResponse.json({ settlement: { id: 1 } }) + }), + ) + render() + + 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 | null = null + server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => { + posted = await request.json() as Record + 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() + + 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) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index de8a4b03f..1c79124c1 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -9,8 +9,9 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import { saveWithReceipts } from './receiptUploads' -import { convertBooked, convertedLine, tripAmountOf, useExchangeRates } from '../../hooks/useExchangeRates' +import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates' import { splitShareLabel, useExpenseFx } from './expenseFx' +import { useFreezeMissingRates } from './useFreezeMissingRates' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { downloadBlob, openFile } from '../../utils/fileDownload' @@ -22,7 +23,7 @@ import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import { ReceiptPreviewModal } from './ReceiptPreviewModal' -import type { BudgetParticipantFinal } from '@trek/shared' +import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' @@ -60,6 +61,11 @@ interface SettlementData { // What the trip ends up costing each participant. Computed server-side off the // same ledger as the balances, so the breakdown can't contradict them. finalBudgets: BudgetParticipantFinal[] + // The currency the figures are in: the display currency, or the trip's own when + // neither the server nor `base_rate` could quote the pair. + currency?: string + // Rows no rate could convert, left out of every figure above. + unconverted?: BudgetUnconverted } // One row in the unified Costs ledger — either an expense or a settle-up payment, @@ -87,7 +93,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". const tripCurrency = (trip?.currency || base).toUpperCase() // Anchored on the trip currency's quote, the one the server books with (#2525). - const { convert } = useExchangeRates(base, tripCurrency) + const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, which the empty views would @@ -121,14 +127,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) + // The browser's own figure for the display currency goes along, for the server to + // answer in it when it cannot fetch a quote itself. const loadSettlement = useCallback(() => { - budgetApi.settlement(tripId, base) + budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) .then(s => { setSettlement(s); setSettlementError(false) }) .catch(() => setSettlementError(true)) - }, [tripId, base]) + }, [tripId, base, tripCurrency, displayPerTrip]) useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) - useEffect(() => { loadSettlement() }, [budgetItems.length, base]) + useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement]) + + // Rows the server could not count get a rate frozen from the browser's, and the + // settlement is read again once they count. + useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement }) // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. const [searchParams, setSearchParams] = useSearchParams() @@ -254,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { try { - await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base }) + await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency)) loadSettlement() } catch { toast.error(t('common.unknownError')) } } @@ -265,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const flows = settlement?.flows || [] if (!flows.length) return try { - for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }) + for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency)) } catch { toast.error(t('common.unknownError')) } // Refresh even when one transfer failed: the ones created before it are real, // and leaving them in the flow list invites a second, doubled settle-up. @@ -535,7 +547,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )} {(editingSettlement || addingPayment) && ( - { setEditingSettlement(null); setAddingPayment(false) }} onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )} @@ -1173,8 +1185,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A // A transfer can be made in any currency — paying a rouble debt in euros is normal — // so it carries its own, defaulting to the display currency. The server freezes its // FX rate on write, the same way an expense's is frozen. -function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { - tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void +function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: { + tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation() const toast = useToast() @@ -1195,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const save = async () => { if (!valid) return setSaving(true) - const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day } + const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency) try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) diff --git a/client/src/components/Budget/useFreezeMissingRates.test.ts b/client/src/components/Budget/useFreezeMissingRates.test.ts new file mode 100644 index 000000000..52d3060ce --- /dev/null +++ b/client/src/components/Budget/useFreezeMissingRates.test.ts @@ -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[0] + +function mountHook(over: Partial = {}) { + 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() + }) +}) diff --git a/client/src/components/Budget/useFreezeMissingRates.ts b/client/src/components/Budget/useFreezeMissingRates.ts new file mode 100644 index 000000000..9fdbccd2a --- /dev/null +++ b/client/src/components/Budget/useFreezeMissingRates.ts @@ -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() + +/** 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 => { + const rates = await fetchFreshRates(trip) + const lent: Record = {} + 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]) +} diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts index 1eca924b3..b096fb3da 100644 --- a/client/src/hooks/useExchangeRates.test.ts +++ b/client/src/hooks/useExchangeRates.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { server } from '../../tests/helpers/msw/server' -import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, clearExchangeRateCache, tripAmountOf, useExchangeRates } from './useExchangeRates' +import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates' const FX_URL = 'https://api.frankfurter.dev/v2/rates' @@ -45,6 +45,94 @@ describe('fetchExchangeRates (#1561)', () => { server.use(http.get(FX_URL, () => HttpResponse.error())) expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 }) }) + + // A request the network never answers held the Costs panel's heal for as long as the + // browser kept the socket open. It gives up after 10 s like any other failure. + it('gives up on a request that hangs after 10 s and serves the stale cache', async () => { + vi.useFakeTimers() + const fetchSpy = vi.spyOn(globalThis, 'fetch') + try { + localStorage.setItem('trek_fx_NOK', JSON.stringify({ rates: { NOK: 1, USD: 0.1 }, ts: Date.now() - 24 * 60 * 60 * 1000 })) + let signal: AbortSignal | undefined + fetchSpy.mockImplementation((_input, init) => new Promise((_resolve, reject) => { + signal = init?.signal ?? undefined + signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + })) + const pending = fetchExchangeRates('NOK') + await vi.advanceTimersByTimeAsync(9_999) + expect(signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(signal?.aborted).toBe(true) + expect(await pending).toEqual({ NOK: 1, USD: 0.1 }) + } finally { + fetchSpy.mockRestore() + vi.useRealTimers() + } + }) +}) + +// The rates the client lends the server to freeze a rate with. Only a fresh table counts: +// a stale one would pin a figure from another day on the row for good. +describe('freshRates and fetchFreshRates', () => { + beforeEach(() => { + clearExchangeRateCache() + }) + + it('reads a fresh cache and ignores a stale or missing one', () => { + expect(freshRates('AUD')).toBeNull() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + expect(freshRates('aud')).toBeNull() + clearExchangeRateCache() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() })) + expect(freshRates('aud')).toEqual({ AUD: 1, VND: 18241.3 }) + }) + + it('fetches when the cache is stale and gives the new table', async () => { + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + server.use(http.get(FX_URL, () => HttpResponse.json([{ quote: 'VND', rate: 18241.3 }]))) + expect(await fetchFreshRates('AUD')).toEqual({ AUD: 1, VND: 18241.3 }) + }) + + it('is null when the fetch fails and only a stale table is left', async () => { + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + server.use(http.get(FX_URL, () => HttpResponse.error())) + expect(await fetchFreshRates('AUD')).toBeNull() + }) +}) + +describe('withFallbackFx', () => { + beforeEach(() => { + clearExchangeRateCache() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61, XAU: 1e-10 }, ts: Date.now() })) + }) + + it('lends the fresh trip-currency rate for a foreign currency', () => { + expect(withFallbackFx({ name: 'Pho', currency: 'vnd', total_price: 8920000 }, 'aud')).toEqual({ + name: 'Pho', currency: 'vnd', total_price: 8920000, + fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } }, + }) + expect(withFallbackFx({ from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR' }, 'AUD').fallback_fx) + .toEqual({ base: 'AUD', rates: { EUR: 0.61 } }) + }) + + it('leaves the data alone when there is nothing to lend', () => { + const inTrip = { name: 'Tram', currency: 'AUD' } + expect(withFallbackFx(inTrip, 'AUD')).toBe(inTrip) + // No currency at all is the trip's own. + expect(withFallbackFx({ name: 'Tram', currency: null }, 'AUD')).toEqual({ name: 'Tram', currency: null }) + // An explicit rate is the caller's and wins on the server anyway. + expect(withFallbackFx({ currency: 'VND', exchange_rate: 17000 }, 'AUD')).toEqual({ currency: 'VND', exchange_rate: 17000 }) + // No quote for the currency, no trip currency, or a rate the contract would refuse. + expect(withFallbackFx({ currency: 'JPY' }, 'AUD')).toEqual({ currency: 'JPY' }) + expect(withFallbackFx({ currency: 'VND' }, null)).toEqual({ currency: 'VND' }) + expect(withFallbackFx({ currency: 'XAU' }, 'AUD')).toEqual({ currency: 'XAU' }) + }) + + it('lends nothing from a stale table', () => { + clearExchangeRateCache() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + expect(withFallbackFx({ currency: 'VND' }, 'AUD')).toEqual({ currency: 'VND' }) + }) }) // A cost entered in a foreign currency is money that was actually paid, at a rate that @@ -171,6 +259,45 @@ describe('useExchangeRates anchored on the trip currency (#2525)', () => { const { result } = renderHook(() => useExchangeRates('EUR', 'EUR')) await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1)) expect(calls).toBe(1) + expect(result.current.displayPerTrip).toBe(1) + }) + + // What the settlement is asked to label its figures with when the server has no quote + // (`base_rate`): the same figure `convert` takes a trip amount to the display currency with. + it('gives the display currency per 1 trip currency from the trip\'s quote', async () => { + quotes() + const { result } = renderHook(() => useExchangeRates('USD', 'EUR')) + await waitFor(() => expect(result.current.displayPerTrip).toBe(1.1398)) + expect(result.current.convert(1, 'EUR')).toBe(result.current.displayPerTrip) + }) + + it('falls back to the display currency\'s quote for it, and is null without either', async () => { + server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD' + ? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }]) + : HttpResponse.error()))) + const { result } = renderHook(() => useExchangeRates('USD', 'EUR')) + await waitFor(() => expect(result.current.displayPerTrip).toBe(1.25)) + + clearExchangeRateCache() + let calls = 0 + server.use(http.get(FX_URL, () => { calls++; return HttpResponse.error() })) + const none = renderHook(() => useExchangeRates('GBP', 'AUD')) + await waitFor(() => expect(calls).toBe(2)) + expect(none.result.current.displayPerTrip).toBeNull() + }) + + // A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the + // settlement query takes, and sent as `base_rate` it would fail the whole read with a 400. + it('is null for a figure the settlement query would refuse, which convert still uses', () => { + localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() })) + localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() })) + const high = renderHook(() => useExchangeRates('IRR', 'XAU')) + expect(high.result.current.convert(1, 'XAU')).toBe(2.6e9) + expect(high.result.current.displayPerTrip).toBeNull() + // The other way round the figure falls under 1e-9. + const low = renderHook(() => useExchangeRates('XAU', 'IRR')) + expect(low.result.current.convert(2.6e9, 'IRR')).toBeCloseTo(1, 10) + expect(low.result.current.displayPerTrip).toBeNull() }) }) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 2ea56af58..c8e1ddd17 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import type { BudgetFallbackFx } from '@trek/shared' import { currencyDecimals } from '../utils/formatters' /** @@ -10,6 +11,9 @@ import { currencyDecimals } from '../utils/formatters' */ const TTL_MS = 6 * 60 * 60 * 1000 // 6h +// A hung request would otherwise hold every caller, the freeze-rates heal included, +// for as long as the browser keeps the socket open. +const FETCH_TIMEOUT_MS = 10_000 const mem = new Map; ts: number }>() function readCache(base: string): { rates: Record; ts: number } | null { @@ -35,8 +39,12 @@ export async function fetchExchangeRates(base: string): Promise stop.abort(), FETCH_TIMEOUT_MS) try { - const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`) + const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`, { signal: stop.signal }) .then(r => r.json()) as Array<{ quote?: string; rate?: number }> if (!Array.isArray(d)) return cached?.rates ?? null // Frankfurter omits the base's own self-rate, so seed it with `base = 1`. @@ -50,9 +58,58 @@ export async function fetchExchangeRates(base: string): Promise | 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 | 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( + data: T, + tripCurrency: string | null | undefined, +): T & { fallback_fx?: BudgetFallbackFx } { + const trip = (tripCurrency || '').toUpperCase() + const cur = (data.currency || '').toUpperCase() + if (!CURRENCY_CODE.test(trip) || !CURRENCY_CODE.test(cur) || cur === trip || data.exchange_rate != null) return data + const rate = freshRates(trip)?.[cur] + if (!isSendableRate(rate)) return data + return { ...data, fallback_fx: { base: trip, rates: { [cur]: rate } } } +} + /** * Convert a booked amount the way the server's settlement does (#1335, #1445): the FX * rate frozen when the expense or transfer was entered wins over today's rate, so a cost @@ -229,5 +286,16 @@ export function useExchangeRates(base: string, tripCurrency?: string | null) { [rates, anchorRates, upper], ) - return { rates, convert } + // Units of the display currency per 1 trip currency, from the same quotes `convert` uses. + // The settlement takes it as `base_rate` to answer in the display currency when the + // server has no quote of its own. Null while neither quote is known, and null for a + // figure the settlement query refuses (a trip in XAU shown in IRR runs past 1e9): sent + // along, it would fail the whole read with a 400, healthy server or not. + const perTrip = rates?.[anchor] + const quoted = anchor === upper + ? 1 + : crossRate(anchorRates, upper, anchor) ?? (perTrip && perTrip > 0 ? 1 / perTrip : null) + const displayPerTrip = isSendableRate(quoted) ? quoted : null + + return { rates, convert, displayPerTrip } } diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 1db224e33..0fb55490d 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -7,13 +7,14 @@ import { import MDancingTrek from '../../../components/MDancingTrek' import { useAuthStore } from '../../../../store/authStore' import { useSettingsStore } from '../../../../store/settingsStore' -import { useExchangeRates } from '../../../../hooks/useExchangeRates' +import { useExchangeRates, withFallbackFx } from '../../../../hooks/useExchangeRates' import { useTranslation } from '../../../../i18n' import { amountToInputString, formatMoney } from '../../../../utils/formatters' import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { budgetApi } from '../../../../api/client' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' +import { useFreezeMissingRates } from '../../../../components/Budget/useFreezeMissingRates' import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import CustomSelect from '../../../../components/shared/CustomSelect' @@ -56,18 +57,19 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase() // Anchored on the trip currency's quote, the one the server books with (#2525). - const { convert } = useExchangeRates(base, tripCurrency) + const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency) const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, and the final budget would // read that as "the trip cost nobody anything", a claim we cannot make. const [settlementError, setSettlementError] = useState(false) + // Sends the browser's own figure for the display currency, as CostsPanel.tsx does. const loadSettlement = useCallback(() => { - budgetApi.settlement(tripId, base) + budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) .then(s => { setSettlement(s); setSettlementError(false) }) .catch(() => setSettlementError(true)) - }, [tripId, base]) + }, [tripId, base, tripCurrency, displayPerTrip]) // Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on // trip/base change; further refreshes are explicit after each mutation below @@ -79,6 +81,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { useEffect(() => { loadSettlement() }, [loadSettlement]) + useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement }) const [search, setSearch] = useState('') const [segment, setSegment] = useState('all') @@ -571,6 +574,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { }} tripId={tripId} base={base} + tripCurrency={tripCurrency} people={tripMembers} me={me} toast={toast} @@ -881,12 +885,13 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: { * (editable like an expense's, unlike the legacy created_at-only date, see * `settlementDate` in CostsPanel.helpers.ts). */ -function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: { +function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, people, me, toast, t, onSaved }: { open: boolean editing: CostsSettlement | null onClose: () => void tripId: number base: string + tripCurrency: string people: TripMember[] me: number toast: { error: (message: string) => void } @@ -917,7 +922,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa const save = async () => { if (!valid || saving) return setSaving(true) - const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day } + const data = withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }, tripCurrency) try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index 4b6707a4c..bd5fb6ef7 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -1,4 +1,4 @@ -import type { BudgetParticipantFinal, CostCategory } from '@trek/shared' +import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared' import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates' @@ -116,6 +116,10 @@ export interface CostsSettlementResponse { settlements: CostsSettlement[] /** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */ finalBudgets: BudgetParticipantFinal[] + /** The currency the figures are in: the display currency, or the trip's own without a quote for it. */ + currency?: string + /** Rows no rate could convert, left out of every figure above. */ + unconverted?: BudgetUnconverted } // ── hero / tile totals (spec §3.1-§3.3) ──────────────────────────────────── diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index a3eade30b..77bd378cd 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -1,8 +1,9 @@ -// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-016 +// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-021 import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { buildBudgetItem } from '../../../tests/helpers/factories'; +import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories'; +import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; import { useTripStore } from '../tripStore'; let addToast: ReturnType; @@ -284,3 +285,75 @@ describe('budgetSlice', () => { expect(addToast).toHaveBeenCalledTimes(2); }); }); + +// An AUD trip whose server cannot fetch rates while the browser can: a write in another +// currency lends the browser's rate, so the server can freeze one on entry. +describe('budgetSlice: lent rates', () => { + let sent: Record[] = []; + + 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; + 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; + 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]]); + }); +}); diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index dd237ac66..7d5f20921 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -3,8 +3,9 @@ import { budgetRepo } from '../../repo/budgetRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { BudgetItem, BudgetItemMember } from '../../types' -import type { BudgetCreateItemRequest, BudgetUpdateItemRequest } from '@trek/shared' +import type { BudgetCreateItemRequest, BudgetFallbackFx, BudgetFreezeRatesResponse, BudgetUpdateItemRequest } from '@trek/shared' import { getApiErrorMessage } from '../../types' +import { withFallbackFx } from '../../hooks/useExchangeRates' import { notify } from '../notify' type SetState = StoreApi['setState'] @@ -19,6 +20,16 @@ export interface BudgetSlice { toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise + freezeMissingRates: (tripId: number | string, fallback?: BudgetFallbackFx) => Promise +} + +/** + * The open trip's currency when `tripId` is that trip, which is what a lent rate has to be + * quoted against. Null for any other trip: the store only knows the one that is open. + */ +function openTripCurrency(get: GetState, tripId: number | string): string | null { + const trip = get().trip + return trip && String(trip.id) === String(tripId) ? trip.currency : null } export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({ @@ -33,7 +44,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => addBudgetItem: async (tripId, data) => { try { - const result = await budgetApi.create(tripId, data) + // A foreign currency goes out with the rate the browser holds for it, so the server + // can freeze one even when its own fetch fails. + const result = await budgetApi.create(tripId, withFallbackFx(data, openTripCurrency(get, tripId))) set(state => ({ budgetItems: [...state.budgetItems, result.item] })) return result.item } catch (err: unknown) { @@ -43,7 +56,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => updateBudgetItem: async (tripId, id, data) => { try { - const result = await budgetApi.update(tripId, id, data) + const result = await budgetApi.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId))) set(state => ({ budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) })) @@ -151,4 +164,15 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => notify(getApiErrorMessage(err, 'Error reordering budget items'), 'error') } }, + + // Freezes a rate onto the rows the settlement could not count (see useFreezeMissingRates). + // Not optimistic: nothing changes locally until the server says which rows it froze. + freezeMissingRates: async (tripId, fallback) => { + const result = await budgetApi.freezeRates(tripId, fallback ? { fallback_fx: fallback } : {}) + if (result.items.length > 0) { + const healed = new Map(result.items.map(item => [item.id, item])) + set(state => ({ budgetItems: state.budgetItems.map(item => healed.get(item.id) ?? item) })) + } + return result + }, }) diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 4b95a4b11..504ce0549 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -4,6 +4,7 @@ import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { budgetApi } from '../../../../src/api/client' import { localToday } from '../../../../src/components/Planner/today' +import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates' import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useAuthStore } from '../../../../src/store/authStore' import { useSettingsStore } from '../../../../src/store/settingsStore' @@ -14,7 +15,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-COSTT-001 to FE-MOB-COSTT-044 +// FE-MOB-COSTT-001 to FE-MOB-COSTT-049 // The add/edit expense sheet is the shared desktop-sized form; the panel only // owns when it opens and what happens on save, so it is stubbed here. @@ -723,7 +724,12 @@ describe('MCostsTab', () => { expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument() fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' })) - expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' }) + // The pound rate the phone holds goes along; the server only uses it when the + // currency changes and it has no rate of its own, so the frozen one stays. + expect(update).toHaveBeenCalledWith(7, 505, { + from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28', + fallback_fx: { base: 'USD', rates: { GBP: 0.5 } }, + }) }) it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => { @@ -794,6 +800,63 @@ describe('MCostsTab', () => { expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67') }) + // The server could not fetch rates, so the museum's pounds never froze one and the + // settlement left them out. An editor's phone lends its own rate and reads again. + it('FE-MOB-COSTT-047: an editor heals a bill the server could not convert and reads the settlement again', async () => { + resetFreezeAttempts() + let healed = false + server.use( + http.get('/api/trips/:id/budget/settlement', ({ request }) => { + settlementBases.push(new URL(request.url).searchParams.get('base') ?? '') + return HttpResponse.json(healed + ? { ...SETTLEMENT, unconverted: { item_ids: [], settlement_ids: [], currencies: [] } } + : { ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } }) + }), + ) + const freeze = vi.spyOn(budgetApi, 'freezeRates').mockImplementation(async () => { + healed = true + return { items: [{ ...MUSEUM, exchange_rate: 0.5 }], settlements: [], unresolved: [] } + }) + await renderTab() + + await waitFor(() => expect(freeze).toHaveBeenCalledWith(7, { fallback_fx: { base: 'USD', rates: { GBP: 0.5 } } })) + await waitFor(() => expect(settlementBases).toHaveLength(2)) + expect(freeze).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COSTT-048: a read-only member\'s phone never freezes a rate', async () => { + resetFreezeAttempts() + serveSettlement({ ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } }) + const freeze = vi.spyOn(budgetApi, 'freezeRates') + await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] })) + + await new Promise(r => setTimeout(r, 50)) + expect(freeze).not.toHaveBeenCalled() + expect(settlementBases).toHaveLength(1) + }) + + it('FE-MOB-COSTT-049: asks the settlement in the display currency with the phone\'s own rate for it', async () => { + seedStore(useSettingsStore, { + settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' }, + }) + const rates: (string | null)[] = [] + server.use( + http.get('/api/trips/:id/budget/settlement', ({ request }) => { + const url = new URL(request.url) + settlementBases.push(url.searchParams.get('base') ?? '') + rates.push(url.searchParams.get('base_rate')) + return HttpResponse.json(SETTLEMENT) + }), + ) + render() + await screen.findByText('+£12.50') + + // Pounds per dollar from the trip currency's own table, what the server labels with + // when it cannot fetch a quote itself. + expect(settlementBases).toEqual(['GBP']) + expect(rates).toEqual(['0.5']) + }) + it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => { // Cleared, the server would store NULL and the ledger would quietly fall // back to the day the payment was recorded on. diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index c2dab9aca..241b14cf2 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, Headers, + HttpCode, HttpException, Param, Post, @@ -28,6 +29,8 @@ import { BudgetReorderCategoriesDto, BudgetCreateSettlementDto, BudgetUpdateSettlementDto, + BudgetFreezeRatesDto, + BudgetSettlementQueryDto, } from './budget.dto'; /** @@ -37,8 +40,9 @@ import { * every handler verifies trip access (404); mutations check 'budget_edit' (403); * create is 201, the rest 200; bespoke 404 bodies reproduced; mutations * broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes - * (summary, settlement, reorder/*) are declared before /:id so they win over the - * param. Updating total_price on a reservation-linked item syncs the price back. + * (summary, settlement, freeze-rates, reorder/*) are declared before /:id so they + * 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 * (global ZodValidationPipe). This replaced the legacy bespoke 400s ('Name is @@ -66,14 +70,16 @@ export class BudgetController { 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') settlement( @CurrentUser() user: User, @Trip() trip: TripAccess, @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') @@ -91,7 +97,10 @@ export class BudgetController { ) { const settlement = await this.budget.createSettlement( 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, ); // 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, currency: body.currency, settled_at: body.settled_at, + fallback_fx: body.fallback_fx, }); if (!settlement) { throw new HttpException({ error: 'Settlement not found' }, 404); @@ -141,6 +151,33 @@ export class BudgetController { 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') @Post() async create( diff --git a/server/src/nest/budget/budget.dto.ts b/server/src/nest/budget/budget.dto.ts index 6a56bab77..0dd6993ec 100644 --- a/server/src/nest/budget/budget.dto.ts +++ b/server/src/nest/budget/budget.dto.ts @@ -9,6 +9,8 @@ import { budgetReorderCategoriesRequestSchema, budgetCreateSettlementRequestSchema, budgetUpdateSettlementRequestSchema, + budgetFreezeRatesRequestSchema, + budgetSettlementQuerySchema, } from '@trek/shared'; /** @@ -26,3 +28,6 @@ export class BudgetReorderItemsDto extends createZodDto(budgetReorderItemsReques export class BudgetReorderCategoriesDto extends createZodDto(budgetReorderCategoriesRequestSchema) {} export class BudgetCreateSettlementDto extends createZodDto(budgetCreateSettlementRequestSchema) {} 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) {} diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 63d5b42b6..2e79f480c 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -72,7 +72,8 @@ function formatCents(cents: number): string { * 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 * 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() export class BudgetMcp { @@ -177,7 +178,7 @@ export class BudgetMcp { @Tool({ 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: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), @@ -249,7 +250,7 @@ export class BudgetMcp { @Tool({ 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: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), @@ -390,7 +391,7 @@ export class BudgetMcp { @Tool({ 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: { tripId: z.number().int().positive(), 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({ 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: { tripId: z.number().int().positive(), from_user_id: z.number().int().positive().describe('User ID of the member who paid'), @@ -455,7 +456,7 @@ export class BudgetMcp { @Tool({ 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: { tripId: z.number().int().positive(), settlementId: z.number().int().positive(), @@ -505,6 +506,31 @@ export class BudgetMcp { 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 --- @ResourceTemplate({ diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index f66689234..1563c08b9 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; 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 { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; +import { byCodeUnit } from '../common/compare'; import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types'; 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 - * 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 (rate 1 or none) converts through - * today's `rates`, units of each currency per 1 of their base, and without them stays - * as it is. Pre-rework rows store currency = NULL, which means the trip's own. + * What `exchange_rate` holds on a row whose rate was never frozen. The column is + * `REAL NOT NULL DEFAULT 1`, so without a migration 1 is the only way to store "no + * rate". It is read in exactly two places, isFrozenRate and UNFROZEN_SQL; nothing + * else in this file compares a rate with 1. + */ +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 * read the same rows differently. */ -function toTripAmount( - amount: number, +function tripConverter( itemCurrency: string | null | undefined, itemRate: number | null | undefined, tripCurrency: string, rates: Record | null, -): number { +): ((amount: number) => number) | null { const cur = (itemCurrency || tripCurrency).toUpperCase(); - if (cur === tripCurrency) return amount; - if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; - if (!rates) return amount; - const rCur = rates[cur]; - const rTrip = rates[tripCurrency]; - if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; - return amount; + if (cur === tripCurrency) return amount => amount; + if (isFrozenRate(itemRate)) return amount => amount / itemRate; + const rCur = rates?.[cur]; + const rTrip = rates?.[tripCurrency]; + if (rCur && rCur > 0 && rTrip && rTrip > 0) return amount => (amount / rCur) * rTrip; + return null; } /** * 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 - * amount as it is, the way every conversion here degrades without rates. + * units of that currency per 1 base). Null when either quote is missing. */ -function quoteRatio(rates: Record | null, to: string, from: string): number { +function quoteRatio(rates: Record | null, to: string, from: string): number | null { const rTo = rates?.[to]; 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 * converts with it via `amount / rate`. * - * Only freezes for a foreign currency with no explicit rate; degrades to live - * rates if the fetch fails. On update it (re)freezes only when the currency - * changes (checked against `budget_items`), so an unrelated edit never moves - * money. Callers must invoke this *before* the (synchronous) DB write — the raw - * create/update stay sync because better-sqlite3 transactions can't await. + * Only freezes for a foreign currency with no explicit rate. The server's own rate + * comes first; `fallback_fx`, rates the caller lent for this write, only fills a + * currency the server cannot quote (resolveRate). With neither, a new row is + * stored unfrozen and left out of every figure until a rate turns up. On update it + * (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( tripId: string | number, - data: { currency?: string | null; exchange_rate?: number }, + data: { currency?: string | null; exchange_rate?: number; fallback_fx?: BudgetFallbackFx }, existingItemId?: string | number, existingCurrency?: string | null, ): Promise { + const fallback = data.fallback_fx; + delete data.fallback_fx; if (data.exchange_rate != null) return; // an explicit rate from the caller wins const cur = (data.currency || '').toUpperCase(); if (!cur) return; // currency not being set in this request @@ -397,16 +422,98 @@ export class BudgetService { if (existingCurrency !== undefined) { prior = (existingCurrency || '').toUpperCase(); } 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 (prior !== undefined && prior === cur) return; // currency unchanged - const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId); - const tripCur = (trip?.currency || 'EUR').toUpperCase(); + const tripCur = this.tripCurrency(tripId); if (cur === tripCur) return; // same as the trip currency → no conversion to freeze - const rates = await this.exchangeRates.getRates(tripCur); - const r = rates?.[cur]; - if (r && r > 0) data.exchange_rate = r; + const rate = this.resolveRate(tripCur, cur, await this.exchangeRates.getRates(tripCur), fallback); + if (rate !== null) data.exchange_rate = rate; + // 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 | 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>[]; 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 WHERE bi.trip_id = ? `, 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(); for (const item of items) { const own = members.filter(m => m.budget_item_id === item.id); 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 // 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) { - 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); if (!p) { 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 * (#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 - * 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 | null = null): { total: number; byCategory: Record } { + tripTotals(tripId: string | number, tripCurrency: string, rates: Record | null = null): { total: number; byCategory: Record; unconverted: number[] } { const trip = (tripCurrency || 'EUR').toUpperCase(); - const rows = this.db.all<{ 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, + const rows = this.db.all<{ id: number; category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>( + 'SELECT id, category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId, ); let total = 0; const byCategory: Record = {}; + const unconverted: number[] = []; 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; const cat = r.category || ''; byCategory[cat] = (byCategory[cat] || 0) + cents; @@ -930,6 +1045,7 @@ export class BudgetService { return { total: total / 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(` SELECT 1 FROM budget_items 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 `, tripId, trip); 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 * 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. + * + * 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( tripId: string | number, - opts: { base?: string; rates?: Record | null; tripCurrency?: string } = {}, + opts: { base?: string; rates?: Record | null; tripCurrency?: string; baseRate?: number } = {}, ) { - const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase(); - const tripCurrency = (opts.tripCurrency || base).toUpperCase(); + const requested = (opts.base || opts.tripCurrency || 'EUR').toUpperCase(); + const tripCurrency = (opts.tripCurrency || requested).toUpperCase(); const rates = opts.rates ?? null; // 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 @@ -1017,8 +1139,8 @@ export class BudgetService { // 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. // Legacy rows without a frozen rate convert via base with live rates. - const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => - toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates); + const converterFor = (itemCurrency: string | null | undefined, itemRate?: number | null) => + tripConverter(itemCurrency, itemRate, tripCurrency, rates); // 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 // 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 // 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. - 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 // 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 - // toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall - // back to the old behaviour: assume they were entered in the current display base - // and convert with live rates. - const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => { - if (sCurrency) { - const cur = sCurrency.toUpperCase(); - if (cur === tripCurrency) return amount; - 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. - return amount / displayFactor; - }; + // the expenses; a frozen currency without a usable rate falls back to live rates, + // and without those the transfer is left out like an expense. Legacy rows + // (currency = NULL) have no frozen rate, so fall back to the old behaviour: assume + // they were entered in the current display base and convert with live rates. + const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) => + sCurrency + ? tripConverter(sCurrency, sRate, tripCurrency, rates) + // The inverse of the display factor, so such a transfer reads back as entered. + : (amount: number) => amount / displayFactor; + const unconvertedItems: number[] = []; + const unconvertedSettlements: number[] = []; + const unconvertedCurrencies = new Set(); const items = this.db.all('SELECT * FROM budget_items WHERE trip_id = ?', tripId); const allMembers = this.db.all(` @@ -1073,8 +1198,6 @@ export class BudgetService { // 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 // 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 = {}; 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 }; @@ -1099,6 +1222,15 @@ export class BudgetService { const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents); 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 payers = allPayers.filter(p => p.budget_item_id === item.id); 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. let creditCents = 0; 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); ensure(p.user_id, p).cents += paid; 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) : {}; for (const m of members) { 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); ensure(m.user_id, m).cents -= memberShare; } @@ -1165,9 +1297,15 @@ export class BudgetService { return balances[id]; }; 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 // 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); 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; @@ -1259,6 +1397,12 @@ export class BudgetService { }, }; }) 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 * 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 - * 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 effectiveBase = (base || trip).toUpperCase(); const rates = (await this.exchangeRates.getRates(trip)) ?? (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[1]) { + async create(tripId: string, data: Parameters[1] & { fallback_fx?: BudgetFallbackFx }) { await this.freezeForeignRate(tripId, data); return this.createBudgetItem(tripId, data); } - async update(id: string | number, tripId: string | number, data: Parameters[2]) { + async update(id: string | number, tripId: string | number, data: Parameters[2] & { fallback_fx?: BudgetFallbackFx }) { await this.freezeForeignRate(tripId, data, id); 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); } - 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; // 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). @@ -1422,7 +1569,7 @@ export class BudgetService { 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 // the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a // settled position on an unrelated edit. diff --git a/server/src/nest/budget/exchange-rates.service.ts b/server/src/nest/budget/exchange-rates.service.ts index d13510029..d04b253ae 100644 --- a/server/src/nest/budget/exchange-rates.service.ts +++ b/server/src/nest/budget/exchange-rates.service.ts @@ -8,14 +8,16 @@ import { Injectable } from '@nestjs/common'; * 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]`. * - * Everything degrades gracefully: if the fetch fails (offline, upstream down), - * callers get `null`/identity conversion and amounts are treated as already in - * the base currency rather than throwing. + * Everything degrades without throwing: if the fetch fails (offline, upstream + * down), callers get `null`. Budget rows that already froze a rate still convert; + * 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 * fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and * 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 * upstream feed, so they must share one cache (same reasoning as the * permissions-cache.ts cache). diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index dab7b9650..dc37335ee 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -73,6 +73,8 @@ export class TripReadModelService { total: totals.total, by_category: totals.byCategory, 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) diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 991c94d16..4e1f7a291 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -63,9 +63,13 @@ export class TripPromptsMcp { .join('\n'); const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); 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 { 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}` } }], }; } diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 619e50f49..8f8bd870a 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -294,4 +294,160 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { expect(res.status).toBe(404); 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'); + }); + }); }); diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index a7b4c19ea..d13742c10 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -394,6 +394,9 @@ describe('Budget summary and settlement', () => { expect(res.status).toBe(200); expect(res.body.balances).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: [] }); }); }); diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index c825852eb..b9c51e96e 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -362,6 +362,7 @@ describe('Settlement tools', () => { { id: user.id } as User, { id: trip.id, user_id: user.id } as TripAccess, String(trip.id), + {}, ); await withHarness(user.id, async (h) => { @@ -398,7 +399,7 @@ describe('Settlement tools', () => { { id: user.id } as User, { id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess, String(trip.id), - 'USD', + { base: 'USD' }, ); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } }); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index a5084b376..8f052dc66 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -40,6 +40,8 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createBudgetItem, createPlace, addTripMember } from '../../helpers/factories'; 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(() => { 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([]); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index ea953102a..b662214bc 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -172,6 +172,7 @@ beforeEach(() => { total: totals.total, by_category: totals.byCategory, currency: trip.currency, + unconverted_item_ids: totals.unconverted, }, packing: packingRows, // array shape; packing prompt tolerates it reservations: [], @@ -535,6 +536,32 @@ describe('Prompt: budget-overview', () => { 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 () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Empty Budget' }); diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index 5df4a25dc..75871bc4e 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; +import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; 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 { User } from '../../../src/types'; @@ -57,8 +59,8 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou } as Partial); 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. - expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); - expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); + expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5', {})).toEqual({ transfers: [] }); + expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR', undefined); }); 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' }), settlement, } as Partial); - new BudgetController(svc).settlement(user, tripRow, '5', 'GBP'); - expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD'); + new BudgetController(svc).settlement(user, tripRow, '5', { base: 'GBP' }); + 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); + // 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', () => { @@ -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'); }); + 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); + 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', () => { const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial); 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); + 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); + 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 /', () => { // The legacy 'Name is required' 400 is now produced by the global diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index 9fe0398e2..46d6bdfcb 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -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) ─────────────────────────── describe('freezeForeignRate', () => { diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 171401252..9fd5de96a 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -1187,6 +1187,7 @@ describe('trip totals read every row in the trip currency (#2525)', () => { expect(budget.tripTotals(trip.id, 'EUR')).toEqual({ total: 785.26, byCategory: { accommodation: 685.26, food: 100 }, + unconverted: [], }); // Both rows carry what they need, so nothing waits on today's rates. 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(); + } + }); +}); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index e2382565c..ae468ce17 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -212,6 +212,8 @@ describe('getTripSummary shaping', () => { expect(summary.budget.total).toBe(785.26); expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 }); 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 () => { diff --git a/shared/src/budget/budget.schema.spec.ts b/shared/src/budget/budget.schema.spec.ts index 86642bbc8..6b9773fa4 100644 --- a/shared/src/budget/budget.schema.spec.ts +++ b/shared/src/budget/budget.schema.spec.ts @@ -1,5 +1,11 @@ import { budgetCreateItemRequestSchema, + budgetUpdateItemRequestSchema, + budgetCreateSettlementRequestSchema, + budgetUpdateSettlementRequestSchema, + budgetFreezeRatesRequestSchema, + budgetSettlementQuerySchema, + budgetUnconvertedSchema, budgetUpdateMembersRequestSchema, budgetToggleMemberPaidRequestSchema, budgetReorderItemsRequestSchema, @@ -61,3 +67,93 @@ describe('typeToCostCategory', () => { 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, + ); + }); +}); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 1e7978014..f86aff2ca 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -1,3 +1,5 @@ +import { idSchema } from '../common/primitives.schema'; + import { z } from 'zod'; /** @@ -113,6 +115,9 @@ export type BudgetItemReceipt = z.infer; * plus the embedded `members` (equal-split participants), `payers` and `receipts` arrays. * 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). + * 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({ id: z.number(), @@ -151,6 +156,29 @@ const memberInputSchema = z.object({ 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; + export const budgetCreateItemRequestSchema = z.object({ name: z.string().min(1), category: z.string().optional(), @@ -176,6 +204,8 @@ export const budgetCreateItemRequestSchema = z.object({ place_id: z.number().optional(), // Receipt files to link to this expense 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; @@ -195,6 +225,7 @@ export const budgetUpdateItemRequestSchema = z.object({ ticket_json: z.string().nullable().optional(), expense_date: z.string().nullable().optional(), receipt_file_ids: z.array(z.number()).optional(), + fallback_fx: budgetFallbackFxSchema.optional(), }); export type BudgetUpdateItemRequest = z.infer; @@ -209,7 +240,9 @@ export type BudgetUpdatePayersRequest = z.infer; @@ -253,9 +288,60 @@ export const budgetUpdateSettlementRequestSchema = z.object({ amount: z.number(), currency: z.string().nullable().optional(), settled_at: z.string().nullable().optional(), + fallback_fx: budgetFallbackFxSchema.optional(), }); export type BudgetUpdateSettlementRequest = z.infer; +/** + * 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; + +/** 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; + +/** + * 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; + +/** + * 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; + /** * What the trip actually costs one participant, as GET …/budget/settlement * returns it alongside the balances and the suggested flows. diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index d470e23e2..d88becf17 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -157,11 +157,12 @@ Requires `budget:read` or `budget:write` scope. The Budget addon must be enabled | `delete_budget_item` | Remove a budget item. | | `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. | -| `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. | | `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). | | `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