diff --git a/backend/src/index.ts b/backend/src/index.ts index 7195a29e..ddd5fef8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -169,6 +169,8 @@ import { recurringBillingRouter } from './routes/recurring-billing.js'; import { splitPaymentsRouter } from './routes/split-payments.js'; import { transfersRouter } from './routes/transfers.js'; import { threeDSRouter } from './routes/3ds.js'; +import { chargebacksRouter } from './routes/chargebacks.js'; +import { merchantRiskRouter } from './routes/merchant-risk.js'; // Validate environment variables at startup validateEnv(); @@ -400,6 +402,10 @@ apiV1Router.use('/split-payments', splitPaymentsRouter); apiV1Router.use('/transfers', transfersRouter); // 3D Secure card-payment authentication — Issue #925 apiV1Router.use('/3ds', threeDSRouter); +// Merchant onboarding risk tiering — Issue #928 +apiV1Router.use('/merchant-risk', merchantRiskRouter); +// Chargeback management with evidence submission — Issue #926 +apiV1Router.use('/chargebacks', chargebacksRouter); apiV1Router.get('/compression/metrics', (_req, res) => { res.json(getCompressionMetrics()); }); diff --git a/backend/src/middleware/__tests__/queryLogger.test.ts b/backend/src/middleware/__tests__/queryLogger.test.ts new file mode 100644 index 00000000..d6d28306 --- /dev/null +++ b/backend/src/middleware/__tests__/queryLogger.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +const onSlowQueryMock = vi.fn(); +const queryProfilerMock = { + getStats: vi.fn().mockReturnValue({ total: 0, slow: 0 }), + getTopSlowQueries: vi.fn().mockReturnValue([]), + reset: vi.fn(), +}; + +const metricsMock = { + increment: vi.fn(), + distribution: vi.fn(), +}; + +vi.mock('@sentry/node', () => ({ + captureEvent: vi.fn(), +})); + +vi.mock('../../config/database.js', () => ({ + SLOW_QUERY_THRESHOLD_MS: 100, + VERY_SLOW_QUERY_THRESHOLD_MS: 2000, + queryProfiler: queryProfilerMock, + withQueryProfiling: vi.fn(), + onSlowQuery: onSlowQueryMock, + withQueryTimer: vi.fn(), +})); + +vi.mock('../../observability/datadog.js', () => ({ + default: metricsMock, +})); + +const { + configureQueryLogger, + getQueryMetrics, + resetQueryMetrics, + queryLoggerMiddleware, + createPrismaQueryListener, + getSlowQueryDashboard, +} = await import('../../middleware/queryLogger.js'); + +function makeRes() { + const headers: Record = {}; + const res: any = { + getHeader: (name: string) => headers[name], + setHeader: (name: string, value: unknown) => { + headers[name] = value; + }, + send: (body: unknown) => body, + }; + return { res, headers }; +} + +describe('queryLogger', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetQueryMetrics(); + configureQueryLogger({ + logAllQueries: false, + slowThresholdMs: 100, + criticalThresholdMs: 2000, + alertCooldownMs: 300_000, + emitMetrics: true, + }); + }); + + it('starts with empty metrics', () => { + const metrics = getQueryMetrics(); + expect(metrics.totalQueries).toBe(0); + expect(metrics.avgDurationMs).toBe(0); + expect(metrics.slowPercentage).toBe(0); + }); + + it('records a slow query through the express middleware', () => { + const { res, headers } = makeRes(); + headers['x-query-duration-ms'] = 250; + const next = vi.fn(); + + const middleware = queryLoggerMiddleware('test-source'); + middleware({ method: 'GET', path: '/api/v1/things' } as any, res, next); + + res.send({ ok: true }); + + const metrics = getQueryMetrics(); + expect(metrics.totalQueries).toBe(1); + expect(metrics.slowQueries).toBe(1); + expect(metrics.avgDurationMs).toBe(250); + expect(next).toHaveBeenCalled(); + }); + + it('classifies a critical query and computes slow percentage', () => { + const middleware = queryLoggerMiddleware('test-source'); + + const first = makeRes(); + first.headers['x-query-duration-ms'] = 2500; + middleware({ method: 'POST', path: '/api/v1/payments' } as any, first.res, vi.fn()); + first.res.send({}); + + const second = makeRes(); + second.headers['x-query-duration-ms'] = 10; + middleware({ method: 'GET', path: '/api/v1/fast' } as any, second.res, vi.fn()); + second.res.send({}); + + const metrics = getQueryMetrics(); + expect(metrics.criticalQueries).toBe(1); + expect(metrics.totalQueries).toBe(1); + expect(metrics.totalDurationMs).toBe(2500); + }); + + it('ignores responses without a duration header', () => { + const { res } = makeRes(); + const middleware = queryLoggerMiddleware('test-source'); + middleware({ method: 'GET', path: '/health' } as any, res, vi.fn()); + res.send({}); + expect(getQueryMetrics().totalQueries).toBe(0); + }); + + it('registers a slow query handler at import time', () => { + expect(onSlowQueryMock).toHaveBeenCalledTimes(1); + expect(typeof onSlowQueryMock.mock.calls[0][0]).toBe('function'); + }); + + it('processes prisma query events and emits metrics', () => { + const listener = createPrismaQueryListener(); + listener({ + timestamp: new Date(), + query: 'SELECT * FROM payments WHERE id = $1', + params: '["1"]', + duration: 150, + target: 'primary', + }); + + expect(getQueryMetrics().slowQueries).toBe(1); + expect(metricsMock.distribution).toHaveBeenCalledWith( + 'db.query.duration', + 150, + { target: 'primary' }, + ); + }); + + it('tracks critical prisma queries separately', () => { + const listener = createPrismaQueryListener(); + listener({ + timestamp: new Date(), + query: 'SELECT * FROM audit_logs', + params: '[]', + duration: 5000, + target: 'primary', + }); + expect(getQueryMetrics().criticalQueries).toBe(1); + }); + + it('returns a slow query dashboard combining profiler and middleware data', () => { + queryProfilerMock.getStats.mockReturnValue({ total: 3, slow: 1 }); + queryProfilerMock.getTopSlowQueries.mockReturnValue([ + { query: 'SELECT 1', durationMs: 500 }, + ]); + const dashboard = getSlowQueryDashboard(); + expect(dashboard.profiler).toEqual({ total: 3, slow: 1 }); + expect(dashboard.slowThresholdMs).toBe(100); + expect(dashboard.recentSlow[0].signature).toBeDefined(); + }); +}); diff --git a/backend/src/routes/chargebacks.ts b/backend/src/routes/chargebacks.ts new file mode 100644 index 00000000..a35fc253 --- /dev/null +++ b/backend/src/routes/chargebacks.ts @@ -0,0 +1,223 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { + createChargeback, + getChargeback, + listChargebacks, + updateChargebackStatus, + submitEvidence, + removeEvidence, + addNote, + summarizeChargebacks, + ChargebackStatus, +} from '../services/chargebacks.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validateRequest } from '../middleware/validate.js'; + +export const chargebacksRouter = Router(); + +const reasonSchema = z.enum([ + 'fraudulent', + 'unrecognized', + 'product_not_received', + 'product_unacceptable', + 'duplicate_charge', + 'subscription_canceled', + 'credit_not_processed', +]); + +const statusSchema = z.enum([ + 'received', + 'under_review', + 'evidence_required', + 'evidence_submitted', + 'representment', + 'won', + 'lost', + 'accepted', +]); + +const evidenceTypeSchema = z.enum([ + 'receipt', + 'tracking_number', + 'customer_communication', + 'refund_policy', + 'delivery_proof', + 'signature', + 'avs_cvv_match', + 'ip_location', + 'other', +]); + +const idParamsSchema = z.object({ id: z.string().min(1) }); + +const createChargebackSchema = z.object({ + merchantId: z.string().min(1), + paymentId: z.string().min(1), + customerId: z.string().optional(), + amount: z.number().positive(), + currency: z.string().min(1), + reason: reasonSchema, + evidenceWindowDays: z.number().int().positive().max(120).optional(), +}); + +const listQuerySchema = z.object({ + merchantId: z.string().optional(), + status: statusSchema.optional(), +}); + +const statusUpdateSchema = z.object({ + status: statusSchema, + actor: z.string().min(1), +}); + +const evidenceSchema = z.object({ + type: evidenceTypeSchema, + description: z.string().min(1), + fileUrl: z.string().url().optional(), + submittedBy: z.string().min(1), +}); + +const noteSchema = z.object({ + note: z.string().min(1), + actor: z.string().min(1), +}); + +function notFound(id: string): AppError { + return new AppError(404, `Chargeback not found: ${id}`, 'CHARGEBACK_NOT_FOUND'); +} + +/** + * @route POST /api/v1/chargebacks + * @desc Register a new chargeback case + */ +chargebacksRouter.post( + '/', + validateRequest({ body: createChargebackSchema }), + asyncHandler(async (req: Request, res: Response) => { + const chargeback = createChargeback(req.body); + res.status(201).json({ success: true, data: chargeback }); + }), +); + +/** + * @route GET /api/v1/chargebacks/summary + * @desc Aggregate chargeback metrics, optionally scoped to a merchant + */ +chargebacksRouter.get('/summary', (req: Request, res: Response) => { + const merchantId = typeof req.query.merchantId === 'string' ? req.query.merchantId : undefined; + res.json({ success: true, data: summarizeChargebacks(merchantId) }); +}); + +/** + * @route GET /api/v1/chargebacks + * @desc List chargebacks, optionally filtered by merchant or status + */ +chargebacksRouter.get( + '/', + validateRequest({ query: listQuerySchema }), + (req: Request, res: Response) => { + const data = listChargebacks({ + merchantId: req.query.merchantId as string | undefined, + status: req.query.status as ChargebackStatus | undefined, + }); + res.json({ success: true, data, count: data.length }); + }, +); + +/** + * @route GET /api/v1/chargebacks/:id + * @desc Retrieve a single chargeback case with evidence and timeline + */ +chargebacksRouter.get( + '/:id', + validateRequest({ params: idParamsSchema }), + (req: Request, res: Response) => { + const chargeback = getChargeback(req.params.id); + if (!chargeback) throw notFound(req.params.id); + res.json({ success: true, data: chargeback }); + }, +); + +/** + * @route PATCH /api/v1/chargebacks/:id/status + * @desc Advance a chargeback through its lifecycle + */ +chargebacksRouter.patch( + '/:id/status', + validateRequest({ params: idParamsSchema, body: statusUpdateSchema }), + (req: Request, res: Response) => { + try { + const chargeback = updateChargebackStatus(req.params.id, req.body.status, req.body.actor); + res.json({ success: true, data: chargeback }); + } catch (err) { + if (err instanceof Error && err.message === 'Chargeback not found') { + throw notFound(req.params.id); + } + throw new AppError(400, (err as Error).message, 'INVALID_STATUS_TRANSITION'); + } + }, +); + +/** + * @route POST /api/v1/chargebacks/:id/evidence + * @desc Submit supporting evidence for a chargeback + */ +chargebacksRouter.post( + '/:id/evidence', + validateRequest({ params: idParamsSchema, body: evidenceSchema }), + (req: Request, res: Response) => { + try { + const chargeback = submitEvidence(req.params.id, req.body); + res.status(201).json({ success: true, data: chargeback }); + } catch (err) { + if (err instanceof Error && err.message === 'Chargeback not found') { + throw notFound(req.params.id); + } + throw new AppError(400, (err as Error).message, 'EVIDENCE_SUBMISSION_FAILED'); + } + }, +); + +/** + * @route DELETE /api/v1/chargebacks/:id/evidence/:evidenceId + * @desc Remove a previously submitted evidence item + */ +chargebacksRouter.delete( + '/:id/evidence/:evidenceId', + validateRequest({ params: idParamsSchema.extend({ evidenceId: z.string().min(1) }) }), + (req: Request, res: Response) => { + try { + const chargeback = removeEvidence( + req.params.id, + req.params.evidenceId, + (req.query.actor as string) || 'system', + ); + res.json({ success: true, data: chargeback }); + } catch (err) { + const message = (err as Error).message; + if (message === 'Chargeback not found') throw notFound(req.params.id); + throw new AppError(404, message, 'EVIDENCE_NOT_FOUND'); + } + }, +); + +/** + * @route POST /api/v1/chargebacks/:id/notes + * @desc Attach an internal note to a chargeback case + */ +chargebacksRouter.post( + '/:id/notes', + validateRequest({ params: idParamsSchema, body: noteSchema }), + (req: Request, res: Response) => { + try { + const chargeback = addNote(req.params.id, req.body.note, req.body.actor); + res.status(201).json({ success: true, data: chargeback }); + } catch (err) { + if (err instanceof Error && err.message === 'Chargeback not found') { + throw notFound(req.params.id); + } + throw err; + } + }, +); diff --git a/backend/src/routes/database.ts b/backend/src/routes/database.ts index 79248436..985e438a 100644 --- a/backend/src/routes/database.ts +++ b/backend/src/routes/database.ts @@ -11,6 +11,7 @@ * GET /api/v1/database/query-plans — EXPLAIN ANALYZE for hot queries * GET /api/v1/database/alerts — Database performance alerts * GET /api/v1/database/table-scans — pg_stat_user_tables snapshot + * POST /api/v1/database/analyse-queries — Index recommendations from observed SQL * * — Read-replica routing (Issue #881) —————————————————————————————————————— * GET /api/v1/database/replicas — List replica health + routing stats @@ -28,9 +29,31 @@ import { } from '../config/database.js'; import { getSlowQueryDashboard, resetQueryMetrics } from '../middleware/queryLogger.js'; import { poolMetrics, connectionLeaseManager, poolExhaustionManager } from '../config/database.js'; +import { z } from 'zod'; +import { + analyseQuery, + recommendIndexes, + detectFullTableScans, + consolidateByTable, + type QueryStats, +} from '../services/query-optimization.js'; +import { validateRequest } from '../middleware/validate.js'; const router = Router(); +const analyseQuerySchema = z.object({ + queries: z + .array( + z.object({ + sql: z.string().min(1), + executions: z.number().int().nonnegative().optional(), + avgDurationMs: z.number().nonnegative().optional(), + rowsExamined: z.number().nonnegative().optional(), + }), + ) + .min(1, 'At least one query is required'), +}); + router.get('/stats', async (_req: Request, res: Response) => { const dashboard = getSlowQueryDashboard(); const poolMetricsSnapshot = poolMetrics.snapshot(); @@ -97,6 +120,38 @@ router.post('/metrics/reset', async (_req: Request, res: Response) => { res.json({ data: { message: 'Query metrics reset' } }); }); +/** + * POST /database/analyse-queries + * + * Analyses a batch of observed SQL statements and returns index + * recommendations plus any statements flagged as likely full-table scans. + * Backs the index-analysis workflow for Issue #739. + */ +router.post( + '/analyse-queries', + validateRequest({ body: analyseQuerySchema }), + (req: Request, res: Response) => { + const stats = (req.body.queries as Array<{ + sql: string; + executions?: number; + avgDurationMs?: number; + rowsExamined?: number; + }>) + .map((entry) => analyseQuery(entry.sql, entry)) + .filter((s): s is QueryStats => s !== null); + + const recommendations = recommendIndexes(stats); + res.json({ + data: { + analysed: stats.length, + recommendations, + byTable: consolidateByTable(recommendations), + fullTableScans: detectFullTableScans(stats), + }, + }); + }, +); + // ── Read-replica routing endpoints — Issue #881 ─────────────────────────────── /** diff --git a/backend/src/routes/merchant-risk.ts b/backend/src/routes/merchant-risk.ts new file mode 100644 index 00000000..8276f071 --- /dev/null +++ b/backend/src/routes/merchant-risk.ts @@ -0,0 +1,147 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { + assessMerchant, + reassessMerchant, + getAssessment, + listAssessments, + getPolicyForTier, + evaluateTransactionLimit, + RiskTier, +} from '../services/merchant-risk-tiering.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validateRequest } from '../middleware/validate.js'; + +export const merchantRiskRouter = Router(); + +const riskSignalsSchema = z.object({ + businessAgeMonths: z.number().nonnegative(), + monthlyVolumeUsd: z.number().nonnegative(), + chargebacks90d: z.number().nonnegative(), + refundRatePct: z.number().min(0).max(100), + sanctionsHits: z.number().nonnegative(), + kycVerified: z.boolean(), + kybVerified: z.boolean(), + jurisdictionRisk: z.number().min(0).max(1).optional(), +}); + +const merchantParamsSchema = z.object({ + merchantId: z.string().min(1), +}); + +const listQuerySchema = z.object({ + tier: z.enum(['low', 'medium', 'high', 'critical']).optional(), +}); + +const tierParamsSchema = z.object({ + tier: z.enum(['low', 'medium', 'high', 'critical']), +}); + +const limitCheckSchema = z.object({ + amountUsd: z.number().positive(), +}); + +const assessValidation = validateRequest({ + params: merchantParamsSchema, + body: riskSignalsSchema, +}); + +const merchantValidation = validateRequest({ params: merchantParamsSchema }); + +/** + * @route POST /api/v1/merchant-risk/:merchantId/assess + * @desc Run (or re-run) a risk assessment and assign a risk tier + */ +merchantRiskRouter.post( + '/:merchantId/assess', + assessValidation, + asyncHandler(async (req: Request, res: Response) => { + const { merchantId } = req.params; + const assessment = assessMerchant(merchantId, req.body); + res.status(201).json({ success: true, data: assessment }); + }), +); + +/** + * @route POST /api/v1/merchant-risk/:merchantId/reassess + * @desc Periodic re-scoring of an existing merchant + */ +merchantRiskRouter.post( + '/:merchantId/reassess', + assessValidation, + asyncHandler(async (req: Request, res: Response) => { + const { merchantId } = req.params; + const assessment = reassessMerchant(merchantId, req.body); + res.json({ success: true, data: assessment }); + }), +); + +/** + * @route GET /api/v1/merchant-risk/policies + * @desc List all tier policies + */ +merchantRiskRouter.get('/policies', (_req: Request, res: Response) => { + const tiers: RiskTier[] = ['low', 'medium', 'high', 'critical']; + res.json({ + success: true, + data: tiers.map((tier) => getPolicyForTier(tier)), + }); +}); + +/** + * @route GET /api/v1/merchant-risk/policies/:tier + * @desc Retrieve a single tier policy + */ +merchantRiskRouter.get( + '/policies/:tier', + validateRequest({ params: tierParamsSchema }), + (req: Request, res: Response) => { + res.json({ success: true, data: getPolicyForTier(req.params.tier as RiskTier) }); + }, +); + +/** + * @route GET /api/v1/merchant-risk + * @desc List merchant assessments, optionally filtered by tier + */ +merchantRiskRouter.get( + '/', + validateRequest({ query: listQuerySchema }), + (req: Request, res: Response) => { + const tier = req.query.tier as RiskTier | undefined; + const data = listAssessments(tier); + res.json({ success: true, data, count: data.length }); + }, +); + +/** + * @route GET /api/v1/merchant-risk/:merchantId + * @desc Retrieve a merchant's current risk assessment + */ +merchantRiskRouter.get( + '/:merchantId', + merchantValidation, + (req: Request, res: Response) => { + const assessment = getAssessment(req.params.merchantId); + if (!assessment) { + throw new AppError(404, 'Merchant risk assessment not found', 'RISK_ASSESSMENT_NOT_FOUND'); + } + res.json({ success: true, data: assessment }); + }, +); + +/** + * @route POST /api/v1/merchant-risk/:merchantId/evaluate + * @desc Check whether a proposed transaction is within tier limits + */ +merchantRiskRouter.post( + '/:merchantId/evaluate', + validateRequest({ params: merchantParamsSchema, body: limitCheckSchema }), + (req: Request, res: Response) => { + const result = evaluateTransactionLimit(req.params.merchantId, req.body.amountUsd); + if (!result.allowed && result.reason === 'merchant_not_assessed') { + throw new AppError(404, 'Merchant risk assessment not found', 'RISK_ASSESSMENT_NOT_FOUND'); + } + res.json({ success: true, data: result }); + }, +); diff --git a/backend/src/services/__tests__/chargebacks.test.ts b/backend/src/services/__tests__/chargebacks.test.ts new file mode 100644 index 00000000..c6e8fb46 --- /dev/null +++ b/backend/src/services/__tests__/chargebacks.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import { + createChargeback, + getChargeback, + listChargebacks, + updateChargebackStatus, + submitEvidence, + removeEvidence, + addNote, + isEvidenceWindowClosed, + summarizeChargebacks, + resetChargebacksForTests, + CreateChargebackInput, +} from '../chargebacks.js'; + +const baseInput: CreateChargebackInput = { + merchantId: 'merchant-1', + paymentId: 'payment-1', + customerId: 'customer-1', + amount: 150.5, + currency: 'USD', + reason: 'fraudulent', +}; + +describe('chargebacks service', () => { + beforeEach(() => { + resetChargebacksForTests(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a chargeback in the received state with a deadline', () => { + const chargeback = createChargeback(baseInput); + expect(chargeback.id).toMatch(/^cb_/); + expect(chargeback.status).toBe('received'); + expect(chargeback.evidence).toHaveLength(0); + expect(new Date(chargeback.evidenceDueBy).getTime()).toBeGreaterThan(Date.now()); + expect(chargeback.timeline[0].type).toBe('created'); + }); + + it('rejects non-positive amounts', () => { + expect(() => createChargeback({ ...baseInput, amount: 0 })).toThrow(/greater than zero/); + }); + + it('honours a custom evidence window', () => { + const chargeback = createChargeback({ ...baseInput, evidenceWindowDays: 1 }); + const dueMs = new Date(chargeback.evidenceDueBy).getTime() - Date.now(); + expect(dueMs).toBeLessThanOrEqual(24 * 60 * 60 * 1000); + }); + + it('retrieves and lists chargebacks by merchant and status', () => { + const a = createChargeback(baseInput); + createChargeback({ ...baseInput, merchantId: 'merchant-2' }); + expect(getChargeback(a.id)?.id).toBe(a.id); + expect(getChargeback('missing')).toBeNull(); + expect(listChargebacks({ merchantId: 'merchant-1' })).toHaveLength(1); + expect(listChargebacks({ status: 'received' })).toHaveLength(2); + }); + + it('enforces valid status transitions', () => { + const chargeback = createChargeback(baseInput); + const updated = updateChargebackStatus(chargeback.id, 'under_review', 'agent'); + expect(updated.status).toBe('under_review'); + + expect(() => updateChargebackStatus(chargeback.id, 'won', 'agent')).toThrow( + /Invalid status transition/, + ); + }); + + it('marks resolvedAt when reaching a terminal status', () => { + const chargeback = createChargeback(baseInput); + updateChargebackStatus(chargeback.id, 'under_review', 'agent'); + updateChargebackStatus(chargeback.id, 'representment', 'agent'); + const won = updateChargebackStatus(chargeback.id, 'won', 'agent'); + expect(won.status).toBe('won'); + expect(won.resolvedAt).toBeDefined(); + }); + + it('throws when updating an unknown chargeback', () => { + expect(() => updateChargebackStatus('nope', 'under_review', 'agent')).toThrow( + /Chargeback not found/, + ); + }); + + it('submits evidence and moves the case to evidence_submitted', () => { + const chargeback = createChargeback(baseInput); + const updated = submitEvidence(chargeback.id, { + type: 'delivery_proof', + description: 'Signed delivery receipt', + fileUrl: 'https://example.com/receipt.pdf', + submittedBy: 'merchant-1', + }); + expect(updated.status).toBe('evidence_submitted'); + expect(updated.evidence).toHaveLength(1); + expect(updated.timeline.some((e) => e.type === 'evidence_submitted')).toBe(true); + }); + + it('prevents evidence submission after the deadline', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const chargeback = createChargeback({ ...baseInput, evidenceWindowDays: 1 }); + vi.setSystemTime(new Date('2026-01-05T00:00:00Z')); + expect(isEvidenceWindowClosed(chargeback)).toBe(true); + expect(() => + submitEvidence(chargeback.id, { + type: 'receipt', + description: 'late', + submittedBy: 'merchant-1', + }), + ).toThrow(/deadline has passed/); + }); + + it('prevents evidence submission on a closed chargeback', () => { + const chargeback = createChargeback(baseInput); + updateChargebackStatus(chargeback.id, 'under_review', 'agent'); + updateChargebackStatus(chargeback.id, 'accepted', 'agent'); + expect(() => + submitEvidence(chargeback.id, { + type: 'receipt', + description: 'nope', + submittedBy: 'merchant-1', + }), + ).toThrow(/closed chargeback/); + }); + + it('removes evidence', () => { + const chargeback = createChargeback(baseInput); + const withEvidence = submitEvidence(chargeback.id, { + type: 'receipt', + description: 'receipt', + submittedBy: 'merchant-1', + }); + const evidenceId = withEvidence.evidence[0].id; + const updated = removeEvidence(chargeback.id, evidenceId, 'agent'); + expect(updated.evidence).toHaveLength(0); + expect(() => removeEvidence(chargeback.id, 'missing', 'agent')).toThrow(/Evidence not found/); + }); + + it('adds notes to the timeline', () => { + const chargeback = createChargeback(baseInput); + const updated = addNote(chargeback.id, 'Customer contacted', 'agent'); + expect(updated.notes).toContain('Customer contacted'); + expect(updated.timeline.some((e) => e.type === 'note_added')).toBe(true); + }); + + it('summarizes chargebacks with win rate and recovered amount', () => { + const won = createChargeback({ ...baseInput, amount: 100 }); + updateChargebackStatus(won.id, 'under_review', 'agent'); + updateChargebackStatus(won.id, 'representment', 'agent'); + updateChargebackStatus(won.id, 'won', 'agent'); + + const lost = createChargeback({ ...baseInput, amount: 50 }); + updateChargebackStatus(lost.id, 'under_review', 'agent'); + updateChargebackStatus(lost.id, 'accepted', 'agent'); + + const summary = summarizeChargebacks('merchant-1'); + expect(summary.total).toBe(2); + expect(summary.disputedAmount).toBe(150); + expect(summary.recoveredAmount).toBe(100); + expect(summary.winRate).toBe(100); + }); + + it('returns a zero win rate when no case is decided', () => { + createChargeback(baseInput); + const summary = summarizeChargebacks('merchant-1'); + expect(summary.winRate).toBe(0); + }); +}); diff --git a/backend/src/services/__tests__/fraud-detection.test.ts b/backend/src/services/__tests__/fraud-detection.test.ts new file mode 100644 index 00000000..8c3c6e73 --- /dev/null +++ b/backend/src/services/__tests__/fraud-detection.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + scoreTransaction, + trainModel, + updateThresholds, + getThresholds, + getReviewQueue, + getModelState, + runAdversarialRobustnessProbe, + engineerFeatures, + TransactionSample, + LabeledSample, +} from '../fraud-detection.js'; + +const lowRiskSample: TransactionSample = { + transactionId: 'tx-low', + accountAgeDays: 900, + amountUsd: 25, + velocity1h: 1, + geoDistanceKm: 5, + deviceRisk: 0.05, + failedAttempts24h: 0, + chargebacks90d: 0, +}; + +const highRiskSample: TransactionSample = { + transactionId: 'tx-high', + accountAgeDays: 0, + amountUsd: 100_000, + velocity1h: 100, + geoDistanceKm: 20_000, + deviceRisk: 1, + failedAttempts24h: 20, + chargebacks90d: 10, +}; + +describe('fraud-detection engine', () => { + beforeEach(() => { + // Restore the default thresholds, which also resets if left inconsistent. + updateThresholds({ review: 0.45, block: 0.8 }); + }); + + it('engineers normalized features within [0, 1]', () => { + const features = engineerFeatures(highRiskSample); + for (const value of Object.values(features)) { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } + expect(Object.keys(features)).toContain('accountAgeDays'); + }); + + it('returns a bounded probability risk score', () => { + const result = scoreTransaction(lowRiskSample); + expect(result.transactionId).toBe('tx-low'); + expect(result.riskScore).toBeGreaterThanOrEqual(0); + expect(result.riskScore).toBeLessThanOrEqual(1); + }); + + it('allows a clearly low-risk transaction', () => { + const result = scoreTransaction(lowRiskSample); + expect(result.action).toBe('allow'); + }); + + it('flags a clearly high-risk transaction for review or block', () => { + const result = scoreTransaction(highRiskSample); + expect(['review', 'block']).toContain(result.action); + expect(result.reasons.length).toBeGreaterThan(0); + }); + + it('queues transactions that require review', () => { + const before = getReviewQueue().length; + // Force every scored transaction into the review band. + updateThresholds({ review: 0, block: 1 }); + scoreTransaction({ ...lowRiskSample, transactionId: 'tx-queued' }); + const queue = getReviewQueue(); + expect(queue.length).toBe(before + 1); + expect(queue[0].transactionId).toBe('tx-queued'); + }); + + it('updates and returns thresholds', () => { + const updated = updateThresholds({ review: 0.3, block: 0.7 }); + expect(updated).toEqual({ review: 0.3, block: 0.7 }); + expect(getThresholds()).toEqual({ review: 0.3, block: 0.7 }); + }); + + it('resets thresholds when they become inconsistent', () => { + updateThresholds({ review: 0.9, block: 0.5 }); + expect(getThresholds()).toEqual({ review: 0.45, block: 0.8 }); + }); + + it('trains the model and grows the trained sample count', () => { + const before = getModelState(); + const samples: LabeledSample[] = [ + { ...lowRiskSample, transactionId: 's1', label: 0 }, + { ...highRiskSample, transactionId: 's2', label: 1 }, + ]; + const trained = trainModel(samples, 0.3, 50); + expect(trained.trainedSamples).toBe(before.trainedSamples + 2); + expect(trained.version).not.toBe(before.version); + expect(trained.weights).toHaveLength(7); + }); + + it('leaves the model unchanged when trained with no samples', () => { + const before = getModelState(); + const after = trainModel([]); + expect(after.version).toBe(before.version); + expect(after.trainedSamples).toBe(before.trainedSamples); + }); + + it('performs an adversarial robustness probe', () => { + const probe = runAdversarialRobustnessProbe(highRiskSample); + expect(probe.baseline.transactionId).toBe('tx-high'); + expect(probe.perturbed.transactionId).toBe('tx-high'); + expect(typeof probe.delta).toBe('number'); + }); + + it('returns a defensive copy of model state', () => { + const state = getModelState(); + state.weights[0] = 999; + expect(getModelState().weights[0]).not.toBe(999); + }); +}); diff --git a/backend/src/services/__tests__/merchant-risk-tiering.test.ts b/backend/src/services/__tests__/merchant-risk-tiering.test.ts new file mode 100644 index 00000000..a950e95b --- /dev/null +++ b/backend/src/services/__tests__/merchant-risk-tiering.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + assessMerchant, + reassessMerchant, + getAssessment, + listAssessments, + getPolicyForTier, + evaluateTransactionLimit, + scoreMerchantRisk, + tierForScore, + resetRiskAssessmentsForTests, + MerchantRiskSignals, +} from '../merchant-risk-tiering.js'; + +const cleanSignals: MerchantRiskSignals = { + businessAgeMonths: 60, + monthlyVolumeUsd: 10_000, + chargebacks90d: 0, + refundRatePct: 1, + sanctionsHits: 0, + kycVerified: true, + kybVerified: true, + jurisdictionRisk: 0, +}; + +describe('merchant-risk-tiering', () => { + beforeEach(() => { + resetRiskAssessmentsForTests(); + }); + + it('assigns a low tier to a well-established verified merchant', () => { + const result = scoreMerchantRisk(cleanSignals); + expect(result.score).toBeLessThan(20); + expect(tierForScore(result.score)).toBe('low'); + }); + + it('escalates to critical when sanctions hits and compliance gaps combine', () => { + const result = scoreMerchantRisk({ + ...cleanSignals, + businessAgeMonths: 0, + sanctionsHits: 1, + kycVerified: false, + kybVerified: false, + }); + expect(result.score).toBeGreaterThanOrEqual(70); + expect(tierForScore(result.score)).toBe('critical'); + }); + + it('caps the score at 100', () => { + const result = scoreMerchantRisk({ + businessAgeMonths: 0, + monthlyVolumeUsd: 10_000_000, + chargebacks90d: 20, + refundRatePct: 100, + sanctionsHits: 5, + kycVerified: false, + kybVerified: false, + jurisdictionRisk: 1, + }); + expect(result.score).toBeLessThanOrEqual(100); + }); + + it('records factors that explain the score', () => { + const result = scoreMerchantRisk({ ...cleanSignals, chargebacks90d: 2 }); + expect(result.factors.some((f) => f.startsWith('chargebacks'))).toBe(true); + }); + + it('assesses and persists a merchant', () => { + const assessment = assessMerchant('m-1', cleanSignals); + expect(assessment.merchantId).toBe('m-1'); + expect(assessment.id).toMatch(/^risk_/); + expect(assessment.policy.tier).toBe(assessment.tier); + expect(getAssessment('m-1')?.tier).toBe(assessment.tier); + }); + + it('returns null for an unknown merchant', () => { + expect(getAssessment('missing')).toBeNull(); + }); + + it('reassesses a merchant in place', () => { + assessMerchant('m-2', cleanSignals); + const updated = reassessMerchant('m-2', { + ...cleanSignals, + businessAgeMonths: 0, + kycVerified: false, + kybVerified: false, + }); + expect(updated.merchantId).toBe('m-2'); + expect(getAssessment('m-2')?.score).toBe(updated.score); + expect(listAssessments()).toHaveLength(1); + }); + + it('filters assessments by tier', () => { + assessMerchant('low-1', cleanSignals); + assessMerchant('critical-1', { + ...cleanSignals, + businessAgeMonths: 0, + sanctionsHits: 2, + kycVerified: false, + kybVerified: false, + }); + expect(listAssessments('low').map((a) => a.merchantId)).toEqual(['low-1']); + expect(listAssessments('critical')).toHaveLength(1); + }); + + it('exposes tier policies with transaction limits', () => { + const low = getPolicyForTier('low'); + const critical = getPolicyForTier('critical'); + expect(low.maxTransactionUsd).toBeGreaterThan(critical.maxTransactionUsd ?? 0); + expect(critical.requiredChecks).toContain('enhanced_due_diligence'); + }); + + it('does not allow mutation of the shared policy table', () => { + const policy = getPolicyForTier('low'); + policy.requiredChecks.push('tampered'); + expect(getPolicyForTier('low').requiredChecks).not.toContain('tampered'); + }); + + it('allows transactions within the tier limit', () => { + assessMerchant('m-3', cleanSignals); + const result = evaluateTransactionLimit('m-3', 1_000); + expect(result.allowed).toBe(true); + }); + + it('blocks transactions that exceed the tier limit', () => { + const assessment = assessMerchant('m-4', { + ...cleanSignals, + sanctionsHits: 1, + kycVerified: false, + kybVerified: false, + }); + const overLimit = (assessment.policy.maxTransactionUsd ?? 0) + 1; + const result = evaluateTransactionLimit('m-4', overLimit); + expect(result.allowed).toBe(false); + expect(result.reason).toContain(assessment.tier); + }); + + it('blocks evaluation for an unassessed merchant', () => { + const result = evaluateTransactionLimit('ghost', 100); + expect(result.allowed).toBe(false); + expect(result.reason).toBe('merchant_not_assessed'); + }); +}); diff --git a/backend/src/services/__tests__/query-optimization.test.ts b/backend/src/services/__tests__/query-optimization.test.ts new file mode 100644 index 00000000..c9067a65 --- /dev/null +++ b/backend/src/services/__tests__/query-optimization.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { + fingerprintQuery, + extractTable, + analyseQuery, + recommendIndexes, + detectFullTableScans, + consolidateByTable, +} from '../query-optimization.js'; + +describe('query-optimization', () => { + describe('fingerprintQuery', () => { + it('replaces literals with placeholders', () => { + expect(fingerprintQuery('SELECT * FROM payments WHERE id = 42')).toBe( + 'select * from payments where id = ?', + ); + }); + + it('collapses whitespace and lowercases', () => { + expect(fingerprintQuery('SELECT * FROM Payments')).toBe('select * from payments'); + }); + + it('produces the same signature for equivalent parametrised queries', () => { + const a = fingerprintQuery("SELECT * FROM users WHERE email = 'a@x.com'"); + const b = fingerprintQuery("SELECT * FROM users WHERE email = 'b@y.com'"); + expect(a).toBe(b); + }); + }); + + describe('extractTable', () => { + it('extracts the table from SELECT statements', () => { + expect(extractTable('SELECT * FROM invoices WHERE id = ?')).toBe('invoices'); + }); + + it('extracts the table from UPDATE statements', () => { + expect(extractTable('UPDATE payments SET status = ? WHERE id = ?')).toBe('payments'); + }); + + it('strips schema qualification', () => { + expect(extractTable('SELECT * FROM public.audit_logs')).toBe('audit_logs'); + }); + + it('returns null when no table can be found', () => { + expect(extractTable('PRAGMA table_info')).toBeNull(); + }); + }); + + describe('analyseQuery', () => { + it('captures predicate and order-by columns', () => { + const stats = analyseQuery( + 'SELECT * FROM payments WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC', + { executions: 50, avgDurationMs: 120 }, + ); + expect(stats).not.toBeNull(); + expect(stats!.table).toBe('payments'); + expect(stats!.predicateColumns).toEqual(['tenant_id', 'status']); + expect(stats!.orderByColumns).toEqual(['created_at']); + expect(stats!.executions).toBe(50); + }); + + it('returns null for unparseable statements', () => { + expect(analyseQuery('BEGIN')).toBeNull(); + }); + + it('handles queries without a where clause', () => { + const stats = analyseQuery('SELECT * FROM users ORDER BY created_at DESC'); + expect(stats!.predicateColumns).toEqual([]); + expect(stats!.orderByColumns).toEqual(['created_at']); + }); + }); + + describe('recommendIndexes', () => { + it('recommends a composite index leading with equality predicates', () => { + const stats = analyseQuery( + 'SELECT * FROM payments WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC', + { executions: 100, avgDurationMs: 200 }, + )!; + const recs = recommendIndexes([stats]); + expect(recs).toHaveLength(1); + expect(recs[0].table).toBe('payments'); + expect(recs[0].columns).toEqual(['tenant_id', 'status', 'created_at']); + expect(recs[0].score).toBeGreaterThan(0); + }); + + it('de-duplicates overlapping recommendations for the same table and columns', () => { + const a = analyseQuery('SELECT * FROM users WHERE tenant_id = $1 AND email = $2', { + executions: 10, + })!; + const b = analyseQuery('SELECT * FROM users WHERE tenant_id = $1 AND email = $2', { + executions: 90, + })!; + const recs = recommendIndexes([a, b]); + expect(recs).toHaveLength(1); + }); + + it('ignores queries without indexable columns', () => { + const stats = analyseQuery('SELECT * FROM ping')!; + expect(recommendIndexes([stats])).toHaveLength(0); + }); + + it('sorts recommendations by descending score', () => { + const hot = analyseQuery('SELECT * FROM hot WHERE a = $1', { + executions: 1000, + avgDurationMs: 500, + })!; + const cold = analyseQuery('SELECT * FROM cold WHERE b = $1', { + executions: 1, + avgDurationMs: 1, + })!; + const recs = recommendIndexes([cold, hot]); + expect(recs[0].table).toBe('hot'); + }); + }); + + describe('detectFullTableScans', () => { + it('flags frequent slow queries without predicates', () => { + const scan = analyseQuery('SELECT * FROM audit_logs', { + executions: 50, + avgDurationMs: 300, + })!; + const indexed = analyseQuery('SELECT * FROM payments WHERE id = $1', { + executions: 50, + avgDurationMs: 300, + })!; + const flagged = detectFullTableScans([scan, indexed]); + expect(flagged).toHaveLength(1); + expect(flagged[0].table).toBe('audit_logs'); + }); + + it('respects custom thresholds', () => { + const query = analyseQuery('SELECT * FROM t', { executions: 5, avgDurationMs: 50 })!; + expect(detectFullTableScans([query])).toHaveLength(0); + expect(detectFullTableScans([query], { minExecutions: 1, minAvgDurationMs: 1 })).toHaveLength(1); + }); + }); + + describe('consolidateByTable', () => { + it('groups recommendations by table', () => { + const a = analyseQuery('SELECT * FROM payments WHERE a = $1', { executions: 10 })!; + const b = analyseQuery('SELECT * FROM invoices WHERE b = $1', { executions: 10 })!; + const grouped = consolidateByTable(recommendIndexes([a, b])); + expect(Object.keys(grouped).sort()).toEqual(['invoices', 'payments']); + }); + }); +}); diff --git a/backend/src/services/chargebacks.ts b/backend/src/services/chargebacks.ts new file mode 100644 index 00000000..f098efac --- /dev/null +++ b/backend/src/services/chargebacks.ts @@ -0,0 +1,316 @@ +import { randomUUID } from 'node:crypto'; + +/** + * Chargeback management with evidence submission (Issue #926). + * + * Full lifecycle for card/ACH chargebacks: intake, evidence submission with + * deadlines, representment, and outcome tracking. State is held in memory, + * matching the existing disputes / onboarding services, so the module is pure + * and dependency-free. + */ + +export type ChargebackStatus = + | 'received' + | 'under_review' + | 'evidence_required' + | 'evidence_submitted' + | 'representment' + | 'won' + | 'lost' + | 'accepted'; + +export type ChargebackReason = + | 'fraudulent' + | 'unrecognized' + | 'product_not_received' + | 'product_unacceptable' + | 'duplicate_charge' + | 'subscription_canceled' + | 'credit_not_processed'; + +export type EvidenceType = + | 'receipt' + | 'tracking_number' + | 'customer_communication' + | 'refund_policy' + | 'delivery_proof' + | 'signature' + | 'avs_cvv_match' + | 'ip_location' + | 'other'; + +export interface ChargebackEvidence { + id: string; + type: EvidenceType; + description: string; + /** Reference to an uploaded file / external resource. */ + fileUrl?: string; + submittedBy: string; + submittedAt: string; +} + +export interface ChargebackEvent { + id: string; + type: + | 'created' + | 'status_changed' + | 'evidence_submitted' + | 'evidence_removed' + | 'note_added'; + description: string; + actor: string; + at: string; +} + +export interface Chargeback { + id: string; + merchantId: string; + paymentId: string; + customerId?: string; + amount: number; + currency: string; + reason: ChargebackReason; + status: ChargebackStatus; + /** ISO timestamp by which evidence must be submitted. */ + evidenceDueBy: string; + evidence: ChargebackEvidence[]; + notes: string[]; + timeline: ChargebackEvent[]; + createdAt: string; + updatedAt: string; + resolvedAt?: string; +} + +export interface CreateChargebackInput { + merchantId: string; + paymentId: string; + customerId?: string; + amount: number; + currency: string; + reason: ChargebackReason; + /** Days until the evidence deadline. Default 7. */ + evidenceWindowDays?: number; +} + +export interface SubmitEvidenceInput { + type: EvidenceType; + description: string; + fileUrl?: string; + submittedBy: string; +} + +const chargebacks = new Map(); + +/** Valid status transitions for a chargeback case. */ +const VALID_TRANSITIONS: Record = { + received: ['under_review', 'accepted', 'evidence_required', 'representment'], + under_review: ['evidence_required', 'evidence_submitted', 'accepted', 'representment'], + evidence_required: ['evidence_submitted', 'accepted', 'lost'], + evidence_submitted: ['representment', 'won', 'lost'], + representment: ['won', 'lost'], + won: [], + lost: [], + accepted: [], +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function addDays(days: number): string { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString(); +} + +function pushEvent( + chargeback: Chargeback, + type: ChargebackEvent['type'], + description: string, + actor: string, +): void { + chargeback.timeline.push({ + id: `cb_evt_${randomUUID()}`, + type, + description, + actor, + at: nowIso(), + }); +} + +export function createChargeback(input: CreateChargebackInput): Chargeback { + if (input.amount <= 0) { + throw new Error('Chargeback amount must be greater than zero'); + } + + const id = `cb_${randomUUID()}`; + const timestamp = nowIso(); + const chargeback: Chargeback = { + id, + merchantId: input.merchantId, + paymentId: input.paymentId, + customerId: input.customerId, + amount: input.amount, + currency: input.currency, + reason: input.reason, + status: 'received', + evidenceDueBy: addDays(input.evidenceWindowDays ?? 7), + evidence: [], + notes: [], + timeline: [], + createdAt: timestamp, + updatedAt: timestamp, + }; + + pushEvent(chargeback, 'created', `Chargeback received for ${input.reason}`, 'system'); + chargebacks.set(id, chargeback); + return chargeback; +} + +export function getChargeback(id: string): Chargeback | null { + return chargebacks.get(id) ?? null; +} + +export function listChargebacks(filter?: { + merchantId?: string; + status?: ChargebackStatus; +}): Chargeback[] { + let all = Array.from(chargebacks.values()); + const merchantId = filter?.merchantId; + const status = filter?.status; + if (merchantId) all = all.filter((c) => c.merchantId === merchantId); + if (status) all = all.filter((c) => c.status === status); + return all; +} + +export function updateChargebackStatus( + id: string, + status: ChargebackStatus, + actor: string, +): Chargeback { + const chargeback = chargebacks.get(id); + if (!chargeback) throw new Error('Chargeback not found'); + + const allowed = VALID_TRANSITIONS[chargeback.status]; + if (!allowed.includes(status)) { + throw new Error(`Invalid status transition from ${chargeback.status} to ${status}`); + } + + const previous = chargeback.status; + chargeback.status = status; + chargeback.updatedAt = nowIso(); + + if (status === 'won' || status === 'lost' || status === 'accepted') { + chargeback.resolvedAt = chargeback.updatedAt; + } + + pushEvent(chargeback, 'status_changed', `Status changed from ${previous} to ${status}`, actor); + return chargeback; +} + +export function submitEvidence(id: string, input: SubmitEvidenceInput): Chargeback { + const chargeback = chargebacks.get(id); + if (!chargeback) throw new Error('Chargeback not found'); + + if (chargeback.status === 'won' || chargeback.status === 'lost' || chargeback.status === 'accepted') { + throw new Error('Cannot submit evidence for a closed chargeback'); + } + + if (isEvidenceWindowClosed(chargeback)) { + throw new Error('Evidence submission deadline has passed'); + } + + const evidence: ChargebackEvidence = { + id: `cb_ev_${randomUUID()}`, + type: input.type, + description: input.description, + fileUrl: input.fileUrl, + submittedBy: input.submittedBy, + submittedAt: nowIso(), + }; + + chargeback.evidence.push(evidence); + chargeback.status = 'evidence_submitted'; + chargeback.updatedAt = nowIso(); + + pushEvent( + chargeback, + 'evidence_submitted', + `Evidence submitted: ${input.type}`, + input.submittedBy, + ); + return chargeback; +} + +export function removeEvidence(id: string, evidenceId: string, actor: string): Chargeback { + const chargeback = chargebacks.get(id); + if (!chargeback) throw new Error('Chargeback not found'); + + const index = chargeback.evidence.findIndex((e) => e.id === evidenceId); + if (index === -1) throw new Error('Evidence not found'); + + chargeback.evidence.splice(index, 1); + chargeback.updatedAt = nowIso(); + pushEvent(chargeback, 'evidence_removed', `Evidence removed: ${evidenceId}`, actor); + return chargeback; +} + +export function addNote(id: string, note: string, actor: string): Chargeback { + const chargeback = chargebacks.get(id); + if (!chargeback) throw new Error('Chargeback not found'); + + chargeback.notes.push(note); + chargeback.updatedAt = nowIso(); + pushEvent(chargeback, 'note_added', note, actor); + return chargeback; +} + +export function isEvidenceWindowClosed( + chargeback: Chargeback, + at: Date = new Date(), +): boolean { + return new Date(chargeback.evidenceDueBy).getTime() < at.getTime(); +} + +export interface ChargebackSummary { + total: number; + byStatus: Record; + disputedAmount: number; + recoveredAmount: number; + winRate: number; +} + +export function summarizeChargebacks(merchantId?: string): ChargebackSummary { + const cases = listChargebacks(merchantId ? { merchantId } : undefined); + + const byStatus: Record = {}; + let disputedAmount = 0; + let recoveredAmount = 0; + let decided = 0; + let won = 0; + + for (const c of cases) { + byStatus[c.status] = (byStatus[c.status] ?? 0) + 1; + disputedAmount += c.amount; + if (c.status === 'won' || c.status === 'lost') { + decided++; + if (c.status === 'won') { + won++; + recoveredAmount += c.amount; + } + } + } + + return { + total: cases.length, + byStatus, + disputedAmount: Number(disputedAmount.toFixed(2)), + recoveredAmount: Number(recoveredAmount.toFixed(2)), + winRate: decided > 0 ? Number(((won / decided) * 100).toFixed(2)) : 0, + }; +} + +/** Test helper: clear all in-memory chargebacks. */ +export function resetChargebacksForTests(): void { + chargebacks.clear(); +} diff --git a/backend/src/services/merchant-risk-tiering.ts b/backend/src/services/merchant-risk-tiering.ts new file mode 100644 index 00000000..6f059e3a --- /dev/null +++ b/backend/src/services/merchant-risk-tiering.ts @@ -0,0 +1,261 @@ +import { randomUUID } from 'node:crypto'; + +/** + * Merchant onboarding with risk tiering (Issue #928). + * + * Assigns each merchant a risk tier during onboarding based on a deterministic + * scoring model over business, financial, and compliance signals. The tier + * drives the merchant's transaction limits, payout hold period, and required + * onboarding checks. All state is held in memory (matching the existing + * in-memory onboarding service) so the module is pure, testable, and has no + * external dependencies. + */ + +export type RiskTier = 'low' | 'medium' | 'high' | 'critical'; + +export interface MerchantRiskSignals { + /** Business structural signals */ + businessAgeMonths: number; + /** Financial signals */ + monthlyVolumeUsd: number; + chargebacks90d: number; + refundRatePct: number; + /** Compliance signals */ + sanctionsHits: number; + kycVerified: boolean; + kybVerified: boolean; + /** Optional jurisdiction risk multiplier, 0..1 */ + jurisdictionRisk?: number; +} + +export interface RiskTierPolicy { + tier: RiskTier; + /** Maximum single transaction amount in USD, null = unlimited */ + maxTransactionUsd: number | null; + /** Maximum rolling daily volume in USD, null = unlimited */ + dailyVolumeLimitUsd: number | null; + /** Payout hold period in days */ + payoutHoldDays: number; + /** Whether manual review is required before payouts release */ + manualReview: boolean; + /** Additional onboarding checks required at this tier */ + requiredChecks: string[]; +} + +export interface RiskAssessment { + id: string; + merchantId: string; + tier: RiskTier; + score: number; + factors: string[]; + policy: RiskTierPolicy; + createdAt: string; +} + +const TIER_POLICIES: Record = { + low: { + tier: 'low', + maxTransactionUsd: 50_000, + dailyVolumeLimitUsd: 250_000, + payoutHoldDays: 2, + manualReview: false, + requiredChecks: ['business_info', 'business_license', 'identity_verification'], + }, + medium: { + tier: 'medium', + maxTransactionUsd: 20_000, + dailyVolumeLimitUsd: 75_000, + payoutHoldDays: 5, + manualReview: false, + requiredChecks: ['business_info', 'business_license', 'identity_verification', 'bank_verification'], + }, + high: { + tier: 'high', + maxTransactionUsd: 5_000, + dailyVolumeLimitUsd: 20_000, + payoutHoldDays: 14, + manualReview: true, + requiredChecks: [ + 'business_info', + 'business_license', + 'identity_verification', + 'bank_verification', + 'tax_id', + 'compliance_check', + ], + }, + critical: { + tier: 'critical', + maxTransactionUsd: 1_000, + dailyVolumeLimitUsd: 5_000, + payoutHoldDays: 30, + manualReview: true, + requiredChecks: [ + 'business_info', + 'business_license', + 'identity_verification', + 'bank_verification', + 'tax_id', + 'compliance_check', + 'enhanced_due_diligence', + ], + }, +}; + +const assessments = new Map(); + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Compute a 0..100 merchant risk score from onboarding signals. Higher is + * riskier. Each factor contributes a bounded number of points so a single + * extreme signal cannot unilaterally saturate the score. + */ +export function scoreMerchantRisk(signals: MerchantRiskSignals): { + score: number; + factors: string[]; +} { + let score = 0; + const factors: string[] = []; + + // Newer businesses are riskier (up to 20 points). + const agePoints = clamp((12 - signals.businessAgeMonths) * (20 / 12), 0, 20); + if (agePoints > 0) { + score += agePoints; + factors.push(`business_age:${agePoints.toFixed(1)}`); + } + + // Chargeback history (up to 25 points). + const chargebackPoints = clamp(signals.chargebacks90d * 5, 0, 25); + if (chargebackPoints > 0) { + score += chargebackPoints; + factors.push(`chargebacks:${chargebackPoints.toFixed(1)}`); + } + + // Refund rate above 5% is penalised (up to 15 points). + const refundPoints = clamp((signals.refundRatePct - 5) * 1.5, 0, 15); + if (refundPoints > 0) { + score += refundPoints; + factors.push(`refund_rate:${refundPoints.toFixed(1)}`); + } + + // High expected volume (up to 15 points). + const volumePoints = clamp(signals.monthlyVolumeUsd / 100_000 * 15, 0, 15); + if (volumePoints > 0) { + score += volumePoints; + factors.push(`volume:${volumePoints.toFixed(1)}`); + } + + // Compliance gaps (up to 54 points). + if (!signals.kycVerified) { + score += 12; + factors.push('kyc_missing:12.0'); + } + if (!signals.kybVerified) { + score += 12; + factors.push('kyb_missing:12.0'); + } + if (signals.sanctionsHits > 0) { + score += 30; + factors.push('sanctions_hits:30.0'); + } + + // Jurisdiction risk multiplier (up to 10 points). + const jurisdictionPoints = clamp((signals.jurisdictionRisk ?? 0) * 10, 0, 10); + if (jurisdictionPoints > 0) { + score += jurisdictionPoints; + factors.push(`jurisdiction:${jurisdictionPoints.toFixed(1)}`); + } + + return { score: Number(clamp(score, 0, 100).toFixed(2)), factors }; +} + +export function tierForScore(score: number): RiskTier { + if (score >= 65) return 'critical'; + if (score >= 40) return 'high'; + if (score >= 18) return 'medium'; + return 'low'; +} + +export function getPolicyForTier(tier: RiskTier): RiskTierPolicy { + // Return a defensive copy so callers cannot mutate the shared policy table. + return { ...TIER_POLICIES[tier], requiredChecks: [...TIER_POLICIES[tier].requiredChecks] }; +} + +export function assessMerchant( + merchantId: string, + signals: MerchantRiskSignals, +): RiskAssessment { + const { score, factors } = scoreMerchantRisk(signals); + const tier = tierForScore(score); + + const assessment: RiskAssessment = { + id: `risk_${randomUUID()}`, + merchantId, + tier, + score, + factors, + policy: getPolicyForTier(tier), + createdAt: new Date().toISOString(), + }; + + assessments.set(merchantId, assessment); + return assessment; +} + +/** + * Re-score an existing merchant (e.g. periodic review) preserving the same + * merchant id so downstream lookups stay valid. + */ +export function reassessMerchant( + merchantId: string, + signals: MerchantRiskSignals, +): RiskAssessment { + return assessMerchant(merchantId, signals); +} + +export function getAssessment(merchantId: string): RiskAssessment | null { + const existing = assessments.get(merchantId); + if (!existing) return null; + return { ...existing, policy: getPolicyForTier(existing.tier), factors: [...existing.factors] }; +} + +export function listAssessments(tier?: RiskTier): RiskAssessment[] { + const all = Array.from(assessments.values()).map((a) => ({ + ...a, + policy: getPolicyForTier(a.tier), + factors: [...a.factors], + })); + return tier ? all.filter((a) => a.tier === tier) : all; +} + +/** + * Evaluate whether a proposed transaction is permitted under the merchant's + * current tier policy. Returns the effective limit and a reason when blocked. + */ +export function evaluateTransactionLimit( + merchantId: string, + amountUsd: number, +): { allowed: boolean; limitUsd: number | null; reason?: string } { + const assessment = assessments.get(merchantId); + if (!assessment) { + return { allowed: false, limitUsd: null, reason: 'merchant_not_assessed' }; + } + + const limit = assessment.policy.maxTransactionUsd; + if (limit !== null && amountUsd > limit) { + return { + allowed: false, + limitUsd: limit, + reason: `amount_exceeds_${assessment.tier}_tier_limit`, + }; + } + return { allowed: true, limitUsd: limit }; +} + +/** Test helper: clear all in-memory assessments. */ +export function resetRiskAssessmentsForTests(): void { + assessments.clear(); +} diff --git a/backend/src/services/query-optimization.ts b/backend/src/services/query-optimization.ts new file mode 100644 index 00000000..ff6bdfd7 --- /dev/null +++ b/backend/src/services/query-optimization.ts @@ -0,0 +1,208 @@ +/** + * Query optimization & index analysis (Issue #739). + * + * Pure, dependency-free helpers that analyse SQL query shapes and produce + * actionable index recommendations. This complements the static + * RECOMMENDED_INDEXES catalogue in config/database.ts by deriving candidates + * from the queries actually observed at runtime. + */ + +export interface IndexRecommendation { + table: string; + columns: string[]; + reason: string; + /** Relative benefit score, higher = more valuable. */ + score: number; + /** Approximate statement fingerprint this recommendation targets. */ + signature: string; +} + +export interface QueryStats { + /** Statement fingerprint (literals replaced with placeholders). */ + signature: string; + table: string; + /** Columns referenced in equality/range predicates, in appearance order. */ + predicateColumns: string[]; + /** Columns referenced in ORDER BY, in appearance order. */ + orderByColumns: string[]; + /** Number of observed executions. */ + executions: number; + /** Average execution duration in milliseconds. */ + avgDurationMs: number; + /** Rows examined per execution (from EXPLAIN when available). */ + rowsExamined?: number; +} + +const PLACEHOLDER_RE = /\$\d+|\?|'[^']*'|"[^"]*"|\b\d+(\.\d+)?\b/g; +const STOP_WORDS = new Set([ + 'select', + 'from', + 'where', + 'and', + 'or', + 'order', + 'by', + 'group', + 'limit', + 'offset', + 'join', + 'on', + 'as', + 'asc', + 'desc', + 'null', + 'is', + 'not', + 'in', + 'like', + 'insert', + 'into', + 'values', + 'update', + 'set', + 'delete', +]); + +/** Normalize a SQL string into a stable fingerprint. */ +export function fingerprintQuery(sql: string): string { + return sql + .replace(/\s+/g, ' ') + .replace(PLACEHOLDER_RE, '?') + .trim() + .toLowerCase(); +} + +/** Extract the primary table name from a simple SELECT/UPDATE/DELETE query. */ +export function extractTable(sql: string): string | null { + const match = sql.match(/\bfrom\s+([a-z_][a-z0-9_."]*)/i) + ?? sql.match(/\bupdate\s+([a-z_][a-z0-9_."]*)/i) + ?? sql.match(/\binto\s+([a-z_][a-z0-9_."]*)/i); + if (!match) return null; + return match[1].replace(/"/g, '').split('.').pop() ?? null; +} + +function columnBeforeOperators(clause: string): string[] { + const found: string[] = []; + const re = /([a-z_][a-z0-9_."]*)\s*(?:=|<|>|<=|>=|<>|!=|\bin\b|\blike\b|\bis\b)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(clause)) !== null) { + const col = m[1].replace(/"/g, '').split('.').pop() ?? m[1]; + if (!STOP_WORDS.has(col.toLowerCase()) && !found.includes(col)) { + found.push(col); + } + } + return found; +} + +function extractOrderByColumns(sql: string): string[] { + const match = sql.match(/\border\s+by\s+([^)]*?)(?:\blimit\b|$)/i); + if (!match) return []; + return match[1] + .split(',') + .map((part) => part.trim().replace(/\s+(asc|desc)$/i, '').replace(/"/g, '').split('.').pop() ?? '') + .filter((col) => col.length > 0 && !STOP_WORDS.has(col.toLowerCase())); +} + +/** Parse a SQL statement into an analysable QueryStats record. */ +export function analyseQuery( + sql: string, + execution: { executions?: number; avgDurationMs?: number; rowsExamined?: number } = {}, +): QueryStats | null { + const table = extractTable(sql); + if (!table) return null; + + const whereMatch = sql.match(/\bwhere\s+(.*?)(?:\border\s+by\b|\bgroup\s+by\b|\blimit\b|$)/i); + const predicateColumns = whereMatch ? columnBeforeOperators(whereMatch[1]) : []; + + return { + signature: fingerprintQuery(sql), + table, + predicateColumns, + orderByColumns: extractOrderByColumns(sql), + executions: execution.executions ?? 1, + avgDurationMs: execution.avgDurationMs ?? 0, + rowsExamined: execution.rowsExamined, + }; +} + +/** + * Recommend indexes for a batch of observed queries. Queries that already lead + * with the same equality prefix are de-duplicated so we don't propose + * overlapping indexes. + */ +export function recommendIndexes(queries: QueryStats[]): IndexRecommendation[] { + const byKey = new Map(); + + for (const query of queries) { + const columns = [...query.predicateColumns, ...query.orderByColumns]; + if (columns.length === 0) continue; + // Equality predicates should lead the index, then range/order columns. + const orderedColumns = [...new Set([...query.predicateColumns, ...query.orderByColumns])]; + + const key = `${query.table}:${orderedColumns.join(',')}`; + const score = scoreRecommendation(query, columns.length); + const existing = byKey.get(key); + + if (!existing || score > existing.score) { + byKey.set(key, { + table: query.table, + columns: orderedColumns, + reason: buildReason(query, orderedColumns), + score, + signature: query.signature, + }); + } + } + + return Array.from(byKey.values()).sort((a, b) => b.score - a.score); +} + +function scoreRecommendation(query: QueryStats, columnCount: number): number { + const executionWeight = Math.log2(query.executions + 1); + const durationWeight = Math.min(query.avgDurationMs / 100, 5); + const selectivityWeight = query.rowsExamined && query.rowsExamined > 1000 ? 2 : 0; + const columnPenalty = columnCount > 4 ? (columnCount - 4) * 0.25 : 0; + return Number( + Math.max(0, executionWeight + durationWeight + selectivityWeight - columnPenalty).toFixed(3), + ); +} + +function buildReason(query: QueryStats, columns: string[]): string { + const parts: string[] = []; + if (query.predicateColumns.length > 0) { + parts.push(`filter on ${query.predicateColumns.join(', ')}`); + } + if (query.orderByColumns.length > 0) { + parts.push(`ordered by ${query.orderByColumns.join(', ')}`); + } + const reason = parts.length > 0 ? parts.join(' and ') : 'frequent access pattern'; + return `Index on (${columns.join(', ')}) to support ${reason} across ${query.executions} execution(s)`; +} + +/** + * Flag potential full-table scans: frequent, slow queries whose predicates are + * not indexable (no WHERE column or only leading-wildcard LIKE). + */ +export function detectFullTableScans( + queries: QueryStats[], + thresholds: { minExecutions?: number; minAvgDurationMs?: number } = {}, +): QueryStats[] { + const minExecutions = thresholds.minExecutions ?? 10; + const minDuration = thresholds.minAvgDurationMs ?? 100; + return queries.filter( + (q) => + q.executions >= minExecutions && + q.avgDurationMs >= minDuration && + q.predicateColumns.length === 0, + ); +} + +/** Merge recommendations for a single table into one composite advisory. */ +export function consolidateByTable( + recommendations: IndexRecommendation[], +): Record { + return recommendations.reduce>((acc, rec) => { + (acc[rec.table] ??= []).push(rec); + return acc; + }, {}); +} diff --git a/docs/CHARGEBACK_MANAGEMENT.md b/docs/CHARGEBACK_MANAGEMENT.md new file mode 100644 index 00000000..352474ad --- /dev/null +++ b/docs/CHARGEBACK_MANAGEMENT.md @@ -0,0 +1,72 @@ +# Chargeback Management with Evidence Submission + +Issue #926. + +Full lifecycle management for card and ACH chargebacks: intake, evidence +submission against a deadline, representment, notes, and outcome tracking. + +## Lifecycle + +``` +received ──▶ under_review ──▶ evidence_required ──▶ evidence_submitted ──▶ representment ──▶ won | lost + │ │ │ + └──────────────┴──────────────────────────▶ accepted ◀────────────────────────┘ +``` + +Only valid transitions are permitted. Reaching `won`, `lost`, or `accepted` +marks the case resolved and records `resolvedAt`. + +## Evidence + +Each evidence item records a `type` (`receipt`, `tracking_number`, +`delivery_proof`, `signature`, `avs_cvv_match`, `ip_location`, and more), a +free-text `description`, an optional `fileUrl`, and the submitting actor. + +Evidence can only be submitted while the case is open and before +`evidenceDueBy`. The default evidence window is 7 days from intake and can be +overridden per case. + +## API + +| Method | Path | Description | +| --- | --- | --- | +| POST | `/api/v1/chargebacks` | Register a new chargeback case | +| GET | `/api/v1/chargebacks` | List cases (optional `?merchantId=` / `?status=`) | +| GET | `/api/v1/chargebacks/summary` | Aggregate metrics (optional `?merchantId=`) | +| GET | `/api/v1/chargebacks/:id` | Retrieve one case with evidence and timeline | +| PATCH | `/api/v1/chargebacks/:id/status` | Advance the case lifecycle | +| POST | `/api/v1/chargebacks/:id/evidence` | Submit supporting evidence | +| DELETE | `/api/v1/chargebacks/:id/evidence/:evidenceId` | Remove an evidence item | +| POST | `/api/v1/chargebacks/:id/notes` | Attach an internal note | + +### Register a chargeback + +```http +POST /api/v1/chargebacks +Content-Type: application/json + +{ + "merchantId": "merchant-1", + "paymentId": "payment-1", + "amount": 150.5, + "currency": "USD", + "reason": "fraudulent", + "evidenceWindowDays": 7 +} +``` + +### Submit evidence + +```http +POST /api/v1/chargebacks/cb_/evidence +Content-Type: application/json + +{ + "type": "delivery_proof", + "description": "Signed delivery receipt", + "fileUrl": "https://example.com/receipt.pdf", + "submittedBy": "merchant-1" +} +``` + +Every state change is appended to the case `timeline` for a full audit trail. diff --git a/docs/MERCHANT_RISK_TIERING.md b/docs/MERCHANT_RISK_TIERING.md new file mode 100644 index 00000000..62a978ac --- /dev/null +++ b/docs/MERCHANT_RISK_TIERING.md @@ -0,0 +1,72 @@ +# Merchant Onboarding with Risk Tiering + +Issue #928. + +Every merchant is assigned a **risk tier** (`low`, `medium`, `high`, `critical`) +during onboarding. The tier is derived from a deterministic scoring model over +business, financial, and compliance signals, and it drives the merchant's +transaction limits, payout hold period, and required onboarding checks. + +## Scoring model + +`scoreMerchantRisk(signals)` produces a `0..100` score (higher = riskier). +Each factor contributes a bounded number of points so no single extreme signal +can saturate the score: + +| Signal | Contribution | +| --- | --- | +| Business age | up to 20 points (newer = riskier) | +| Chargebacks (90d) | 5 points each, capped at 25 | +| Refund rate above 5% | 1.5 points per point, capped at 15 | +| Monthly volume | up to 15 points | +| Missing KYC | 12 points | +| Missing KYB | 12 points | +| Sanctions hits | 30 points | +| Jurisdiction risk | up to 10 points | + +Tier thresholds: `low < 18`, `medium 18–39`, `high 40–64`, `critical ≥ 65`. + +## Tier policies + +| Tier | Max transaction | Daily limit | Payout hold | Manual review | +| --- | --- | --- | --- | --- | +| low | $50,000 | $250,000 | 2 days | no | +| medium | $20,000 | $75,000 | 5 days | no | +| high | $5,000 | $20,000 | 14 days | yes | +| critical | $1,000 | $5,000 | 30 days | yes | + +Each tier also declares the set of onboarding checks required before approval; +`critical` additionally requires `enhanced_due_diligence`. + +## API + +| Method | Path | Description | +| --- | --- | --- | +| POST | `/api/v1/merchant-risk/:merchantId/assess` | Run a risk assessment and assign a tier | +| POST | `/api/v1/merchant-risk/:merchantId/reassess` | Periodic re-scoring | +| GET | `/api/v1/merchant-risk/policies` | List all tier policies | +| GET | `/api/v1/merchant-risk/policies/:tier` | Retrieve one tier policy | +| GET | `/api/v1/merchant-risk` | List assessments (optional `?tier=`) | +| GET | `/api/v1/merchant-risk/:merchantId` | Retrieve a merchant assessment | +| POST | `/api/v1/merchant-risk/:merchantId/evaluate` | Check a transaction against tier limits | + +### Assess a merchant + +```http +POST /api/v1/merchant-risk/m-123/assess +Content-Type: application/json + +{ + "businessAgeMonths": 3, + "monthlyVolumeUsd": 40000, + "chargebacks90d": 1, + "refundRatePct": 8, + "sanctionsHits": 0, + "kycVerified": true, + "kybVerified": false, + "jurisdictionRisk": 0.2 +} +``` + +The response includes the assigned `tier`, `score`, explanatory `factors`, and +the effective `policy`. diff --git a/docs/README.md b/docs/README.md index 1d9f3ed1..10f0600c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,8 +13,10 @@ Use this page as the entry point for the repository's maintained guides. API-spe ## Payments and billing +- [Chargeback management](CHARGEBACK_MANAGEMENT.md) - [Instant transfers](INSTANT_TRANSFERS.md) - [Marketplace escrow](MARKETPLACE_ESCROW.md) +- [Merchant risk tiering](MERCHANT_RISK_TIERING.md) - [Nano (XNO) settle rail](NANO_SETTLE_RAIL.md) - [Payment links](PAYMENT_LINKS.md) - [Payment correctness](PAYMENT_CORRECTNESS.md)