Files
pn-new-crm/src/lib/services/reports/currency.ts

103 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { getRate } from '@/lib/services/currency';
import { logger } from '@/lib/logger';
/**
* Port's default currency for money normalisation. Falls back to USD
* when the column is null (legacy ports). Shared across the report
* services so every money figure lands in one reporting currency.
*/
export async function resolvePortCurrency(portId: string): Promise<string> {
const [row] = await db
.select({ defaultCurrency: ports.defaultCurrency })
.from(ports)
.where(eq(ports.id, portId));
return row?.defaultCurrency ?? 'USD';
}
/**
* Convert `amount` from `from` → `to`, rounding the result to 2dp.
*
* Used for one-off per-row figures in report TABLES (recent payments,
* expense ledger, …) where a single normalised display value is wanted and
* the cent-rounding is correct because the value is shown standalone, not
* accumulated. For SUMS use {@link CurrencyAccumulator} instead — accumulating
* per-row `normalizeAmount` results compounds rounding drift (audit M19).
*
* On a missing/stale rate this returns `null` rather than the former silent
* `?? amount` fallback that added an unconverted foreign amount straight into
* a port-currency figure (audit L25). Callers decide how to degrade.
*/
export async function normalizeAmount(
amount: number,
from: string,
to: string,
): Promise<number | null> {
if (!amount) return amount;
const f = from.toUpperCase();
const t = to.toUpperCase();
if (f === t) return amount;
const rate = await getRate(f, t);
if (rate == null) {
logger.warn({ from: f, to: t }, 'Report normalizeAmount: FX rate unavailable; skipping figure');
return null;
}
return Number((amount * rate).toFixed(2));
}
/**
* Sums money amounts in their SOURCE currency, grouped by currency, then
* converts each currency bucket to the target currency exactly ONCE at
* settle time — rounding only the final per-target figure (audit M19).
*
* This avoids two correctness bugs of the old per-row `normalizeAmount`-then-
* accumulate pattern:
* 1. cents-rounding every row before adding compounded ±0.5¢×N drift;
* 2. a missing/stale rate silently added the raw foreign amount into the
* port-currency total (audit L25) — here an unconvertible bucket is
* skipped and counted, never folded in at the wrong scale.
*/
export class CurrencyAccumulator {
/** sourceCurrency (upper) → summed raw amount in that source currency */
private readonly buckets = new Map<string, number>();
/** Add a raw amount in its own currency. No conversion happens here. */
add(amount: number, currency: string): void {
if (!amount) return;
const c = currency.toUpperCase();
this.buckets.set(c, (this.buckets.get(c) ?? 0) + amount);
}
/**
* Convert every bucket to `target` once, sum, and round only the final
* figure. Unconvertible buckets (no/stale rate) are skipped and counted in
* `unconvertible` rather than added at the wrong scale.
*/
async settle(target: string): Promise<{ total: number; unconvertible: number }> {
const t = target.toUpperCase();
let total = 0;
let unconvertible = 0;
for (const [currency, sum] of this.buckets) {
if (sum === 0) continue;
if (currency === t) {
total += sum;
continue;
}
const rate = await getRate(currency, t);
if (rate == null) {
unconvertible += 1;
logger.warn(
{ from: currency, to: t, amount: sum },
'Report aggregate: FX rate unavailable; bucket excluded from total',
);
continue;
}
total += sum * rate;
}
return { total: Number(total.toFixed(2)), unconvertible };
}
}