Files
pn-new-crm/src/lib/services/reports/financial.service.ts
Matt b690fb8d56 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

633 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { payments } from '@/lib/db/schema/pipeline';
import { expenses } from '@/lib/db/schema/financial';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { resolvePortCurrency, normalizeAmount } from './currency';
import {
agingBucket,
AGING_BUCKETS,
monthKey,
monthRange,
netContribution,
type AgingBucket,
} from './financial-math';
import type { DateRange } from './sales.service';
// ─── KPI strip ───────────────────────────────────────────────────────────────
export interface FinancialKpis {
/** Σ of every payment received in range (refunds net out — they carry
* negative amounts). In port currency. */
revenueCollected: number;
depositsCollected: number;
balanceCollected: number;
/** Absolute value of refund-type payments issued in range. */
refundsIssued: number;
/** Σ expected deposit across active (open, non-archived) deals — the
* pipeline forecast in deposit terms. */
pipelineExpected: number;
/** Σ (expected collected) across active deals, floored at 0 per deal.
* The payments-model analogue of outstanding AR. */
expectedDepositsOutstanding: number;
/** Σ expenses booked in range, normalised to port currency. */
expensesTotal: number;
/** revenueCollected expensesTotal. */
netContribution: number;
currency: string;
}
/** Per-deal deposit position — expected vs collected. Internal; feeds the
* pipeline/outstanding KPIs, the aging chart, and the outstanding table. */
interface DepositPosition {
interestId: string;
clientName: string;
mooring: string | null;
expected: number;
collected: number;
remaining: number;
currency: string;
daysOutstanding: number;
}
/**
* Expected-vs-collected deposit position for every active deal that has
* an expected deposit set. Collected = Σ deposit-type payments for that
* interest. Amounts normalised to port currency. Age measured from deal
* creation (no invoice due dates exist in the payments model).
*/
async function getDepositPositions(portId: string): Promise<DepositPosition[]> {
const targetCurrency = await resolvePortCurrency(portId);
const now = Date.now();
const rows = await db
.select({
interestId: interests.id,
clientName: clients.fullName,
mooring: berths.mooringNumber,
expected: interests.depositExpectedAmount,
expectedCurrency: interests.depositExpectedCurrency,
createdAt: interests.createdAt,
})
.from(interests)
.innerJoin(clients, eq(clients.id, interests.clientId))
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(and(activeInterestsWhere(portId), sql`${interests.depositExpectedAmount} IS NOT NULL`));
// Deposits collected per interest (deposit-type payments only).
const collectedRows = await db
.select({
interestId: payments.interestId,
amount: payments.amount,
currency: payments.currency,
})
.from(payments)
.where(and(eq(payments.portId, portId), eq(payments.paymentType, 'deposit')));
const collectedByInterest = new Map<string, number>();
for (const r of collectedRows) {
const amount = await normalizeAmount(
Number(r.amount ?? 0),
r.currency ?? targetCurrency,
targetCurrency,
);
collectedByInterest.set(r.interestId, (collectedByInterest.get(r.interestId) ?? 0) + amount);
}
const positions: DepositPosition[] = [];
for (const r of rows) {
const expected = await normalizeAmount(
Number(r.expected ?? 0),
r.expectedCurrency ?? targetCurrency,
targetCurrency,
);
const collected = collectedByInterest.get(r.interestId) ?? 0;
const remaining = Math.max(0, expected - collected);
const daysOutstanding = r.createdAt
? Math.floor((now - new Date(r.createdAt).getTime()) / 86_400_000)
: 0;
positions.push({
interestId: r.interestId,
clientName: r.clientName,
mooring: r.mooring,
expected,
collected,
remaining,
currency: targetCurrency,
daysOutstanding,
});
}
return positions;
}
/** Σ of all payments in range, bucketed by payment type, normalised. */
async function sumPaymentsInRange(
portId: string,
range: DateRange,
targetCurrency: string,
): Promise<{ total: number; deposit: number; balance: number; refund: number }> {
const rows = await db
.select({
paymentType: payments.paymentType,
amount: payments.amount,
currency: payments.currency,
})
.from(payments)
.where(
and(
eq(payments.portId, portId),
gte(payments.receivedAt, range.from),
lte(payments.receivedAt, range.to),
),
);
const acc = { total: 0, deposit: 0, balance: 0, refund: 0 };
for (const r of rows) {
const amount = await normalizeAmount(
Number(r.amount ?? 0),
r.currency ?? targetCurrency,
targetCurrency,
);
acc.total += amount;
if (r.paymentType === 'deposit') acc.deposit += amount;
else if (r.paymentType === 'balance') acc.balance += amount;
else if (r.paymentType === 'refund') acc.refund += amount; // already negative
}
return acc;
}
export async function getFinancialKpis(portId: string, range: DateRange): Promise<FinancialKpis> {
const currency = await resolvePortCurrency(portId);
const [paymentsAgg, positions, expensesTotal] = await Promise.all([
sumPaymentsInRange(portId, range, currency),
getDepositPositions(portId),
sumExpensesInRange(portId, range, currency),
]);
const pipelineExpected = positions.reduce((s, p) => s + p.expected, 0);
const expectedDepositsOutstanding = positions.reduce((s, p) => s + p.remaining, 0);
return {
revenueCollected: paymentsAgg.total,
depositsCollected: paymentsAgg.deposit,
balanceCollected: paymentsAgg.balance,
refundsIssued: Math.abs(paymentsAgg.refund),
pipelineExpected,
expectedDepositsOutstanding,
expensesTotal,
netContribution: netContribution(paymentsAgg.total, expensesTotal),
currency,
};
}
async function sumExpensesInRange(
portId: string,
range: DateRange,
targetCurrency: string,
): Promise<number> {
const rows = await db
.select({ amount: expenses.amount, currency: expenses.currency })
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
isNull(expenses.archivedAt),
gte(expenses.expenseDate, range.from),
lte(expenses.expenseDate, range.to),
),
);
let total = 0;
for (const r of rows) {
total += await normalizeAmount(
Number(r.amount ?? 0),
r.currency ?? targetCurrency,
targetCurrency,
);
}
return total;
}
// ─── Charts ──────────────────────────────────────────────────────────────────
export interface RevenueByMonthRow {
month: string; // YYYY-MM
deposit: number;
balance: number;
}
/** Monthly collected revenue, split deposit vs balance. Continuous month
* axis (zero-filled) over the selected range, normalised to port currency. */
export async function getRevenueByMonth(
portId: string,
range: DateRange,
): Promise<RevenueByMonthRow[]> {
const currency = await resolvePortCurrency(portId);
const rows = await db
.select({
paymentType: payments.paymentType,
amount: payments.amount,
paymentCurrency: payments.currency,
receivedAt: payments.receivedAt,
})
.from(payments)
.where(
and(
eq(payments.portId, portId),
gte(payments.receivedAt, range.from),
lte(payments.receivedAt, range.to),
),
);
const byMonth = new Map<string, { deposit: number; balance: number }>();
for (const key of monthRange(range.from, range.to)) byMonth.set(key, { deposit: 0, balance: 0 });
for (const r of rows) {
if (!r.receivedAt) continue;
const key = monthKey(new Date(r.receivedAt));
const bucket = byMonth.get(key);
if (!bucket) continue;
const amount = await normalizeAmount(
Number(r.amount ?? 0),
r.paymentCurrency ?? currency,
currency,
);
if (r.paymentType === 'balance') bucket.balance += amount;
else if (r.paymentType !== 'refund') bucket.deposit += amount; // deposit + other → deposit bar
}
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
}
export interface CollectionFunnelRow {
stage: 'eoi' | 'deposit' | 'contract' | 'won';
label: string;
count: number;
}
/** Money funnel: EOI sent → deposit received → contract reached → won,
* counting distinct deals reaching each step within the range. */
export async function getCollectionFunnel(
portId: string,
range: DateRange,
): Promise<CollectionFunnelRow[]> {
const [eoiRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interests)
.where(
and(
eq(interests.portId, portId),
gte(interests.dateEoiSent, range.from),
lte(interests.dateEoiSent, range.to),
),
);
const [depositRow] = await db
.select({ count: sql<number>`count(distinct ${payments.interestId})::int` })
.from(payments)
.where(
and(
eq(payments.portId, portId),
eq(payments.paymentType, 'deposit'),
gte(payments.receivedAt, range.from),
lte(payments.receivedAt, range.to),
),
);
const [contractRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interests)
.where(
and(
eq(interests.portId, portId),
sql`${interests.pipelineStage} = 'contract'`,
isNull(interests.archivedAt),
),
);
const [wonRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interests)
.where(
and(
eq(interests.portId, portId),
eq(interests.outcome, 'won'),
gte(interests.outcomeAt, range.from),
lte(interests.outcomeAt, range.to),
),
);
return [
{ stage: 'eoi', label: 'EOI sent', count: Number(eoiRow?.count ?? 0) },
{ stage: 'deposit', label: 'Deposit received', count: Number(depositRow?.count ?? 0) },
{ stage: 'contract', label: 'At contract', count: Number(contractRow?.count ?? 0) },
{ stage: 'won', label: 'Won', count: Number(wonRow?.count ?? 0) },
];
}
export interface AgingRow {
bucket: AgingBucket;
count: number;
value: number;
}
/** Expected deposits still outstanding, bucketed by deal age. */
export async function getExpectedDepositAging(portId: string): Promise<AgingRow[]> {
const positions = await getDepositPositions(portId);
const byBucket = new Map<AgingBucket, { count: number; value: number }>();
for (const b of AGING_BUCKETS) byBucket.set(b, { count: 0, value: 0 });
for (const p of positions) {
if (p.remaining <= 0) continue;
const bucket = byBucket.get(agingBucket(p.daysOutstanding))!;
bucket.count += 1;
bucket.value += p.remaining;
}
return AGING_BUCKETS.map((bucket) => ({ bucket, ...byBucket.get(bucket)! }));
}
export interface CashFlowRow {
month: string;
inflow: number;
outflow: number;
}
/** Monthly inflow (payments received) vs outflow (expenses booked). */
export async function getCashFlow(portId: string, range: DateRange): Promise<CashFlowRow[]> {
const currency = await resolvePortCurrency(portId);
const byMonth = new Map<string, { inflow: number; outflow: number }>();
for (const key of monthRange(range.from, range.to)) byMonth.set(key, { inflow: 0, outflow: 0 });
const paymentRows = await db
.select({
amount: payments.amount,
currency: payments.currency,
receivedAt: payments.receivedAt,
})
.from(payments)
.where(
and(
eq(payments.portId, portId),
gte(payments.receivedAt, range.from),
lte(payments.receivedAt, range.to),
),
);
for (const r of paymentRows) {
if (!r.receivedAt) continue;
const bucket = byMonth.get(monthKey(new Date(r.receivedAt)));
if (!bucket) continue;
bucket.inflow += await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
}
const expenseRows = await db
.select({
amount: expenses.amount,
currency: expenses.currency,
expenseDate: expenses.expenseDate,
})
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
isNull(expenses.archivedAt),
gte(expenses.expenseDate, range.from),
lte(expenses.expenseDate, range.to),
),
);
for (const r of expenseRows) {
if (!r.expenseDate) continue;
const bucket = byMonth.get(monthKey(new Date(r.expenseDate)));
if (!bucket) continue;
bucket.outflow += await normalizeAmount(
Number(r.amount ?? 0),
r.currency ?? currency,
currency,
);
}
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
}
export interface ExpenseBreakdownRow {
category: string;
total: number;
}
/** Expenses for the period grouped by category, normalised, sorted desc. */
export async function getExpenseBreakdown(
portId: string,
range: DateRange,
): Promise<ExpenseBreakdownRow[]> {
const currency = await resolvePortCurrency(portId);
const rows = await db
.select({ category: expenses.category, amount: expenses.amount, currency: expenses.currency })
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
isNull(expenses.archivedAt),
gte(expenses.expenseDate, range.from),
lte(expenses.expenseDate, range.to),
),
);
const byCategory = new Map<string, number>();
for (const r of rows) {
const cat = r.category ?? 'Uncategorised';
const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
byCategory.set(cat, (byCategory.get(cat) ?? 0) + amount);
}
return Array.from(byCategory.entries())
.map(([category, total]) => ({ category, total }))
.sort((a, b) => b.total - a.total);
}
// ─── Tables ──────────────────────────────────────────────────────────────────
export interface OutstandingDepositRow {
interestId: string;
clientName: string;
mooring: string | null;
expected: number;
collected: number;
remaining: number;
currency: string;
daysOutstanding: number;
}
/** Active deals with an expected deposit not yet fully collected, sorted
* by remaining amount desc. The "chase these" list. */
export async function getOutstandingDeposits(portId: string): Promise<OutstandingDepositRow[]> {
const positions = await getDepositPositions(portId);
return positions
.filter((p) => p.remaining > 0)
.sort((a, b) => b.remaining - a.remaining)
.slice(0, 50);
}
export interface RecentPaymentRow {
id: string;
receivedAt: string;
clientName: string;
mooring: string | null;
paymentType: string;
amount: number;
currency: string;
}
export async function getRecentPayments(
portId: string,
range: DateRange,
): Promise<RecentPaymentRow[]> {
const currency = await resolvePortCurrency(portId);
const rows = await db
.select({
id: payments.id,
receivedAt: payments.receivedAt,
clientName: clients.fullName,
mooring: berths.mooringNumber,
paymentType: payments.paymentType,
amount: payments.amount,
paymentCurrency: payments.currency,
})
.from(payments)
.innerJoin(clients, eq(clients.id, payments.clientId))
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, payments.interestId), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(
and(
eq(payments.portId, portId),
gte(payments.receivedAt, range.from),
lte(payments.receivedAt, range.to),
),
)
.orderBy(desc(payments.receivedAt))
.limit(50);
const out: RecentPaymentRow[] = [];
for (const r of rows) {
out.push({
id: r.id,
receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '',
clientName: r.clientName,
mooring: r.mooring,
paymentType: r.paymentType,
amount: await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency),
currency,
});
}
return out;
}
export interface RefundRow {
id: string;
receivedAt: string;
clientName: string;
amount: number;
currency: string;
notes: string | null;
}
/** Refund-type payments in range (write-off / refund log analogue). */
export async function getRefundLog(portId: string, range: DateRange): Promise<RefundRow[]> {
const currency = await resolvePortCurrency(portId);
const rows = await db
.select({
id: payments.id,
receivedAt: payments.receivedAt,
clientName: clients.fullName,
amount: payments.amount,
paymentCurrency: payments.currency,
notes: payments.notes,
})
.from(payments)
.innerJoin(clients, eq(clients.id, payments.clientId))
.where(
and(
eq(payments.portId, portId),
eq(payments.paymentType, 'refund'),
gte(payments.receivedAt, range.from),
lte(payments.receivedAt, range.to),
),
)
.orderBy(desc(payments.receivedAt))
.limit(50);
const out: RefundRow[] = [];
for (const r of rows) {
out.push({
id: r.id,
receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '',
clientName: r.clientName,
amount: Math.abs(
await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency),
),
currency,
notes: r.notes,
});
}
return out;
}
export interface ExpenseLedgerRow {
id: string;
expenseDate: string;
payer: string | null;
category: string | null;
establishmentName: string | null;
amount: number;
currency: string;
paymentStatus: string | null;
}
export async function getExpenseLedger(
portId: string,
range: DateRange,
): Promise<ExpenseLedgerRow[]> {
const currency = await resolvePortCurrency(portId);
const rows = await db
.select({
id: expenses.id,
expenseDate: expenses.expenseDate,
payer: expenses.payer,
category: expenses.category,
establishmentName: expenses.establishmentName,
amount: expenses.amount,
expenseCurrency: expenses.currency,
paymentStatus: expenses.paymentStatus,
})
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
isNull(expenses.archivedAt),
gte(expenses.expenseDate, range.from),
lte(expenses.expenseDate, range.to),
),
)
.orderBy(desc(expenses.expenseDate))
.limit(50);
const out: ExpenseLedgerRow[] = [];
for (const r of rows) {
out.push({
id: r.id,
expenseDate: r.expenseDate ? new Date(r.expenseDate).toISOString() : '',
payer: r.payer,
category: r.category,
establishmentName: r.establishmentName,
amount: await normalizeAmount(Number(r.amount ?? 0), r.expenseCurrency ?? currency, currency),
currency,
paymentStatus: r.paymentStatus,
});
}
return out;
}