diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index b08cfde73..7489f9aba 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -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, diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 631863b02..77a57d404 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -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, diff --git a/server/src/nest/budget/budget-money.helpers.ts b/server/src/nest/budget/budget-money.helpers.ts index 1d18c76bf..9f1ecc19a 100644 --- a/server/src/nest/budget/budget-money.helpers.ts +++ b/server/src/nest/budget/budget-money.helpers.ts @@ -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 | 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 | 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 }; +} diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 130429986..3f7f1310e 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -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 | 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(); @@ -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 } diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index ba32067e5..d8de90f8c 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -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 () => {