Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/analytics/__tests__/merchantPerformance.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
26 changes: 26 additions & 0 deletions backend/analytics/__tests__/revenueAnalytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
});
33 changes: 33 additions & 0 deletions backend/analytics/__tests__/revenueAnomaly.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
76 changes: 76 additions & 0 deletions backend/analytics/domain/MerchantPerformanceService.ts
Original file line number Diff line number Diff line change
@@ -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<RevenueReportFilter, "merchantId"> = {},
): 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");
}
}
70 changes: 70 additions & 0 deletions backend/analytics/domain/RevenueAnalyticsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RevenueEntryType, RevenueEntry[]>;
totals: {
revenue: number;
refunds: number;
adjustments: number;
netRevenue: number;
};
period: { from: string | null; to: string | null };
}

export class RevenueAnalyticsService {
constructor(private readonly pool: Pool) {}

Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions backend/analytics/domain/RevenueAnomalyService.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<RevenueAnomaly | null> {
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;
}
}
2 changes: 1 addition & 1 deletion backend/analytics/query/funnelQueryHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

/**
Expand Down
36 changes: 36 additions & 0 deletions backend/audit/__tests__/auditExport.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading
Loading