fix(audit): financial — M19 (group-by-currency accumulation, full-precision rates), M23 (invoice money rounding + 0% discount), L25 (no silent unconverted/stale FX), L26 (companyNotes updatedAt)
M23 numeric(12,2) schema precision deferred to a migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@ import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { convert } from '@/lib/services/currency';
|
||||
import { getRate } from '@/lib/services/currency';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* Port's default currency for money normalisation. Falls back to USD
|
||||
@@ -18,15 +19,84 @@ export async function resolvePortCurrency(portId: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `amount` from `from` → `to`. Returns the amount unchanged when
|
||||
* the currencies match or a rate is unavailable (so a missing FX rate
|
||||
* degrades to "report in source units" rather than dropping the figure).
|
||||
* 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> {
|
||||
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 converted = await convert(amount, f, t);
|
||||
return converted?.result ?? 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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user