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;
}
@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 };
}
}
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();
}
}
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;
}
}
@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' };
}
}
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;
}
}
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 (3)
Great breakdown of the interceptor pattern for inline fraud scoring. One thing I would add from working with PSD2 bank transaction feeds:
Velocity rules need to dedup the pending to booked lifecycle. When a payment lands on an instant rail, it typically appears in the bank transaction stream twice: once as pending, once as booked. If your VelocityRule counts raw rows without collapsing those duplicates, a single real-world payment inflates the count and can trip the threshold - exactly the failure mode you described where you block legitimate payments out of caution. Keying the count on the bank transactionId (or endToEndId for ISO 20022 rails) with a hash fallback for the roughly 15 to 20 percent of transactions that arrive without one keeps the velocity window honest.
Cross-account layering is invisible to a per-transaction interceptor. Your TransactionContext is scoped to a single transaction, but structured-layering patterns (rapid movement across multiple accounts to obscure origin) only become visible at the customer level. When you aggregate across all accounts for a customer - which PSD2 consent gives you - running a secondary scoring pass at customer scope catches velocity patterns that no individual transaction would surface. This slots into your FraudRule interface cleanly as a separate rule, so it does not touch the core settlement path.
Solid catch on the dedup issue, and it maps onto exactly the same failure mode someone flagged on the AML article too, this pending to booked double count keeps showing up wherever a bank feed is the source of truth. If VelocityRule is counting raw rows, one real instant payment quietly becomes two, and now a completely normal customer trips the threshold for something that never actually happened. Keying the count on transactionId, or endToEndId for ISO 20022 rails, with a hash fallback for the transactions that arrive without one, is the right fix, and it is a good reminder that the interceptor is only as honest as the data feeding it. The cross account layering point is the one I want to sit with a bit longer. You are right that TransactionContext scoped to one transaction can never see this pattern by design, since the whole point of layering is spreading activity across accounts specifically so no single transaction looks unusual. Running a secondary scoring pass at the customer level, using what PSD2 consent already gives access to, fits cleanly as its own rule without touching settlement at all. That is a real gap in what I wrote, since inline scoring alone was never going to catch something that only exists at a wider scope. Appreciate you pointing this out clearly.
The customer-level pass is where this gets genuinely interesting, and you're right that PSD2 consent already unlocks it — with one caveat worth flagging. The account-list endpoint only returns the full account set when the original consent was granted at customer (AIS) scope rather than per-account, so the secondary scoring inherits whatever the customer actually consented to, not necessarily the entire relationship. Running it as a nightly batch keyed on the natural-person identifier keeps the inline interceptor fast and lets the harder cross-account math happen out of band. The sharp edge is intra-customer transfers: moving money between your own checking and savings is normal and should net out, but if the score just sums gross inflows across accounts it inflates the layering signal for entirely benign behavior. Normalizing by currency and removing the outbound leg on internal transfers, matched on amount plus a close timestamp plus counterparty self-reference, keeps the signal honest. And worth noting for the consent design itself — broadening AIS scope specifically for fraud detection is often cleaner as its own reauthorization step than piggybacking on the original payment consent, since customers reading the SCA prompt treat "make a payment" and "let us monitor all my accounts for fraud" as two genuinely different asks.