feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.
Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
(inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
ledger
- every money figure normalised to port currency via a shared
resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)
UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.
Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.
TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
|
|
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
2026-06-02 12:52:28 +02:00
|
|
|
|
import { getRate } from '@/lib/services/currency';
|
|
|
|
|
|
import { logger } from '@/lib/logger';
|
feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.
Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
(inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
ledger
- every money figure normalised to port currency via a shared
resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)
UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.
Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.
TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-02 12:52:28 +02:00
|
|
|
|
* 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.
|
feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.
Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
(inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
ledger
- every money figure normalised to port currency via a shared
resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)
UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.
Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.
TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
|
|
|
|
*/
|
2026-06-02 12:52:28 +02:00
|
|
|
|
export async function normalizeAmount(
|
|
|
|
|
|
amount: number,
|
|
|
|
|
|
from: string,
|
|
|
|
|
|
to: string,
|
|
|
|
|
|
): Promise<number | null> {
|
feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.
Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
(inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
ledger
- every money figure normalised to port currency via a shared
resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)
UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.
Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.
TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
|
|
|
|
if (!amount) return amount;
|
|
|
|
|
|
const f = from.toUpperCase();
|
|
|
|
|
|
const t = to.toUpperCase();
|
|
|
|
|
|
if (f === t) return amount;
|
2026-06-02 12:52:28 +02:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.
Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
(inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
ledger
- every money figure normalised to port currency via a shared
resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)
UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.
Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.
TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
|
|
|
|
}
|