mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
250 lines
7.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|