refactor(budget): the money and rate helpers move out of the budget service

This commit is contained in:
Maurice
2026-10-12 21:13:57 +00:00
parent e6aaeac67e
commit a360e3d391
2 changed files with 109 additions and 107 deletions
@@ -0,0 +1,107 @@
import { sumMinor } from '@trek/shared';
/**
* Re-denominate whole trip-currency cents into whole display-currency cents
* without losing or inventing one (#1382).
*
* Rounding every balance on its own lets the rounded set drift away from the sum
* it came from: a squared-up trip viewed in another currency then shows a cent
* that no payment flow can ever clear, and the drift moves whenever the live rate
* does, money appearing without a single expense being touched. Rounding down
* and handing the leftover to the largest fractions keeps Σ(converted) equal to
* converted(Σ). `factor === 1` (the display currency IS the trip currency, the
* common case) is the identity and never touches a float.
*/
/**
* Add money in whole cents, not in floats (#1964).
*
* A total that does not divide evenly is split into parts that are each exact
* to the cent (163.21 across two people is 81.61 and 81.60), but adding those
* two doubles gives 163.20999999999998, and that is what got written back over
* the clean total the client sent. It then surfaced anywhere the number is
* printed without Intl doing the rounding: the expense form when reopened, the
* mobile cost sheet, and the price stamped onto a linked booking.
*
* This is the same rule the settlement maths in budget.service.ts already follows
* (toTripCents), applied to the one arithmetic that had been left in euros.
*/
export function sumMoney(amounts: number[]): number {
return sumMinor(amounts) / 100;
}
/**
* Convert a set of trip cents to display cents so that they still add up: floor
* each one, then hand the cents lost to flooring to the largest fractions. `total`
* is what the set has to sum to. By default that is the rounded conversion of its
* own sum; the rows behind a figure pass the figure's already allocated cents
* instead, so a list nested under a line lands exactly on that line.
*
* `unit` is the smallest step the currency is shown in, in cents (100 for won or
* yen, else 1). The ledger keeps hundredths, but a set handed out in hundredths of
* a won and rounded one by one on screen stops adding up (#2604). In the trip's own
* currency the remainders are integers, so booking what is offered clears it.
*/
export function allocateDisplayCents(cents: number[], factor: number, total?: number, unit = 1): number[] {
if (factor === 1 && unit === 1) return [...cents];
const exact = cents.map((c) => (c * factor) / unit);
const out = exact.map((v) => Math.floor(v));
const target = total === undefined ? Math.round((cents.reduce((a, c) => a + c, 0) * factor) / unit) : total / unit;
const drift = target - out.reduce((a, v) => a + v, 0);
const byFraction = exact
.map((v, i) => ({ i, frac: factor === 1 ? cents[i] - out[i] * unit : v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac || a.i - b.i);
for (let k = 0; k < drift && k < byFraction.length; k++) out[byFraction[k].i] += 1;
return unit === 1 ? out : out.map((v) => v * unit);
}
/**
* 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 the repositories'
* unfrozen predicate (BG89-BG92); nothing in budget.service.ts compares a rate with 1.
*/
export const NOT_FROZEN_RATE = 1;
/** Whether a stored rate is one that was frozen, rather than the "not frozen" sentinel. */
export function isFrozenRate(rate: number | null | undefined): rate is number {
return rate != null && Number.isFinite(rate) && rate > 0 && rate !== NOT_FROZEN_RATE;
}
/**
* 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.
*/
export function tripConverter(
itemCurrency: string | null | undefined,
itemRate: number | null | undefined,
tripCurrency: string,
rates: Record<string, number> | null,
): ((amount: number) => number) | null {
const cur = (itemCurrency || tripCurrency).toUpperCase();
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). Null when either quote is missing.
*/
export function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number | null {
const rTo = rates?.[to];
const rFrom = rates?.[from];
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null;
}
+2 -107
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 { currencyDecimals, splitEqualShares, toMinor } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
@@ -31,6 +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, isFrozenRate, NOT_FROZEN_RATE, quoteRatio, sumMoney, tripConverter } from './budget-money.helpers';
type Trip = TripAccess;
@@ -74,112 +75,6 @@ export function splitLegacyTicketNote(
return { note, ticket };
}
/**
* Re-denominate whole trip-currency cents into whole display-currency cents
* without losing or inventing one (#1382).
*
* Rounding every balance on its own lets the rounded set drift away from the sum
* it came from: a squared-up trip viewed in another currency then shows a cent
* that no payment flow can ever clear, and the drift moves whenever the live rate
* does — money appearing without a single expense being touched. Rounding down
* and handing the leftover to the largest fractions keeps Σ(converted) equal to
* converted(Σ). `factor === 1` (the display currency IS the trip currency, the
* common case) is the identity and never touches a float.
*/
/**
* Add money in whole cents, not in floats (#1964).
*
* A total that does not divide evenly is split into parts that are each exact
* to the cent — 163.21 across two people is 81.61 and 81.60 — but adding those
* two doubles gives 163.20999999999998, and that is what got written back over
* the clean total the client sent. It then surfaced anywhere the number is
* printed without Intl doing the rounding: the expense form when reopened, the
* mobile cost sheet, and the price stamped onto a linked booking.
*
* This is the same rule the settlement maths in this file already follows
* (toTripCents), applied to the one arithmetic that had been left in euros.
*/
function sumMoney(amounts: number[]): number {
return sumMinor(amounts) / 100;
}
/**
* Convert a set of trip cents to display cents so that they still add up: floor
* each one, then hand the cents lost to flooring to the largest fractions. `total`
* is what the set has to sum to. By default that is the rounded conversion of its
* own sum; the rows behind a figure pass the figure's already allocated cents
* instead, so a list nested under a line lands exactly on that line.
*
* `unit` is the smallest step the currency is shown in, in cents (100 for won or
* yen, else 1). The ledger keeps hundredths, but a set handed out in hundredths of
* a won and rounded one by one on screen stops adding up (#2604). In the trip's own
* currency the remainders are integers, so booking what is offered clears it.
*/
function allocateDisplayCents(cents: number[], factor: number, total?: number, unit = 1): number[] {
if (factor === 1 && unit === 1) return [...cents];
const exact = cents.map(c => (c * factor) / unit);
const out = exact.map(v => Math.floor(v));
const target = total === undefined ? Math.round((cents.reduce((a, c) => a + c, 0) * factor) / unit) : total / unit;
const drift = target - out.reduce((a, v) => a + v, 0);
const byFraction = exact
.map((v, i) => ({ i, frac: factor === 1 ? cents[i] - out[i] * unit : v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac || a.i - b.i);
for (let k = 0; k < drift && k < byFraction.length; k++) out[byFraction[k].i] += 1;
return unit === 1 ? out : out.map(v => v * unit);
}
/**
* 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 the repositories'
* unfrozen predicate (BG89-BG92); 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;
}
/**
* 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 tripConverter(
itemCurrency: string | null | undefined,
itemRate: number | null | undefined,
tripCurrency: string,
rates: Record<string, number> | null,
): ((amount: number) => number) | null {
const cur = (itemCurrency || tripCurrency).toUpperCase();
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). Null when either quote is missing.
*/
function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number | null {
const rTo = rates?.[to];
const rFrom = rates?.[from];
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null;
}
/**
* Budget domain service — owns the budget SQL (moved from the legacy
* services/budgetService.ts: identical statements, the `||` falsy-coercion