From 0683665c881e6ad59cffb71f0f3d00e9c1ca9c10 Mon Sep 17 00:00:00 2001 From: CeceOs92 Date: Sat, 17 Oct 2026 18:09:27 +0100 Subject: [PATCH] Subtrackr issues 1760 - 1763 --- .../__tests__/merchantPerformance.test.ts | 31 ++++++++ .../__tests__/revenueAnalytics.test.ts | 26 +++++++ .../__tests__/revenueAnomaly.test.ts | 33 ++++++++ .../domain/MerchantPerformanceService.ts | 76 +++++++++++++++++++ .../domain/RevenueAnalyticsService.ts | 70 +++++++++++++++++ .../analytics/domain/RevenueAnomalyService.ts | 62 +++++++++++++++ backend/analytics/query/funnelQueryHandler.ts | 2 +- backend/audit/__tests__/auditExport.test.ts | 36 +++++++++ backend/audit/controller/auditController.ts | 62 +++++++++++++++ backend/audit/controller/index.ts | 7 +- backend/audit/domain/HashChainService.ts | 2 +- 11 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 backend/analytics/__tests__/merchantPerformance.test.ts create mode 100644 backend/analytics/__tests__/revenueAnomaly.test.ts create mode 100644 backend/analytics/domain/MerchantPerformanceService.ts create mode 100644 backend/analytics/domain/RevenueAnomalyService.ts create mode 100644 backend/audit/__tests__/auditExport.test.ts diff --git a/backend/analytics/__tests__/merchantPerformance.test.ts b/backend/analytics/__tests__/merchantPerformance.test.ts new file mode 100644 index 00000000..23b0d088 --- /dev/null +++ b/backend/analytics/__tests__/merchantPerformance.test.ts @@ -0,0 +1,31 @@ +import { MerchantPerformanceService } from "../domain/MerchantPerformanceService"; + +describe("MerchantPerformanceService", () => { + const service = new MerchantPerformanceService(); + const entries = [ + { id: "1", merchantId: "m1", type: "revenue" as const, amount: 100, currency: "USD", occurredAt: new Date("2026-03-01") }, + { id: "2", merchantId: "m2", type: "revenue" as const, amount: 900, currency: "USD", occurredAt: new Date("2026-03-01") }, + ]; + const subscriptions = [ + { merchantId: "m1", status: "active" as const }, + { merchantId: "m1", status: "canceled" as const }, + { merchantId: "m2", status: "past_due" as const }, + ]; + + it("scopes every performance metric to the requested merchant", () => { + expect(service.getMetrics("m1", entries, subscriptions)).toMatchObject({ + merchantId: "m1", + grossRevenue: 100, + netRevenue: 100, + activeSubscriptions: 1, + pastDueSubscriptions: 0, + churnRate: 50, + }); + }); + + it("exports metrics as CSV and JSON", () => { + const metrics = service.getMetrics("m1", entries, subscriptions); + expect(service.export(metrics, "csv")).toContain('"merchant_id","m1"'); + expect(JSON.parse(service.export(metrics, "json"))).toMatchObject({ merchantId: "m1" }); + }); +}); diff --git a/backend/analytics/__tests__/revenueAnalytics.test.ts b/backend/analytics/__tests__/revenueAnalytics.test.ts index 8452d6f4..8e5b3ada 100644 --- a/backend/analytics/__tests__/revenueAnalytics.test.ts +++ b/backend/analytics/__tests__/revenueAnalytics.test.ts @@ -81,4 +81,30 @@ describe("RevenueAnalyticsService", () => { expect(result.revenueChurnRate).toBe(0); }); }); + + describe("buildRevenueReport", () => { + const entries = [ + { id: "sale", merchantId: "m1", type: "revenue" as const, amount: 100, currency: "USD", occurredAt: new Date("2026-02-02") }, + { id: "refund", merchantId: "m1", type: "refund" as const, amount: -20, currency: "USD", occurredAt: new Date("2026-02-03") }, + { id: "credit", merchantId: "m1", type: "adjustment" as const, amount: -5, currency: "USD", occurredAt: new Date("2026-02-04") }, + { id: "outside", merchantId: "m2", type: "revenue" as const, amount: 999, currency: "USD", occurredAt: new Date("2026-01-01") }, + ]; + + it("reports revenue, refunds and adjustments on separate lines", () => { + const report = service.buildRevenueReport(entries); + expect(report.lines.revenue.map((entry) => entry.id)).toEqual(["sale", "outside"]); + expect(report.lines.refund.map((entry) => entry.id)).toEqual(["refund"]); + expect(report.lines.adjustment.map((entry) => entry.id)).toEqual(["credit"]); + expect(report.totals).toEqual({ revenue: 1099, refunds: 20, adjustments: -5, netRevenue: 1074 }); + }); + + it("filters inclusively by period and merchant", () => { + const report = service.buildRevenueReport(entries, { + merchantId: "m1", + from: new Date("2026-02-03"), + to: new Date("2026-02-04T23:59:59Z"), + }); + expect(report.totals).toEqual({ revenue: 0, refunds: 20, adjustments: -5, netRevenue: -25 }); + }); + }); }); diff --git a/backend/analytics/__tests__/revenueAnomaly.test.ts b/backend/analytics/__tests__/revenueAnomaly.test.ts new file mode 100644 index 00000000..bfe0690b --- /dev/null +++ b/backend/analytics/__tests__/revenueAnomaly.test.ts @@ -0,0 +1,33 @@ +import { RevenueAnomalyService } from "../domain/RevenueAnomalyService"; + +describe("RevenueAnomalyService", () => { + const notifier = { send: jest.fn().mockResolvedValue(undefined) }; + const service = new RevenueAnomalyService(notifier); + + beforeEach(() => notifier.send.mockClear()); + + it("uses the configured percentage threshold", () => { + expect(service.detect("m1", 100, 119, { thresholdPercent: 20, channels: [] })).toBeNull(); + expect(service.detect("m1", 100, 80, { thresholdPercent: 20, channels: [] })).toMatchObject({ + direction: "decrease", + changePercent: -20, + }); + }); + + it("delivers an anomaly once to every configured channel", async () => { + const anomaly = await service.evaluateAndAlert("m1", 100, 150, { + thresholdPercent: 25, + channels: ["email", "webhook", "email"], + }); + expect(anomaly).not.toBeNull(); + expect(notifier.send).toHaveBeenCalledTimes(2); + expect(notifier.send).toHaveBeenCalledWith("email", anomaly); + expect(notifier.send).toHaveBeenCalledWith("webhook", anomaly); + }); + + it("does not alert when disabled or when no baseline exists", async () => { + expect(await service.evaluateAndAlert("m1", 0, 100, { thresholdPercent: 1, channels: ["email"] })).toBeNull(); + expect(await service.evaluateAndAlert("m1", 100, 200, { thresholdPercent: 1, channels: ["email"], enabled: false })).toBeNull(); + expect(notifier.send).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/analytics/domain/MerchantPerformanceService.ts b/backend/analytics/domain/MerchantPerformanceService.ts new file mode 100644 index 00000000..103025ba --- /dev/null +++ b/backend/analytics/domain/MerchantPerformanceService.ts @@ -0,0 +1,76 @@ +import type { RevenueEntry, RevenueReportFilter } from "./RevenueAnalyticsService"; +import { RevenueAnalyticsService } from "./RevenueAnalyticsService"; +import type { Pool } from "../../shared/db/connectionPool"; + +export interface MerchantSubscriptionMetric { + merchantId: string; + status: "active" | "canceled" | "past_due" | "trialing"; +} + +export interface MerchantPerformanceMetrics { + merchantId: string; + grossRevenue: number; + refunds: number; + adjustments: number; + netRevenue: number; + activeSubscriptions: number; + pastDueSubscriptions: number; + churnRate: number; + generatedAt: string; +} + +export class MerchantPerformanceService { + private readonly revenue = new RevenueAnalyticsService({} as Pool); + + getMetrics( + merchantId: string, + entries: RevenueEntry[], + subscriptions: MerchantSubscriptionMetric[], + period: Omit = {}, + ): MerchantPerformanceMetrics { + if (!merchantId.trim()) throw new Error("merchantId is required"); + + const report = this.revenue.buildRevenueReport(entries, { ...period, merchantId }); + const scoped = subscriptions.filter((subscription) => subscription.merchantId === merchantId); + const activeSubscriptions = scoped.filter( + (subscription) => + subscription.status === "active" || subscription.status === "trialing", + ).length; + const canceled = scoped.filter( + (subscription) => subscription.status === "canceled", + ).length; + const pastDueSubscriptions = scoped.filter( + (subscription) => subscription.status === "past_due", + ).length; + + return { + merchantId, + grossRevenue: report.totals.revenue, + refunds: report.totals.refunds, + adjustments: report.totals.adjustments, + netRevenue: report.totals.netRevenue, + activeSubscriptions, + pastDueSubscriptions, + churnRate: scoped.length ? Math.round((canceled / scoped.length) * 10_000) / 100 : 0, + generatedAt: new Date().toISOString(), + }; + } + + export(metrics: MerchantPerformanceMetrics, format: "csv" | "json"): string { + if (format === "json") return JSON.stringify(metrics, null, 2); + + const rows: Array<[string, string | number]> = [ + ["merchant_id", metrics.merchantId], + ["gross_revenue", metrics.grossRevenue], + ["refunds", metrics.refunds], + ["adjustments", metrics.adjustments], + ["net_revenue", metrics.netRevenue], + ["active_subscriptions", metrics.activeSubscriptions], + ["past_due_subscriptions", metrics.pastDueSubscriptions], + ["churn_rate_percent", metrics.churnRate], + ["generated_at", metrics.generatedAt], + ]; + const escape = (value: string | number) => `"${String(value).replace(/"/g, '""')}"`; + return ["metric,value", ...rows.map(([name, value]) => `${escape(name)},${escape(value)}`)].join("\n"); + } +} diff --git a/backend/analytics/domain/RevenueAnalyticsService.ts b/backend/analytics/domain/RevenueAnalyticsService.ts index 9448d4ff..1f79074a 100644 --- a/backend/analytics/domain/RevenueAnalyticsService.ts +++ b/backend/analytics/domain/RevenueAnalyticsService.ts @@ -54,6 +54,34 @@ export interface RevenueDashboardData { generatedAt: string; } +export type RevenueEntryType = "revenue" | "refund" | "adjustment"; + +export interface RevenueEntry { + id: string; + merchantId: string; + type: RevenueEntryType; + amount: number; + currency: string; + occurredAt: Date; +} + +export interface RevenueReportFilter { + from?: Date; + to?: Date; + merchantId?: string; +} + +export interface RevenueReport { + lines: Record; + totals: { + revenue: number; + refunds: number; + adjustments: number; + netRevenue: number; + }; + period: { from: string | null; to: string | null }; +} + export class RevenueAnalyticsService { constructor(private readonly pool: Pool) {} @@ -82,6 +110,48 @@ export class RevenueAnalyticsService { return mrr * 12; } + /** + * Build a period report without folding refunds and adjustments into gross + * revenue. Refund amounts are reported as positive values and deducted from + * net revenue; adjustments retain their sign. + */ + buildRevenueReport(entries: RevenueEntry[], filter: RevenueReportFilter = {}): RevenueReport { + if (filter.from && filter.to && filter.from > filter.to) { + throw new RangeError("Revenue report start date must not be after end date"); + } + + const { from, to, merchantId } = filter; + const filtered = entries.filter((entry) => { + const occurredAt = new Date(entry.occurredAt); + return ( + (!merchantId || entry.merchantId === merchantId) && + (!from || occurredAt >= from) && + (!to || occurredAt <= to) + ); + }); + const lines: RevenueReport["lines"] = { revenue: [], refund: [], adjustment: [] }; + for (const entry of filtered) lines[entry.type].push(entry); + + const round = (value: number) => Math.round(value * 100) / 100; + const revenue = round(lines.revenue.reduce((sum, entry) => sum + entry.amount, 0)); + const refunds = round(lines.refund.reduce((sum, entry) => sum + Math.abs(entry.amount), 0)); + const adjustments = round(lines.adjustment.reduce((sum, entry) => sum + entry.amount, 0)); + + return { + lines, + totals: { + revenue, + refunds, + adjustments, + netRevenue: round(revenue - refunds + adjustments), + }, + period: { + from: filter.from?.toISOString() ?? null, + to: filter.to?.toISOString() ?? null, + }, + }; + } + /** * Compute churn rate for a period. * Customer churn = customers lost / total customers at start diff --git a/backend/analytics/domain/RevenueAnomalyService.ts b/backend/analytics/domain/RevenueAnomalyService.ts new file mode 100644 index 00000000..49a0c690 --- /dev/null +++ b/backend/analytics/domain/RevenueAnomalyService.ts @@ -0,0 +1,62 @@ +export type RevenueAlertChannel = "in_app" | "email" | "push" | "sms" | "webhook"; + +export interface RevenueAnomalyConfig { + /** Minimum absolute percentage change that triggers an alert. */ + thresholdPercent: number; + channels: RevenueAlertChannel[]; + enabled?: boolean; +} + +export interface RevenueAnomaly { + merchantId: string; + previousRevenue: number; + currentRevenue: number; + changePercent: number; + direction: "increase" | "decrease"; + detectedAt: string; +} + +export interface RevenueAnomalyNotifier { + send(channel: RevenueAlertChannel, anomaly: RevenueAnomaly): Promise; +} + +export class RevenueAnomalyService { + constructor(private readonly notifier: RevenueAnomalyNotifier) {} + + detect( + merchantId: string, + previousRevenue: number, + currentRevenue: number, + config: RevenueAnomalyConfig, + ): RevenueAnomaly | null { + if (!Number.isFinite(config.thresholdPercent) || config.thresholdPercent < 0) { + throw new RangeError("Revenue anomaly threshold must be a non-negative number"); + } + if (config.enabled === false || previousRevenue === 0) return null; + + const changePercent = ((currentRevenue - previousRevenue) / Math.abs(previousRevenue)) * 100; + if (Math.abs(changePercent) < config.thresholdPercent) return null; + + return { + merchantId, + previousRevenue, + currentRevenue, + changePercent: Math.round(changePercent * 100) / 100, + direction: changePercent >= 0 ? "increase" : "decrease", + detectedAt: new Date().toISOString(), + }; + } + + async evaluateAndAlert( + merchantId: string, + previousRevenue: number, + currentRevenue: number, + config: RevenueAnomalyConfig, + ): Promise { + const anomaly = this.detect(merchantId, previousRevenue, currentRevenue, config); + if (!anomaly) return null; + + await Promise.all([...new Set(config.channels)].map((channel) => this.notifier.send(channel, anomaly))); + return anomaly; + } +} diff --git a/backend/analytics/query/funnelQueryHandler.ts b/backend/analytics/query/funnelQueryHandler.ts index b8280e8a..26c0acb5 100644 --- a/backend/analytics/query/funnelQueryHandler.ts +++ b/backend/analytics/query/funnelQueryHandler.ts @@ -140,7 +140,7 @@ export class FunnelQueryHandler { if (result.rows.length === 0) { return this.emptyResult(); } - return this.transformRow(result.rows[0]); + return this.transformRow({ ...result.rows[0], period: 'aggregated', cohort: null }); } /** diff --git a/backend/audit/__tests__/auditExport.test.ts b/backend/audit/__tests__/auditExport.test.ts new file mode 100644 index 00000000..880e6b30 --- /dev/null +++ b/backend/audit/__tests__/auditExport.test.ts @@ -0,0 +1,36 @@ +import { AuditController } from '../controller/auditController'; +import { AuditWriter } from '../domain/AuditWriter'; +import { BlockchainAnchor } from '../domain/BlockchainAnchor'; +import { HashChainService } from '../domain/HashChainService'; + +describe('AuditController exports', () => { + const createController = () => { + const chain = new HashChainService(); + return new AuditController(chain, new AuditWriter(chain), new BlockchainAnchor(chain)); + }; + + it('exports filtered CSV with actor and ISO event time', async () => { + const controller = createController(); + await controller.record({ actorId: 'auditor@example.com', action: 'invoice.read', resourceType: 'invoice', resourceId: 'inv-1' }); + await controller.record({ actorId: 'admin@example.com', action: 'user.update', resourceType: 'user', resourceId: 'usr-1' }); + + const report = await controller.exportReport({ resourceType: 'invoice' }, 'csv'); + expect(report.count).toBe(1); + expect(report.content).toContain('actor_id,occurred_at'); + expect(report.content).toContain('"auditor@example.com"'); + expect(report.content).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(report.content).not.toContain('admin@example.com'); + }); + + it('exports JSON with actor and readable event time', async () => { + const controller = createController(); + await controller.record({ actorId: 'actor-1', action: 'refund.create', resourceType: 'payment', resourceId: 'pay-1' }); + + const report = await controller.exportReport({}, 'json'); + expect(JSON.parse(report.content)[0]).toMatchObject({ + actorId: 'actor-1', + action: 'refund.create', + }); + expect(JSON.parse(report.content)[0].occurredAt).toEqual(expect.any(String)); + }); +}); diff --git a/backend/audit/controller/auditController.ts b/backend/audit/controller/auditController.ts index 1cf96db2..bcb69d09 100644 --- a/backend/audit/controller/auditController.ts +++ b/backend/audit/controller/auditController.ts @@ -26,6 +26,14 @@ export interface AuditVerificationResult { lastAnchoredAt: number | null; } +export interface AuditExportResult { + format: 'csv' | 'json'; + contentType: 'text/csv' | 'application/json'; + filename: string; + content: string; + count: number; +} + export class AuditController { constructor( private chain: HashChainService, @@ -69,6 +77,60 @@ export class AuditController { return { data, meta: { page, limit, total, totalPages } }; } + /** Export all events matching the audit filters in an auditor-friendly format. */ + async exportReport( + filter: Omit, + format: 'csv' | 'json', + ): Promise { + const result = await this.query({ ...filter, page: 1, limit: Number.MAX_SAFE_INTEGER }); + const events = result.data as Array<{ + id: string; + actorId: string; + action: string; + resourceType: string; + resourceId: string; + timestamp: number; + metadata: Record; + }>; + const date = new Date().toISOString().slice(0, 10); + + if (format === 'json') { + const rows = events.map((event) => ({ + ...event, + occurredAt: new Date(event.timestamp).toISOString(), + })); + return { + format, + contentType: 'application/json', + filename: `audit-report-${date}.json`, + content: JSON.stringify(rows, null, 2), + count: rows.length, + }; + } + + const escape = (value: unknown): string => + `"${String(value ?? '').replace(/"/g, '""')}"`; + const header = ['id', 'actor_id', 'occurred_at', 'action', 'resource_type', 'resource_id', 'metadata']; + const rows = events.map((event) => + [ + event.id, + event.actorId, + new Date(event.timestamp).toISOString(), + event.action, + event.resourceType, + event.resourceId, + JSON.stringify(event.metadata), + ].map(escape).join(','), + ); + return { + format, + contentType: 'text/csv', + filename: `audit-report-${date}.csv`, + content: [header.join(','), ...rows].join('\n'), + count: rows.length, + }; + } + async verify(): Promise { const result = this.chain.verify(); const lastAnchor = this.anchor.getLastAnchor(); diff --git a/backend/audit/controller/index.ts b/backend/audit/controller/index.ts index b0b0cc1f..7b4ff499 100644 --- a/backend/audit/controller/index.ts +++ b/backend/audit/controller/index.ts @@ -1,4 +1,9 @@ export { AuditController } from './auditController'; -export type { AuditQueryFilter, AuditQueryResult, AuditVerificationResult } from './auditController'; +export type { + AuditExportResult, + AuditQueryFilter, + AuditQueryResult, + AuditVerificationResult, +} from './auditController'; export { SensitiveOpsAuditController } from './sensitiveOpsAuditController'; export type { SensitiveOpContext, SensitiveOpResult } from './sensitiveOpsAuditController'; diff --git a/backend/audit/domain/HashChainService.ts b/backend/audit/domain/HashChainService.ts index 7cf94140..3e975536 100644 --- a/backend/audit/domain/HashChainService.ts +++ b/backend/audit/domain/HashChainService.ts @@ -43,7 +43,7 @@ export class HashChainService { computeHash(data: { id: string; actorId: string; action: string; resourceType: string; resourceId: string; timestamp: number; prevHash: string }): string { return createHash('sha256') - .update(prevHash) + .update(data.prevHash) .update(data.id) .update(data.actorId) .update(data.action)