DEV Community

Cover image for NestJS Had Three Seconds to Decide if This Payment Was Fraud
Peace Melodi
Peace Melodi

Posted on

NestJS Had Three Seconds to Decide if This Payment Was Fraud

A payment comes in through an instant payment rail. It has to settle in seconds, not minutes, not hours. Somewhere inside that tiny window, a bank or fintech has to decide whether this transaction looks suspicious enough to hold, question, or block, before the money is gone and settlement is final. There is no quiet background job that can check this later, because by the time a later check would run, the payment has already cleared.

This is the real shift that instant payment rails like FedNow and RTP have forced onto fraud detection. Fraud checks used to have some breathing room, a batch job overnight, a review the next morning. Instant settlement removes that breathing room completely, and a backend that cannot make a fast, reliable decision in that moment is a backend that either blocks legitimate payments out of caution, or lets suspicious ones through because checking properly took too long.

Why fraud detection has to run inline, not after the fact

The instinct from older systems is to record the transaction, let it settle, and flag anything suspicious afterward for a human to look at. That approach assumes there is time after settlement to act, and with instant payments, there usually is not. Fraud detection for these rails needs to sit directly in the path of the transaction, before settlement is confirmed, which means it has to be fast and structured cleanly, not bolted on as an afterthought.

Structuring fraud rules as their own dedicated module

Fraud rules change constantly, new patterns get identified, thresholds get adjusted, and new checks get added. Keeping this logic in its own module, separate from payment processing itself, means rules can evolve without touching the core payment flow every time.

export interface FraudRule {
  name: string;
  evaluate(transaction: TransactionContext): FraudRuleResult;
}

export interface FraudRuleResult {
  triggered: boolean;
  riskScore: number;
  reason?: string;
}

export interface TransactionContext {
  amount: number;
  currency: string;
  senderAccountId: string;
  recipientAccountId: string;
  recentTransactionCount: number;
  isNewRecipient: boolean;
}
Enter fullscreen mode Exit fullscreen mode
@Injectable()
export class UnusualAmountRule implements FraudRule {
  name = 'unusual_amount';

  evaluate(transaction: TransactionContext): FraudRuleResult {
    const threshold = 5000;

    if (transaction.amount > threshold && transaction.isNewRecipient) {
      return {
        triggered: true,
        riskScore: 40,
        reason: 'Large payment to a new recipient',
      };
    }

    return { triggered: false, riskScore: 0 };
  }
}

@Injectable()
export class VelocityRule implements FraudRule {
  name = 'velocity';

  evaluate(transaction: TransactionContext): FraudRuleResult {
    if (transaction.recentTransactionCount > 10) {
      return {
        triggered: true,
        riskScore: 35,
        reason: 'Unusually high transaction frequency in a short window',
      };
    }

    return { triggered: false, riskScore: 0 };
  }
}
Enter fullscreen mode Exit fullscreen mode

Each rule stays simple and focused on one specific pattern. Adding a new rule later means writing a new class that follows the same interface, not editing a large block of tangled logic.

Scoring a transaction with an interceptor before it reaches settlement

An interceptor is a natural place to run every active rule against an incoming transaction, before the request is allowed to continue toward the part of the system that actually settles the payment.

@Injectable()
export class FraudScoringInterceptor implements NestInterceptor {
  constructor(
    @Inject('FRAUD_RULES') private readonly rules: FraudRule[],
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const transaction: TransactionContext = request.body.transactionContext;

    const results = this.rules.map((rule) => ({
      rule: rule.name,
      ...rule.evaluate(transaction),
    }));

    const totalRiskScore = results.reduce(
      (sum, result) => sum + result.riskScore,
      0,
    );

    request.fraudAssessment = {
      totalRiskScore,
      triggeredRules: results.filter((result) => result.triggered),
    };

    return next.handle();
  }
}
Enter fullscreen mode Exit fullscreen mode

Running this as an interceptor means every relevant endpoint gets consistent scoring, without each controller needing to remember to call the fraud logic manually.

Deciding what happens at each risk level

A raw risk score only matters if something clear happens because of it. A dedicated decision service keeps this logic in one place, so the thresholds for holding, reviewing, or blocking a transaction are defined once and applied consistently.

export enum FraudDecision {
  ALLOW = 'allow',
  HOLD_FOR_REVIEW = 'hold_for_review',
  BLOCK = 'block',
}

@Injectable()
export class FraudDecisionService {
  decide(totalRiskScore: number): FraudDecision {
    if (totalRiskScore >= 70) {
      return FraudDecision.BLOCK;
    }

    if (totalRiskScore >= 35) {
      return FraudDecision.HOLD_FOR_REVIEW;
    }

    return FraudDecision.ALLOW;
  }
}
Enter fullscreen mode Exit fullscreen mode
@Injectable()
export class SettlementService {
  constructor(private readonly decisionService: FraudDecisionService) {}

  async processTransaction(request: any): Promise<SettlementResult> {
    const decision = this.decisionService.decide(
      request.fraudAssessment.totalRiskScore,
    );

    if (decision === FraudDecision.BLOCK) {
      throw new ForbiddenException('Transaction blocked due to fraud risk');
    }

    if (decision === FraudDecision.HOLD_FOR_REVIEW) {
      return this.holdForManualReview(request);
    }

    return this.settle(request);
  }

  private async holdForManualReview(request: any): Promise<SettlementResult> {
    return { status: 'held', reason: 'Pending manual fraud review' };
  }

  private async settle(request: any): Promise<SettlementResult> {
    return { status: 'settled' };
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps the actual settlement logic clean, since it only needs to ask what the decision was, not repeat fraud logic itself.

Keeping this fast enough to matter

None of this is useful if it slows settlement down past what an instant payment rail allows. Rules need to stay simple and avoid slow external calls wherever possible, and anything that genuinely needs external data, like a recipient reputation check, should be cached aggressively so a repeated check does not cost a fresh lookup every time.

@Injectable()
export class RecipientReputationRule implements FraudRule {
  name = 'recipient_reputation';
  private cache = new Map<string, { score: number; cachedAt: number }>();
  private readonly cacheTtlMs = 30000;

  constructor(private readonly reputationService: ReputationService) {}

  async evaluateWithCache(recipientAccountId: string): Promise<number> {
    const cached = this.cache.get(recipientAccountId);

    if (cached && Date.now() - cached.cachedAt < this.cacheTtlMs) {
      return cached.score;
    }

    const score = await this.reputationService.getScore(recipientAccountId);
    this.cache.set(recipientAccountId, { score, cachedAt: Date.now() });

    return score;
  }
}
Enter fullscreen mode Exit fullscreen mode

A short cache window like this keeps the check fast for the vast majority of repeated recipients, while still refreshing regularly enough that reputation data does not go stale for long.

The bigger picture

Fraud detection built for instant payments is not just about having good rules, it is about having a system structured so those rules can run quickly, consistently, and in the right place, before settlement, not after. What NestJS gives you here is a clean separation between the rules themselves, the scoring step, and the decision that follows, which means the fraud logic can keep evolving without destabilizing the payment flow it sits inside.

No rules engine catches everything, and fraud patterns keep changing, but a system built this way at least gives a bank or fintech a fighting chance of catching what matters in the only window that is left to catch it.

If you are building payment infrastructure that needs fraud checks running fast and reliably inside a real settlement flow, this is exactly the kind of work I focus on.

I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.

LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi

Top comments (0)