From c1497353289579b070111efc31712c75c91fe46e Mon Sep 17 00:00:00 2001 From: mbo223 Date: Mon, 19 Oct 2026 19:44:28 +0100 Subject: [PATCH] feat: implement issues #941 #942 #943 #944 - #941: multi-currency P&L reporting service + route + tests - #942: payment method usage analytics service + route + tests - #943: customer lifetime value dashboard service + route + tests - #944: cash flow forecasting service + route + tests - chore: gitignore snapshot ignores Wired new routes into backend/src/index.ts --- .gitignore | 8 +- backend/src/index.ts | 8 ++ backend/src/routes/cash-flow.ts | 42 ++++++++++ backend/src/routes/clv.ts | 41 ++++++++++ .../src/routes/payment-method-analytics.ts | 31 ++++++++ backend/src/routes/pnl.ts | 46 +++++++++++ .../src/services/__tests__/cash-flow.test.ts | 41 ++++++++++ backend/src/services/__tests__/clv.test.ts | 48 ++++++++++++ .../payment-method-analytics.test.ts | 44 +++++++++++ backend/src/services/__tests__/pnl.test.ts | 45 +++++++++++ backend/src/services/cash-flow.service.ts | 49 ++++++++++++ backend/src/services/clv.service.ts | 71 +++++++++++++++++ .../payment-method-analytics.service.ts | 51 ++++++++++++ backend/src/services/pnl.service.ts | 78 +++++++++++++++++++ 14 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/cash-flow.ts create mode 100644 backend/src/routes/clv.ts create mode 100644 backend/src/routes/payment-method-analytics.ts create mode 100644 backend/src/routes/pnl.ts create mode 100644 backend/src/services/__tests__/cash-flow.test.ts create mode 100644 backend/src/services/__tests__/clv.test.ts create mode 100644 backend/src/services/__tests__/payment-method-analytics.test.ts create mode 100644 backend/src/services/__tests__/pnl.test.ts create mode 100644 backend/src/services/cash-flow.service.ts create mode 100644 backend/src/services/clv.service.ts create mode 100644 backend/src/services/payment-method-analytics.service.ts create mode 100644 backend/src/services/pnl.service.ts diff --git a/.gitignore b/.gitignore index 6d96a058..f440ba03 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,10 @@ npm-debug.log* # Keys *.pem -.claude \ No newline at end of file +.claude +# additional ignores +*.snap +**/*.snap +__snapshots__/ +**/__snapshots__/ + diff --git a/backend/src/index.ts b/backend/src/index.ts index 3b6f2689..17864514 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -94,6 +94,10 @@ import { funnelTrackingRouter } from './routes/funnel-tracking.js'; import { mlForecastRouter } from './routes/ml-forecast.js'; import { abTestingRouter } from './routes/ab-testing.js'; import { customerHealthRouter } from './routes/customer-health.js'; +import { pnlRouter } from './routes/pnl.js'; +import { clvRouter } from './routes/clv.js'; +import { paymentMethodAnalyticsRouter } from './routes/payment-method-analytics.js'; +import { cashFlowRouter } from './routes/cash-flow.js'; import { kybRouter } from './routes/kyb.js'; import { kycRouter } from './routes/kyc.js'; import { batchRouter } from './routes/batch.js'; @@ -356,6 +360,10 @@ apiV1Router.use('/funnels', funnelTrackingRouter); apiV1Router.use('/forecast/ml', mlForecastRouter); apiV1Router.use('/ab-tests', abTestingRouter); apiV1Router.use('/customer-health', customerHealthRouter); +apiV1Router.use('/reports/pnl', pnlRouter); +apiV1Router.use('/clv', clvRouter); +apiV1Router.use('/payment-methods/analytics', paymentMethodAnalyticsRouter); +apiV1Router.use('/cash-flow', cashFlowRouter); apiV1Router.use('/nfc', nfcRouter); apiV1Router.use('/cache', cacheRouter); apiV1Router.use('/circuit-breaker', circuitBreakerRouter); diff --git a/backend/src/routes/cash-flow.ts b/backend/src/routes/cash-flow.ts new file mode 100644 index 00000000..f1f91c25 --- /dev/null +++ b/backend/src/routes/cash-flow.ts @@ -0,0 +1,42 @@ +// Cash flow forecasting routes — Issue #944 +// Mount at /api/v1/cash-flow +import { Router, Request, Response } from 'express'; +import { cashFlowService } from '../services/cash-flow.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; + +export const cashFlowRouter = Router(); +function parseMoney(value: unknown, field: string): number { + const n = Number(value); if (!Number.isFinite(n) || n < 0) throw new AppError(400, `${field} must be a non-negative number`, 'VALIDATION_ERROR'); + return n; +} +function parseDate(value: unknown): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + const d = new Date(String(value)); if (isNaN(d.getTime())) throw new AppError(400, 'Invalid date', 'VALIDATION_ERROR'); + return d; +} +cashFlowRouter.post('/inflow', asyncHandler(async (req: Request, res: Response) => { + const { amount, currency, reference, metadata, timestamp } = req.body as Record; + const flow = cashFlowService.recordInflow(parseMoney(amount, 'amount'), { + currency: typeof currency === 'string' ? currency : 'USD', reference: typeof reference === 'string' ? reference : undefined, + metadata: metadata as Record | undefined, timestamp: timestamp ? parseDate(timestamp) : undefined }); + res.status(201).json({ ok: true, flow }); +})); +cashFlowRouter.post('/outflow', asyncHandler(async (req: Request, res: Response) => { + const { amount, currency, reference, metadata, timestamp } = req.body as Record; + const flow = cashFlowService.recordOutflow(parseMoney(amount, 'amount'), { + currency: typeof currency === 'string' ? currency : 'USD', reference: typeof reference === 'string' ? reference : undefined, + metadata: metadata as Record | undefined, timestamp: timestamp ? parseDate(timestamp) : undefined }); + res.status(201).json({ ok: true, flow }); +})); +cashFlowRouter.get('/summary', asyncHandler(async (req: Request, res: Response) => { + res.json(cashFlowService.summary({ since: parseDate(req.query.since), until: parseDate(req.query.until), + currency: typeof req.query.currency === 'string' ? req.query.currency : undefined })); +})); +cashFlowRouter.get('/forecast', asyncHandler(async (req: Request, res: Response) => { + const horizon = req.query.horizon ? Number(req.query.horizon) : undefined; + if (horizon !== undefined && (!Number.isFinite(horizon) || horizon < 1 || horizon > 60)) throw new AppError(400, 'horizon must be between 1 and 60', 'VALIDATION_ERROR'); + res.json(cashFlowService.forecast({ horizon, asOf: parseDate(req.query.asOf), currency: typeof req.query.currency === 'string' ? req.query.currency : undefined })); +})); +cashFlowRouter.get('/flows', asyncHandler(async (_req: Request, res: Response) => { + const flows = cashFlowService.list(); res.json({ flows, count: flows.length }); +})); diff --git a/backend/src/routes/clv.ts b/backend/src/routes/clv.ts new file mode 100644 index 00000000..3edcc8be --- /dev/null +++ b/backend/src/routes/clv.ts @@ -0,0 +1,41 @@ +// Customer Lifetime Value dashboard routes — Issue #943 +// Mount at /api/v1/clv +import { Router, Request, Response } from 'express'; +import { clvService } from '../services/clv.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; + +export const clvRouter = Router(); +type PaymentStatus = 'completed' | 'pending' | 'failed' | 'refunded'; +function parseMoney(value: unknown, field: string): number { + const n = Number(value); if (!Number.isFinite(n) || n < 0) throw new AppError(400, `${field} must be a non-negative number`, 'VALIDATION_ERROR'); + return n; +} +function parseDate(value: unknown): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + const d = new Date(String(value)); if (isNaN(d.getTime())) throw new AppError(400, 'Invalid date', 'VALIDATION_ERROR'); + return d; +} +function parseStatus(value: unknown): PaymentStatus { + if (typeof value !== 'string' || !['completed', 'pending', 'failed', 'refunded'].includes(value)) return 'completed'; + return value as PaymentStatus; +} +clvRouter.post('/payments', asyncHandler(async (req: Request, res: Response) => { + const { customerId, amount, currency, status, timestamp, metadata } = req.body as Record; + if (typeof customerId !== 'string' || customerId.length === 0) throw new AppError(400, 'customerId is required', 'VALIDATION_ERROR'); + const entry = clvService.recordPayment({ + customerId, amount: parseMoney(amount, 'amount'), currency: typeof currency === 'string' ? currency : 'USD', + status: parseStatus(status), timestamp: timestamp ? parseDate(timestamp) : undefined, + metadata: metadata as Record | undefined }); + res.status(201).json({ ok: true, payment: entry }); +})); +clvRouter.get('/dashboard', asyncHandler(async (req: Request, res: Response) => { + const asOf = parseDate(req.query.asOf); const limit = req.query.limit ? Number(req.query.limit) : undefined; + if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) throw new AppError(400, 'limit must be a positive integer', 'VALIDATION_ERROR'); + res.json(clvService.dashboard({ asOf, limit })); +})); +clvRouter.get('/:customerId', asyncHandler(async (req: Request, res: Response) => { + res.json(clvService.getCLV(req.params.customerId, parseDate(req.query.asOf))); +})); +clvRouter.get('/payments', asyncHandler(async (_req: Request, res: Response) => { + const payments = clvService.list(); res.json({ payments, count: payments.length }); +})); diff --git a/backend/src/routes/payment-method-analytics.ts b/backend/src/routes/payment-method-analytics.ts new file mode 100644 index 00000000..6638c914 --- /dev/null +++ b/backend/src/routes/payment-method-analytics.ts @@ -0,0 +1,31 @@ +// Payment method usage analytics routes — Issue #942 +// Mount at /api/v1/payment-methods/analytics +import { Router, Request, Response } from 'express'; +import { paymentMethodAnalyticsService } from '../services/payment-method-analytics.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; + +export const paymentMethodAnalyticsRouter = Router(); +function parseDate(value: unknown): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + const d = new Date(String(value)); if (isNaN(d.getTime())) throw new AppError(400, 'Invalid date', 'VALIDATION_ERROR'); + return d; +} +paymentMethodAnalyticsRouter.post('/usage', asyncHandler(async (req: Request, res: Response) => { + const { method, amount, currency, status, timestamp, metadata } = req.body as Record; + if (typeof method !== 'string' || method.length === 0) throw new AppError(400, 'method is required', 'VALIDATION_ERROR'); + const n = Number(amount); if (!Number.isFinite(n) || n < 0) throw new AppError(400, 'amount must be a non-negative number', 'VALIDATION_ERROR'); + const validStatus = ['success', 'failure', 'pending'].includes(String(status ?? '')) ? (status as 'success' | 'failure' | 'pending') : 'success'; + const entry = paymentMethodAnalyticsService.recordUsage({ + method, amount: n, currency: typeof currency === 'string' ? currency : 'USD', status: validStatus, + timestamp: timestamp ? parseDate(timestamp) : undefined, metadata: metadata as Record | undefined }); + res.status(201).json({ ok: true, event: entry }); +})); +paymentMethodAnalyticsRouter.get('/analytics', asyncHandler(async (req: Request, res: Response) => { + res.json(paymentMethodAnalyticsService.getAnalytics({ since: parseDate(req.query.since) })); +})); +paymentMethodAnalyticsRouter.get('/:method/stats', asyncHandler(async (req: Request, res: Response) => { + res.json(paymentMethodAnalyticsService.getMethodStats(req.params.method)); +})); +paymentMethodAnalyticsRouter.get('/usage', asyncHandler(async (_req: Request, res: Response) => { + const events = paymentMethodAnalyticsService.list(); res.json({ events, count: events.length }); +})); diff --git a/backend/src/routes/pnl.ts b/backend/src/routes/pnl.ts new file mode 100644 index 00000000..20aacec6 --- /dev/null +++ b/backend/src/routes/pnl.ts @@ -0,0 +1,46 @@ +// Multi-currency Profit & Loss routes — Issue #941 +// Mount at /api/v1/reports/pnl +import { Router, Request, Response } from 'express'; +import { pnlService } from '../services/pnl.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; + +export const pnlRouter = Router(); +function parseMoney(value: unknown, field: string): number { + const n = Number(value); if (!Number.isFinite(n) || n < 0) throw new AppError(400, `${field} must be a non-negative number`, 'VALIDATION_ERROR'); + return n; +} +function parseDate(value: unknown): Date | undefined { + if (value === undefined || value === null || value === '') return undefined; + const d = new Date(String(value)); if (isNaN(d.getTime())) throw new AppError(400, 'Invalid date', 'VALIDATION_ERROR'); + return d; +} +function resolveCurrency(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) throw new AppError(400, 'currency is required', 'VALIDATION_ERROR'); + return value.toUpperCase(); +} +pnlRouter.post('/revenue', asyncHandler(async (req: Request, res: Response) => { + const { amount, currency, reference, metadata, timestamp } = req.body as Record; + const entry = pnlService.recordRevenue(parseMoney(amount, 'amount'), resolveCurrency(currency), { + reference: typeof reference === 'string' ? reference : undefined, + metadata: metadata as Record | undefined, timestamp: timestamp ? parseDate(timestamp) : undefined }); + res.status(201).json({ ok: true, entry }); +})); +pnlRouter.post('/cost', asyncHandler(async (req: Request, res: Response) => { + const { amount, currency, reference, metadata, timestamp } = req.body as Record; + const entry = pnlService.recordCost(parseMoney(amount, 'amount'), resolveCurrency(currency), { + reference: typeof reference === 'string' ? reference : undefined, + metadata: metadata as Record | undefined, timestamp: timestamp ? parseDate(timestamp) : undefined }); + res.status(201).json({ ok: true, entry }); +})); +pnlRouter.post('/fx', asyncHandler(async (req: Request, res: Response) => { + const { from, to, rate } = req.body as Record; + if (typeof from !== 'string' || typeof to !== 'string') throw new AppError(400, 'from and to currencies are required', 'VALIDATION_ERROR'); + const r = Number(rate); if (!Number.isFinite(r) || r <= 0) throw new AppError(400, 'rate must be a positive number', 'VALIDATION_ERROR'); + pnlService.setFXRate(from, to, r); res.json({ ok: true, from: from.toUpperCase(), to: to.toUpperCase(), rate: r }); +})); +pnlRouter.get('/report', asyncHandler(async (req: Request, res: Response) => { + res.json(pnlService.report({ since: parseDate(req.query.since), until: parseDate(req.query.until) })); +})); +pnlRouter.get('/ledger', asyncHandler(async (_req: Request, res: Response) => { + res.json({ entries: pnlService.list(), count: pnlService.count() }); +})); diff --git a/backend/src/services/__tests__/cash-flow.test.ts b/backend/src/services/__tests__/cash-flow.test.ts new file mode 100644 index 00000000..3073779a --- /dev/null +++ b/backend/src/services/__tests__/cash-flow.test.ts @@ -0,0 +1,41 @@ +// Cash flow forecasting service tests — Issue #944 +import { describe, expect, it, beforeEach } from 'vitest'; +import { CashFlowService } from '../cash-flow.js'; +const base = new Date('2026-01-01T00:00:00Z'); +function days(d: number): Date { return new Date(base.getTime() + d * 24 * 60 * 60 * 1000); } + +describe('CashFlowService — Issue #944', () => { + let service: CashFlowService; + beforeEach(() => { service = new CashFlowService(); }); + it('records inflow and outflow and computes summary', () => { + service.recordInflow(1000, { timestamp: base }); service.recordOutflow(300, { timestamp: days(1) }); service.recordOutflow(200, { timestamp: days(2) }); + const summary = service.summary(); + expect(summary.totalInflow).toBe(1000); expect(summary.totalOutflow).toBe(500); expect(summary.netFlow).toBe(500); + expect(summary.currentBalance).toBe(500); expect(summary.transactionCount).toBe(3); + }); + it('forecasts balance using average net flow per period', () => { + for (let i = 0; i < 3; i++) { service.recordInflow(100, { timestamp: days(i) }); service.recordOutflow(40, { timestamp: days(i) }); } + const fc = service.forecast({ horizon: 3, asOf: days(10) }); + expect(fc.startingBalance).toBe(180); expect(fc.avgNetFlowPerPeriod).toBeCloseTo(60, 1); expect(fc.horizon).toBe(3); + expect(fc.forecast).toHaveLength(3); expect(fc.riskOfShortfall).toBe(false); + expect(fc.forecast[2].projectedBalance).toBeCloseTo(360, 1); + }); + it('detects shortfall risk when outflows exceed inflows', () => { + service.recordInflow(10, { timestamp: base }); service.recordOutflow(1000, { timestamp: base }); + const fc = service.forecast({ horizon: 2, asOf: days(5) }); + expect(fc.riskOfShortfall).toBe(true); expect(fc.minBalance).toBeLessThan(0); + }); + it('returns zero forecast with no history', () => { + const fc = service.forecast({ horizon: 3, asOf: base }); + expect(fc.startingBalance).toBe(0); expect(fc.avgNetFlowPerPeriod).toBe(0); expect(fc.riskOfShortfall).toBe(false); + }); + it('filters summary by currency and time window', () => { + service.recordInflow(1000, { currency: 'USD', timestamp: base }); service.recordInflow(500, { currency: 'EUR', timestamp: days(1) }); service.recordOutflow(200, { currency: 'USD', timestamp: days(2) }); + const usd = service.summary({ currency: 'USD' }); expect(usd.totalInflow).toBe(1000); expect(usd.totalOutflow).toBe(200); + const window = service.summary({ since: days(1), until: days(3) }); expect(window.transactionCount).toBe(2); + }); + it('is read-only for forecast methods', () => { + service.recordInflow(100, { timestamp: base }); const before = service.list().length; + service.forecast({ horizon: 3, asOf: base }); expect(service.list().length).toBe(before); + }); +}); diff --git a/backend/src/services/__tests__/clv.test.ts b/backend/src/services/__tests__/clv.test.ts new file mode 100644 index 00000000..cbbf162f --- /dev/null +++ b/backend/src/services/__tests__/clv.test.ts @@ -0,0 +1,48 @@ +// Customer Lifetime Value service tests — Issue #943 +import { describe, expect, it, beforeEach } from 'vitest'; +import { CLVService } from '../clv.js'; +const base = new Date('2026-01-01T00:00:00Z'); +function daysFromBase(days: number): Date { return new Date(base.getTime() + days * 24 * 60 * 60 * 1000); } + +describe('CLVService — Issue #943', () => { + let service: CLVService; + beforeEach(() => { service = new CLVService(); }); + it('computes CLV for a customer with repeat purchases', () => { + const now = daysFromBase(60); + service.recordPayment({ customerId: 'c1', amount: 100, status: 'completed', timestamp: base }); + service.recordPayment({ customerId: 'c1', amount: 200, status: 'completed', timestamp: daysFromBase(30) }); + service.recordPayment({ customerId: 'c1', amount: 300, status: 'completed', timestamp: daysFromBase(60) }); + const clv = service.getCLV('c1', now); + expect(clv.totalSpend).toBe(600); expect(clv.orderCount).toBe(3); expect(clv.averageOrderValue).toBe(200); + expect(clv.lifespanMonths).toBeCloseTo(2, 1); expect(clv.clv).toBeCloseTo(600, 1); expect(clv.tier).toBe('gold'); + }); + it('returns zero CLV for a customer with no completed payments', () => { + service.recordPayment({ customerId: 'c1', amount: 100, status: 'failed' }); + const clv = service.getCLV('c1'); expect(clv.orderCount).toBe(0); expect(clv.clv).toBe(0); expect(clv.tier).toBe('bronze'); + }); + it('ranks customers by CLV in dashboard', () => { + const now = daysFromBase(30); + service.recordPayment({ customerId: 'low', amount: 10, status: 'completed', timestamp: base }); + service.recordPayment({ customerId: 'high', amount: 5000, status: 'completed', timestamp: base }); + const dash = service.dashboard({ asOf: now }); + expect(dash.totalCustomers).toBe(2); expect(dash.topCustomers[0].customerId).toBe('high'); + expect(dash.topCustomers[0].clv).toBeGreaterThan(dash.topCustomers[1].clv); + expect(dash.totalSpend).toBe(5010); expect(dash.averageCLV).toBeGreaterThan(0); expect(dash.medianCLV).toBeGreaterThan(0); + }); + it('distributes customers into tiers', () => { + service.recordPayment({ customerId: 'p1', amount: 12000, status: 'completed', timestamp: base }); + service.recordPayment({ customerId: 'p2', amount: 200, status: 'completed', timestamp: base }); + const dash = service.dashboard(); expect(dash.distribution.platinum).toBe(1); expect(dash.distribution.bronze).toBe(1); + }); + it('ignores refunded/failed payments in totals', () => { + service.recordPayment({ customerId: 'c1', amount: 1000, status: 'completed', timestamp: base }); + service.recordPayment({ customerId: 'c1', amount: 500, status: 'refunded', timestamp: base }); + service.recordPayment({ customerId: 'c1', amount: 300, status: 'failed', timestamp: base }); + const clv = service.getCLV('c1'); expect(clv.totalSpend).toBe(1000); expect(clv.orderCount).toBe(1); + }); + it('caps lifespan at the configured maximum', () => { + const now = daysFromBase(1000); + service.recordPayment({ customerId: 'c1', amount: 100, status: 'completed', timestamp: base }); + const clv = service.getCLV('c1', now); expect(clv.lifespanMonths).toBeLessThanOrEqual(36); + }); +}); diff --git a/backend/src/services/__tests__/payment-method-analytics.test.ts b/backend/src/services/__tests__/payment-method-analytics.test.ts new file mode 100644 index 00000000..754197cc --- /dev/null +++ b/backend/src/services/__tests__/payment-method-analytics.test.ts @@ -0,0 +1,44 @@ +// Payment method analytics service tests — Issue #942 +import { describe, expect, it, beforeEach } from 'vitest'; +import { PaymentMethodAnalyticsService } from '../payment-method-analytics.js'; +const base = new Date('2026-01-01T00:00:00Z'); +function days(d: number): Date { return new Date(base.getTime() + d * 24 * 60 * 60 * 1000); } + +describe('PaymentMethodAnalyticsService — Issue #942', () => { + let service: PaymentMethodAnalyticsService; + beforeEach(() => { service = new PaymentMethodAnalyticsService(); }); + it('records usage events and computes per-method stats', () => { + service.recordUsage({ method: 'card', amount: 100, status: 'success', timestamp: base }); + service.recordUsage({ method: 'card', amount: 200, status: 'failure', timestamp: days(1) }); + service.recordUsage({ method: 'card', amount: 300, status: 'success', timestamp: days(2) }); + service.recordUsage({ method: 'bank_account', amount: 500, status: 'success', timestamp: base }); + const card = service.getMethodStats('card'); + expect(card.method).toBe('card'); expect(card.usageCount).toBe(3); expect(card.successCount).toBe(2); + expect(card.failureCount).toBe(1); expect(card.totalVolume).toBe(600); expect(card.successRate).toBeCloseTo(2 / 3); + expect(card.averageTicket).toBe(200); expect(card.averageSuccessTicket).toBe(200); + const ba = service.getMethodStats('bank_account'); expect(ba.successRate).toBe(1); expect(ba.averageTicket).toBe(500); + }); + it('ranks methods by usage count in analytics', () => { + service.recordUsage({ method: 'paypal', amount: 10, status: 'success', timestamp: base }); + service.recordUsage({ method: 'card', amount: 100, status: 'success', timestamp: base }); + service.recordUsage({ method: 'card', amount: 200, status: 'success', timestamp: base }); + const analytics = service.getAnalytics(); + expect(analytics.totalEvents).toBe(3); expect(analytics.topMethod!.method).toBe('card'); + expect(analytics.byMethod[0].method).toBe('card'); expect(analytics.byMethod[1].method).toBe('paypal'); + expect(analytics.overallSuccessRate).toBe(1); + }); + it('handles unknown method gracefully', () => { + const stats = service.getMethodStats('nonexistent'); + expect(stats.usageCount).toBe(0); expect(stats.successRate).toBe(0); expect(stats.lastUsed).toBeNull(); + }); + it('filters analytics by since date', () => { + service.recordUsage({ method: 'card', amount: 100, status: 'success', timestamp: days(0) }); + service.recordUsage({ method: 'card', amount: 200, status: 'success', timestamp: days(10) }); + expect(service.getAnalytics().totalEvents).toBe(2); + expect(service.getAnalytics({ since: days(5) }).totalEvents).toBe(1); + }); + it('is read-only for analytics methods', () => { + service.recordUsage({ method: 'card', amount: 100, status: 'success', timestamp: base }); + const before = service.list().length; service.getAnalytics(); expect(service.list().length).toBe(before); + }); +}); diff --git a/backend/src/services/__tests__/pnl.test.ts b/backend/src/services/__tests__/pnl.test.ts new file mode 100644 index 00000000..d4a1568b --- /dev/null +++ b/backend/src/services/__tests__/pnl.test.ts @@ -0,0 +1,45 @@ +// Multi-currency P&L service tests — Issue #941 +import { describe, expect, it, beforeEach } from 'vitest'; +import { PnLService } from '../pnl.js'; + +describe('PnLService — Issue #941', () => { + let service: PnLService; + beforeEach(() => { service = new PnLService('USD'); service.setFXRate('EUR', 'USD', 1.1); service.setFXRate('GBP', 'USD', 1.25); }); + it('records revenue and cost entries', () => { + const rev = service.recordRevenue(100, 'USD', { reference: 'inv-1' }); + const cost = service.recordCost(40, 'USD', { reference: 'ref-1' }); + expect(rev.type).toBe('revenue'); expect(rev.amount).toBe(100); expect(rev.currency).toBe('USD'); + expect(cost.type).toBe('cost'); expect(service.count()).toBe(2); + }); + it('computes per-currency P&L and base-currency totals', () => { + service.recordRevenue(1000, 'USD'); service.recordCost(300, 'USD'); + service.recordRevenue(500, 'EUR'); service.recordCost(200, 'EUR'); + const report = service.report(); expect(report.baseCurrency).toBe('USD'); + const usd = report.lines.find((l) => l.currency === 'USD'); + expect(usd!.revenue).toBe(1000); expect(usd!.cost).toBe(300); expect(usd!.profit).toBe(700); expect(usd!.margin).toBe(0.7); expect(usd!.revenueBase).toBe(1000); + const eur = report.lines.find((l) => l.currency === 'EUR'); + expect(eur!.revenueBase).toBe(550); expect(eur!.costBase).toBe(220); expect(eur!.profitBase).toBe(330); + expect(report.totals.revenue).toBe(1550); expect(report.totals.cost).toBe(520); expect(report.totals.profit).toBe(1030); + expect(report.totals.margin).toBeCloseTo(1030 / 1550); + }); + it('margin is zero when revenue is zero', () => { + service.recordCost(50, 'USD'); const report = service.report(); + const usd = report.lines.find((l) => l.currency === 'USD')!; + expect(usd.revenue).toBe(0); expect(usd.margin).toBe(0); expect(report.totals.margin).toBe(0); + }); + it('throws when converting with no FX rate', () => { + service.recordRevenue(100, 'JPY'); expect(() => service.report()).toThrow(/No FX rate/); + }); + it('filters report by time window', () => { + const base = new Date('2026-01-01T00:00:00Z'); + service.recordRevenue(100, 'USD', { timestamp: new Date(base.getTime() - 10 * 24 * 60 * 60 * 1000) }); + service.recordRevenue(200, 'USD', { timestamp: base }); + expect(service.report().totals.revenue).toBe(300); + expect(service.report({ since: base }).totals.revenue).toBe(200); + }); + it('converts amount to base currency', () => { + expect(service.convert(100, 'EUR', 'USD')).toBe(110); expect(service.convert(100, 'USD', 'USD')).toBe(100); + }); + it('throws on invalid FX rate', () => { expect(() => service.setFXRate('USD', 'EUR', -1)).toThrow(); expect(() => service.setFXRate('USD', 'EUR', 0)).toThrow(); }); + it('is read-only for report methods', () => { service.recordRevenue(100, 'USD'); const before = service.count(); service.report(); expect(service.count()).toBe(before); }); +}); diff --git a/backend/src/services/cash-flow.service.ts b/backend/src/services/cash-flow.service.ts new file mode 100644 index 00000000..6ca27a04 --- /dev/null +++ b/backend/src/services/cash-flow.service.ts @@ -0,0 +1,49 @@ +// Cash flow forecasting service — Issue #944 +export interface CashFlow { id: string; type: 'inflow' | 'outflow'; amount: number; currency: string; timestamp: Date; reference?: string; metadata?: Record; } +export interface ForecastPoint { period: number; timestamp: string; projectedInflow: number; projectedOutflow: number; projectedNetFlow: number; projectedBalance: number; } +export interface CashFlowForecast { asOf: string; startingBalance: number; currency: string; avgNetFlowPerPeriod: number; horizon: number; forecast: ForecastPoint[]; minBalance: number; minBalancePeriod: number | null; riskOfShortfall: boolean; } +export interface CashFlowSummary { asOf: string; totalInflow: number; totalOutflow: number; netFlow: number; currentBalance: number; transactionCount: number; } +export class CashFlowService { + private readonly flows: CashFlow[] = []; + recordFlow(entry: Omit & { timestamp?: Date }): CashFlow { + const flow: CashFlow = { id: crypto.randomUUID(), type: entry.type, amount: entry.amount, currency: entry.currency, + timestamp: entry.timestamp ?? new Date(), reference: entry.reference, metadata: entry.metadata }; + this.flows.push(flow); return flow; + } + recordInflow(amount: number, opts?: { currency?: string; reference?: string; metadata?: Record; timestamp?: Date }) { + return this.recordFlow({ type: 'inflow', amount, currency: (opts?.currency ?? 'USD'), reference: opts?.reference, metadata: opts?.metadata, timestamp: opts?.timestamp }); + } + recordOutflow(amount: number, opts?: { currency?: string; reference?: string; metadata?: Record; timestamp?: Date }) { + return this.recordFlow({ type: 'outflow', amount, currency: (opts?.currency ?? 'USD'), reference: opts?.reference, metadata: opts?.metadata, timestamp: opts?.timestamp }); + } + list(): CashFlow[] { return [...this.flows]; } + summary(opts?: { since?: Date; until?: Date; currency?: string }): CashFlowSummary { + const since = opts?.since; const until = opts?.until; const currency = (opts?.currency ?? 'USD').toUpperCase(); + const flows = this.flows.filter((f) => { if (f.currency.toUpperCase() !== currency) return false; if (since && f.timestamp < since) return false; if (until && f.timestamp >= until) return false; return true; }); + const totalInflow = flows.filter((f) => f.type === 'inflow').reduce((s, f) => s + f.amount, 0); + const totalOutflow = flows.filter((f) => f.type === 'outflow').reduce((s, f) => s + f.amount, 0); + return { asOf: new Date().toISOString(), totalInflow, totalOutflow, netFlow: totalInflow - totalOutflow, currentBalance: totalInflow - totalOutflow, transactionCount: flows.length }; + } + forecast(opts?: { horizon?: number; asOf?: Date; currency?: string }): CashFlowForecast { + const horizon = Math.max(1, Math.min(opts?.horizon ?? 12, 60)); const now = opts?.asOf ?? new Date(); const currency = (opts?.currency ?? 'USD').toUpperCase(); + const summary = this.summary({ until: now, currency }); const startingBalance = summary.currentBalance; + let avgNetFlowPerPeriod = 0; + if (summary.transactionCount > 0) { + const periodMs = 30 * 24 * 60 * 60 * 1000; const oldest = now.getTime() - horizon * periodMs; + const recent = this.flows.filter((f) => f.currency.toUpperCase() === currency && f.timestamp >= new Date(oldest) && f.timestamp <= now); + const recentInflow = recent.filter((f) => f.type === 'inflow').reduce((s, f) => s + f.amount, 0); + const recentOutflow = recent.filter((f) => f.type === 'outflow').reduce((s, f) => s + f.amount, 0); + avgNetFlowPerPeriod = (recentInflow - recentOutflow) / Math.max(1, horizon); + } + const forecast: ForecastPoint[] = []; let balance = startingBalance; let minBalance = startingBalance; let minBalancePeriod: number | null = null; + for (let period = 1; period <= horizon; period++) { + const timestamp = new Date(now.getTime() + period * 30 * 24 * 60 * 60 * 1000); + const projectedInflow = Math.max(0, avgNetFlowPerPeriod > 0 ? avgNetFlowPerPeriod : 0); + const projectedOutflow = Math.max(0, -avgNetFlowPerPeriod); const projectedNetFlow = projectedInflow - projectedOutflow; + balance += projectedNetFlow; if (balance < minBalance) { minBalance = balance; minBalancePeriod = period; } + forecast.push({ period, timestamp: timestamp.toISOString(), projectedInflow, projectedOutflow, projectedNetFlow, projectedBalance: balance }); + } + return { asOf: now.toISOString(), startingBalance, currency, avgNetFlowPerPeriod, horizon, forecast, minBalance, minBalancePeriod, riskOfShortfall: minBalance < 0 }; + } +} +export const cashFlowService = new CashFlowService(); diff --git a/backend/src/services/clv.service.ts b/backend/src/services/clv.service.ts new file mode 100644 index 00000000..a8b1f0ca --- /dev/null +++ b/backend/src/services/clv.service.ts @@ -0,0 +1,71 @@ +// Customer Lifetime Value dashboard service — Issue #943 +export interface CLVPayment { + id: string; customerId: string; amount: number; currency: string; + status: 'completed' | 'pending' | 'failed' | 'refunded'; timestamp: Date; + metadata?: Record; +} +export interface CustomerCLV { + customerId: string; totalSpend: number; orderCount: number; averageOrderValue: number; + firstPurchase: string | null; lastPurchase: string | null; lifespanMonths: number; + purchaseFrequencyPerMonth: number; clv: number; tier: 'bronze' | 'silver' | 'gold' | 'platinum'; +} +export interface CLVDashboard { + asOf: string; totalCustomers: number; totalSpend: number; averageCLV: number; + medianCLV: number; topCustomers: CustomerCLV[]; + distribution: Record<'bronze' | 'silver' | 'gold' | 'platinum', number>; +} +const LIFESPAN_CAP_MONTHS = 36; const MIN_LIFESPAN_MONTHS = 1 / 12; +function tierFor(clv: number): CustomerCLV['tier'] { + if (clv >= 10000) return 'platinum'; if (clv >= 5000) return 'gold'; + if (clv >= 1000) return 'silver'; return 'bronze'; +} +export class CLVService { + private readonly payments: CLVPayment[] = []; + recordPayment(entry: Omit & { timestamp?: Date }): CLVPayment { + const payment: CLVPayment = { id: crypto.randomUUID(), customerId: entry.customerId, amount: entry.amount, + currency: entry.currency, status: entry.status, timestamp: entry.timestamp ?? new Date(), metadata: entry.metadata }; + this.payments.push(payment); return payment; + } + list(): CLVPayment[] { return [...this.payments]; } + private customerPayments(customerId: string): CLVPayment[] { + return this.payments.filter((p) => p.customerId === customerId && p.status === 'completed') + .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + } + getCLV(customerId: string, asOf?: Date): CustomerCLV { + const now = asOf ?? new Date(); const payments = this.customerPayments(customerId); + const totalSpend = payments.reduce((s, p) => s + p.amount, 0); const orderCount = payments.length; + if (orderCount === 0) { + return { customerId, totalSpend: 0, orderCount: 0, averageOrderValue: 0, firstPurchase: null, + lastPurchase: null, lifespanMonths: 0, purchaseFrequencyPerMonth: 0, clv: 0, tier: 'bronze' }; + } + const averageOrderValue = totalSpend / orderCount; + const firstPurchase = payments[payments.length - 1].timestamp; + const lastPurchase = payments[0].timestamp; + const lifespanMonths = Math.max(MIN_LIFESPAN_MONTHS, (now.getTime() - firstPurchase.getTime()) / (1000 * 60 * 60 * 24 * 30)); + const cappedLifespan = Math.min(lifespanMonths, LIFESPAN_CAP_MONTHS); + const purchaseFrequencyPerMonth = orderCount / cappedLifespan; + const clv = averageOrderValue * purchaseFrequencyPerMonth * cappedLifespan; + return { customerId, totalSpend, orderCount, averageOrderValue, firstPurchase: firstPurchase.toISOString(), + lastPurchase: lastPurchase.toISOString(), lifespanMonths: cappedLifespan, purchaseFrequencyPerMonth, clv, tier: tierFor(clv) }; + } + dashboard(opts?: { asOf?: Date; limit?: number }): CLVDashboard { + const now = opts?.asOf ?? new Date(); + const customerIds = [...new Set(this.payments.map((p) => p.customerId))]; + const customers = customerIds.map((id) => this.getCLV(id, now)).sort((a, b) => b.clv - a.clv); + const totalCustomers = customers.length; + const totalSpend = customers.reduce((s, c) => s + c.totalSpend, 0); + const clvs = customers.map((c) => c.clv); + const averageCLV = totalCustomers === 0 ? 0 : clvs.reduce((s, v) => s + v, 0) / totalCustomers; + const medianCLV = median(clvs); + const limit = opts?.limit ?? 20; const topCustomers = customers.slice(0, limit); + const distribution = { bronze: 0, silver: 0, gold: 0, platinum: 0 }; + for (const c of customers) distribution[c.tier] += 1; + return { asOf: now.toISOString(), totalCustomers, totalSpend, averageCLV, medianCLV, topCustomers, distribution }; + } +} +function median(values: number[]): number { + if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} +export const clvService = new CLVService(); diff --git a/backend/src/services/payment-method-analytics.service.ts b/backend/src/services/payment-method-analytics.service.ts new file mode 100644 index 00000000..03d26253 --- /dev/null +++ b/backend/src/services/payment-method-analytics.service.ts @@ -0,0 +1,51 @@ +// Payment method usage analytics service — Issue #942 +export interface PaymentMethodUsageEvent { + id: string; method: string; amount: number; currency: string; + status: 'success' | 'failure' | 'pending'; timestamp: Date; + metadata?: Record; +} +export interface PaymentMethodStats { + method: string; usageCount: number; successCount: number; failureCount: number; pendingCount: number; + totalVolume: number; successVolume: number; successRate: number; averageTicket: number; + averageSuccessTicket: number; lastUsed: string | null; +} +export interface PaymentMethodAnalytics { + asOf: string; totalEvents: number; totalVolume: number; overallSuccessRate: number; + byMethod: PaymentMethodStats[]; topMethod: PaymentMethodStats | null; +} +export class PaymentMethodAnalyticsService { + private readonly events: PaymentMethodUsageEvent[] = []; + recordUsage(entry: Omit & { timestamp?: Date }): PaymentMethodUsageEvent { + const event: PaymentMethodUsageEvent = { id: crypto.randomUUID(), timestamp: entry.timestamp ?? new Date(), + method: entry.method.toLowerCase(), ...entry }; + this.events.push(event); return event; + } + list(): PaymentMethodUsageEvent[] { return [...this.events]; } + private methodEvents(method: string): PaymentMethodUsageEvent[] { + const m = method.toLowerCase(); return this.events.filter((e) => e.method === m); + } + getMethodStats(method: string): PaymentMethodStats { return this.computeStats(this.methodEvents(method)); } + getAnalytics(opts?: { since?: Date }): PaymentMethodAnalytics { + const since = opts?.since; const events = since ? this.events.filter((e) => e.timestamp >= since) : this.events; + const byMethodMap = new Map(); + for (const e of events) { const arr = byMethodMap.get(e.method) ?? []; arr.push(e); byMethodMap.set(e.method, arr); } + const byMethod = [...byMethodMap.entries()].map(([method, evs]) => this.computeStats(evs)) + .sort((a, b) => b.usageCount - a.usageCount || b.totalVolume - a.totalVolume); + const totalVolume = events.reduce((s, e) => s + e.amount, 0); + const overallSuccessRate = events.length === 0 ? 0 : events.filter((e) => e.status === 'success').length / events.length; + return { asOf: new Date().toISOString(), totalEvents: events.length, totalVolume, overallSuccessRate, byMethod, + topMethod: byMethod.length > 0 ? byMethod[0] : null }; + } + private computeStats(evs: PaymentMethodUsageEvent[]): PaymentMethodStats { + const usageCount = evs.length; const successCount = evs.filter((e) => e.status === 'success').length; + const failureCount = evs.filter((e) => e.status === 'failure').length; const pendingCount = evs.filter((e) => e.status === 'pending').length; + const totalVolume = evs.reduce((s, e) => s + e.amount, 0); const successVolume = evs.filter((e) => e.status === 'success').reduce((s, e) => s + e.amount, 0); + const successRate = usageCount === 0 ? 0 : successCount / usageCount; + const averageTicket = usageCount === 0 ? 0 : totalVolume / usageCount; + const averageSuccessTicket = successCount === 0 ? 0 : successVolume / successCount; + const lastUsed = usageCount === 0 ? null : evs[0].timestamp.toISOString(); + return { method: evs.length > 0 ? evs[0].method : '', usageCount, successCount, failureCount, pendingCount, + totalVolume, successVolume, successRate, averageTicket, averageSuccessTicket, lastUsed }; + } +} +export const paymentMethodAnalyticsService = new PaymentMethodAnalyticsService(); diff --git a/backend/src/services/pnl.service.ts b/backend/src/services/pnl.service.ts new file mode 100644 index 00000000..413b7b4e --- /dev/null +++ b/backend/src/services/pnl.service.ts @@ -0,0 +1,78 @@ +// Multi-currency Profit & Loss reporting service — Issue #941 +export interface FXRate { from: string; to: string; rate: number; } +export interface PLLine { + currency: string; revenue: number; cost: number; profit: number; margin: number; + revenueBase: number; costBase: number; profitBase: number; +} +export interface PLReport { + baseCurrency: string; asOf: string; lines: PLLine[]; + totals: { revenue: number; cost: number; profit: number; margin: number; }; + fxRates: Record; +} +export interface PLRecord { + id: string; type: 'revenue' | 'cost'; amount: number; currency: string; + timestamp: Date; reference?: string; metadata?: Record; +} +type RateKey = `${string}|${string}`; + +export class PnLService { + private readonly ledger: PLRecord[] = []; + private readonly fxRates = new Map(); + private readonly baseCurrency: string; + constructor(baseCurrency = 'USD') { this.baseCurrency = baseCurrency.toUpperCase(); } + record(entry: Omit & { timestamp?: Date }): PLRecord { + const record: PLRecord = { id: crypto.randomUUID(), type: entry.type, amount: entry.amount, + currency: entry.currency, timestamp: entry.timestamp ?? new Date(), + reference: entry.reference, metadata: entry.metadata }; + this.ledger.push(record); return record; + } + recordRevenue(amount: number, currency: string, opts?: { reference?: string; metadata?: Record; timestamp?: Date }): PLRecord { + return this.record({ type: 'revenue', amount, currency: currency.toUpperCase(), ...opts }); + } + recordCost(amount: number, currency: string, opts?: { reference?: string; metadata?: Record; timestamp?: Date }): PLRecord { + return this.record({ type: 'cost', amount, currency: currency.toUpperCase(), ...opts }); + } + setFXRate(from: string, to: string, rate: number): void { + if (!Number.isFinite(rate) || rate <= 0) throw new Error(`Invalid FX rate ${rate} for ${from}->${to}`); + this.fxRates.set(`${from.toUpperCase()}|${to.toUpperCase()}`, rate); + } + convert(amount: number, fromCurrency: string, toCurrency: string): number { + const from = fromCurrency.toUpperCase(); const to = toCurrency.toUpperCase(); + if (from === to) return amount; + const rate = this.fxRates.get(`${from}|${to}`); + if (!rate) throw new Error(`No FX rate configured for ${from}->${to}`); + return amount * rate; + } + private rateToBase(currency: string): number { + const cur = currency.toUpperCase(); if (cur === this.baseCurrency) return 1; + const rate = this.fxRates.get(`${cur}|${this.baseCurrency}`); + if (!rate) throw new Error(`No FX rate configured for ${cur}->${this.baseCurrency}`); + return rate; + } + report(opts?: { since?: Date; until?: Date }): PLReport { + const since = opts?.since; const until = opts?.until; + const byCurrency = new Map(); + for (const entry of this.ledger) { + if (since && entry.timestamp < since) continue; + if (until && entry.timestamp >= until) continue; + const cur = entry.currency.toUpperCase(); + const acc = byCurrency.get(cur) ?? { revenue: 0, cost: 0 }; + if (entry.type === 'revenue') acc.revenue += entry.amount; else acc.cost += entry.amount; + byCurrency.set(cur, acc); + } + const lines: PLLine[] = []; let totalRevenue = 0; let totalCost = 0; let totalProfit = 0; + for (const [currency, acc] of [...byCurrency.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const revenue = acc.revenue; const cost = acc.cost; const profit = revenue - cost; + const margin = revenue === 0 ? 0 : profit / revenue; const rate = this.rateToBase(currency); + lines.push({ currency, revenue, cost, profit, margin, revenueBase: revenue * rate, costBase: cost * rate, profitBase: profit * rate }); + totalRevenue += revenue * rate; totalCost += cost * rate; totalProfit += profit * rate; + } + const totalMargin = totalRevenue === 0 ? 0 : totalProfit / totalRevenue; + return { baseCurrency: this.baseCurrency, asOf: new Date().toISOString(), lines, + totals: { revenue: totalRevenue, cost: totalCost, profit: totalProfit, margin: totalMargin }, + fxRates: Object.fromEntries([...this.fxRates.entries()].map(([k, v]) => [k, v])) }; + } + list(): PLRecord[] { return [...this.ledger]; } + count(): number { return this.ledger.length; } +} +export const pnlService = new PnLService('USD');