Skip to main content

Transaction Monitoring

Implement real-time fraud detection by scoring transactions and monitoring for suspicious patterns.

Business Scenario

For each transaction, you want to:
  1. Calculate a risk score based on multiple signals
  2. Compare against historical patterns
  3. Check VPAs against blocklists
  4. Make approve/review/block decisions in real-time

Architecture

Implementation

Step 1: Create Risk Scoring Service

// 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

// 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

// 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:
ProfileLow ThresholdMedium ThresholdHigh ThresholdUse Case
Conservative204060High-value transactions
Balanced306080General e-commerce
Aggressive407090Low-risk payments
// 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