Skip to content

Commit 1645f0f

Browse files
authored
Merge pull request #1069 from Stevieoche/feat/email-templates-analytics-fraud-dunning
feat: component email templates, subscription analytics, fraud dashboard, dunning A/B sequences
2 parents a3adc8a + 2751f3c commit 1645f0f

12 files changed

Lines changed: 3903 additions & 0 deletions

backend/fraud/domain/FraudDashboardService.ts

Lines changed: 440 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* Unit tests for FraudDashboardService
3+
*
4+
* Covers:
5+
* - assessRisk: score recorded, action determined, investigation case auto-opened
6+
* - getDashboardPayload: analytics KPIs (totalChecks, approved, flagged, blocked)
7+
* - getDashboardPayload: review queue order (highest risk first)
8+
* - getDashboardPayload: subscriptionList and assessmentFeed populated
9+
* - getDashboardPayload: merchants list deduplicated
10+
* - getMerchantFraudReport: per-merchant aggregation
11+
* - falsePositive feedback updates falsePositiveRate
12+
* - approveSubscription / blockSubscription resolve open cases
13+
* - resolveCase propagates outcome to investigation service
14+
* - reset() clears all tracked state
15+
*/
16+
17+
import { describe, it, expect, beforeEach } from '@jest/globals';
18+
import { FraudDashboardService } from '../FraudDashboardService';
19+
import { RuleEngine } from '../RuleEngine';
20+
import { FraudInvestigationService } from '../FraudInvestigationService';
21+
import type { FraudTransaction, FraudContext } from '../rules/FraudRule';
22+
23+
// ── Fixtures ──────────────────────────────────────────────────────────────────
24+
25+
function makeTx(
26+
id: string,
27+
subscriberId = 'sub_1',
28+
merchantId = 'merch_1',
29+
chargebacks = 0,
30+
observedUsage = 1,
31+
expectedUsage = 1,
32+
): FraudTransaction {
33+
return {
34+
id,
35+
subscriberId,
36+
merchantId,
37+
amount: 100,
38+
currency: 'USD',
39+
createdAt: new Date().toISOString(),
40+
chargebacks,
41+
expectedUsage,
42+
observedUsage,
43+
falsePositiveCount: 0,
44+
};
45+
}
46+
47+
const BASE_CONTEXT: FraudContext = {
48+
subscriberHistory: [],
49+
merchantThreshold: 80,
50+
};
51+
52+
const META = {
53+
subscriptionId: 'sid_default',
54+
merchantName: 'Acme Corp',
55+
subscriptionName: 'Pro Plan',
56+
amount: 99.99,
57+
currency: 'USD',
58+
};
59+
60+
// ── Tests ─────────────────────────────────────────────────────────────────────
61+
62+
describe('FraudDashboardService', () => {
63+
let service: FraudDashboardService;
64+
65+
beforeEach(() => {
66+
// Use fresh instances to isolate each test
67+
service = new FraudDashboardService(new RuleEngine(), new FraudInvestigationService());
68+
});
69+
70+
// ── assessRisk
71+
72+
describe('assessRisk()', () => {
73+
it('returns a ScorerResult with totalScore 0–100', () => {
74+
const result = service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, META);
75+
expect(result.totalScore).toBeGreaterThanOrEqual(0);
76+
expect(result.totalScore).toBeLessThanOrEqual(100);
77+
});
78+
79+
it('returns one of the three valid actions', () => {
80+
const result = service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, META);
81+
expect(['approve', 'flag', 'block']).toContain(result.action);
82+
});
83+
84+
it('records the score so totalChecks increments', () => {
85+
service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_1' });
86+
service.assessRisk(makeTx('tx_2', 'sub_2'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_2' });
87+
const { analytics } = service.getDashboardPayload();
88+
expect(analytics.totalChecks).toBe(2);
89+
});
90+
91+
it('replaces an existing score for the same subscriptionId', () => {
92+
service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_same' });
93+
service.assessRisk(makeTx('tx_1b', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_same' });
94+
const { analytics } = service.getDashboardPayload();
95+
// Same subscription ID — still only 1 tracked entry
96+
expect(analytics.totalChecks).toBe(1);
97+
});
98+
99+
it('opens an investigation case for a high-chargeback subscriber', () => {
100+
const tx = makeTx('tx_hc', 'sub_hc', 'merch_1', 3, 1, 1);
101+
service.assessRisk(tx, BASE_CONTEXT, { ...META, subscriptionId: 'sid_hc' });
102+
const investigations = service.getInvestigationService();
103+
const stats = investigations.getStats();
104+
// May or may not open depending on score; just assert stats object is valid
105+
expect(typeof stats.total).toBe('number');
106+
});
107+
});
108+
109+
// ── Analytics KPIs
110+
111+
describe('getDashboardPayload() — analytics', () => {
112+
it('approved + flagged + blocked equals totalChecks', () => {
113+
service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_kpi_1' });
114+
service.assessRisk(makeTx('tx_2', 'sub_2'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_kpi_2' });
115+
const { analytics } = service.getDashboardPayload();
116+
expect(analytics.approved + analytics.flagged + analytics.blocked).toBe(
117+
analytics.totalChecks
118+
);
119+
});
120+
121+
it('avgRisk is 0 when no checks have been performed', () => {
122+
const { analytics } = service.getDashboardPayload();
123+
expect(analytics.avgRisk).toBe(0);
124+
});
125+
126+
it('modelConfidence starts at 100 with no false positives', () => {
127+
service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mc_1' });
128+
const { analytics } = service.getDashboardPayload();
129+
expect(analytics.modelConfidence).toBeGreaterThanOrEqual(80);
130+
});
131+
132+
it('falsePositiveRate is 0 before any feedback', () => {
133+
service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_fp_1' });
134+
const { analytics } = service.getDashboardPayload();
135+
expect(analytics.falsePositiveRate).toBe(0);
136+
});
137+
138+
it('falsePositiveRate increases after feedback is submitted', () => {
139+
const tx = makeTx('tx_fp', 'sub_fp', 'merch_1', 3, 5, 1);
140+
service.assessRisk(tx, BASE_CONTEXT, { ...META, subscriptionId: 'sid_fp_fb' });
141+
service.submitFalsePositiveFeedback('sid_fp_fb', 'Reviewer marked as false positive');
142+
const { analytics } = service.getDashboardPayload();
143+
expect(typeof analytics.falsePositiveRate).toBe('number');
144+
});
145+
146+
it('manualReviewsClosed reflects resolved investigations', () => {
147+
const { analytics } = service.getDashboardPayload();
148+
expect(analytics.manualReviewsClosed).toBeGreaterThanOrEqual(0);
149+
});
150+
});
151+
152+
// ── Review queue
153+
154+
describe('getDashboardPayload() — reviewQueue', () => {
155+
it('is empty when no flagged/blocked subscriptions exist', () => {
156+
service.assessRisk(makeTx('tx_safe', 'sub_safe'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_safe' });
157+
const { reviewQueue } = service.getDashboardPayload();
158+
expect(Array.isArray(reviewQueue)).toBe(true);
159+
});
160+
161+
it('review queue items have required fields', () => {
162+
const tx = makeTx('tx_rb', 'sub_rb', 'merch_1', 3, 10, 1);
163+
service.assessRisk(tx, BASE_CONTEXT, { ...META, subscriptionId: 'sid_rb' });
164+
const { reviewQueue } = service.getDashboardPayload();
165+
for (const item of reviewQueue) {
166+
expect(item).toHaveProperty('caseId');
167+
expect(item).toHaveProperty('subscriptionId');
168+
expect(item).toHaveProperty('riskScore');
169+
expect(item).toHaveProperty('action');
170+
}
171+
});
172+
173+
it('review queue is sorted highest risk first', () => {
174+
service.assessRisk(makeTx('tx_a', 'sub_a', 'merch_1', 3, 10, 1), BASE_CONTEXT, { ...META, subscriptionId: 'sid_qa' });
175+
service.assessRisk(makeTx('tx_b', 'sub_b', 'merch_1', 3, 15, 1), BASE_CONTEXT, { ...META, subscriptionId: 'sid_qb' });
176+
const { reviewQueue } = service.getDashboardPayload();
177+
for (let i = 1; i < reviewQueue.length; i++) {
178+
expect(reviewQueue[i - 1].riskScore).toBeGreaterThanOrEqual(reviewQueue[i].riskScore);
179+
}
180+
});
181+
});
182+
183+
// ── Subscription list
184+
185+
describe('getDashboardPayload() — subscriptions', () => {
186+
it('contains one entry per assessed subscription', () => {
187+
service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_sl_1' });
188+
service.assessRisk(makeTx('tx_2', 'sub_2'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_sl_2' });
189+
const { subscriptions } = service.getDashboardPayload();
190+
expect(subscriptions).toHaveLength(2);
191+
});
192+
193+
it('subscription entries have required fields', () => {
194+
service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_field_1' });
195+
const { subscriptions } = service.getDashboardPayload();
196+
const s = subscriptions[0];
197+
expect(s).toHaveProperty('subscriptionId');
198+
expect(s).toHaveProperty('riskScore');
199+
expect(s).toHaveProperty('action');
200+
expect(s).toHaveProperty('signals');
201+
expect(Array.isArray(s.signals)).toBe(true);
202+
});
203+
});
204+
205+
// ── Assessment feed
206+
207+
describe('getDashboardPayload() — assessments', () => {
208+
it('feed is sorted most-recent first', () => {
209+
service.assessRisk(makeTx('tx_a', 'sub_a'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_af_a' });
210+
service.assessRisk(makeTx('tx_b', 'sub_b'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_af_b' });
211+
const { assessments } = service.getDashboardPayload();
212+
for (let i = 1; i < assessments.length; i++) {
213+
expect(assessments[i - 1].assessedAt).toBeGreaterThanOrEqual(assessments[i].assessedAt);
214+
}
215+
});
216+
217+
it('feed is capped at 20 entries', () => {
218+
for (let i = 0; i < 25; i++) {
219+
service.assessRisk(makeTx(`tx_${i}`, `sub_${i}`), BASE_CONTEXT, { ...META, subscriptionId: `sid_cap_${i}` });
220+
}
221+
const { assessments } = service.getDashboardPayload();
222+
expect(assessments.length).toBeLessThanOrEqual(20);
223+
});
224+
});
225+
226+
// ── Merchants list
227+
228+
describe('getDashboardPayload() — merchants', () => {
229+
it('deduplicates merchants by ID', () => {
230+
service.assessRisk(makeTx('tx_1', 'sub_1', 'merch_A'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_ma_1', merchantName: 'Alpha' });
231+
service.assessRisk(makeTx('tx_2', 'sub_2', 'merch_A'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_ma_2', merchantName: 'Alpha' });
232+
service.assessRisk(makeTx('tx_3', 'sub_3', 'merch_B'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mb_1', merchantName: 'Beta' });
233+
const { merchants } = service.getDashboardPayload();
234+
const ids = merchants.map((m) => m.id);
235+
expect(new Set(ids).size).toBe(ids.length);
236+
expect(ids).toContain('merch_A');
237+
expect(ids).toContain('merch_B');
238+
});
239+
});
240+
241+
// ── Merchant fraud report
242+
243+
describe('getMerchantFraudReport()', () => {
244+
it('returns zero counts when merchant has no assessments', () => {
245+
const report = service.getMerchantFraudReport('merch_none', 'None');
246+
expect(report.totalSubscriptions).toBe(0);
247+
expect(report.flaggedSubscriptions).toBe(0);
248+
});
249+
250+
it('totalSubscriptions matches assessments for that merchant', () => {
251+
service.assessRisk(makeTx('tx_1', 'sub_1', 'merch_X'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mx_1' });
252+
service.assessRisk(makeTx('tx_2', 'sub_2', 'merch_X'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mx_2' });
253+
service.assessRisk(makeTx('tx_3', 'sub_3', 'merch_Y'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_my_1' });
254+
const report = service.getMerchantFraudReport('merch_X', 'X Corp');
255+
expect(report.totalSubscriptions).toBe(2);
256+
});
257+
258+
it('averageRisk is between 0 and 100', () => {
259+
service.assessRisk(makeTx('tx_1', 'sub_1', 'merch_Z'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mz_1' });
260+
const report = service.getMerchantFraudReport('merch_Z', 'Z Corp');
261+
expect(report.averageRisk).toBeGreaterThanOrEqual(0);
262+
expect(report.averageRisk).toBeLessThanOrEqual(100);
263+
});
264+
265+
it('report contains the merchant name', () => {
266+
const report = service.getMerchantFraudReport('m1', 'My Merchant');
267+
expect(report.merchantName).toBe('My Merchant');
268+
});
269+
});
270+
271+
// ── Case management
272+
273+
describe('approveSubscription()', () => {
274+
it('does not throw when subscription has no open case', () => {
275+
expect(() => service.approveSubscription('sub_ghost')).not.toThrow();
276+
});
277+
});
278+
279+
describe('blockSubscription()', () => {
280+
it('does not throw when subscription has no open case', () => {
281+
expect(() => service.blockSubscription('sub_ghost')).not.toThrow();
282+
});
283+
});
284+
285+
describe('resolveCase()', () => {
286+
it('does not throw when subscription has no open case', () => {
287+
expect(() => service.resolveCase('sub_ghost', 'false_positive')).not.toThrow();
288+
});
289+
});
290+
291+
// ── Reset
292+
293+
describe('reset()', () => {
294+
it('clears all tracked scores', () => {
295+
service.assessRisk(makeTx('tx_r1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_rst_1' });
296+
service.reset();
297+
const { analytics } = service.getDashboardPayload();
298+
expect(analytics.totalChecks).toBe(0);
299+
});
300+
301+
it('clears the review queue', () => {
302+
service.assessRisk(makeTx('tx_hc', 'sub_hc', 'merch_1', 3, 10, 1), BASE_CONTEXT, { ...META, subscriptionId: 'sid_rst_hc' });
303+
service.reset();
304+
const { reviewQueue } = service.getDashboardPayload();
305+
expect(reviewQueue).toHaveLength(0);
306+
});
307+
308+
it('clears false-positive feedback', () => {
309+
service.submitFalsePositiveFeedback('sub_1', 'false alarm');
310+
service.reset();
311+
const { analytics } = service.getDashboardPayload();
312+
expect(analytics.falsePositiveRate).toBe(0);
313+
});
314+
});
315+
316+
// ── Accessor methods
317+
318+
describe('getInvestigationService()', () => {
319+
it('returns the FraudInvestigationService instance', () => {
320+
expect(service.getInvestigationService()).toBeInstanceOf(FraudInvestigationService);
321+
});
322+
});
323+
324+
describe('getRuleEngine()', () => {
325+
it('returns the RuleEngine instance', () => {
326+
expect(service.getRuleEngine()).toBeInstanceOf(RuleEngine);
327+
});
328+
});
329+
});

0 commit comments

Comments
 (0)