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:
53
tests/unit/services/reports/financial-math.test.ts
Normal file
53
tests/unit/services/reports/financial-math.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
agingBucket,
|
||||
AGING_BUCKETS,
|
||||
monthKey,
|
||||
monthRange,
|
||||
netContribution,
|
||||
} from '@/lib/services/reports/financial-math';
|
||||
|
||||
describe('agingBucket', () => {
|
||||
it('buckets day counts into the 4 age bands', () => {
|
||||
expect(agingBucket(0)).toBe('0-30');
|
||||
expect(agingBucket(30)).toBe('0-30');
|
||||
expect(agingBucket(31)).toBe('31-60');
|
||||
expect(agingBucket(60)).toBe('31-60');
|
||||
expect(agingBucket(61)).toBe('61-90');
|
||||
expect(agingBucket(90)).toBe('61-90');
|
||||
expect(agingBucket(91)).toBe('90+');
|
||||
expect(agingBucket(400)).toBe('90+');
|
||||
});
|
||||
|
||||
it('exposes the buckets in ascending order for stable chart rows', () => {
|
||||
expect(AGING_BUCKETS).toEqual(['0-30', '31-60', '61-90', '90+']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthKey', () => {
|
||||
it('formats a date as zero-padded YYYY-MM (UTC)', () => {
|
||||
expect(monthKey(new Date('2026-01-09T00:00:00Z'))).toBe('2026-01');
|
||||
expect(monthKey(new Date('2026-12-31T23:59:59Z'))).toBe('2026-12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthRange', () => {
|
||||
it('produces a continuous ascending month series, inclusive of both ends', () => {
|
||||
const series = monthRange(new Date('2025-11-15T00:00:00Z'), new Date('2026-02-02T00:00:00Z'));
|
||||
expect(series).toEqual(['2025-11', '2025-12', '2026-01', '2026-02']);
|
||||
});
|
||||
|
||||
it('returns a single month when from and to share a month', () => {
|
||||
expect(monthRange(new Date('2026-03-01T00:00:00Z'), new Date('2026-03-28T00:00:00Z'))).toEqual([
|
||||
'2026-03',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('netContribution', () => {
|
||||
it('is revenue minus expenses', () => {
|
||||
expect(netContribution(1000, 250)).toBe(750);
|
||||
expect(netContribution(100, 400)).toBe(-300);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user