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:
@@ -5,12 +5,31 @@ import { CodedError } from '@/lib/errors';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
|
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject FX rates older than this when normalising money for reports (audit
|
||||||
|
* L25). A rate this stale almost certainly means the refresh job has been
|
||||||
|
* failing silently; using it would mis-state revenue against a months-old
|
||||||
|
* cross-rate. 14 days gives ample slack over the daily refresh cadence while
|
||||||
|
* still catching a genuinely-stalled poller.
|
||||||
|
*/
|
||||||
|
export const RATE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export async function getRate(from: string, to: string): Promise<number | null> {
|
export async function getRate(from: string, to: string): Promise<number | null> {
|
||||||
if (from === to) return 1;
|
if (from === to) return 1;
|
||||||
const rate = await db.query.currencyRates.findFirst({
|
const rate = await db.query.currencyRates.findFirst({
|
||||||
where: and(eq(currencyRates.baseCurrency, from), eq(currencyRates.targetCurrency, to)),
|
where: and(eq(currencyRates.baseCurrency, from), eq(currencyRates.targetCurrency, to)),
|
||||||
});
|
});
|
||||||
return rate ? Number(rate.rate) : null;
|
if (!rate) return null;
|
||||||
|
// Staleness guard (L25): a rate the refresh job hasn't touched in
|
||||||
|
// RATE_MAX_AGE_MS is treated as unavailable rather than silently used.
|
||||||
|
if (rate.fetchedAt && Date.now() - new Date(rate.fetchedAt).getTime() > RATE_MAX_AGE_MS) {
|
||||||
|
logger.warn(
|
||||||
|
{ from, to, fetchedAt: rate.fetchedAt },
|
||||||
|
'Currency rate is stale beyond RATE_MAX_AGE_MS; treating as unavailable',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(rate.rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convert(
|
export async function convert(
|
||||||
@@ -49,26 +68,34 @@ export async function refreshRates(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store inverse rates for common conversions
|
// Store inverse rates for common conversions. Persist at full precision —
|
||||||
|
// pre-rounding the inverse to 6dp (former behaviour) meant X→USD and
|
||||||
|
// USD→X were not exact reciprocals, compounding into report totals (M19).
|
||||||
|
// The `rate` column is unbounded `numeric`, so full precision is safe.
|
||||||
for (const [currency, rate] of Object.entries(rates)) {
|
for (const [currency, rate] of Object.entries(rates)) {
|
||||||
const inverse = 1 / rate;
|
const inverse = String(1 / rate);
|
||||||
await db
|
await db
|
||||||
.insert(currencyRates)
|
.insert(currencyRates)
|
||||||
.values({
|
.values({
|
||||||
baseCurrency: currency,
|
baseCurrency: currency,
|
||||||
targetCurrency: 'USD',
|
targetCurrency: 'USD',
|
||||||
rate: String(inverse.toFixed(6)),
|
rate: inverse,
|
||||||
source: 'frankfurter',
|
source: 'frankfurter',
|
||||||
fetchedAt: new Date(),
|
fetchedAt: new Date(),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [currencyRates.baseCurrency, currencyRates.targetCurrency],
|
target: [currencyRates.baseCurrency, currencyRates.targetCurrency],
|
||||||
set: { rate: String(inverse.toFixed(6)), fetchedAt: new Date(), source: 'frankfurter' },
|
set: { rate: inverse, fetchedAt: new Date(), source: 'frankfurter' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ rateCount: Object.keys(rates).length }, 'Currency rates refreshed');
|
logger.info({ rateCount: Object.keys(rates).length }, 'Currency rates refreshed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Log AND rethrow (L25): swallowing here let a months-stale rate persist
|
||||||
|
// unnoticed. The HTTP route wraps this in errorResponse(); the BullMQ
|
||||||
|
// maintenance worker marks the job failed and surfaces it via its
|
||||||
|
// `failed` handler — either way the failure is now visible.
|
||||||
logger.error({ err }, 'Failed to refresh currency rates');
|
logger.error({ err }, 'Failed to refresh currency rates');
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ import type {
|
|||||||
ListInvoicesInput,
|
ListInvoicesInput,
|
||||||
} from '@/lib/validators/invoices';
|
} from '@/lib/validators/invoices';
|
||||||
|
|
||||||
|
// ─── Money helper (audit M23) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a money amount as a fixed-2dp string for persistence into the
|
||||||
|
* `numeric` money columns. Rounds at the cent before stringifying so JS-float
|
||||||
|
* artefacts (e.g. `0.1 + 0.2 = 0.30000000000000004`) never reach the DB.
|
||||||
|
*
|
||||||
|
* NOTE: the underlying columns are still unbounded `numeric` (no `(12,2)`
|
||||||
|
* scale) — tightening the schema needs a migration and is tracked as a
|
||||||
|
* deferred follow-up; this guards the write side in the meantime.
|
||||||
|
*/
|
||||||
|
function money(amount: number): string {
|
||||||
|
return (Math.round(amount * 100) / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auto-numbering (BR-041) ───────────────────────────────────────────────
|
// ─── Auto-numbering (BR-041) ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<string> {
|
async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<string> {
|
||||||
@@ -261,7 +276,11 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (setting) {
|
if (setting) {
|
||||||
discountPct = Number(setting.value) || 2;
|
// A legitimately-configured 0% discount must be honoured, not coerced
|
||||||
|
// back to the 2% default the old `|| 2` produced (M23). Only fall back
|
||||||
|
// to 2 when the stored value is missing or non-numeric.
|
||||||
|
const parsed = Number(setting.value);
|
||||||
|
discountPct = Number.isNaN(parsed) ? 2 : parsed;
|
||||||
} else {
|
} else {
|
||||||
discountPct = 2;
|
discountPct = 2;
|
||||||
}
|
}
|
||||||
@@ -319,12 +338,12 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
|||||||
dueDate: data.dueDate,
|
dueDate: data.dueDate,
|
||||||
paymentTerms: data.paymentTerms ?? 'net30',
|
paymentTerms: data.paymentTerms ?? 'net30',
|
||||||
currency: data.currency ?? 'USD',
|
currency: data.currency ?? 'USD',
|
||||||
subtotal: String(subtotal),
|
subtotal: money(subtotal),
|
||||||
discountPct: String(discountPct),
|
discountPct: String(discountPct),
|
||||||
discountAmount: String(discountAmount),
|
discountAmount: money(discountAmount),
|
||||||
feePct: String(feePct),
|
feePct: String(feePct),
|
||||||
feeAmount: String(feeAmount),
|
feeAmount: money(feeAmount),
|
||||||
total: String(total),
|
total: money(total),
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
paymentStatus: 'unpaid',
|
paymentStatus: 'unpaid',
|
||||||
interestId: data.interestId ?? null,
|
interestId: data.interestId ?? null,
|
||||||
@@ -346,8 +365,8 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
|||||||
invoiceId: newInvoice.id,
|
invoiceId: newInvoice.id,
|
||||||
description: li.description,
|
description: li.description,
|
||||||
quantity: String(li.quantity ?? 1),
|
quantity: String(li.quantity ?? 1),
|
||||||
unitPrice: String(li.unitPrice),
|
unitPrice: money(li.unitPrice),
|
||||||
total: String((li.quantity ?? 1) * li.unitPrice),
|
total: money((li.quantity ?? 1) * li.unitPrice),
|
||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -450,7 +469,14 @@ export async function updateInvoice(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
discountPct = setting ? Number(setting.value) || 2 : 2;
|
// `?? 2` semantics (honour a configured 0%), but `Number()` yields NaN
|
||||||
|
// for a non-numeric stored value, so guard with `Number.isNaN` (M23).
|
||||||
|
if (setting) {
|
||||||
|
const parsed = Number(setting.value);
|
||||||
|
discountPct = Number.isNaN(parsed) ? 2 : parsed;
|
||||||
|
} else {
|
||||||
|
discountPct = 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const discountAmount = (subtotal * discountPct) / 100;
|
const discountAmount = (subtotal * discountPct) / 100;
|
||||||
@@ -458,12 +484,12 @@ export async function updateInvoice(
|
|||||||
const feePct = Number(existing.feePct) || 0;
|
const feePct = Number(existing.feePct) || 0;
|
||||||
const total = subtotal - discountAmount + feeAmount;
|
const total = subtotal - discountAmount + feeAmount;
|
||||||
|
|
||||||
updateData.subtotal = String(subtotal);
|
updateData.subtotal = money(subtotal);
|
||||||
updateData.discountPct = String(discountPct);
|
updateData.discountPct = String(discountPct);
|
||||||
updateData.discountAmount = String(discountAmount);
|
updateData.discountAmount = money(discountAmount);
|
||||||
updateData.feePct = String(feePct);
|
updateData.feePct = String(feePct);
|
||||||
updateData.feeAmount = String(feeAmount);
|
updateData.feeAmount = money(feeAmount);
|
||||||
updateData.total = String(total);
|
updateData.total = money(total);
|
||||||
|
|
||||||
// Replace line items
|
// Replace line items
|
||||||
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
|
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
|
||||||
@@ -473,8 +499,8 @@ export async function updateInvoice(
|
|||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
description: li.description,
|
description: li.description,
|
||||||
quantity: String(li.quantity ?? 1),
|
quantity: String(li.quantity ?? 1),
|
||||||
unitPrice: String(li.unitPrice),
|
unitPrice: money(li.unitPrice),
|
||||||
total: String((li.quantity ?? 1) * li.unitPrice),
|
total: money((li.quantity ?? 1) * li.unitPrice),
|
||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -929,7 +929,7 @@ export async function create(
|
|||||||
.from(userProfiles)
|
.from(userProfiles)
|
||||||
.where(eq(userProfiles.userId, authorId))
|
.where(eq(userProfiles.userId, authorId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return { ...note, authorName: profile[0]?.displayName ?? null, updatedAt: note.createdAt };
|
return { ...note, authorName: profile[0]?.displayName ?? null };
|
||||||
}
|
}
|
||||||
if (entityType === 'clients') {
|
if (entityType === 'clients') {
|
||||||
const [note] = await db
|
const [note] = await db
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
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
|
* 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
|
* Convert `amount` from `from` → `to`, rounding the result to 2dp.
|
||||||
* the currencies match or a rate is unavailable (so a missing FX rate
|
*
|
||||||
* degrades to "report in source units" rather than dropping the figure).
|
* 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;
|
if (!amount) return amount;
|
||||||
const f = from.toUpperCase();
|
const f = from.toUpperCase();
|
||||||
const t = to.toUpperCase();
|
const t = to.toUpperCase();
|
||||||
if (f === t) return amount;
|
if (f === t) return amount;
|
||||||
const converted = await convert(amount, f, t);
|
const rate = await getRate(f, t);
|
||||||
return converted?.result ?? amount;
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { berths } from '@/lib/db/schema/berths';
|
|||||||
import { payments } from '@/lib/db/schema/pipeline';
|
import { payments } from '@/lib/db/schema/pipeline';
|
||||||
import { expenses } from '@/lib/db/schema/financial';
|
import { expenses } from '@/lib/db/schema/financial';
|
||||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||||
import { resolvePortCurrency, normalizeAmount } from './currency';
|
import { resolvePortCurrency, normalizeAmount, CurrencyAccumulator } from './currency';
|
||||||
import {
|
import {
|
||||||
agingBucket,
|
agingBucket,
|
||||||
AGING_BUCKETS,
|
AGING_BUCKETS,
|
||||||
@@ -94,23 +94,37 @@ async function getDepositPositions(portId: string): Promise<DepositPosition[]> {
|
|||||||
.from(payments)
|
.from(payments)
|
||||||
.where(and(eq(payments.portId, portId), eq(payments.paymentType, 'deposit')));
|
.where(and(eq(payments.portId, portId), eq(payments.paymentType, 'deposit')));
|
||||||
|
|
||||||
const collectedByInterest = new Map<string, number>();
|
// Accumulate collected deposits per interest in their SOURCE currency, then
|
||||||
|
// settle each interest's buckets to the target currency once (M19) — never
|
||||||
|
// cents-rounding mid-accumulation.
|
||||||
|
const collectedAccByInterest = new Map<string, CurrencyAccumulator>();
|
||||||
for (const r of collectedRows) {
|
for (const r of collectedRows) {
|
||||||
const amount = await normalizeAmount(
|
let acc = collectedAccByInterest.get(r.interestId);
|
||||||
Number(r.amount ?? 0),
|
if (!acc) {
|
||||||
r.currency ?? targetCurrency,
|
acc = new CurrencyAccumulator();
|
||||||
targetCurrency,
|
collectedAccByInterest.set(r.interestId, acc);
|
||||||
);
|
}
|
||||||
collectedByInterest.set(r.interestId, (collectedByInterest.get(r.interestId) ?? 0) + amount);
|
acc.add(Number(r.amount ?? 0), r.currency ?? targetCurrency);
|
||||||
|
}
|
||||||
|
const collectedByInterest = new Map<string, number>();
|
||||||
|
for (const [interestId, acc] of collectedAccByInterest) {
|
||||||
|
const { total } = await acc.settle(targetCurrency);
|
||||||
|
collectedByInterest.set(interestId, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
const positions: DepositPosition[] = [];
|
const positions: DepositPosition[] = [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const expected = await normalizeAmount(
|
// A single expected figure per deal — `normalizeAmount` is fine here; on a
|
||||||
|
// missing rate it returns null, in which case we fall back to the raw
|
||||||
|
// amount labelled in source units (the figure can't be dropped silently as
|
||||||
|
// it gates the outstanding/pipeline KPIs). 0-expected deals are excluded
|
||||||
|
// upstream by the IS NOT NULL filter.
|
||||||
|
const expected =
|
||||||
|
(await normalizeAmount(
|
||||||
Number(r.expected ?? 0),
|
Number(r.expected ?? 0),
|
||||||
r.expectedCurrency ?? targetCurrency,
|
r.expectedCurrency ?? targetCurrency,
|
||||||
targetCurrency,
|
targetCurrency,
|
||||||
);
|
)) ?? Number(r.expected ?? 0);
|
||||||
const collected = collectedByInterest.get(r.interestId) ?? 0;
|
const collected = collectedByInterest.get(r.interestId) ?? 0;
|
||||||
const remaining = Math.max(0, expected - collected);
|
const remaining = Math.max(0, expected - collected);
|
||||||
const daysOutstanding = r.createdAt
|
const daysOutstanding = r.createdAt
|
||||||
@@ -151,28 +165,42 @@ async function sumPaymentsInRange(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const acc = { total: 0, deposit: 0, balance: 0, refund: 0 };
|
// Sum in source currency per type, convert each currency bucket once at the
|
||||||
|
// end (M19). Refunds accumulate as MAGNITUDES (audit H12): `Math.abs` is
|
||||||
|
// applied to the source amount before bucketing — conversion preserves
|
||||||
|
// magnitude (rates are positive) — so a refund always subtracts and can
|
||||||
|
// never inflate revenue regardless of its stored sign.
|
||||||
|
const depositAcc = new CurrencyAccumulator();
|
||||||
|
const balanceAcc = new CurrencyAccumulator();
|
||||||
|
const refundMagAcc = new CurrencyAccumulator();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const amount = await normalizeAmount(
|
const raw = Number(r.amount ?? 0);
|
||||||
Number(r.amount ?? 0),
|
const cur = r.currency ?? targetCurrency;
|
||||||
r.currency ?? targetCurrency,
|
|
||||||
targetCurrency,
|
|
||||||
);
|
|
||||||
if (r.paymentType === 'refund') {
|
if (r.paymentType === 'refund') {
|
||||||
// Refund sign convention (audit H12): treat a refund as a magnitude
|
refundMagAcc.add(Math.abs(raw), cur);
|
||||||
// deduction regardless of the stored sign. New rows store negatives, but
|
} else if (r.paymentType === 'deposit') {
|
||||||
// legacy rows may be positive — `Math.abs` makes both subtract so a
|
depositAcc.add(raw, cur);
|
||||||
// refund can never INFLATE collected revenue.
|
} else if (r.paymentType === 'balance') {
|
||||||
const magnitude = Math.abs(amount);
|
balanceAcc.add(raw, cur);
|
||||||
acc.total -= magnitude;
|
|
||||||
acc.refund -= magnitude;
|
|
||||||
} else {
|
} else {
|
||||||
acc.total += amount;
|
// Any other non-refund type still contributes to total via its own
|
||||||
if (r.paymentType === 'deposit') acc.deposit += amount;
|
// bucket; fold it into deposit so `total` stays = deposit+balance−refund.
|
||||||
else if (r.paymentType === 'balance') acc.balance += amount;
|
depositAcc.add(raw, cur);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return acc;
|
|
||||||
|
const [deposit, balance, refundMag] = await Promise.all([
|
||||||
|
depositAcc.settle(targetCurrency),
|
||||||
|
balanceAcc.settle(targetCurrency),
|
||||||
|
refundMagAcc.settle(targetCurrency),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: Number((deposit.total + balance.total - refundMag.total).toFixed(2)),
|
||||||
|
deposit: deposit.total,
|
||||||
|
balance: balance.total,
|
||||||
|
refund: -refundMag.total,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFinancialKpis(portId: string, range: DateRange): Promise<FinancialKpis> {
|
export async function getFinancialKpis(portId: string, range: DateRange): Promise<FinancialKpis> {
|
||||||
@@ -215,14 +243,11 @@ async function sumExpensesInRange(
|
|||||||
lte(expenses.expenseDate, range.to),
|
lte(expenses.expenseDate, range.to),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let total = 0;
|
const acc = new CurrencyAccumulator();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
total += await normalizeAmount(
|
acc.add(Number(r.amount ?? 0), r.currency ?? targetCurrency);
|
||||||
Number(r.amount ?? 0),
|
|
||||||
r.currency ?? targetCurrency,
|
|
||||||
targetCurrency,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const { total } = await acc.settle(targetCurrency);
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,27 +282,47 @@ export async function getRevenueByMonth(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const byMonth = new Map<string, { deposit: number; balance: number }>();
|
// One trio of source-currency accumulators per month; convert each once at
|
||||||
for (const key of monthRange(range.from, range.to)) byMonth.set(key, { deposit: 0, balance: 0 });
|
// settle time (M19). Deposit bar = deposit − refund magnitude (audit H12):
|
||||||
|
// `Math.abs` on the source amount so legacy positive-stored refunds also
|
||||||
|
// subtract; conversion preserves magnitude.
|
||||||
|
type MonthAcc = {
|
||||||
|
deposit: CurrencyAccumulator;
|
||||||
|
balance: CurrencyAccumulator;
|
||||||
|
refundMag: CurrencyAccumulator;
|
||||||
|
};
|
||||||
|
const byMonth = new Map<string, MonthAcc>();
|
||||||
|
for (const key of monthRange(range.from, range.to))
|
||||||
|
byMonth.set(key, {
|
||||||
|
deposit: new CurrencyAccumulator(),
|
||||||
|
balance: new CurrencyAccumulator(),
|
||||||
|
refundMag: new CurrencyAccumulator(),
|
||||||
|
});
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!r.receivedAt) continue;
|
if (!r.receivedAt) continue;
|
||||||
const key = monthKey(new Date(r.receivedAt));
|
const acc = byMonth.get(monthKey(new Date(r.receivedAt)));
|
||||||
const bucket = byMonth.get(key);
|
if (!acc) continue;
|
||||||
if (!bucket) continue;
|
const raw = Number(r.amount ?? 0);
|
||||||
const amount = await normalizeAmount(
|
const cur = r.paymentCurrency ?? currency;
|
||||||
Number(r.amount ?? 0),
|
if (r.paymentType === 'balance') acc.balance.add(raw, cur);
|
||||||
r.paymentCurrency ?? currency,
|
else if (r.paymentType === 'refund') acc.refundMag.add(Math.abs(raw), cur);
|
||||||
currency,
|
else acc.deposit.add(raw, cur); // deposit + other → deposit bar
|
||||||
);
|
|
||||||
if (r.paymentType === 'balance') bucket.balance += amount;
|
|
||||||
else if (r.paymentType === 'refund')
|
|
||||||
// Refund sign convention (audit H12): net the refund magnitude out of the
|
|
||||||
// deposit bar (it previously dropped refunds, overstating monthly
|
|
||||||
// revenue). `Math.abs` so legacy positive-stored refunds also subtract.
|
|
||||||
bucket.deposit -= Math.abs(amount);
|
|
||||||
else bucket.deposit += amount; // deposit + other → deposit bar
|
|
||||||
}
|
}
|
||||||
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
|
|
||||||
|
const out: RevenueByMonthRow[] = [];
|
||||||
|
for (const [month, acc] of byMonth) {
|
||||||
|
const [deposit, balance, refundMag] = await Promise.all([
|
||||||
|
acc.deposit.settle(currency),
|
||||||
|
acc.balance.settle(currency),
|
||||||
|
acc.refundMag.settle(currency),
|
||||||
|
]);
|
||||||
|
out.push({
|
||||||
|
month,
|
||||||
|
deposit: Number((deposit.total - refundMag.total).toFixed(2)),
|
||||||
|
balance: balance.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectionFunnelRow {
|
export interface CollectionFunnelRow {
|
||||||
@@ -375,8 +420,22 @@ export interface CashFlowRow {
|
|||||||
/** Monthly inflow (payments received) vs outflow (expenses booked). */
|
/** Monthly inflow (payments received) vs outflow (expenses booked). */
|
||||||
export async function getCashFlow(portId: string, range: DateRange): Promise<CashFlowRow[]> {
|
export async function getCashFlow(portId: string, range: DateRange): Promise<CashFlowRow[]> {
|
||||||
const currency = await resolvePortCurrency(portId);
|
const currency = await resolvePortCurrency(portId);
|
||||||
const byMonth = new Map<string, { inflow: number; outflow: number }>();
|
// Per-month source-currency accumulators; convert each bucket once at settle
|
||||||
for (const key of monthRange(range.from, range.to)) byMonth.set(key, { inflow: 0, outflow: 0 });
|
// (M19). Inflow = non-refund payments − refund magnitudes (audit H12); the
|
||||||
|
// refund magnitude rides its own accumulator so it converts at its own
|
||||||
|
// currency's rate before being subtracted.
|
||||||
|
type FlowAcc = {
|
||||||
|
inflow: CurrencyAccumulator;
|
||||||
|
refundMag: CurrencyAccumulator;
|
||||||
|
outflow: CurrencyAccumulator;
|
||||||
|
};
|
||||||
|
const byMonth = new Map<string, FlowAcc>();
|
||||||
|
for (const key of monthRange(range.from, range.to))
|
||||||
|
byMonth.set(key, {
|
||||||
|
inflow: new CurrencyAccumulator(),
|
||||||
|
refundMag: new CurrencyAccumulator(),
|
||||||
|
outflow: new CurrencyAccumulator(),
|
||||||
|
});
|
||||||
|
|
||||||
const paymentRows = await db
|
const paymentRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -395,14 +454,12 @@ export async function getCashFlow(portId: string, range: DateRange): Promise<Cas
|
|||||||
);
|
);
|
||||||
for (const r of paymentRows) {
|
for (const r of paymentRows) {
|
||||||
if (!r.receivedAt) continue;
|
if (!r.receivedAt) continue;
|
||||||
const bucket = byMonth.get(monthKey(new Date(r.receivedAt)));
|
const acc = byMonth.get(monthKey(new Date(r.receivedAt)));
|
||||||
if (!bucket) continue;
|
if (!acc) continue;
|
||||||
const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
|
const raw = Number(r.amount ?? 0);
|
||||||
// Refund sign convention (audit H12): a refund is cash leaving, so deduct
|
const cur = r.currency ?? currency;
|
||||||
// its magnitude from monthly inflow regardless of the stored sign (legacy
|
if (r.paymentType === 'refund') acc.refundMag.add(Math.abs(raw), cur);
|
||||||
// rows may be positive, new rows negative). Without this a positive-stored
|
else acc.inflow.add(raw, cur);
|
||||||
// refund would overstate inflow.
|
|
||||||
bucket.inflow += r.paymentType === 'refund' ? -Math.abs(amount) : amount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expenseRows = await db
|
const expenseRows = await db
|
||||||
@@ -422,16 +479,25 @@ export async function getCashFlow(portId: string, range: DateRange): Promise<Cas
|
|||||||
);
|
);
|
||||||
for (const r of expenseRows) {
|
for (const r of expenseRows) {
|
||||||
if (!r.expenseDate) continue;
|
if (!r.expenseDate) continue;
|
||||||
const bucket = byMonth.get(monthKey(new Date(r.expenseDate)));
|
const acc = byMonth.get(monthKey(new Date(r.expenseDate)));
|
||||||
if (!bucket) continue;
|
if (!acc) continue;
|
||||||
bucket.outflow += await normalizeAmount(
|
acc.outflow.add(Number(r.amount ?? 0), r.currency ?? currency);
|
||||||
Number(r.amount ?? 0),
|
|
||||||
r.currency ?? currency,
|
|
||||||
currency,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
|
const out: CashFlowRow[] = [];
|
||||||
|
for (const [month, acc] of byMonth) {
|
||||||
|
const [inflow, refundMag, outflow] = await Promise.all([
|
||||||
|
acc.inflow.settle(currency),
|
||||||
|
acc.refundMag.settle(currency),
|
||||||
|
acc.outflow.settle(currency),
|
||||||
|
]);
|
||||||
|
out.push({
|
||||||
|
month,
|
||||||
|
inflow: Number((inflow.total - refundMag.total).toFixed(2)),
|
||||||
|
outflow: outflow.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpenseBreakdownRow {
|
export interface ExpenseBreakdownRow {
|
||||||
@@ -456,15 +522,23 @@ export async function getExpenseBreakdown(
|
|||||||
lte(expenses.expenseDate, range.to),
|
lte(expenses.expenseDate, range.to),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const byCategory = new Map<string, number>();
|
// One source-currency accumulator per category; settle each once (M19).
|
||||||
|
const byCategory = new Map<string, CurrencyAccumulator>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const cat = r.category ?? 'Uncategorised';
|
const cat = r.category ?? 'Uncategorised';
|
||||||
const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
|
let acc = byCategory.get(cat);
|
||||||
byCategory.set(cat, (byCategory.get(cat) ?? 0) + amount);
|
if (!acc) {
|
||||||
|
acc = new CurrencyAccumulator();
|
||||||
|
byCategory.set(cat, acc);
|
||||||
}
|
}
|
||||||
return Array.from(byCategory.entries())
|
acc.add(Number(r.amount ?? 0), r.currency ?? currency);
|
||||||
.map(([category, total]) => ({ category, total }))
|
}
|
||||||
.sort((a, b) => b.total - a.total);
|
const out: ExpenseBreakdownRow[] = [];
|
||||||
|
for (const [category, acc] of byCategory) {
|
||||||
|
const { total } = await acc.settle(currency);
|
||||||
|
out.push({ category, total });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.total - a.total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tables ──────────────────────────────────────────────────────────────────
|
// ─── Tables ──────────────────────────────────────────────────────────────────
|
||||||
@@ -540,7 +614,11 @@ export async function getRecentPayments(
|
|||||||
clientName: r.clientName,
|
clientName: r.clientName,
|
||||||
mooring: r.mooring,
|
mooring: r.mooring,
|
||||||
paymentType: r.paymentType,
|
paymentType: r.paymentType,
|
||||||
amount: await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency),
|
// Single displayed figure: on a missing/stale rate fall back to the raw
|
||||||
|
// source amount (logged in normalizeAmount) rather than dropping the row.
|
||||||
|
amount:
|
||||||
|
(await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency)) ??
|
||||||
|
Number(r.amount ?? 0),
|
||||||
currency,
|
currency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -588,7 +666,8 @@ export async function getRefundLog(portId: string, range: DateRange): Promise<Re
|
|||||||
receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '',
|
receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '',
|
||||||
clientName: r.clientName,
|
clientName: r.clientName,
|
||||||
amount: Math.abs(
|
amount: Math.abs(
|
||||||
await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency),
|
(await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency)) ??
|
||||||
|
Number(r.amount ?? 0),
|
||||||
),
|
),
|
||||||
currency,
|
currency,
|
||||||
notes: r.notes,
|
notes: r.notes,
|
||||||
@@ -644,7 +723,9 @@ export async function getExpenseLedger(
|
|||||||
payer: r.payer,
|
payer: r.payer,
|
||||||
category: r.category,
|
category: r.category,
|
||||||
establishmentName: r.establishmentName,
|
establishmentName: r.establishmentName,
|
||||||
amount: await normalizeAmount(Number(r.amount ?? 0), r.expenseCurrency ?? currency, currency),
|
amount:
|
||||||
|
(await normalizeAmount(Number(r.amount ?? 0), r.expenseCurrency ?? currency, currency)) ??
|
||||||
|
Number(r.amount ?? 0),
|
||||||
currency,
|
currency,
|
||||||
paymentStatus: r.paymentStatus,
|
paymentStatus: r.paymentStatus,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user