Transaction Monitoring
Implement real-time fraud detection by scoring transactions and monitoring for suspicious patterns.Business Scenario
For each transaction, you want to:- Calculate a risk score based on multiple signals
- Compare against historical patterns
- Check VPAs against blocklists
- Make approve/review/block decisions in real-time
Architecture
Implementation
Step 1: Create Risk Scoring Service
Copy
// services/transaction-scoring.ts
import { TxnCheckClient } from './fraud-buster';
export interface TransactionSignals {
senderMobile: string;
senderVpa: string;
recipientVpa: string;
amount: number;
currency: string;
deviceId?: string;
ipAddress?: string;
location?: { lat: number; lon: number };
timestamp: Date;
}
export interface RiskScore {
score: number; // 0-100
level: 'low' | 'medium' | 'high' | 'critical';
decision: 'approve' | 'review' | 'block';
factors: RiskFactor[];
processingTimeMs: number;
}
export interface RiskFactor {
name: string;
weight: number;
triggered: boolean;
details?: string;
}
export class TransactionScoringService {
private client: TxnCheckClient;
// Configurable thresholds
private readonly THRESHOLDS = {
LOW: 30,
MEDIUM: 60,
HIGH: 80,
};
constructor(apiKey: string) {
this.client = new TxnCheckClient({ apiKey });
}
async scoreTransaction(signals: TransactionSignals): Promise<RiskScore> {
const startTime = Date.now();
const factors: RiskFactor[] = [];
let totalScore = 0;
// Factor 1: VPA Blocklist Check (Weight: 40)
const blocklistFactor = await this.checkBlocklist(signals);
factors.push(blocklistFactor);
if (blocklistFactor.triggered) {
totalScore += blocklistFactor.weight;
}
// Factor 2: VPA Ownership (Weight: 25)
const ownershipFactor = await this.checkVPAOwnership(signals);
factors.push(ownershipFactor);
if (ownershipFactor.triggered) {
totalScore += ownershipFactor.weight;
}
// Factor 3: Amount Anomaly (Weight: 15)
const amountFactor = await this.checkAmountAnomaly(signals);
factors.push(amountFactor);
if (amountFactor.triggered) {
totalScore += amountFactor.weight;
}
// Factor 4: Velocity Check (Weight: 10)
const velocityFactor = await this.checkVelocity(signals);
factors.push(velocityFactor);
if (velocityFactor.triggered) {
totalScore += velocityFactor.weight;
}
// Factor 5: Time of Day (Weight: 5)
const timeFactor = this.checkTimeOfDay(signals);
factors.push(timeFactor);
if (timeFactor.triggered) {
totalScore += timeFactor.weight;
}
// Factor 6: New Recipient (Weight: 5)
const newRecipientFactor = await this.checkNewRecipient(signals);
factors.push(newRecipientFactor);
if (newRecipientFactor.triggered) {
totalScore += newRecipientFactor.weight;
}
const score = Math.min(totalScore, 100);
const level = this.getLevel(score);
const decision = this.getDecision(score);
return {
score,
level,
decision,
factors,
processingTimeMs: Date.now() - startTime,
};
}
private async checkBlocklist(signals: TransactionSignals): Promise<RiskFactor> {
const factor: RiskFactor = {
name: 'VPA_BLOCKLIST',
weight: 40,
triggered: false,
};
try {
const result = await this.client.vpaChargebackCheck(
[signals.senderVpa, signals.recipientVpa],
{ sync: true }
);
const blocklisted = result.result?.blocklisted || [];
if (blocklisted.length > 0) {
factor.triggered = true;
factor.details = `Blocklisted VPAs: ${blocklisted.map((v: any) => v.vpa).join(', ')}`;
}
} catch (error) {
console.error('Blocklist check failed:', error);
// Don't block on error, but add minor risk
factor.weight = 10;
factor.triggered = true;
factor.details = 'Blocklist check unavailable';
}
return factor;
}
private async checkVPAOwnership(signals: TransactionSignals): Promise<RiskFactor> {
const factor: RiskFactor = {
name: 'VPA_OWNERSHIP',
weight: 25,
triggered: false,
};
try {
const result = await this.client.upiByMobile(signals.senderMobile, { sync: true });
const userVpas = (result.result?.upi || []).map((v: string) => v.toLowerCase());
if (!userVpas.includes(signals.senderVpa.toLowerCase())) {
factor.triggered = true;
factor.details = 'Sender VPA not linked to registered mobile';
}
} catch (error) {
console.error('VPA ownership check failed:', error);
}
return factor;
}
private async checkAmountAnomaly(signals: TransactionSignals): Promise<RiskFactor> {
const factor: RiskFactor = {
name: 'AMOUNT_ANOMALY',
weight: 15,
triggered: false,
};
// Get user's typical transaction amount from your database
const avgAmount = await this.getUserAverageAmount(signals.senderMobile);
if (avgAmount > 0 && signals.amount > avgAmount * 5) {
factor.triggered = true;
factor.details = `Amount ${signals.amount} is 5x higher than average ${avgAmount}`;
}
return factor;
}
private async checkVelocity(signals: TransactionSignals): Promise<RiskFactor> {
const factor: RiskFactor = {
name: 'VELOCITY',
weight: 10,
triggered: false,
};
// Check transaction count in last hour from your database
const recentCount = await this.getRecentTransactionCount(
signals.senderMobile,
60 // minutes
);
if (recentCount > 10) {
factor.triggered = true;
factor.details = `${recentCount} transactions in last hour`;
}
return factor;
}
private checkTimeOfDay(signals: TransactionSignals): RiskFactor {
const factor: RiskFactor = {
name: 'UNUSUAL_TIME',
weight: 5,
triggered: false,
};
const hour = signals.timestamp.getHours();
// High-risk hours: 1 AM - 5 AM
if (hour >= 1 && hour <= 5) {
factor.triggered = true;
factor.details = `Transaction at ${hour}:00 (unusual hours)`;
}
return factor;
}
private async checkNewRecipient(signals: TransactionSignals): Promise<RiskFactor> {
const factor: RiskFactor = {
name: 'NEW_RECIPIENT',
weight: 5,
triggered: false,
};
// Check if sender has transacted with recipient before
const hasHistory = await this.hasTransactionHistory(
signals.senderMobile,
signals.recipientVpa
);
if (!hasHistory) {
factor.triggered = true;
factor.details = 'First transaction to this recipient';
}
return factor;
}
private getLevel(score: number): 'low' | 'medium' | 'high' | 'critical' {
if (score < this.THRESHOLDS.LOW) return 'low';
if (score < this.THRESHOLDS.MEDIUM) return 'medium';
if (score < this.THRESHOLDS.HIGH) return 'high';
return 'critical';
}
private getDecision(score: number): 'approve' | 'review' | 'block' {
if (score < this.THRESHOLDS.MEDIUM) return 'approve';
if (score < this.THRESHOLDS.HIGH) return 'review';
return 'block';
}
// Placeholder methods - implement with your database
private async getUserAverageAmount(mobile: string): Promise<number> {
// Query your transactions database
return 5000; // Default average
}
private async getRecentTransactionCount(mobile: string, minutes: number): Promise<number> {
// Query your transactions database
return 0;
}
private async hasTransactionHistory(senderMobile: string, recipientVpa: string): Promise<boolean> {
// Query your transactions database
return false;
}
}
Step 2: Transaction API Endpoint
Copy
// routes/transactions.ts
import express from 'express';
import { TransactionScoringService } from '../services/transaction-scoring';
const router = express.Router();
const scoringService = new TransactionScoringService(process.env.FRAUD_BUSTER_API_KEY!);
router.post('/score', async (req, res) => {
const {
senderMobile,
senderVpa,
recipientVpa,
amount,
currency = 'INR',
transactionId,
} = req.body;
try {
const riskScore = await scoringService.scoreTransaction({
senderMobile,
senderVpa,
recipientVpa,
amount,
currency,
deviceId: req.headers['x-device-id'] as string,
ipAddress: req.ip,
timestamp: new Date(),
});
// Log for audit
await logRiskScore(transactionId, riskScore);
// Take action based on decision
switch (riskScore.decision) {
case 'approve':
return res.json({
approved: true,
riskScore: riskScore.score,
riskLevel: riskScore.level,
});
case 'review':
// Auto-approve but flag for review
await flagForReview(transactionId, riskScore);
return res.json({
approved: true,
riskScore: riskScore.score,
riskLevel: riskScore.level,
flaggedForReview: true,
});
case 'block':
// Block and alert
await alertFraudTeam(transactionId, riskScore);
return res.status(403).json({
approved: false,
riskScore: riskScore.score,
riskLevel: riskScore.level,
message: 'Transaction blocked for security review',
});
}
} catch (error) {
console.error('Transaction scoring error:', error);
// Fail open with review flag
res.json({
approved: true,
riskScore: 50,
riskLevel: 'medium',
flaggedForReview: true,
error: 'Scoring service unavailable',
});
}
});
// Get risk factors for a transaction (for fraud analysts)
router.get('/:transactionId/risk-factors', async (req, res) => {
const { transactionId } = req.params;
const riskLog = await getRiskLog(transactionId);
if (!riskLog) {
return res.status(404).json({ error: 'Transaction not found' });
}
res.json({
transactionId,
score: riskLog.score,
level: riskLog.level,
decision: riskLog.decision,
factors: riskLog.factors,
timestamp: riskLog.timestamp,
});
});
export default router;
Step 3: Real-time Monitoring Dashboard
Copy
// services/monitoring-dashboard.ts
import { TransactionScoringService, RiskScore } from './transaction-scoring';
export interface MonitoringStats {
totalTransactions: number;
approvedCount: number;
reviewCount: number;
blockedCount: number;
averageScore: number;
scoreDistribution: Record<string, number>;
topRiskFactors: { name: string; count: number }[];
}
export class MonitoringDashboard {
private recentScores: RiskScore[] = [];
private readonly MAX_HISTORY = 10000;
recordScore(score: RiskScore): void {
this.recentScores.push(score);
if (this.recentScores.length > this.MAX_HISTORY) {
this.recentScores.shift();
}
}
getStats(minutes: number = 60): MonitoringStats {
const cutoff = Date.now() - minutes * 60 * 1000;
const recent = this.recentScores.filter(
(s) => s.processingTimeMs > cutoff
);
const totalTransactions = recent.length;
const approvedCount = recent.filter((s) => s.decision === 'approve').length;
const reviewCount = recent.filter((s) => s.decision === 'review').length;
const blockedCount = recent.filter((s) => s.decision === 'block').length;
const averageScore = recent.reduce((sum, s) => sum + s.score, 0) / totalTransactions || 0;
// Score distribution buckets
const scoreDistribution: Record<string, number> = {
'0-20': 0,
'21-40': 0,
'41-60': 0,
'61-80': 0,
'81-100': 0,
};
recent.forEach((s) => {
if (s.score <= 20) scoreDistribution['0-20']++;
else if (s.score <= 40) scoreDistribution['21-40']++;
else if (s.score <= 60) scoreDistribution['41-60']++;
else if (s.score <= 80) scoreDistribution['61-80']++;
else scoreDistribution['81-100']++;
});
// Top risk factors
const factorCounts: Record<string, number> = {};
recent.forEach((s) => {
s.factors
.filter((f) => f.triggered)
.forEach((f) => {
factorCounts[f.name] = (factorCounts[f.name] || 0) + 1;
});
});
const topRiskFactors = Object.entries(factorCounts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
return {
totalTransactions,
approvedCount,
reviewCount,
blockedCount,
averageScore: Math.round(averageScore),
scoreDistribution,
topRiskFactors,
};
}
}
Configuring Thresholds
Adjust thresholds based on your risk appetite:| Profile | Low Threshold | Medium Threshold | High Threshold | Use Case |
|---|---|---|---|---|
| Conservative | 20 | 40 | 60 | High-value transactions |
| Balanced | 30 | 60 | 80 | General e-commerce |
| Aggressive | 40 | 70 | 90 | Low-risk payments |
Copy
// Dynamic threshold configuration
const THRESHOLD_PROFILES = {
conservative: { LOW: 20, MEDIUM: 40, HIGH: 60 },
balanced: { LOW: 30, MEDIUM: 60, HIGH: 80 },
aggressive: { LOW: 40, MEDIUM: 70, HIGH: 90 },
};
// Choose profile based on transaction type
function getThresholds(transactionType: string) {
switch (transactionType) {
case 'large_transfer': return THRESHOLD_PROFILES.conservative;
case 'p2p_payment': return THRESHOLD_PROFILES.balanced;
case 'small_purchase': return THRESHOLD_PROFILES.aggressive;
default: return THRESHOLD_PROFILES.balanced;
}
}
Production Recommendations
Cache Results
Cache blocklist results for 5-10 minutes to reduce latency
Async Logging
Log risk scores asynchronously to avoid blocking transactions
Circuit Breaker
Implement circuit breaker for API failures with graceful degradation
A/B Testing
Test threshold changes with shadow mode before production
