diff --git a/backend/fraud/controller/fraudAnalyticsController.ts b/backend/fraud/controller/fraudAnalyticsController.ts new file mode 100644 index 00000000..1586738b --- /dev/null +++ b/backend/fraud/controller/fraudAnalyticsController.ts @@ -0,0 +1,85 @@ +/** + * Fraud Analytics REST API (framework-agnostic handler functions) + * + * Endpoints: + * GET /fraud/analytics/summary – overall fraud summary + * GET /fraud/analytics/trend – time-series trend + * GET /fraud/analytics/top-risk – top risk merchants + * GET /fraud/analytics/signals – fraud signal breakdown + * GET /fraud/analytics/report/:id – per-merchant fraud report + * GET /fraud/analytics/recommendations – prevention recommendations + */ + +import { fraudAnalyticsService } from '../../services/analytics/fraudAnalyticsService'; + +// ── Response helpers ────────────────────────────────────────────────────────── + +function ok(data: unknown) { + return { success: true, data }; +} + +function err(message: string, status = 400) { + return { success: false, error: { message }, status }; +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/** + * GET /fraud/analytics/summary + * Query params: merchantId (optional) + */ +export function getFraudSummary(params: { merchantId?: string }) { + const summary = fraudAnalyticsService.getFraudSummary(params.merchantId); + return ok(summary); +} + +/** + * GET /fraud/analytics/trend + * Query params: days (optional, default 30, max 90) + */ +export function getFraudTrend(params: { days?: string }) { + const days = params.days !== undefined ? parseInt(params.days, 10) : 30; + if (Number.isNaN(days) || days < 1 || days > 90) { + return err('days must be a number between 1 and 90'); + } + return ok(fraudAnalyticsService.getFraudTrend(days)); +} + +/** + * GET /fraud/analytics/top-risk + * Query params: limit (optional, default 10) + */ +export function getTopRiskMerchants(params: { limit?: string }) { + const limit = params.limit !== undefined ? parseInt(params.limit, 10) : 10; + if (Number.isNaN(limit) || limit < 1 || limit > 100) { + return err('limit must be a number between 1 and 100'); + } + return ok(fraudAnalyticsService.getTopRiskMerchants(limit)); +} + +/** + * GET /fraud/analytics/signals + */ +export function getSignalBreakdown() { + return ok(fraudAnalyticsService.getSignalBreakdown()); +} + +/** + * GET /fraud/analytics/report/:merchantId + */ +export function getFraudReport(merchantId: string) { + if (!merchantId || merchantId.trim() === '') { + return err('merchantId is required', 400); + } + const report = fraudAnalyticsService.generateFraudReport(merchantId); + return ok(report); +} + +/** + * GET /fraud/analytics/recommendations + * Query params: merchantId (optional) + */ +export function getPreventionRecommendations(params: { merchantId?: string }) { + const recs = fraudAnalyticsService.getPreventionRecommendations(params.merchantId); + return ok(recs); +} diff --git a/backend/fraud/controller/fraudInvestigationController.ts b/backend/fraud/controller/fraudInvestigationController.ts new file mode 100644 index 00000000..44be616b --- /dev/null +++ b/backend/fraud/controller/fraudInvestigationController.ts @@ -0,0 +1,196 @@ +/** + * Fraud Investigation REST API (framework-agnostic handler functions) + * + * Endpoints: + * GET /fraud/investigations – list cases with optional filters + * GET /fraud/investigations/:caseId – get single case + * POST /fraud/investigations – open a case manually + * PATCH /fraud/investigations/:caseId – update case (assign, resolve, escalate, dismiss) + * GET /fraud/investigations/:caseId/notes – list notes for case + * POST /fraud/investigations/:caseId/notes – add a note to case + * GET /fraud/investigations/stats – aggregate statistics + * + * POST /fraud/report – generate full fraud report for a merchant + */ + +import { fraudInvestigationService, InvestigationFilter } from '../domain/FraudInvestigationService'; +import { FraudReviewOutcome, FraudReviewStatus } from '../../../src/types/fraud'; + +// ── Response helpers ────────────────────────────────────────────────────────── + +function ok(data: unknown) { + return { success: true, data }; +} + +function err(message: string, status = 400) { + return { success: false, error: { message }, status }; +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/** GET /fraud/investigations */ +export function listCases(params: { + status?: string; + merchantId?: string; + subscriberId?: string; + minRiskScore?: string; + limit?: string; + offset?: string; +}) { + const filter: InvestigationFilter = {}; + + if (params.status) { + const validStatuses: FraudReviewStatus[] = ['pending', 'reviewed', 'dismissed', 'escalated']; + if (!validStatuses.includes(params.status as FraudReviewStatus)) { + return err(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); + } + filter.status = params.status as FraudReviewStatus; + } + + if (params.merchantId) filter.merchantId = params.merchantId; + if (params.subscriberId) filter.subscriberId = params.subscriberId; + + if (params.minRiskScore) { + const score = parseInt(params.minRiskScore, 10); + if (Number.isNaN(score) || score < 0 || score > 100) { + return err('minRiskScore must be a number between 0 and 100'); + } + filter.minRiskScore = score; + } + + if (params.limit) { + const limit = parseInt(params.limit, 10); + if (Number.isNaN(limit) || limit < 1 || limit > 200) { + return err('limit must be a number between 1 and 200'); + } + filter.limit = limit; + } + + if (params.offset) { + const offset = parseInt(params.offset, 10); + if (Number.isNaN(offset) || offset < 0) { + return err('offset must be a non-negative number'); + } + filter.offset = offset; + } + + return ok(fraudInvestigationService.getCases(filter)); +} + +/** GET /fraud/investigations/:caseId */ +export function getCase(caseId: string) { + const fraudCase = fraudInvestigationService.getCase(caseId); + if (!fraudCase) return err(`Case ${caseId} not found`, 404); + return ok(fraudCase); +} + +/** POST /fraud/investigations */ +export function createCase(body: { + subscriptionId?: string; + subscriberId?: string; + merchantId?: string; + merchantName?: string; + subscriptionName?: string; + riskScore?: number; + reason?: string; +}) { + if (!body.subscriptionId) return err('subscriptionId is required'); + if (!body.subscriberId) return err('subscriberId is required'); + if (!body.merchantId) return err('merchantId is required'); + if (!body.merchantName) return err('merchantName is required'); + if (!body.subscriptionName) return err('subscriptionName is required'); + if (body.riskScore === undefined || body.riskScore === null) { + return err('riskScore is required'); + } + if (body.riskScore < 0 || body.riskScore > 100) { + return err('riskScore must be between 0 and 100'); + } + if (!body.reason) return err('reason is required'); + + const fraudCase = fraudInvestigationService.createCase({ + subscriptionId: body.subscriptionId, + subscriberId: body.subscriberId, + merchantId: body.merchantId, + merchantName: body.merchantName, + subscriptionName: body.subscriptionName, + riskScore: body.riskScore, + reason: body.reason, + }); + + return ok(fraudCase); +} + +/** PATCH /fraud/investigations/:caseId */ +export function updateCase( + caseId: string, + body: { + action?: 'assign' | 'resolve' | 'escalate' | 'dismiss' | 'start_review'; + reviewer?: string; + outcome?: string; + notes?: string; + }, +) { + if (!body.action) return err('action is required'); + + switch (body.action) { + case 'assign': { + if (!body.reviewer) return err('reviewer is required for assign action'); + const result = fraudInvestigationService.assignReviewer(caseId, body.reviewer); + return result.success ? ok(result.case) : err(result.error ?? 'Unknown error', 404); + } + + case 'start_review': { + if (!body.reviewer) return err('reviewer is required for start_review action'); + const result = fraudInvestigationService.startReview(caseId, body.reviewer); + return result.success ? ok(result.case) : err(result.error ?? 'Unknown error', 404); + } + + case 'resolve': { + const validOutcomes: FraudReviewOutcome[] = ['true_positive', 'false_positive', 'needs_follow_up']; + if (!body.outcome || !validOutcomes.includes(body.outcome as FraudReviewOutcome)) { + return err(`outcome must be one of: ${validOutcomes.join(', ')}`); + } + const result = fraudInvestigationService.resolveCase( + caseId, + body.outcome as FraudReviewOutcome, + body.notes, + ); + return result.success ? ok(result.case) : err(result.error ?? 'Unknown error', 404); + } + + case 'escalate': { + const result = fraudInvestigationService.escalateCase(caseId, body.notes); + return result.success ? ok(result.case) : err(result.error ?? 'Unknown error', 404); + } + + case 'dismiss': { + const result = fraudInvestigationService.dismissCase(caseId, body.notes); + return result.success ? ok(result.case) : err(result.error ?? 'Unknown error', 404); + } + + default: + return err(`Unknown action: ${body.action as string}`); + } +} + +/** GET /fraud/investigations/:caseId/notes */ +export function getCaseNotes(caseId: string) { + const fraudCase = fraudInvestigationService.getCase(caseId); + if (!fraudCase) return err(`Case ${caseId} not found`, 404); + return ok(fraudInvestigationService.getNotes(caseId)); +} + +/** POST /fraud/investigations/:caseId/notes */ +export function addCaseNote(caseId: string, body: { author?: string; content?: string }) { + if (!body.author) return err('author is required'); + if (!body.content || body.content.trim() === '') return err('content is required'); + + const note = fraudInvestigationService.addNote(caseId, body.author, body.content); + if (!note) return err(`Case ${caseId} not found`, 404); + return ok(note); +} + +/** GET /fraud/investigations/stats */ +export function getInvestigationStats() { + return ok(fraudInvestigationService.getStats()); +} diff --git a/backend/fraud/controller/fraudReportingController.ts b/backend/fraud/controller/fraudReportingController.ts new file mode 100644 index 00000000..c0b7f8b4 --- /dev/null +++ b/backend/fraud/controller/fraudReportingController.ts @@ -0,0 +1,148 @@ +/** + * Fraud Reporting REST API (framework-agnostic handler functions) + * + * Endpoints: + * POST /fraud/report – generate a full fraud report for a merchant + * GET /fraud/report/:merchantId – retrieve pre-generated fraud report + * GET /fraud/reports/export – export fraud report data as CSV + */ + +import { fraudAnalyticsService } from '../../services/analytics/fraudAnalyticsService'; +import { FraudReport } from '../../../src/types/fraud'; + +// ── Response helpers ────────────────────────────────────────────────────────── + +function ok(data: unknown) { + return { success: true, data }; +} + +function err(message: string, status = 400) { + return { success: false, error: { message }, status }; +} + +// ── Report formatting ───────────────────────────────────────────────────────── + +function reportToCsv(report: FraudReport): string { + const lines: string[] = [ + '# SubTrackr Fraud Report', + `# Merchant: ${report.merchantName} (${report.merchantId})`, + `# Generated: ${new Date().toISOString()}`, + '', + 'Metric,Value', + `Total Subscriptions,${report.totalSubscriptions}`, + `Flagged Subscriptions,${report.flaggedSubscriptions}`, + `Blocked Subscriptions,${report.blockedSubscriptions}`, + `Manual Reviews,${report.manualReviewCount}`, + `Average Risk Score,${report.averageRisk}`, + `Velocity Alerts,${report.velocityAlerts}`, + `Anomaly Alerts,${report.anomalyAlerts}`, + `Chargeback Predictions,${report.chargebackPredictions}`, + `Geolocation Alerts,${report.geolocationAlerts}`, + `High Risk Subscribers,${report.highRiskSubscribers}`, + `Pending Evidence Items,${report.pendingEvidenceCount}`, + `False Positive Feedback,${report.falsePositiveFeedbackCount}`, + ]; + + if (report.recentCases.length > 0) { + lines.push(''); + lines.push('# Recent Cases'); + lines.push('CaseId,SubscriptionId,SubscriberId,RiskScore,Action,Status,Reason,CreatedAt'); + for (const c of report.recentCases) { + const row = [ + c.caseId, + c.subscriptionId, + c.subscriberId, + c.riskScore.toString(), + c.action, + c.status, + `"${c.reason.replace(/"/g, '""')}"`, + c.createdAt, + ].join(','); + lines.push(row); + } + } + + return lines.join('\n'); +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/** + * POST /fraud/report – body: { merchantId: string } + * Generates and returns a full fraud report for the given merchant. + */ +export function generateReport(body: { merchantId?: string }) { + if (!body.merchantId || body.merchantId.trim() === '') { + return err('merchantId is required'); + } + const report = fraudAnalyticsService.generateFraudReport(body.merchantId); + return ok(report); +} + +/** + * GET /fraud/report/:merchantId + * Retrieve a pre-generated (or freshly computed) fraud report. + */ +export function getReport(merchantId: string) { + if (!merchantId || merchantId.trim() === '') { + return err('merchantId path parameter is required', 400); + } + const report = fraudAnalyticsService.generateFraudReport(merchantId); + return ok(report); +} + +/** + * GET /fraud/reports/export?merchantId=...&format=csv|json + * Export fraud report in a chosen format (default: csv). + */ +export function exportReport(params: { merchantId?: string; format?: string }) { + if (!params.merchantId || params.merchantId.trim() === '') { + return err('merchantId query parameter is required'); + } + + const report = fraudAnalyticsService.generateFraudReport(params.merchantId); + const format = params.format ?? 'csv'; + + if (format === 'csv') { + return { + success: true, + contentType: 'text/csv', + filename: `fraud-report-${params.merchantId}-${new Date().toISOString().slice(0, 10)}.csv`, + data: reportToCsv(report), + }; + } + + if (format === 'json') { + return { + success: true, + contentType: 'application/json', + filename: `fraud-report-${params.merchantId}-${new Date().toISOString().slice(0, 10)}.json`, + data: JSON.stringify(report, null, 2), + }; + } + + return err(`Unsupported format "${format}". Use "csv" or "json".`); +} + +/** + * GET /fraud/reports/summary + * Multi-merchant fraud dashboard summary. + */ +export function getMultiMerchantSummary() { + const merchants = fraudAnalyticsService.getTopRiskMerchants(50); + const summary = merchants.map((m) => ({ + merchantId: m.id, + merchantName: m.name, + status: m.status, + averageRisk: m.averageRisk, + activeSubscriptions: m.activeSubscriptions, + blockedSubscriptions: m.blockedSubscriptions, + monthlyVolume: m.monthlyVolume, + falsePositiveRate: m.falsePositiveRate, + })); + return ok({ + generatedAt: new Date().toISOString(), + totalMerchants: merchants.length, + merchants: summary, + }); +} diff --git a/backend/fraud/domain/FraudInvestigationService.ts b/backend/fraud/domain/FraudInvestigationService.ts new file mode 100644 index 00000000..f1b324c2 --- /dev/null +++ b/backend/fraud/domain/FraudInvestigationService.ts @@ -0,0 +1,304 @@ +/** + * FraudInvestigationService + * + * Manages the lifecycle of fraud investigation cases. Cases are opened + * automatically when a risk assessment returns an action of "flag" or "block", + * and can be progressed through a standard review workflow: + * + * open → review → (resolve | escalate | dismiss) + * + * In production each state transition would write to the database. + * This implementation uses an in-memory store that is suitable for the + * dev/test environment and as a specification reference for the DB layer. + */ + +import { FraudCase, FraudRiskScore, FraudReviewOutcome, FraudReviewStatus } from '../../../src/types/fraud'; + +// ── Domain types ────────────────────────────────────────────────────────────── + +export interface InvestigationNote { + noteId: string; + caseId: string; + author: string; + content: string; + createdAt: string; +} + +export interface InvestigationFilter { + status?: FraudReviewStatus; + merchantId?: string; + subscriberId?: string; + minRiskScore?: number; + limit?: number; + offset?: number; +} + +export interface CaseUpdateResult { + success: boolean; + error?: string; + case?: FraudCase; +} + +// ── Counters ────────────────────────────────────────────────────────────────── + +let caseIdCounter = 0; +let noteIdCounter = 0; + +function nextCaseId(): string { + caseIdCounter += 1; + return `case_${Date.now()}_${caseIdCounter}`; +} + +function nextNoteId(): string { + noteIdCounter += 1; + return `note_${Date.now()}_${noteIdCounter}`; +} + +// ── Service ─────────────────────────────────────────────────────────────────── + +export class FraudInvestigationService { + private cases: Map = new Map(); + private notes: Map = new Map(); + + // ── Case creation ───────────────────────────────────────────────────────── + + /** + * Open an investigation case from a risk assessment. + * Returns null if the assessment action is "approve" (nothing to investigate). + */ + openCaseFromAssessment(assessment: FraudRiskScore): FraudCase | null { + if (assessment.action === 'approve') return null; + + const now = new Date().toISOString(); + const caseId = nextCaseId(); + + const fraudCase: FraudCase = { + caseId, + subscriptionId: assessment.subscriptionId, + subscriberId: assessment.subscriberId, + merchantId: assessment.merchantId, + merchantName: assessment.merchantName, + subscriptionName: `Subscription ${assessment.subscriptionId}`, + riskScore: assessment.totalScore, + action: assessment.action, + status: assessment.action === 'block' ? 'escalated' : 'pending', + reason: assessment.reason, + createdAt: now, + updatedAt: now, + evidence: assessment.evidence, + }; + + this.cases.set(caseId, fraudCase); + this.notes.set(caseId, []); + return fraudCase; + } + + /** + * Manually create an investigation case. + */ + createCase(params: { + subscriptionId: string; + subscriberId: string; + merchantId: string; + merchantName: string; + subscriptionName: string; + riskScore: number; + reason: string; + }): FraudCase { + const now = new Date().toISOString(); + const caseId = nextCaseId(); + const action = params.riskScore >= 80 ? 'block' : 'flag'; + + const fraudCase: FraudCase = { + caseId, + subscriptionId: params.subscriptionId, + subscriberId: params.subscriberId, + merchantId: params.merchantId, + merchantName: params.merchantName, + subscriptionName: params.subscriptionName, + riskScore: params.riskScore, + action, + status: action === 'block' ? 'escalated' : 'pending', + reason: params.reason, + createdAt: now, + updatedAt: now, + }; + + this.cases.set(caseId, fraudCase); + this.notes.set(caseId, []); + return fraudCase; + } + + // ── Case retrieval ───────────────────────────────────────────────────────── + + getCase(caseId: string): FraudCase | null { + return this.cases.get(caseId) ?? null; + } + + getCases(filter?: InvestigationFilter): { cases: FraudCase[]; total: number } { + let results = Array.from(this.cases.values()); + + if (filter?.status) { + results = results.filter((c) => c.status === filter.status); + } + if (filter?.merchantId) { + results = results.filter((c) => c.merchantId === filter.merchantId); + } + if (filter?.subscriberId) { + results = results.filter((c) => c.subscriberId === filter.subscriberId); + } + if (filter?.minRiskScore !== undefined) { + results = results.filter((c) => c.riskScore >= (filter.minRiskScore ?? 0)); + } + + // Sort by risk score descending, then by creation date descending + results.sort((a, b) => { + if (b.riskScore !== a.riskScore) return b.riskScore - a.riskScore; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + const total = results.length; + const offset = filter?.offset ?? 0; + const limit = filter?.limit ?? 50; + const page = results.slice(offset, offset + limit); + + return { cases: page, total }; + } + + getOpenCases(): FraudCase[] { + return this.getCases({ status: 'pending' }).cases; + } + + getEscalatedCases(): FraudCase[] { + return this.getCases({ status: 'escalated' }).cases; + } + + // ── Case state transitions ───────────────────────────────────────────────── + + assignReviewer(caseId: string, reviewer: string): CaseUpdateResult { + const fraudCase = this.cases.get(caseId); + if (!fraudCase) return { success: false, error: `Case ${caseId} not found` }; + + fraudCase.reviewer = reviewer; + fraudCase.updatedAt = new Date().toISOString(); + this.cases.set(caseId, fraudCase); + return { success: true, case: fraudCase }; + } + + startReview(caseId: string, reviewer: string): CaseUpdateResult { + const fraudCase = this.cases.get(caseId); + if (!fraudCase) return { success: false, error: `Case ${caseId} not found` }; + if (fraudCase.status === 'reviewed') { + return { success: false, error: 'Case has already been reviewed' }; + } + + fraudCase.status = 'pending'; + fraudCase.reviewer = reviewer; + fraudCase.updatedAt = new Date().toISOString(); + this.cases.set(caseId, fraudCase); + return { success: true, case: fraudCase }; + } + + resolveCase( + caseId: string, + outcome: FraudReviewOutcome, + notes?: string, + ): CaseUpdateResult { + const fraudCase = this.cases.get(caseId); + if (!fraudCase) return { success: false, error: `Case ${caseId} not found` }; + + const now = new Date().toISOString(); + fraudCase.status = 'reviewed'; + fraudCase.outcome = outcome; + fraudCase.reviewedAt = now; + fraudCase.updatedAt = now; + if (notes) fraudCase.notes = notes; + + this.cases.set(caseId, fraudCase); + return { success: true, case: fraudCase }; + } + + escalateCase(caseId: string, notes?: string): CaseUpdateResult { + const fraudCase = this.cases.get(caseId); + if (!fraudCase) return { success: false, error: `Case ${caseId} not found` }; + + fraudCase.status = 'escalated'; + fraudCase.updatedAt = new Date().toISOString(); + if (notes) fraudCase.notes = notes; + this.cases.set(caseId, fraudCase); + return { success: true, case: fraudCase }; + } + + dismissCase(caseId: string, notes?: string): CaseUpdateResult { + const fraudCase = this.cases.get(caseId); + if (!fraudCase) return { success: false, error: `Case ${caseId} not found` }; + + const now = new Date().toISOString(); + fraudCase.status = 'dismissed'; + fraudCase.outcome = 'false_positive'; + fraudCase.reviewedAt = now; + fraudCase.updatedAt = now; + if (notes) fraudCase.notes = notes; + this.cases.set(caseId, fraudCase); + return { success: true, case: fraudCase }; + } + + // ── Notes ───────────────────────────────────────────────────────────────── + + addNote(caseId: string, author: string, content: string): InvestigationNote | null { + const fraudCase = this.cases.get(caseId); + if (!fraudCase) return null; + + const note: InvestigationNote = { + noteId: nextNoteId(), + caseId, + author, + content, + createdAt: new Date().toISOString(), + }; + + const existing = this.notes.get(caseId) ?? []; + existing.push(note); + this.notes.set(caseId, existing); + return note; + } + + getNotes(caseId: string): InvestigationNote[] { + return this.notes.get(caseId) ?? []; + } + + // ── Statistics ───────────────────────────────────────────────────────────── + + getStats(): { + total: number; + pending: number; + escalated: number; + reviewed: number; + dismissed: number; + avgRiskScore: number; + } { + const all = Array.from(this.cases.values()); + const total = all.length; + const pending = all.filter((c) => c.status === 'pending').length; + const escalated = all.filter((c) => c.status === 'escalated').length; + const reviewed = all.filter((c) => c.status === 'reviewed').length; + const dismissed = all.filter((c) => c.status === 'dismissed').length; + const avgRiskScore = + total > 0 ? Math.round(all.reduce((s, c) => s + c.riskScore, 0) / total) : 0; + + return { total, pending, escalated, reviewed, dismissed, avgRiskScore }; + } + + // ── Reset (for testing) ─────────────────────────────────────────────────── + + reset(): void { + this.cases.clear(); + this.notes.clear(); + caseIdCounter = 0; + noteIdCounter = 0; + } +} + +// ── Singleton ───────────────────────────────────────────────────────────────── + +export const fraudInvestigationService = new FraudInvestigationService(); diff --git a/backend/services/analytics/fraudAnalyticsService.ts b/backend/services/analytics/fraudAnalyticsService.ts new file mode 100644 index 00000000..7e1389df --- /dev/null +++ b/backend/services/analytics/fraudAnalyticsService.ts @@ -0,0 +1,315 @@ +/** + * FraudAnalyticsService + * + * Backend analytics layer for fraud detection. + * Aggregates risk data, produces time-series trends, and generates + * per-merchant reports with actionable prevention recommendations. + * + * In production this would query a database or data warehouse. + * The implementation ships with deterministic in-memory data so it + * is immediately usable in the dev / test environment. + */ + +import { + FraudCase, + FraudReport, + FraudSubscriptionRecord, + FraudMerchantRecord, +} from '../../../src/types/fraud'; + +// ── Domain types ────────────────────────────────────────────────────────────── + +export interface FraudSummary { + totalChecks: number; + approved: number; + flagged: number; + blocked: number; + manualReviews: number; + avgRiskScore: number; + falsePositiveRate: number; + modelConfidence: number; + velocityAlerts: number; + anomalyAlerts: number; + chargebackPredictions: number; + geoAnomalyAlerts: number; + updatedAt: string; +} + +export interface FraudTrendPoint { + date: string; // ISO date string YYYY-MM-DD + totalChecks: number; + flagged: number; + blocked: number; + avgRiskScore: number; +} + +export interface FraudSignalBreakdown { + signalType: string; + count: number; + avgScore: number; + percentage: number; +} + +export interface PreventionRecommendation { + id: string; + category: 'velocity' | 'geo' | 'device' | 'chargeback' | 'account' | 'monitoring'; + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + description: string; + impactScore: number; // 0-100 expected risk reduction + effort: 'low' | 'medium' | 'high'; +} + +// ── Seed / mock data helpers ────────────────────────────────────────────────── + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +} + +function buildTrend(days: number): FraudTrendPoint[] { + return Array.from({ length: days }, (_, i) => { + const d = days - i; + const base = 40 + Math.round(Math.sin(i / 3) * 10); + return { + date: daysAgo(d), + totalChecks: base + Math.round(Math.random() * 20), + flagged: Math.round(base * 0.15), + blocked: Math.round(base * 0.05), + avgRiskScore: 30 + Math.round(Math.random() * 20), + }; + }); +} + +const ALL_RECOMMENDATIONS: PreventionRecommendation[] = [ + { + id: 'rec_vel_001', + category: 'velocity', + severity: 'high', + title: 'Implement rate limiting on subscription creation', + description: + 'Limit each subscriber to at most 3 new subscriptions per 24-hour window. ' + + 'Subscribers exceeding this threshold should be placed in a review queue.', + impactScore: 35, + effort: 'low', + }, + { + id: 'rec_geo_001', + category: 'geo', + severity: 'medium', + title: 'Enable geolocation verification for high-value plans', + description: + 'Require additional verification (OTP, email confirmation) when a subscriber ' + + 'accesses a plan from a country that differs from their registration country.', + impactScore: 25, + effort: 'medium', + }, + { + id: 'rec_device_001', + category: 'device', + severity: 'medium', + title: 'Bind trusted device fingerprints per subscriber', + description: + 'Capture a trusted device fingerprint at registration and alert or block ' + + 'payment attempts from unrecognised devices until the subscriber confirms them.', + impactScore: 20, + effort: 'medium', + }, + { + id: 'rec_cb_001', + category: 'chargeback', + severity: 'critical', + title: 'Auto-block subscribers with 2+ chargebacks', + description: + 'Subscribers with two or more chargebacks in the last 90 days should be ' + + 'automatically blocked from new subscriptions and routed to manual review.', + impactScore: 45, + effort: 'low', + }, + { + id: 'rec_cb_002', + category: 'chargeback', + severity: 'high', + title: 'Enforce pre-dispute response within 72 h', + description: + 'When a chargeback is filed, automatically gather transaction evidence and ' + + 'submit a pre-dispute response to reduce chargeback acceptance.', + impactScore: 30, + effort: 'medium', + }, + { + id: 'rec_acct_001', + category: 'account', + severity: 'medium', + title: 'Apply stricter rules to accounts younger than 7 days', + description: + 'New accounts are disproportionately involved in fraud. Apply a temporary ' + + 'flag threshold of 35 (instead of 50) for accounts less than one week old.', + impactScore: 28, + effort: 'low', + }, + { + id: 'rec_mon_001', + category: 'monitoring', + severity: 'low', + title: 'Enable model drift alerts', + description: + 'Track false-positive and false-negative rates weekly. Alert the fraud team ' + + 'when the false-positive rate exceeds 20% so rule weights can be recalibrated.', + impactScore: 15, + effort: 'low', + }, + { + id: 'rec_vel_002', + category: 'velocity', + severity: 'high', + title: 'Add cross-merchant velocity check', + description: + 'A subscriber creating subscriptions across multiple merchants within a short ' + + 'window is a strong fraud signal. Implement a cross-merchant velocity rule.', + impactScore: 32, + effort: 'high', + }, +]; + +// ── Service ─────────────────────────────────────────────────────────────────── + +export class FraudAnalyticsService { + /** + * Return a high-level fraud summary. + * Pass a merchantId to scope the summary to one merchant. + */ + getFraudSummary(merchantId?: string): FraudSummary { + void merchantId; // In production: filter by merchantId + return { + totalChecks: 257, + approved: 201, + flagged: 38, + blocked: 18, + manualReviews: 24, + avgRiskScore: 34, + falsePositiveRate: 12, + modelConfidence: 88, + velocityAlerts: 15, + anomalyAlerts: 22, + chargebackPredictions: 11, + geoAnomalyAlerts: 8, + updatedAt: new Date().toISOString(), + }; + } + + /** + * Return a time-series trend of fraud events. + * @param days Number of days of history to return (default 30). + */ + getFraudTrend(days = 30): FraudTrendPoint[] { + return buildTrend(Math.max(1, Math.min(days, 90))); + } + + /** + * Return merchants ranked by average risk score, highest first. + * @param limit Maximum number of merchants to return (default 10). + */ + getTopRiskMerchants(limit = 10): FraudMerchantRecord[] { + const merchants: FraudMerchantRecord[] = [ + { + id: 'merch_cipher', + name: 'Cipher Pro', + status: 'high-risk', + activeSubscriptions: 46, + blockedSubscriptions: 9, + averageRisk: 67, + monthlyVolume: 7825, + falsePositiveRate: 8, + }, + { + id: 'merch_nova', + name: 'Nova Stream', + status: 'watch', + activeSubscriptions: 128, + blockedSubscriptions: 4, + averageRisk: 41, + monthlyVolume: 18650, + falsePositiveRate: 14, + }, + { + id: 'merch_orbit', + name: 'Orbit Tools', + status: 'healthy', + activeSubscriptions: 83, + blockedSubscriptions: 1, + averageRisk: 22, + monthlyVolume: 9420, + falsePositiveRate: 5, + }, + ]; + return merchants + .sort((a, b) => b.averageRisk - a.averageRisk) + .slice(0, limit); + } + + /** + * Return a breakdown of fraud signal types by frequency and average score. + */ + getSignalBreakdown(): FraudSignalBreakdown[] { + const signals = [ + { signalType: 'velocity', count: 47, avgScore: 28 }, + { signalType: 'usage-anomaly', count: 62, avgScore: 22 }, + { signalType: 'chargeback', count: 31, avgScore: 38 }, + { signalType: 'geolocation-anomaly', count: 24, avgScore: 24 }, + { signalType: 'device-mismatch', count: 18, avgScore: 20 }, + { signalType: 'pattern-shift', count: 15, avgScore: 26 }, + ]; + const total = signals.reduce((s, r) => s + r.count, 0); + return signals.map((s) => ({ + ...s, + percentage: total > 0 ? Math.round((s.count / total) * 100) : 0, + })); + } + + /** + * Generate a comprehensive fraud report for a merchant. + */ + generateFraudReport(merchantId: string): FraudReport { + return { + merchantId, + merchantName: merchantId === 'merch_cipher' ? 'Cipher Pro' : + merchantId === 'merch_nova' ? 'Nova Stream' : 'Orbit Tools', + totalSubscriptions: 46, + flaggedSubscriptions: 12, + blockedSubscriptions: 9, + manualReviewCount: 7, + averageRisk: 67, + velocityAlerts: 5, + anomalyAlerts: 8, + chargebackPredictions: 4, + highRiskSubscribers: 6, + geolocationAlerts: 3, + pendingEvidenceCount: 4, + falsePositiveFeedbackCount: 2, + recentCases: [], + }; + } + + /** + * Return actionable prevention recommendations for a merchant. + * Higher-severity recommendations are sorted first. + */ + getPreventionRecommendations(merchantId?: string): PreventionRecommendation[] { + void merchantId; + const severityOrder: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + }; + return [...ALL_RECOMMENDATIONS].sort( + (a, b) => (severityOrder[a.severity] ?? 4) - (severityOrder[b.severity] ?? 4) + ); + } +} + +// ── Singleton ───────────────────────────────────────────────────────────────── + +export const fraudAnalyticsService = new FraudAnalyticsService(); diff --git a/backend/services/analytics/index.ts b/backend/services/analytics/index.ts index 395a7407..5cfecb01 100644 --- a/backend/services/analytics/index.ts +++ b/backend/services/analytics/index.ts @@ -18,3 +18,10 @@ export { RetentionCalculator, RETENTION_CURVE_DAYS } from './retentionCalculator export { cohortTableToCsv, ltvBreakdownToCsv, cohortTableToPdf, churnBreakdownToPdf, buildSimplePdf } from './cohortReportExport'; export { SubscriberRecordRepository, subscriberRecordRepository } from './subscriberRecordRepository'; export { AnalyticsDashboardApi, analyticsDashboardApi } from './analyticsDashboardApi'; +export { FraudAnalyticsService, fraudAnalyticsService } from './fraudAnalyticsService'; +export type { + FraudSummary, + FraudTrendPoint, + FraudSignalBreakdown, + PreventionRecommendation, +} from './fraudAnalyticsService'; \ No newline at end of file diff --git a/contracts/fraud/src/prevention.rs b/contracts/fraud/src/prevention.rs new file mode 100644 index 00000000..f47825c7 --- /dev/null +++ b/contracts/fraud/src/prevention.rs @@ -0,0 +1,152 @@ +//! Prevention Recommendations Module +//! +//! Generates on-chain fraud prevention recommendations based on a subscriber's +//! risk score. Recommendations are structured data that the UI layer can +//! display and track. + +#![no_std] + +use soroban_sdk::{contracttype, Env, String, Vec}; + +/// Severity level of a prevention recommendation (0 = low, 100 = critical). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PreventionRecommendation { + /// Recommendation identifier, unique within a response. + pub id: u32, + /// Short human-readable title. + pub title: String, + /// Detailed description of the recommended action. + pub description: String, + /// Category slug (e.g. "velocity", "geo", "device", "chargeback", "account"). + pub category: String, + /// Severity score 0–100. Higher = more urgent. + pub severity: u32, + /// Estimated percentage reduction in risk if recommendation is implemented. + pub impact_score: u32, +} + +/// Return a set of prevention recommendations tailored to `risk_score`. +/// +/// # Arguments +/// * `env` – Soroban environment +/// * `risk_score` – Current total risk score for the subscriber (0–100) +/// +/// # Returns +/// A `Vec` sorted by `severity` descending. +pub fn get_prevention_recommendations( + env: &Env, + risk_score: u32, +) -> Vec { + let mut recs: Vec = Vec::new(env); + + // Always recommend velocity rate-limiting + recs.push_back(PreventionRecommendation { + id: 1, + title: String::from_str(env, "Rate-limit subscription creation"), + description: String::from_str( + env, + "Limit each subscriber to 3 new subscriptions per 24-hour window to curb velocity fraud.", + ), + category: String::from_str(env, "velocity"), + severity: velocity_severity(risk_score), + impact_score: 35, + }); + + // Recommend chargeback blocking for elevated risk + if risk_score >= 30 { + recs.push_back(PreventionRecommendation { + id: 2, + title: String::from_str(env, "Auto-block on 2+ chargebacks"), + description: String::from_str( + env, + "Subscribers with two or more chargebacks in 90 days should be automatically blocked from new subscriptions.", + ), + category: String::from_str(env, "chargeback"), + severity: chargeback_severity(risk_score), + impact_score: 45, + }); + } + + // Recommend geo verification for medium-high risk + if risk_score >= 40 { + recs.push_back(PreventionRecommendation { + id: 3, + title: String::from_str(env, "Require geo verification for cross-border access"), + description: String::from_str( + env, + "Enforce OTP or email confirmation when a subscriber accesses from a country other than their home country.", + ), + category: String::from_str(env, "geo"), + severity: geo_severity(risk_score), + impact_score: 25, + }); + } + + // Recommend device binding for medium-high risk + if risk_score >= 40 { + recs.push_back(PreventionRecommendation { + id: 4, + title: String::from_str(env, "Bind trusted device fingerprints"), + description: String::from_str( + env, + "Capture a trusted device at registration and alert or block payments from unrecognised devices.", + ), + category: String::from_str(env, "device"), + severity: device_severity(risk_score), + impact_score: 20, + }); + } + + // Recommend tighter rules for new accounts at high risk + if risk_score >= 50 { + recs.push_back(PreventionRecommendation { + id: 5, + title: String::from_str(env, "Lower flag threshold for new accounts"), + description: String::from_str( + env, + "Apply a flag threshold of 35 instead of 50 for accounts younger than 7 days.", + ), + category: String::from_str(env, "account"), + severity: account_severity(risk_score), + impact_score: 28, + }); + } + + // Recommend model drift monitoring at all times + recs.push_back(PreventionRecommendation { + id: 6, + title: String::from_str(env, "Enable model drift monitoring"), + description: String::from_str( + env, + "Alert the fraud team when the false-positive rate exceeds 20% so rule weights can be recalibrated.", + ), + category: String::from_str(env, "monitoring"), + severity: 20_u32.saturating_add(risk_score / 5).min(40), + impact_score: 15, + }); + + recs +} + +// ── Severity helpers ────────────────────────────────────────────────────────── + +fn velocity_severity(risk: u32) -> u32 { + if risk >= 80 { 90 } else if risk >= 50 { 70 } else if risk >= 30 { 50 } else { 30 } +} + +fn chargeback_severity(risk: u32) -> u32 { + if risk >= 80 { 100 } else if risk >= 50 { 80 } else { 60 } +} + +fn geo_severity(risk: u32) -> u32 { + if risk >= 80 { 70 } else if risk >= 50 { 55 } else { 40 } +} + +fn device_severity(risk: u32) -> u32 { + if risk >= 80 { 65 } else if risk >= 50 { 50 } else { 35 } +} + +fn account_severity(risk: u32) -> u32 { + if risk >= 80 { 75 } else { 55 } +} diff --git a/src/components/fraud/FraudCaseCard.tsx b/src/components/fraud/FraudCaseCard.tsx new file mode 100644 index 00000000..1bad09c8 --- /dev/null +++ b/src/components/fraud/FraudCaseCard.tsx @@ -0,0 +1,368 @@ +/** + * FraudCaseCard + * + * Displays a single fraud case in the investigation workflow. + * Shows risk score badge, subscriber details, signal pills, status badge, + * evidence count, and action buttons (Approve / Block / Dismiss). + * + * The card supports an expand/collapse mode to show signal details + * and evidence without leaving the list view. + */ + +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View, Pressable } from 'react-native'; +import { FraudCase } from '../../types/fraud'; +import { colors, spacing, typography, borderRadius } from '../../utils/constants'; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface FraudCaseCardProps { + fraudCase: FraudCase; + onApprove: (caseId: string) => void; + onBlock: (caseId: string) => void; + onDismiss: (caseId: string) => void; + onViewEvidence?: (caseId: string) => void; + expanded?: boolean; + onToggleExpand?: (caseId: string) => void; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function riskColor(score: number): string { + if (score >= 80) return colors.error; + if (score >= 50) return colors.warning; + return colors.success; +} + +function statusColor(status: FraudCase['status']): string { + switch (status) { + case 'pending': + return colors.warning; + case 'escalated': + return colors.error; + case 'reviewed': + return colors.success; + case 'dismissed': + return colors.textSecondary; + default: + return colors.textSecondary; + } +} + +function statusLabel(status: FraudCase['status']): string { + switch (status) { + case 'pending': + return 'Pending'; + case 'escalated': + return 'Escalated'; + case 'reviewed': + return 'Reviewed'; + case 'dismissed': + return 'Dismissed'; + default: + return status; + } +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export const FraudCaseCard: React.FC = ({ + fraudCase, + onApprove, + onBlock, + onDismiss, + onViewEvidence, + expanded = false, + onToggleExpand, +}) => { + const { + caseId, + merchantName, + subscriberId, + subscriptionName, + riskScore, + action, + status, + reason, + evidence = [], + notes, + signals = [], + } = fraudCase; + + const rc = riskColor(riskScore); + const sc = statusColor(status); + + const handleToggle = () => onToggleExpand?.(caseId); + + return ( + + {/* Header row */} + + {/* Risk score badge */} + + {riskScore} + + + {/* Merchant + subscription info */} + + + {merchantName} + + + {subscriptionName} + + + {subscriberId} + + + + {/* Status badge */} + + {statusLabel(status)} + + + + {/* Reason */} + {reason} + + {/* Expanded content */} + {expanded && ( + + {/* Signals */} + {signals.length > 0 && ( + + {signals.map((signal, idx) => ( + + + {signal.kind} ({signal.score}) + + + ))} + + )} + + {/* Notes */} + {notes ? Note: {notes} : null} + + {/* Evidence count */} + {evidence.length > 0 && onViewEvidence && ( + onViewEvidence(caseId)} + accessibilityRole="button" + accessibilityLabel={`View ${evidence.length} evidence item${evidence.length !== 1 ? 's' : ''}`}> + + View {evidence.length} evidence item{evidence.length !== 1 ? 's' : ''} + + + )} + + )} + + {/* Action strip */} + {status !== 'reviewed' && status !== 'dismissed' && ( + + onApprove(caseId)} + accessibilityRole="button" + accessibilityLabel="Approve this case"> + Approve + + + onBlock(caseId)} + accessibilityRole="button" + accessibilityLabel="Block this case"> + Block + + + onDismiss(caseId)} + accessibilityRole="button" + accessibilityLabel="Dismiss this case"> + Dismiss + + + )} + + {/* Outcome badge for closed cases */} + {(status === 'reviewed' || status === 'dismissed') && ( + + + {status === 'reviewed' ? `Case reviewed — action: ${action}` : 'Case dismissed'} + + + )} + + ); +}; + +// ── Styles ──────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface ?? colors.background, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border ?? '#E5E7EB', + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.sm, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + scoreBadge: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + scoreText: { + ...typography.h3, + color: '#fff', + fontWeight: '700', + }, + headerInfo: { + flex: 1, + gap: 2, + }, + merchantName: { + ...typography.body, + color: colors.text, + fontWeight: '600', + }, + subscriptionName: { + ...(typography.caption ?? typography.body), + color: colors.textSecondary, + fontSize: 13, + }, + subscriberId: { + ...(typography.caption ?? typography.body), + color: colors.textSecondary, + fontSize: 11, + }, + statusBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: borderRadius.sm, + flexShrink: 0, + }, + statusText: { + color: '#fff', + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + reason: { + ...typography.body, + color: colors.textSecondary, + fontSize: 13, + }, + expandedContent: { + gap: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border ?? '#E5E7EB', + paddingTop: spacing.sm, + }, + signalsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + }, + signalChip: { + borderWidth: 1, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + }, + signalChipText: { + fontSize: 11, + color: colors.text, + }, + notesText: { + ...typography.body, + color: colors.textSecondary, + fontSize: 12, + fontStyle: 'italic', + }, + evidenceButton: { + alignSelf: 'flex-start', + paddingVertical: spacing.xs, + }, + evidenceButtonText: { + color: colors.primary, + fontSize: 13, + fontWeight: '600', + }, + actionRow: { + flexDirection: 'row', + gap: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border ?? '#E5E7EB', + paddingTop: spacing.sm, + }, + actionButton: { + flex: 1, + paddingVertical: spacing.sm, + borderRadius: borderRadius.sm, + alignItems: 'center', + }, + approveButton: { + backgroundColor: colors.success + '20', + borderWidth: 1, + borderColor: colors.success, + }, + approveText: { + color: colors.success, + fontWeight: '600', + fontSize: 13, + }, + blockButton: { + backgroundColor: colors.error + '20', + borderWidth: 1, + borderColor: colors.error, + }, + blockText: { + color: colors.error, + fontWeight: '600', + fontSize: 13, + }, + dismissButton: { + backgroundColor: colors.textSecondary + '20', + borderWidth: 1, + borderColor: colors.textSecondary, + }, + dismissText: { + color: colors.textSecondary, + fontWeight: '600', + fontSize: 13, + }, + closedBanner: { + backgroundColor: colors.surface ?? '#F3F4F6', + borderRadius: borderRadius.sm, + padding: spacing.sm, + }, + closedBannerText: { + color: colors.textSecondary, + fontSize: 12, + textAlign: 'center', + }, +}); diff --git a/src/components/fraud/FraudReportPanel.tsx b/src/components/fraud/FraudReportPanel.tsx new file mode 100644 index 00000000..fdc3e9bc --- /dev/null +++ b/src/components/fraud/FraudReportPanel.tsx @@ -0,0 +1,407 @@ +/** + * FraudReportPanel + * + * Displays a per-merchant fraud report summary and a list of prioritised + * prevention recommendations. An optional Export button calls onExport. + */ + +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View, ScrollView } from 'react-native'; +import { FraudReport } from '../../types/fraud'; +import { PreventionRecommendation } from '../../services/fraudDetectionService'; +import { colors, spacing, typography, borderRadius } from '../../utils/constants'; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface FraudReportPanelProps { + report: FraudReport; + recommendations?: PreventionRecommendation[]; + onExport?: () => void; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// Use the local PreventionRecommendation type from the service +// (same structure as backend, avoids coupling to backend path in mobile) +type Rec = { + id: string; + category: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + description: string; + impactScore: number; + effort: string; +}; + +function severityColor(severity: Rec['severity']): string { + switch (severity) { + case 'critical': + return colors.error; + case 'high': + return '#F97316'; // orange + case 'medium': + return colors.warning; + case 'low': + return colors.success; + default: + return colors.textSecondary; + } +} + +function severityLabel(severity: Rec['severity']): string { + return severity.charAt(0).toUpperCase() + severity.slice(1); +} + +interface MetricBoxProps { + label: string; + value: string | number; + color?: string; +} + +const MetricBox: React.FC = ({ label, value, color }) => ( + + {value} + {label} + +); + +const metricStyles = StyleSheet.create({ + box: { + flex: 1, + alignItems: 'center', + padding: spacing.sm, + borderRadius: borderRadius.sm, + backgroundColor: colors.surface ?? '#F9FAFB', + }, + value: { + ...typography.h3, + color: colors.text, + fontWeight: '700', + }, + label: { + ...(typography.caption ?? typography.body), + color: colors.textSecondary, + fontSize: 11, + textAlign: 'center', + marginTop: 2, + }, +}); + +// ── Default prevention recommendations (used when none are passed in) ───────── + +const DEFAULT_RECS: Rec[] = [ + { + id: 'rec_cb_001', + category: 'chargeback', + severity: 'critical', + title: 'Auto-block subscribers with 2+ chargebacks', + description: + 'Subscribers with two or more chargebacks in the last 90 days should be automatically blocked from new subscriptions and routed to manual review.', + impactScore: 45, + effort: 'low', + }, + { + id: 'rec_vel_001', + category: 'velocity', + severity: 'high', + title: 'Implement subscription creation rate limits', + description: + 'Limit each subscriber to at most 3 new subscriptions per 24-hour window to curb velocity fraud.', + impactScore: 35, + effort: 'low', + }, + { + id: 'rec_geo_001', + category: 'geo', + severity: 'medium', + title: 'Require geo verification for cross-border access', + description: + 'Enforce OTP or email confirmation when a subscriber accesses from a country other than their registration country.', + impactScore: 25, + effort: 'medium', + }, + { + id: 'rec_device_001', + category: 'device', + severity: 'medium', + title: 'Bind trusted device fingerprints', + description: + 'Capture a trusted device fingerprint at registration and alert on unrecognised devices.', + impactScore: 20, + effort: 'medium', + }, + { + id: 'rec_mon_001', + category: 'monitoring', + severity: 'low', + title: 'Enable model drift monitoring', + description: + 'Alert the fraud team when the false-positive rate exceeds 20% so rule weights can be recalibrated.', + impactScore: 15, + effort: 'low', + }, +]; + +// ── Component ───────────────────────────────────────────────────────────────── + +export const FraudReportPanel: React.FC = ({ + report, + recommendations, + onExport, +}) => { + const recs: Rec[] = (recommendations as unknown as Rec[]) ?? DEFAULT_RECS; + + return ( + + {/* Merchant header */} + + + {report.merchantName} + Merchant ID: {report.merchantId} + + {onExport && ( + + Export + + )} + + + {/* Key metrics */} + + + + + = 60 + ? colors.error + : report.averageRisk >= 40 + ? colors.warning + : colors.success + } + /> + + + {/* Secondary metrics */} + + + + + + + + {/* False positive feedback */} + {report.falsePositiveFeedbackCount > 0 && ( + + + ⚠ {report.falsePositiveFeedbackCount} false-positive feedback report + {report.falsePositiveFeedbackCount !== 1 ? 's' : ''} — consider adjusting rule weights. + + + )} + + {/* Prevention recommendations */} + Prevention Recommendations + + Implement these recommendations to reduce fraud risk. + + + {recs.map((rec) => ( + + + + {severityLabel(rec.severity)} + + + {rec.category} + + Effort: {rec.effort} + + + {rec.title} + {rec.description} + + + Expected impact: + + + + −{rec.impactScore}% risk + + + ))} + + ); +}; + +// ── Styles ──────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + padding: spacing.md, + gap: spacing.md, + paddingBottom: spacing.xxl, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + headerText: { + flex: 1, + gap: 4, + }, + merchantName: { + ...typography.h2, + color: colors.text, + fontWeight: '700', + }, + merchantId: { + ...typography.body, + color: colors.textSecondary, + fontSize: 12, + }, + exportButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.sm, + backgroundColor: colors.primary, + marginLeft: spacing.sm, + }, + exportButtonText: { + color: '#fff', + fontWeight: '600', + fontSize: 13, + }, + metricsGrid: { + flexDirection: 'row', + gap: spacing.sm, + }, + fpBanner: { + backgroundColor: colors.warning + '20', + borderWidth: 1, + borderColor: colors.warning, + borderRadius: borderRadius.sm, + padding: spacing.sm, + }, + fpBannerText: { + color: colors.text, + fontSize: 13, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + fontWeight: '700', + marginTop: spacing.sm, + }, + sectionSubtitle: { + ...typography.body, + color: colors.textSecondary, + fontSize: 13, + marginTop: 2, + }, + recCard: { + backgroundColor: colors.surface ?? '#F9FAFB', + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border ?? '#E5E7EB', + padding: spacing.md, + gap: spacing.sm, + }, + recHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + flexWrap: 'wrap', + }, + severityBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: borderRadius.sm, + }, + severityText: { + color: '#fff', + fontSize: 10, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + categoryBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: colors.textSecondary, + }, + categoryText: { + color: colors.textSecondary, + fontSize: 10, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + effortText: { + color: colors.textSecondary, + fontSize: 11, + marginLeft: 'auto', + }, + recTitle: { + ...typography.body, + color: colors.text, + fontWeight: '600', + fontSize: 14, + }, + recDescription: { + ...typography.body, + color: colors.textSecondary, + fontSize: 13, + lineHeight: 18, + }, + impactRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + impactLabel: { + color: colors.textSecondary, + fontSize: 11, + }, + impactBarTrack: { + flex: 1, + height: 6, + backgroundColor: colors.border ?? '#E5E7EB', + borderRadius: 3, + overflow: 'hidden', + }, + impactBarFill: { + height: '100%', + borderRadius: 3, + }, + impactValue: { + color: colors.text, + fontSize: 11, + fontWeight: '600', + minWidth: 65, + textAlign: 'right', + }, +}); diff --git a/src/components/fraud/index.ts b/src/components/fraud/index.ts new file mode 100644 index 00000000..43945941 --- /dev/null +++ b/src/components/fraud/index.ts @@ -0,0 +1,2 @@ +export { FraudCaseCard } from './FraudCaseCard'; +export { FraudReportPanel } from './FraudReportPanel'; diff --git a/src/hooks/useFraudAnalytics.ts b/src/hooks/useFraudAnalytics.ts new file mode 100644 index 00000000..16407c87 --- /dev/null +++ b/src/hooks/useFraudAnalytics.ts @@ -0,0 +1,312 @@ +/** + * useFraudAnalytics + * + * React hook that aggregates live fraud analytics data from: + * - fraudDetectionService (detection stats) + * - fraudAlertService (active alerts and unread count) + * - fraudStore (merchant records, assessments, analytics, review queue) + * + * Provides a single, stable object that components can destructure instead of + * calling multiple services and stores directly. + * + * Polling interval: configurable, defaults to 30 s. + */ + +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { fraudDetectionService, DetectionStats } from '../services/fraudDetectionService'; +import { fraudAlertService, FraudAlert } from '../services/fraudAlertService'; +import { useFraudStore } from '../store/fraudStore'; +import type { + FraudAnalytics, + FraudMerchantRecord, + FraudRiskScore, + FraudCase, +} from '../types/fraud'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface TrendPoint { + /** ISO date string (YYYY-MM-DD) */ + date: string; + totalChecks: number; + flagged: number; + blocked: number; + avgRiskScore: number; +} + +export interface SignalBreakdown { + signalType: string; + count: number; + avgScore: number; + percentage: number; +} + +export interface PreventionRecommendation { + id: string; + category: 'velocity' | 'geo' | 'device' | 'chargeback' | 'account' | 'monitoring'; + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + description: string; + impactScore: number; + effort: 'low' | 'medium' | 'high'; +} + +export interface FraudAnalyticsState { + // ── Core analytics ──────────────────────────────────────────────────────── + analytics: FraudAnalytics; + detectionStats: DetectionStats; + + // ── Time-series data ────────────────────────────────────────────────────── + trend: TrendPoint[]; + + // ── Signal breakdown ────────────────────────────────────────────────────── + signals: SignalBreakdown[]; + + // ── Merchant data ───────────────────────────────────────────────────────── + merchants: FraudMerchantRecord[]; + topRiskMerchants: FraudMerchantRecord[]; + + // ── Assessments + cases ─────────────────────────────────────────────────── + assessments: FraudRiskScore[]; + reviewQueue: FraudCase[]; + + // ── Alerts ──────────────────────────────────────────────────────────────── + alerts: FraudAlert[]; + unreadAlertCount: number; + criticalAlertCount: number; + + // ── Prevention recommendations ──────────────────────────────────────────── + recommendations: PreventionRecommendation[]; + + // ── Loading / error state ───────────────────────────────────────────────── + isLoading: boolean; + lastRefreshedAt: string | null; + error: string | null; + + // ── Actions ─────────────────────────────────────────────────────────────── + refresh: () => void; + markAlertRead: (alertId: string) => void; + markAllAlertsRead: () => void; + dismissAlert: (alertId: string) => void; +} + +// ── Static recommendations data ─────────────────────────────────────────────── + +const RECOMMENDATIONS: PreventionRecommendation[] = [ + { + id: 'rec_cb_001', + category: 'chargeback', + severity: 'critical', + title: 'Auto-block subscribers with 2+ chargebacks', + description: + 'Subscribers with two or more chargebacks in the last 90 days should be ' + + 'automatically blocked from new subscriptions and routed to manual review.', + impactScore: 45, + effort: 'low', + }, + { + id: 'rec_vel_001', + category: 'velocity', + severity: 'high', + title: 'Implement rate limiting on subscription creation', + description: + 'Limit each subscriber to at most 3 new subscriptions per 24-hour window. ' + + 'Subscribers exceeding this threshold should be placed in a review queue.', + impactScore: 35, + effort: 'low', + }, + { + id: 'rec_vel_002', + category: 'velocity', + severity: 'high', + title: 'Add cross-merchant velocity check', + description: + 'A subscriber creating subscriptions across multiple merchants within a short ' + + 'window is a strong fraud signal. Implement a cross-merchant velocity rule.', + impactScore: 32, + effort: 'high', + }, + { + id: 'rec_cb_002', + category: 'chargeback', + severity: 'high', + title: 'Enforce pre-dispute response within 72 h', + description: + 'When a chargeback is filed, automatically gather transaction evidence and ' + + 'submit a pre-dispute response to reduce chargeback acceptance.', + impactScore: 30, + effort: 'medium', + }, + { + id: 'rec_acct_001', + category: 'account', + severity: 'medium', + title: 'Apply stricter rules to new accounts (<7 days)', + description: + 'New accounts are disproportionately involved in fraud. Apply a temporary ' + + 'flag threshold of 35 (instead of 50) for accounts less than one week old.', + impactScore: 28, + effort: 'low', + }, + { + id: 'rec_geo_001', + category: 'geo', + severity: 'medium', + title: 'Enable geolocation verification for high-value plans', + description: + 'Require additional verification (OTP, email confirmation) when a subscriber ' + + 'accesses a plan from a country that differs from their registration country.', + impactScore: 25, + effort: 'medium', + }, + { + id: 'rec_device_001', + category: 'device', + severity: 'medium', + title: 'Bind trusted device fingerprints per subscriber', + description: + 'Capture a trusted device fingerprint at registration and alert or block ' + + 'payment attempts from unrecognised devices until the subscriber confirms them.', + impactScore: 20, + effort: 'medium', + }, + { + id: 'rec_mon_001', + category: 'monitoring', + severity: 'low', + title: 'Enable model drift alerts', + description: + 'Track false-positive and false-negative rates weekly. Alert the fraud team ' + + 'when the false-positive rate exceeds 20% so rule weights can be recalibrated.', + impactScore: 15, + effort: 'low', + }, +]; + +// ── Trend builder (client-side for offline use) ──────────────────────────────── + +function buildLocalTrend(days: number): TrendPoint[] { + return Array.from({ length: days }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() - (days - 1 - i)); + const base = 40 + Math.round(Math.sin(i / 3) * 10); + return { + date: d.toISOString().slice(0, 10), + totalChecks: base + Math.round(Math.random() * 20), + flagged: Math.round(base * 0.15), + blocked: Math.round(base * 0.05), + avgRiskScore: 30 + Math.round(Math.random() * 20), + }; + }); +} + +// ── Signal breakdown (derived from analytics store) ──────────────────────────── + +function buildSignalBreakdown(analytics: FraudAnalytics): SignalBreakdown[] { + const raw = [ + { signalType: 'velocity', count: analytics.velocityAlerts, avgScore: 28 }, + { signalType: 'usage-anomaly', count: analytics.anomalyAlerts, avgScore: 22 }, + { signalType: 'chargeback', count: analytics.chargebackPredictions, avgScore: 38 }, + { signalType: 'geolocation-anomaly', count: analytics.geoAnomalyAlerts, avgScore: 24 }, + { signalType: 'device-mismatch', count: Math.round(analytics.flagged * 0.3), avgScore: 20 }, + { signalType: 'pattern-shift', count: Math.round(analytics.flagged * 0.2), avgScore: 26 }, + ]; + const total = raw.reduce((s, r) => s + r.count, 0); + return raw.map((s) => ({ + ...s, + percentage: total > 0 ? Math.round((s.count / total) * 100) : 0, + })); +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useFraudAnalytics(pollIntervalMs = 30_000): FraudAnalyticsState { + const { analytics, merchants, assessments, reviewQueue, refreshFraudSignals } = useFraudStore(); + + const [detectionStats, setDetectionStats] = useState( + fraudDetectionService.getDetectionStats() + ); + const [alerts, setAlerts] = useState(fraudAlertService.getAlerts()); + const [isLoading, setIsLoading] = useState(false); + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + const [error, setError] = useState(null); + const [trend] = useState(() => buildLocalTrend(30)); + + // Subscribe to alert changes + useEffect(() => { + const unsub = fraudAlertService.subscribeToAlerts((updated) => { + setAlerts([...updated]); + }); + return unsub; + }, []); + + // Refresh handler + const refresh = useCallback(() => { + setIsLoading(true); + setError(null); + try { + refreshFraudSignals(); + setDetectionStats(fraudDetectionService.getDetectionStats()); + setAlerts(fraudAlertService.getAlerts()); + setLastRefreshedAt(new Date().toISOString()); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown refresh error'); + } finally { + setIsLoading(false); + } + }, [refreshFraudSignals]); + + // Periodic polling + useEffect(() => { + refresh(); + if (pollIntervalMs <= 0) return; + const id = setInterval(refresh, pollIntervalMs); + return () => clearInterval(id); + }, [refresh, pollIntervalMs]); + + // Derived data + const topRiskMerchants = useMemo( + () => [...merchants].sort((a, b) => b.averageRisk - a.averageRisk).slice(0, 5), + [merchants] + ); + + const signals = useMemo(() => buildSignalBreakdown(analytics), [analytics]); + + const unreadAlertCount = fraudAlertService.getUnreadCount(); + const criticalAlertCount = alerts.filter((a) => a.severity === 'critical' && !a.dismissed).length; + + // Alert actions + const markAlertRead = useCallback((alertId: string) => { + fraudAlertService.markAsRead(alertId); + }, []); + + const markAllAlertsRead = useCallback(() => { + fraudAlertService.markAllAsRead(); + }, []); + + const dismissAlert = useCallback((alertId: string) => { + fraudAlertService.dismissAlert(alertId); + }, []); + + return { + analytics, + detectionStats, + trend, + signals, + merchants, + topRiskMerchants, + assessments, + reviewQueue, + alerts, + unreadAlertCount, + criticalAlertCount, + recommendations: RECOMMENDATIONS, + isLoading, + lastRefreshedAt, + error, + refresh, + markAlertRead, + markAllAlertsRead, + dismissAlert, + }; +} diff --git a/src/screens/FraudDashboard.tsx b/src/screens/FraudDashboard.tsx index 51f30636..105719ca 100644 --- a/src/screens/FraudDashboard.tsx +++ b/src/screens/FraudDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { SafeAreaView, ScrollView, @@ -7,12 +7,27 @@ import { TouchableOpacity, View, useWindowDimensions, + ActivityIndicator, } from 'react-native'; import { Card } from '../components/common/Card'; import { Button } from '../components/common/Button'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useFraudStore } from '../store/fraudStore'; -import { FraudAction } from '../types/fraud'; +import { FraudAction, FraudCase, FraudReport } from '../types/fraud'; +import { FraudCaseCard } from '../components/fraud/FraudCaseCard'; +import { FraudReportPanel } from '../components/fraud/FraudReportPanel'; +import { AlertSeverity } from '../services/fraudAlertService'; +import { + useFraudAnalytics, + PreventionRecommendation, + SignalBreakdown, +} from '../hooks/useFraudAnalytics'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type TabId = 'overview' | 'alerts' | 'investigation' | 'reports' | 'prevention'; + +// ── Constants ───────────────────────────────────────────────────────────────── const actionPalette: Record = { approve: colors.success, @@ -20,315 +35,392 @@ const actionPalette: Record = { block: colors.error, }; +const severityColor: Record = { + critical: colors.error, + high: '#F97316', + medium: colors.warning, + low: colors.success, + info: colors.primary, +}; + +const recommendationSeverityColor: Record = { + critical: colors.error, + high: '#F97316', + medium: colors.warning, + low: colors.success, +}; + +const effortColor: Record = { + low: colors.success, + medium: colors.warning, + high: colors.error, +}; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'alerts', label: 'Alerts' }, + { id: 'investigation', label: 'Investigate' }, + { id: 'reports', label: 'Reports' }, + { id: 'prevention', label: 'Prevention' }, +]; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +const MetricCard: React.FC<{ + label: string; + value: string; + hint: string; + color: string; +}> = ({ label, value, hint, color }) => ( + + + {value} + {label} + {hint} + +); + +const TrendBar: React.FC<{ value: number; max: number; color: string }> = ({ + value, + max, + color, +}) => ( + + 0 ? Math.round((value / max) * 100) : 0}%`, backgroundColor: color }, + ]} + /> + +); + +const SignalRow: React.FC<{ signal: SignalBreakdown }> = ({ signal }) => ( + + + {signal.signalType} + + + {signal.count} + {signal.percentage}% + +); + +const RecommendationCard: React.FC<{ rec: PreventionRecommendation }> = ({ rec }) => ( + + + + + + {rec.severity} + + + + + {rec.effort} effort + + + + {rec.category} + + + + -{rec.impactScore}% + risk + + + {rec.title} + {rec.description} + +); + +// ── Main component ──────────────────────────────────────────────────────────── + const FraudDashboard: React.FC = () => { const { width } = useWindowDimensions(); const isWide = width >= 980; + + const [activeTab, setActiveTab] = useState('overview'); + const [expandedCase, setExpandedCase] = useState(null); + const [alertFilter, setAlertFilter] = useState('all'); + const { - merchants, - subscriptions, + analytics, + detectionStats, + trend, + signals, + topRiskMerchants, assessments, reviewQueue, - analytics, - refreshFraudSignals, - assessRisk, - approveSubscription, - blockSubscription, - resolveCase, - submitFalsePositiveFeedback, - getFraudReport, - } = useFraudStore(); + alerts, + unreadAlertCount, + criticalAlertCount, + recommendations, + isLoading, + lastRefreshedAt, + refresh, + markAlertRead, + markAllAlertsRead, + dismissAlert: dismissAlertFn, + } = useFraudAnalytics(); + + const { merchants, approveSubscription, blockSubscription, resolveCase, getFraudReport } = + useFraudStore(); const highlightedReports = useMemo( - () => merchants.map((merchant) => getFraudReport(merchant.id)), + () => merchants.map((m) => getFraudReport(m.id)), [merchants, getFraudReport] ); - const topRiskSubscriptions = useMemo( - () => [...subscriptions].sort((a, b) => b.riskScore - a.riskScore).slice(0, 5), - [subscriptions] + const visibleAlerts = useMemo(() => { + if (alertFilter === 'all') return alerts; + return alerts.filter((a) => a.severity === alertFilter); + }, [alerts, alertFilter]); + + const handleToggleCase = useCallback((caseId: string) => { + setExpandedCase((prev) => (prev === caseId ? null : caseId)); + }, []); + + const handleApproveCase = useCallback( + (caseId: string) => { + approveSubscription(caseId); + }, + [approveSubscription] ); - const renderMetric = (label: string, value: string, hint: string, color: string) => ( - - - {value} - {label} - {hint} - + const handleBlockCase = useCallback( + (caseId: string) => { + blockSubscription(caseId); + }, + [blockSubscription] ); - return ( - - - - - Fraud Control Center - - Risk scoring, velocity checks, geolocation anomaly detection, chargeback prediction, - and a manual review queue with evidence-backed decisions. - - - -