fix(costs): move the settlement currency setup into the money helpers and hold the size baselines

This commit is contained in:
Maurice
2026-11-03 22:15:41 +00:00
parent 4587cf2099
commit c6149cd64d
5 changed files with 86 additions and 79 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
+1 -1
View File
@@ -7,7 +7,7 @@
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 1617,
"src/nest/budget/budget.service.ts": 1613,
"src/nest/collections/collections.service.ts": 1426,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
+75 -1
View File
@@ -1,4 +1,4 @@
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { currencyDecimals, splitEqualShares, sumMinor, toMinor } from '@trek/shared';
/**
* Re-denominate whole trip-currency cents into whole display-currency cents
@@ -164,3 +164,77 @@ export function quoteRatio(rates: Record<string, number> | null, to: string, fro
const rFrom = rates?.[from];
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null;
}
/**
* How a settlement reads its currencies: the display currency it answers in, the factor
* from trip cents into it, and the converters for expenses and recorded transfers.
* `calculateSettlement` nets with these; they are pure, so they live here.
*/
export function settlementFx(opts: {
base?: string;
rates?: Record<string, number> | null;
tripCurrency?: string;
baseRate?: number;
}) {
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
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
// the display currency IS the trip currency (the common case) every conversion below
// is the identity, so behaviour is unchanged.
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
// 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 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
// them adding up to zero in whatever currency the viewer picked (#1382).
//
// `rates` may be quoted against any base; the ratio of the two quotes is the
// factor either way. Callers pass the trip currency's own quote (see settlement()),
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
// 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.
//
// 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;
// Every figure goes out in whole steps of that currency: whole won or yen (#2604).
const displayUnit = 10 ** Math.max(0, 2 - currencyDecimals(currency));
const toDisplay = (cents: number[], total?: number) => allocateDisplayCents(cents, displayFactor, total, displayUnit);
// An expense entered in the display currency itself is owed in that currency
// (#2965). Booked through the trip currency at its frozen rate and back at
// today's, 1,081.09 EUR on a yen trip came back a day later as 1,078.86 EUR,
// and a repayment of exactly what was typed left 2.23 EUR owed. Such rows are
// netted apart, in whole display cents as entered; every other row still nets
// in trip cents and converts once, so Σ balances stays 0 in both halves.
const direct = currency !== tripCurrency ? currency : null;
const isDirect = (rowCurrency: string | null | undefined) =>
direct !== null && (rowCurrency || tripCurrency).toUpperCase() === direct;
// 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
// 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, by the
// inverse of the display factor, so such a transfer reads back as entered.
const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) =>
sCurrency ? tripConverter(sCurrency, sRate, tripCurrency, rates) : (amount: number) => amount / displayFactor;
return { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect };
}
+4 -65
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { currencyDecimals, splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
@@ -31,7 +31,7 @@ import { BUDGET_ITEM_TICKET, RESERVATION_METADATA } from '../../db/json-columns'
import { decodeJson, decodeJsonResult, encodeJson } from '../../utils/json-column';
import { DomainError } from '../common/domain-error';
import { GUEST_HAS_PAYMENTS, shareAfterLeaving } from './budget-departure.helpers';
import { allocateDisplayCents, customSplitCents, isFrozenRate, NOT_FROZEN_RATE, quoteRatio, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
import { customSplitCents, isFrozenRate, NOT_FROZEN_RATE, settlementFx, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
type Trip = TripAccess;
@@ -1083,68 +1083,7 @@ export class BudgetService {
tripId: string | number,
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string; baseRate?: number } = {},
) {
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
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
// the display currency IS the trip currency (the common case) every conversion below
// is the identity, so behaviour is unchanged.
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
// 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 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
// them adding up to zero in whatever currency the viewer picked (#1382).
//
// `rates` may be quoted against any base; the ratio of the two quotes is the
// factor either way. Callers pass the trip currency's own quote (see settlement()),
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
// 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.
//
// 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;
// Every figure goes out in whole steps of that currency: whole won or yen (#2604).
const displayUnit = 10 ** Math.max(0, 2 - currencyDecimals(currency));
const toDisplay = (cents: number[], total?: number) => allocateDisplayCents(cents, displayFactor, total, displayUnit);
// An expense entered in the display currency itself is owed in that currency
// (#2965). Booked through the trip currency at its frozen rate and back at
// today's, 1,081.09 EUR on a yen trip came back a day later as 1,078.86 EUR,
// and a repayment of exactly what was typed left 2.23 EUR owed. Such rows are
// netted apart, in whole display cents as entered; every other row still nets
// in trip cents and converts once, so Σ balances stays 0 in both halves.
const direct = currency !== tripCurrency ? currency : null;
const isDirect = (rowCurrency: string | null | undefined) =>
direct !== null && (rowCurrency || tripCurrency).toUpperCase() === direct;
// 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
// 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 { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect } = settlementFx(opts);
const unconvertedItems: number[] = [];
const unconvertedSettlements: number[] = [];
const unconvertedCurrencies = new Set<string>();
@@ -1158,7 +1097,7 @@ 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).
// `direct` is the half in display cents (#2965), see isDirect above.
// `direct` is the half in display cents (#2965), see isDirect in settlementFx.
const balances: Record<
number,
{ user_id: number; username: string; avatar_url: string | null; cents: number; direct: number }
@@ -1807,20 +1807,14 @@ describe('the settlement converts with the quote the entry rate was frozen from
const mine = before.finalBudgets.find(f => f.user_id === me.id)!;
expect(mine.expenses).toBe(12345.67);
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]);
const offered = before.flows.map(f => f.amount);
expect(offered).toHaveLength(1);
expect([6172.83, 6172.84]).toContain(offered[0]);
// Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it.
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: offered[0], currency: 'USD' }, me.id);
expect(before.flows.map(f => [6172.83, 6172.84].includes(f.amount))).toEqual([true]);
// Bob pays exactly what settle-up offers, in dollars; read in euros one cent may stay apart (#2965).
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: before.flows[0].amount, currency: 'USD' }, me.id);
const after = await budget.settlement(trip.id, 'USD', 'EUR');
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
expect(after.flows).toEqual([]);
// Read in the trip currency both go through the euro, each rounded to a euro cent
// on its own, so at most that cent stays apart (#2965); it still adds up to zero.
const inEur = (await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance);
expect(inEur.every(b => Math.abs(b) <= 0.01)).toBe(true);
expect(Math.round(inEur[0] * 100) + Math.round(inEur[1] * 100)).toBe(0);
const inEur = (await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => Math.round(b.balance * 100));
expect([[0, 0], [1, -1], [-1, 1]]).toContainEqual(inEur);
});
it('BUDGET-SVC-DB-083: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {