Files
pn-new-crm/tests/unit/services/reports/financial-math.test.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

54 lines
1.7 KiB
TypeScript

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);
});
});