diff --git a/backend/server.ts b/backend/server.ts index 8e4f29a0..6427e4b0 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -65,6 +65,8 @@ import { createScheduledReportPoller } from './services/notification/jobs/schedu import { createEmailProviderFromEnv } from './services/notification/emailProvider'; import type { ReportAnalyticsSnapshot } from './services/notification/scheduledReportService'; import { createRequestObservability } from './shared/middleware/requestObservability'; +import { createRevenueByCurrencyReportService } from './services/analytics/revenueByCurrencyReport'; +import { createRevenueByCurrencyRouter } from './services/analytics/revenueByCurrencyRouter'; export interface StartServerOptions { port?: number; diff --git a/backend/services/analytics/__tests__/revenueByCurrencyReport.test.ts b/backend/services/analytics/__tests__/revenueByCurrencyReport.test.ts new file mode 100644 index 00000000..48b9c9d1 --- /dev/null +++ b/backend/services/analytics/__tests__/revenueByCurrencyReport.test.ts @@ -0,0 +1,351 @@ +/** + * Unit tests for revenueByCurrencyReport.ts + * + * Covers: + * - toMonthlyEquivalent() for every billing cycle + * - generateReport() — grouping, sorting, percentage, totals + * - generateReport() — filters paused/cancelled subscriptions + * - generateReport() — empty input + * - generateReport() — single currency + * - generateReport() — multiple currencies + * - generateReport() — normalises currency to uppercase + * - exportReport() CSV format — headers, data rows, totals row + * - exportReport() JSON format — round-trips to report object + * - exportReport() CSV — special characters escaped correctly + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { + RevenueByCurrencyReportService, + createRevenueByCurrencyReportService, + toMonthlyEquivalent, + type SubscriptionRevenueInput, + type RevenueByCurrencyReport, +} from '../revenueByCurrencyReport'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const NOW = new Date('2026-03-15T09:00:00.000Z'); + +function makeService(): RevenueByCurrencyReportService { + return createRevenueByCurrencyReportService({ now: () => NOW }); +} + +function sub(overrides: Partial = {}): SubscriptionRevenueInput { + return { + amount: 10, + currency: 'USD', + billingCycle: 'monthly', + status: 'active', + ...overrides, + }; +} + +// ─── toMonthlyEquivalent ────────────────────────────────────────────────────── + +describe('toMonthlyEquivalent', () => { + it('monthly — returns amount unchanged', () => { + expect(toMonthlyEquivalent(100, 'monthly')).toBe(100); + }); + + it('yearly — divides by 12', () => { + expect(toMonthlyEquivalent(1200, 'yearly')).toBeCloseTo(100); + }); + + it('annual — divides by 12', () => { + expect(toMonthlyEquivalent(1200, 'annual')).toBeCloseTo(100); + }); + + it('weekly — multiplies by 52/12', () => { + expect(toMonthlyEquivalent(120, 'weekly')).toBeCloseTo((120 * 52) / 12); + }); + + it('custom — returns amount unchanged', () => { + expect(toMonthlyEquivalent(50, 'custom')).toBe(50); + }); + + it('unknown cycle — returns amount unchanged (safe default)', () => { + expect(toMonthlyEquivalent(50, 'quarterly')).toBe(50); + }); + + it('is case-insensitive', () => { + expect(toMonthlyEquivalent(1200, 'YEARLY')).toBeCloseTo(100); + expect(toMonthlyEquivalent(1200, 'Yearly')).toBeCloseTo(100); + }); +}); + +// ─── generateReport — empty input ──────────────────────────────────────────── + +describe('RevenueByCurrencyReportService.generateReport — empty input', () => { + it('returns zero totals for an empty subscription list', () => { + const svc = makeService(); + const report = svc.generateReport([], 'merch_1'); + expect(report.currencies).toHaveLength(0); + expect(report.totals.totalSubscriptions).toBe(0); + expect(report.totals.totalMonthlyRevenue).toBe(0); + expect(report.totals.totalAnnualRevenue).toBe(0); + expect(report.totals.currencyCount).toBe(0); + }); + + it('includes merchantId and generatedAt in the report', () => { + const svc = makeService(); + const report = svc.generateReport([], 'merch_42'); + expect(report.merchantId).toBe('merch_42'); + expect(report.generatedAt).toBe(NOW.toISOString()); + }); +}); + +// ─── generateReport — filtering by status ──────────────────────────────────── + +describe('RevenueByCurrencyReportService.generateReport — status filtering', () => { + it('excludes cancelled subscriptions', () => { + const svc = makeService(); + const report = svc.generateReport( + [sub({ amount: 100, status: 'active' }), sub({ amount: 50, status: 'cancelled' })], + 'merch_1' + ); + expect(report.totals.totalSubscriptions).toBe(1); + expect(report.totals.totalMonthlyRevenue).toBeCloseTo(100); + }); + + it('excludes paused subscriptions', () => { + const svc = makeService(); + const report = svc.generateReport( + [sub({ amount: 100, status: 'active' }), sub({ amount: 50, status: 'paused' })], + 'merch_1' + ); + expect(report.totals.totalSubscriptions).toBe(1); + expect(report.totals.totalMonthlyRevenue).toBeCloseTo(100); + }); + + it('includes subscriptions with no status field (treated as active)', () => { + const svc = makeService(); + const report = svc.generateReport( + [{ amount: 100, currency: 'USD', billingCycle: 'monthly' }], + 'merch_1' + ); + expect(report.totals.totalSubscriptions).toBe(1); + }); +}); + +// ─── generateReport — single currency ──────────────────────────────────────── + +describe('RevenueByCurrencyReportService.generateReport — single currency', () => { + it('computes monthlyRevenue, annualRevenue, and averagePerSubscription', () => { + const svc = makeService(); + const subs = [ + sub({ amount: 100, currency: 'USD' }), + sub({ amount: 200, currency: 'USD' }), + ]; + const report = svc.generateReport(subs, 'merch_1'); + expect(report.currencies).toHaveLength(1); + const entry = report.currencies[0]!; + expect(entry.currency).toBe('USD'); + expect(entry.subscriptionCount).toBe(2); + expect(entry.monthlyRevenue).toBeCloseTo(300); + expect(entry.annualRevenue).toBeCloseTo(3600); + expect(entry.averagePerSubscription).toBeCloseTo(150); + expect(entry.percentage).toBeCloseTo(1); + }); +}); + +// ─── generateReport — multiple currencies ───────────────────────────────────── + +describe('RevenueByCurrencyReportService.generateReport — multiple currencies', () => { + it('groups by currency and calculates correct percentages', () => { + const svc = makeService(); + const subs = [ + sub({ amount: 300, currency: 'USD' }), + sub({ amount: 100, currency: 'EUR' }), + sub({ amount: 100, currency: 'EUR' }), + ]; + const report = svc.generateReport(subs, 'merch_1'); + expect(report.currencies).toHaveLength(2); + const usd = report.currencies.find((c) => c.currency === 'USD')!; + const eur = report.currencies.find((c) => c.currency === 'EUR')!; + + expect(usd.monthlyRevenue).toBeCloseTo(300); + expect(eur.monthlyRevenue).toBeCloseTo(200); + expect(usd.percentage).toBeCloseTo(300 / 500); + expect(eur.percentage).toBeCloseTo(200 / 500); + // Percentages should sum to ~1 + expect(usd.percentage + eur.percentage).toBeCloseTo(1); + }); + + it('sorts currencies by monthly revenue descending', () => { + const svc = makeService(); + const subs = [ + sub({ amount: 50, currency: 'GBP' }), + sub({ amount: 300, currency: 'USD' }), + sub({ amount: 150, currency: 'EUR' }), + ]; + const report = svc.generateReport(subs, 'merch_1'); + const codes = report.currencies.map((c) => c.currency); + expect(codes).toEqual(['USD', 'EUR', 'GBP']); + }); + + it('accumulates totals across all currencies', () => { + const svc = makeService(); + const subs = [ + sub({ amount: 300, currency: 'USD' }), + sub({ amount: 200, currency: 'EUR' }), + ]; + const report = svc.generateReport(subs, 'merch_1'); + expect(report.totals.totalMonthlyRevenue).toBeCloseTo(500); + expect(report.totals.totalAnnualRevenue).toBeCloseTo(6000); + expect(report.totals.totalSubscriptions).toBe(2); + expect(report.totals.currencyCount).toBe(2); + }); +}); + +// ─── generateReport — currency normalisation ────────────────────────────────── + +describe('RevenueByCurrencyReportService.generateReport — currency normalisation', () => { + it('normalises currency codes to uppercase and groups them', () => { + const svc = makeService(); + const subs = [ + sub({ amount: 100, currency: 'usd' }), + sub({ amount: 200, currency: 'USD' }), + sub({ amount: 50, currency: 'Usd' }), + ]; + const report = svc.generateReport(subs, 'merch_1'); + expect(report.currencies).toHaveLength(1); + expect(report.currencies[0]!.currency).toBe('USD'); + expect(report.currencies[0]!.monthlyRevenue).toBeCloseTo(350); + }); +}); + +// ─── generateReport — billing cycle normalisation ───────────────────────────── + +describe('RevenueByCurrencyReportService.generateReport — billing cycle normalisation', () => { + it('normalises annual subscriptions to monthly equivalent', () => { + const svc = makeService(); + const report = svc.generateReport( + [sub({ amount: 1200, currency: 'USD', billingCycle: 'yearly' })], + 'merch_1' + ); + expect(report.currencies[0]!.monthlyRevenue).toBeCloseTo(100); + expect(report.currencies[0]!.annualRevenue).toBeCloseTo(1200); + }); + + it('normalises weekly subscriptions to monthly equivalent', () => { + const svc = makeService(); + const report = svc.generateReport( + [sub({ amount: 12, currency: 'USD', billingCycle: 'weekly' })], + 'merch_1' + ); + const expectedMonthly = (12 * 52) / 12; + expect(report.currencies[0]!.monthlyRevenue).toBeCloseTo(expectedMonthly); + }); +}); + +// ─── exportReport — JSON ────────────────────────────────────────────────────── + +describe('RevenueByCurrencyReportService.exportReport — JSON', () => { + let report: RevenueByCurrencyReport; + + beforeEach(() => { + const svc = makeService(); + report = svc.generateReport( + [sub({ amount: 100, currency: 'USD' }), sub({ amount: 50, currency: 'EUR' })], + 'merch_1' + ); + }); + + it('produces valid JSON that round-trips to the report object', () => { + const svc = makeService(); + const json = svc.exportReport(report, 'json'); + const parsed = JSON.parse(json) as RevenueByCurrencyReport; + expect(parsed.merchantId).toBe(report.merchantId); + expect(parsed.currencies).toHaveLength(report.currencies.length); + expect(parsed.totals.totalMonthlyRevenue).toBeCloseTo(report.totals.totalMonthlyRevenue); + }); + + it('is pretty-printed (contains newlines)', () => { + const svc = makeService(); + const json = svc.exportReport(report, 'json'); + expect(json).toContain('\n'); + }); +}); + +// ─── exportReport — CSV ─────────────────────────────────────────────────────── + +describe('RevenueByCurrencyReportService.exportReport — CSV', () => { + it('first line is a header row with expected columns', () => { + const svc = makeService(); + const report = svc.generateReport([sub()], 'merch_1'); + const csv = svc.exportReport(report, 'csv'); + const firstLine = csv.split('\n')[0]!; + expect(firstLine).toContain('Currency'); + expect(firstLine).toContain('SubscriptionCount'); + expect(firstLine).toContain('MonthlyRevenue'); + expect(firstLine).toContain('AnnualRevenue'); + expect(firstLine).toContain('Percentage'); + }); + + it('has one data row per currency', () => { + const svc = makeService(); + const report = svc.generateReport( + [sub({ currency: 'USD' }), sub({ currency: 'EUR' }), sub({ currency: 'GBP' })], + 'merch_1' + ); + const csv = svc.exportReport(report, 'csv'); + const lines = csv.split('\n').filter((l) => l.trim().length > 0); + // header + 3 currencies + totals = 5 non-empty lines + expect(lines).toHaveLength(5); + }); + + it('includes a totals row with TOTAL label', () => { + const svc = makeService(); + const report = svc.generateReport([sub()], 'merch_1'); + const csv = svc.exportReport(report, 'csv'); + expect(csv).toContain('"TOTAL"'); + }); + + it('data row contains the currency code', () => { + const svc = makeService(); + const report = svc.generateReport([sub({ currency: 'XLM' })], 'merch_1'); + const csv = svc.exportReport(report, 'csv'); + expect(csv).toContain('"XLM"'); + }); + + it('data row contains the correct subscription count', () => { + const svc = makeService(); + const report = svc.generateReport( + [sub({ currency: 'USD' }), sub({ currency: 'USD' }), sub({ currency: 'USD' })], + 'merch_1' + ); + const csv = svc.exportReport(report, 'csv'); + expect(csv).toContain('"3"'); + }); + + it('escapes double-quotes in values', () => { + // Currency codes are always simple strings, but we can test via the + // service directly by manufacturing a report with a currency containing a quote. + const svc = makeService(); + // Build a minimal report manually to test the escape path. + const report: RevenueByCurrencyReport = { + merchantId: 'test', + generatedAt: NOW.toISOString(), + currencies: [ + { + currency: 'US"D', + subscriptionCount: 1, + monthlyRevenue: 10, + annualRevenue: 120, + averagePerSubscription: 10, + percentage: 1, + }, + ], + totals: { + totalMonthlyRevenue: 10, + totalAnnualRevenue: 120, + totalSubscriptions: 1, + currencyCount: 1, + }, + }; + const csv = svc.exportReport(report, 'csv'); + // Double-quote in value should be escaped as "" + expect(csv).toContain('US""D'); + }); +}); diff --git a/backend/services/analytics/revenueByCurrencyReport.ts b/backend/services/analytics/revenueByCurrencyReport.ts new file mode 100644 index 00000000..c25ab333 --- /dev/null +++ b/backend/services/analytics/revenueByCurrencyReport.ts @@ -0,0 +1,229 @@ +/** + * RevenueByCurrencyReportService + * + * Groups subscription revenue by currency, producing per-currency totals + * (monthly equivalent, annualised, subscription count, share) that can be + * returned as JSON or exported as CSV. + * + * Design goals (issue #1757) + * ────────────────────────── + * - Per-currency totals — each currency gets its own row with MRR, ARR, + * subscription count, average, and percentage share. + * - Exportable — `exportReport(report, format)` produces a CSV or JSON + * string suitable for an HTTP download endpoint. + * - Tested — pure functions, injectable clock, no external dependencies. + * + * The service accepts a `SubscriptionRevenueInput[]` which maps to both the + * frontend `Subscription` type (price / billingCycle) and the backend + * repository `Subscription` type (amount / billingCycle). This avoids coupling + * to either layer. + */ + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** Minimal subscription shape needed for revenue calculation. */ +export interface SubscriptionRevenueInput { + /** Subscription amount / price in the subscription's currency. */ + amount: number; + /** ISO 4217 currency code, e.g. "USD", "EUR". */ + currency: string; + /** Billing cycle — used to normalise to a monthly equivalent. */ + billingCycle: string; + /** Only active subscriptions are included in revenue totals. */ + status?: 'active' | 'paused' | 'cancelled'; +} + +/** One row per currency in the report. */ +export interface CurrencyRevenueEntry { + currency: string; + subscriptionCount: number; + /** Monthly recurring revenue in this currency. */ + monthlyRevenue: number; + /** Annualised revenue (monthly × 12) in this currency. */ + annualRevenue: number; + /** Average monthly revenue per subscription. */ + averagePerSubscription: number; + /** Share of total monthly revenue across all currencies (0–1). */ + percentage: number; +} + +/** Aggregated totals across all currencies. */ +export interface RevenueTotals { + /** Sum of monthly revenue across all currencies (mixed currencies). */ + totalMonthlyRevenue: number; + /** Sum of annual revenue across all currencies (mixed currencies). */ + totalAnnualRevenue: number; + /** Total active subscription count across all currencies. */ + totalSubscriptions: number; + /** Number of distinct currencies in the report. */ + currencyCount: number; +} + +/** The full report returned by `generateReport()`. */ +export interface RevenueByCurrencyReport { + merchantId: string; + generatedAt: string; + currencies: CurrencyRevenueEntry[]; + totals: RevenueTotals; +} + +export type ExportFormat = 'csv' | 'json'; + +// ─── Pure helpers ────────────────────────────────────────────────────────────── + +/** + * Normalise a billing cycle and amount to a monthly equivalent. + * + * monthly → amount as-is + * yearly / annual → amount / 12 + * weekly → amount × 52 / 12 + * custom → amount as-is (assume monthly) + */ +export function toMonthlyEquivalent(amount: number, billingCycle: string): number { + const normalised = billingCycle.toLowerCase(); + switch (normalised) { + case 'yearly': + case 'annual': + return amount / 12; + case 'weekly': + return (amount * 52) / 12; + case 'monthly': + case 'custom': + default: + return amount; + } +} + +/** Escape a value for CSV, handling commas, quotes, and non-ASCII safely. */ +function csvEscape(value: string | number | null | undefined): string { + const text = value === null || value === undefined ? '' : String(value); + return `"${text.normalize('NFC').replace(/"/g, '""')}"`; +} + +/** Format a number to 2 decimal places for display. */ +function fmt(n: number): string { + return n.toFixed(2); +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class RevenueByCurrencyReportService { + private readonly now: () => Date; + + constructor(options: { now?: () => Date } = {}) { + this.now = options.now ?? (() => new Date()); + } + + /** + * Generate a revenue-by-currency report from a list of subscriptions. + * + * Only subscriptions with status "active" (or undefined status) are included. + * The result is sorted by monthly revenue descending so the highest-grossing + * currency appears first. + */ + generateReport( + subscriptions: SubscriptionRevenueInput[], + merchantId: string + ): RevenueByCurrencyReport { + const active = subscriptions.filter( + (s) => s.status === undefined || s.status === 'active' + ); + + // Group by currency and accumulate. + const byCurrency = new Map(); + + for (const sub of active) { + const currency = (sub.currency ?? 'USD').toUpperCase(); + const monthly = toMonthlyEquivalent(sub.amount, sub.billingCycle); + const entry = byCurrency.get(currency) ?? { count: 0, monthly: 0 }; + entry.count += 1; + entry.monthly += monthly; + byCurrency.set(currency, entry); + } + + const totalMonthly = [...byCurrency.values()].reduce((sum, e) => sum + e.monthly, 0); + + const currencies: CurrencyRevenueEntry[] = [...byCurrency.entries()] + .map(([currency, data]) => ({ + currency, + subscriptionCount: data.count, + monthlyRevenue: data.monthly, + annualRevenue: data.monthly * 12, + averagePerSubscription: data.count > 0 ? data.monthly / data.count : 0, + percentage: totalMonthly > 0 ? data.monthly / totalMonthly : 0, + })) + .sort((a, b) => b.monthlyRevenue - a.monthlyRevenue); + + return { + merchantId, + generatedAt: this.now().toISOString(), + currencies, + totals: { + totalMonthlyRevenue: totalMonthly, + totalAnnualRevenue: totalMonthly * 12, + totalSubscriptions: active.length, + currencyCount: currencies.length, + }, + }; + } + + /** + * Export a report as CSV or JSON string. + * + * CSV includes a header row and one data row per currency, followed by + * a totals row. JSON is the full report object pretty-printed. + */ + exportReport(report: RevenueByCurrencyReport, format: ExportFormat): string { + if (format === 'json') { + return JSON.stringify(report, null, 2); + } + + // CSV format + const headers = [ + 'Currency', + 'SubscriptionCount', + 'MonthlyRevenue', + 'AnnualRevenue', + 'AveragePerSubscription', + 'Percentage', + ]; + + const rows: string[] = [headers.map(csvEscape).join(',')]; + + for (const entry of report.currencies) { + rows.push( + [ + csvEscape(entry.currency), + csvEscape(entry.subscriptionCount), + csvEscape(fmt(entry.monthlyRevenue)), + csvEscape(fmt(entry.annualRevenue)), + csvEscape(fmt(entry.averagePerSubscription)), + csvEscape(fmt(entry.percentage * 100) + '%'), + ].join(',') + ); + } + + // Totals row + rows.push(''); + rows.push( + [ + csvEscape('TOTAL'), + csvEscape(report.totals.totalSubscriptions), + csvEscape(fmt(report.totals.totalMonthlyRevenue)), + csvEscape(fmt(report.totals.totalAnnualRevenue)), + csvEscape(''), + csvEscape(report.totals.currencyCount + ' currencies'), + ].join(',') + ); + + return rows.join('\n'); + } +} + +// ─── Singleton factory ──────────────────────────────────────────────────────── + +export function createRevenueByCurrencyReportService( + options?: { now?: () => Date } +): RevenueByCurrencyReportService { + return new RevenueByCurrencyReportService(options); +} diff --git a/backend/services/analytics/revenueByCurrencyRouter.ts b/backend/services/analytics/revenueByCurrencyRouter.ts new file mode 100644 index 00000000..71e5ffaa --- /dev/null +++ b/backend/services/analytics/revenueByCurrencyRouter.ts @@ -0,0 +1,109 @@ +/** + * revenueByCurrencyRouter + * + * Plain Node.js HTTP handler for the revenue-by-currency report endpoint. + * Mounts under `/reports/revenue-by-currency` in server.ts. + * + * Routes + * ────── + * GET /reports/revenue-by-currency?merchantId=…&format=json (default) + * GET /reports/revenue-by-currency?merchantId=…&format=csv + * + * The `format` query parameter selects JSON (default) or CSV export. + * CSV responses use `text/csv` content-type with a `Content-Disposition` + * header for download. JSON responses use `application/json`. + */ + +import type http from 'node:http'; +import { URL } from 'node:url'; +import type { + RevenueByCurrencyReportService, + SubscriptionRevenueInput, + ExportFormat, +} from './revenueByCurrencyReport'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const json = JSON.stringify(body); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(json); +} + +function sendCsv(res: http.ServerResponse, csv: string, filename: string): void { + res.writeHead(200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + res.end(csv); +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** + * Factory that returns the subscriptions for a merchant. + * In production this would call the subscription repository; in tests a + * stub can be injected. + */ +export type SubscriptionProvider = ( + merchantId: string +) => SubscriptionRevenueInput[] | Promise; + +// ─── Router factory ─────────────────────────────────────────────────────────── + +export function createRevenueByCurrencyRouter( + service: RevenueByCurrencyReportService, + subscriptionProvider: SubscriptionProvider +) { + const PREFIX = '/reports/revenue-by-currency'; + + /** + * Handle a request if it matches the `/reports/revenue-by-currency` prefix. + * Returns `true` when handled, `false` to fall through. + */ + return async function handleRevenueByCurrencyRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string + ): Promise { + const method = req.method ?? 'GET'; + + if (!pathname.startsWith(PREFIX)) return false; + + // Only GET is supported. + if (method !== 'GET') { + sendJson(res, 405, { error: 'Method not allowed. Use GET.' }); + return true; + } + + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const merchantId = url.searchParams.get('merchantId') ?? ''; + const formatParam = url.searchParams.get('format') ?? 'json'; + + if (!merchantId) { + sendJson(res, 400, { error: 'merchantId query parameter is required' }); + return true; + } + + const format: ExportFormat = formatParam === 'csv' ? 'csv' : 'json'; + + try { + const subscriptions = await subscriptionProvider(merchantId); + const report = service.generateReport(subscriptions, merchantId); + + if (format === 'csv') { + const csv = service.exportReport(report, 'csv'); + const date = new Date().toISOString().slice(0, 10); + sendCsv(res, csv, `revenue-by-currency_${merchantId}_${date}.csv`); + } else { + sendJson(res, 200, report); + } + return true; + } catch (err) { + sendJson(res, 500, { + error: err instanceof Error ? err.message : String(err), + }); + return true; + } + }; +}