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 { 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 { 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(); /** 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 }; } }