fix(costs): leave out an expense no rate is known for instead of counting it 1:1

A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
This commit is contained in:
Maurice
2026-09-26 12:41:05 +02:00
parent c6a089107f
commit aa1f76ad11
32 changed files with 2071 additions and 137 deletions
@@ -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', () => {