Files
edr-platform/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts

250 lines
7.2 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
export interface FraudRuleConfig {
type: 'VELOCITY' | 'HIGH_VALUE' | 'FAILED_PAYMENTS' | 'MULTIPLE_METHODS';
enabled: boolean;
threshold: number;
timeWindowMinutes?: number;
blockDurationMinutes?: number;
}
@Injectable()
export class FraudService {
private readonly logger = new Logger(FraudService.name);
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
) {}
/**
* Evaluate fraud rules and create alerts if triggered
*/
async evaluateRules(
passengerId: string,
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
context: Record<string, unknown>,
): Promise<{ triggered: boolean; rules: string[] }> {
const triggeredRules: string[] = [];
if (eventType === 'booking.created') {
const velocityTriggered = await this.checkVelocityRule(passengerId);
if (velocityTriggered) triggeredRules.push('VELOCITY');
const amount = (context.amountMinor as number) || 0;
const highValueTriggered = await this.checkHighValueRule(amount);
if (highValueTriggered) triggeredRules.push('HIGH_VALUE');
}
if (eventType === 'payment.failed') {
const failedPaymentTriggered = await this.checkFailedPaymentRule(passengerId);
if (failedPaymentTriggered) triggeredRules.push('FAILED_PAYMENTS');
}
if (triggeredRules.length > 0) {
await this.createFraudAlert(passengerId, eventType, triggeredRules, context);
return { triggered: true, rules: triggeredRules };
}
return { triggered: false, rules: [] };
}
/**
* Check velocity rule: X bookings in Y minutes
*/
private async checkVelocityRule(passengerId: string): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'VELOCITY', enabled: true },
});
if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
const bookingCount = await this.prisma.booking.count({
where: {
passengerId,
createdAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
},
});
return bookingCount > rule.threshold;
}
/**
* Check high-value booking rule
*/
private async checkHighValueRule(amountMinor: number): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'HIGH_VALUE', enabled: true },
});
if (!rule) return false;
// threshold is in ETB (convert minor units to ETB)
const amountEtb = amountMinor / 100;
return amountEtb > rule.threshold;
}
/**
* Check failed payment rule: X failed attempts in Y minutes
*/
private async checkFailedPaymentRule(passengerId: string): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'FAILED_PAYMENTS', enabled: true },
});
if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
const failedCount = await this.prisma.paymentIntent.count({
where: {
booking: { passengerId },
status: 'FAILED',
updatedAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
},
});
return failedCount > rule.threshold;
}
/**
* Create a fraud alert
*/
private async createFraudAlert(
passengerId: string,
eventType: string,
triggeredRules: string[],
context: Record<string, unknown>,
): Promise<void> {
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
select: { iamUserId: true },
});
const alert = await this.prisma.fraudAlert.create({
data: {
iamUserId: passenger?.iamUserId ?? passengerId,
eventType,
triggeredRules,
context: context as any,
severity: triggeredRules.length > 1 ? 'HIGH' : 'MEDIUM',
},
});
this.logger.warn(`Fraud alert created: ${alert.id} for passenger ${passengerId} - rules: ${triggeredRules.join(', ')}`);
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
if (passenger?.iamUserId) await this.blockUserTemporarily(passenger.iamUserId, 30);
}
}
/**
* Block user temporarily
*/
async blockUserTemporarily(iamUserId: string, durationMinutes: number): Promise<void> {
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
await this.prisma.passenger.updateMany({
where: { iamUserId },
data: { blockedUntil },
});
this.logger.warn(`Passenger (iamUserId=${iamUserId}) blocked until ${blockedUntil.toISOString()}`);
}
/**
* Unblock user
*/
async unblockUser(iamUserId: string): Promise<void> {
await this.prisma.passenger.updateMany({
where: { iamUserId },
data: { blockedUntil: null },
});
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
}
/**
* Get all fraud alerts
*/
async getAlerts(userId?: string, limit = 100, offset = 0) {
return this.prisma.fraudAlert.findMany({
where: userId ? { iamUserId: userId } : {},
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
});
}
/**
* Create or update a fraud rule
*/
async upsertRule(
type: string,
config: FraudRuleConfig,
) {
return this.prisma.fraudRule.upsert({
where: { type: type as any },
update: {
enabled: config.enabled,
threshold: config.threshold,
config: config as any,
},
create: {
type: type as any,
enabled: config.enabled,
threshold: config.threshold,
config: config as any,
},
});
}
/**
* Get all fraud rules
*/
async getRules() {
return this.prisma.fraudRule.findMany();
}
/**
* Event listener for booking created
*/
@OnEvent('booking.created')
async onBookingCreated(payload: { booking: any }) {
await this.evaluateRules(payload.booking.passengerId, 'booking.created', {
bookingId: payload.booking.id,
amountMinor: payload.booking.totalMinor,
});
}
/**
* Event listener for payment failed
*/
@OnEvent('payment.failed')
async onPaymentFailed(payload: { booking: { passengerId: string; id: string } }) {
if (!payload.booking?.passengerId) return;
await this.evaluateRules(payload.booking.passengerId, 'payment.failed', {
bookingId: payload.booking.id,
});
}
/**
* Event listener for auth login failed
*/
@OnEvent('auth.login.failed')
async onLoginFailed(payload: { email: string }) {
if (!payload.email) return;
const iamRows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
[payload.email],
);
if (!iamRows.length) return;
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: iamRows[0].id },
select: { id: true },
});
if (!passenger) return;
await this.evaluateRules(passenger.id, 'auth.login.failed', { email: payload.email });
}
}