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>
This commit is contained in:
2026-06-02 00:43:36 +02:00
parent 75fdb9fab4
commit b690fb8d56
14 changed files with 1769 additions and 8 deletions

View File

@@ -0,0 +1,32 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { convert } from '@/lib/services/currency';
/**
* 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`. 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).
*/
export async function normalizeAmount(amount: number, from: string, to: string): Promise<number> {
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;
}