Skip to content

Commit

Permalink
[bal-3193] - backward compatbility for previous alert's subject (#2898)
Browse files Browse the repository at this point in the history
  • Loading branch information
liorzam authored Dec 19, 2024
1 parent a82ff89 commit 4e6945e
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 39 deletions.
3 changes: 2 additions & 1 deletion services/workflows-service/src/alert/alert.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ export class AlertRepository {
}

async findFirst<T extends Pick<Prisma.AlertFindFirstArgs, 'where' | 'orderBy'>>(
args: Prisma.SelectSubset<T, Pick<Prisma.AlertFindFirstArgs, 'where' | 'orderBy'>>,
args: Prisma.SelectSubset<T, Pick<Prisma.AlertFindFirstArgs, 'where' | 'orderBy' | 'include'>>,
projectIds: TProjectIds,
) {
const queryArgs = this.scopeService.scopeFindFirst(args, projectIds);

return await this.prisma.extendedClient.alert.findFirst({
...queryArgs,
where: queryArgs.where,
orderBy: {
createdAt: 'desc',
Expand Down
16 changes: 12 additions & 4 deletions services/workflows-service/src/alert/alert.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DedupeWindow, TDedupeStrategy, TExecutionDetails } from './types';
import { computeHash } from '@ballerine/common';
import { convertTimeUnitToMilliseconds } from '@/data-analytics/utils';
import { DataInvestigationService } from '@/data-analytics/data-investigation.service';
import { equals } from 'class-validator';

const DEFAULT_DEDUPE_STRATEGIES = {
cooldownTimeframeInMinutes: 60 * 24,
Expand Down Expand Up @@ -63,12 +64,17 @@ export class AlertService {
async getAlertWithDefinition(
alertId: string,
projectId: string,
monitoringType: MonitoringType,
): Promise<(Alert & { alertDefinition: AlertDefinition }) | null> {
const alert = await this.alertRepository.findById(
alertId,
const alert = await this.alertRepository.findFirst(
{
where: {
id: alertId,
alertDefinition: {
monitoringType: {
equals: monitoringType,
},
},
},
include: {
alertDefinition: true,
Expand Down Expand Up @@ -346,7 +352,7 @@ export class AlertService {
const projectId = alertDef.projectId;
const now = new Date();

return this.alertRepository.create({
const alertData = {
data: {
projectId,
alertDefinitionId: alertDef.id,
Expand All @@ -371,7 +377,9 @@ export class AlertService {
createdAt: now,
dataTimestamp: now,
},
});
};

return this.alertRepository.create(alertData);
}

private async isDuplicateAlert(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SubjectRecord } from '@/alert/types';
import { ALERT_DEFINITIONS } from './../../scripts/alerts/generate-alerts';
import { SubjectRecord, TExecutionDetails } from '@/alert/types';
import { AppLoggerService } from '@/common/app-logger/app-logger.service';
import { Injectable } from '@nestjs/common';
import { Alert, PaymentMethod, Prisma, TransactionRecordType } from '@prisma/client';
Expand All @@ -15,12 +16,13 @@ import {
TPeerGroupTransactionAverageOptions,
TransactionsAgainstDynamicRulesType,
} from './types';
import type { AlertService } from '@/alert/alert.service';

@Injectable()
export class DataInvestigationService {
constructor(protected readonly logger: AppLoggerService) {}

getInvestigationFilter(projectId: string, inlineRule: InlineRule, subject: SubjectRecord) {
getInvestigationFilter(projectId: string, inlineRule: InlineRule, subject?: SubjectRecord) {
let investigationFilter;

switch (inlineRule.fnInvestigationName) {
Expand Down Expand Up @@ -91,13 +93,51 @@ export class DataInvestigationService {
}

return {
...subject,
// TODO: Backward compatibility, Remove this when all rules are updated, this is a temporary fix
...investigationFilter,
...this.buildSubjectFilterCompetability(inlineRule, subject),
...this._buildTransactionsFiltersByAlert(inlineRule),
projectId,
} satisfies Prisma.TransactionRecordWhereInput;
}

// TODO: can be removed after all rules are updated, support for subjects in the alert
buildSubjectFilterCompetabilityByAlert(
alert: NonNullable<Awaited<ReturnType<AlertService['getAlertWithDefinition']>>>,
) {
const inlineRule =
ALERT_DEFINITIONS[alert.alertDefinition.ruleId as keyof typeof ALERT_DEFINITIONS]?.inlineRule;

if (!inlineRule) {
this.logger.error(`Couldnt find related alert definition by ruleId`, {
alert,
});

return {};
}

const subject = (alert.executionDetails as TExecutionDetails).subject;

return this.buildSubjectFilterCompetability(inlineRule, subject);
}

// TODO: can be removed after all rules are updated
buildSubjectFilterCompetability(inlineRule: InlineRule, subject?: SubjectRecord) {
return {
...(subject?.counterpartyId &&
(inlineRule.subjects[0] === 'counterpartyOriginatorId' ||
inlineRule.subjects[0] === 'counterpartyBeneficiaryId') && {
[inlineRule.subjects[0]]: subject.counterpartyId,
}),
...(subject?.counterpartyOriginatorId && {
counterpartyOriginatorId: subject.counterpartyOriginatorId,
}),
...(subject?.counterpartyBeneficiaryId && {
counterpartyBeneficiaryId: subject?.counterpartyBeneficiaryId,
}),
};
}

investigateTransactionsAgainstDynamicRules(options: TransactionsAgainstDynamicRulesType) {
const {
amountBetween,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export const createAlertDefinition = async (
},
excludePaymentMethods: faker.datatype.boolean(),
},
subjects: [faker.helpers.arrayElement(['counterpartyId', 'businessId', 'transactionId'])],
subjects: [
faker.helpers.arrayElement(['counterpartyBeneficiaryId', 'counterpartyOriginatorId']),
],
},
};

Expand Down
11 changes: 10 additions & 1 deletion services/workflows-service/src/test/helpers/create-alert.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { AlertService } from '@/alert/alert.service';
import { AlertDefinition } from '@prisma/client';
import { createTransactionRecord } from './create-transaction-record';
import { InlineRule } from '@/data-analytics/types';

export const createAlert = async (
projectId: string,
alertDefinition: AlertDefinition,
alertService: AlertService,
transactions: Awaited<ReturnType<typeof createTransactionRecord>>,
) => {
const subject = (alertDefinition.inlineRule as InlineRule).subjects[0] as
| 'counterpartyBeneficiaryId'
| 'counterpartyOriginatorId';

const subjectValue = transactions[0]?.[subject];

// Accessing private method for testing purposes while maintaining types
return await alertService.createAlert(
{
...alertDefinition,
projectId,
},
[],
[{ [subject]: subjectValue }],
{},
{},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Test } from '@nestjs/testing';
import { ClsModule } from 'nestjs-cls';
import { ProjectScopeService } from '@/project/project-scope.service';
import { DataAnalyticsService } from '@/data-analytics/data-analytics.service';
import { TransactionCreatedDto } from '@/transaction/dtos/transaction-created.dto';

export const createTransactionRecord = async (
prisma: PrismaClient,
Expand Down Expand Up @@ -122,7 +123,7 @@ export const createTransactionRecord = async (
brand: faker.helpers.arrayElement(['Visa', 'Mastercard', 'Amex']),
expiryMonth: faker.date.future().getMonth().toString().padStart(2, '0'),
expiryYear: faker.date.future().getFullYear().toString(),
holderName: faker.name.findName(),
holderName: faker.name.fullName(),
tokenized: faker.random.alphaNumeric(24),
cardBin: parseInt(faker.finance.creditCardNumber().slice(0, 6)),
},
Expand All @@ -131,10 +132,30 @@ export const createTransactionRecord = async (
};

// Use createBulk to create the transaction
const createdTransactions = await transactionService.createBulk({
const createdTransactions = (await transactionService.createBulk({
transactionsPayload: [transactionCreateDto],
projectId: project.id,
})) satisfies Array<
| TransactionCreatedDto
| {
errorMessage: string;
correlationId: string;
}
>;

const result = await prisma.transactionRecord.findMany({
include: {
counterpartyOriginator: true,
counterpartyBeneficiary: true,
},
where: {
id: {
in: createdTransactions
.map(transaction => 'id' in transaction && transaction.id)
.filter(Boolean),
},
},
});

return createdTransactions;
return result;
};
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ describe('#TransactionControllerExternal', () => {
const createTransactionWithDate = async (daysAgo: number) => {
const currentDate = new Date();

await createTransactionRecord(app.get(PrismaService), project, {
return await createTransactionRecord(app.get(PrismaService), project, {
date: new Date(currentDate.getTime() - daysAgo * 24 * 60 * 60 * 1000),
});
};
Expand All @@ -635,9 +635,8 @@ describe('#TransactionControllerExternal', () => {
getAlertDefinitionWithTimeOptions('days', 7) as any,
alertService,
);
const alert = await createAlert(project.id, alertDefinition, alertService);

await Promise.all([
const result = await Promise.all([
// 5 transactions in the past 7 days
createTransactionWithDate(1),
createTransactionWithDate(3),
Expand All @@ -652,12 +651,14 @@ describe('#TransactionControllerExternal', () => {
createTransactionWithDate(20),
]);

const alert = await createAlert(project.id, alertDefinition, alertService, result[0]);

const response = await request(app.getHttpServer())
.get(`/external/transactions/by-alert?alertId=${alert.id}`)
.set('authorization', `Bearer ${API_KEY}`);

expect(response.status).toBe(200);
expect(response.body).toHaveLength(5);
expect(response.body).toHaveLength(1);
});
it('returns 404 when alertId is not found', async () => {
const nonExistentAlertId = faker.datatype.uuid();
Expand All @@ -674,12 +675,15 @@ describe('#TransactionControllerExternal', () => {
getAlertDefinitionWithTimeOptions('days', 1) as any,
alertService,
);
const alert = await createAlert(project.id, alertDefinition, alertService);
// TODO: shouldnt happen, might we remove this test?
const alert = await createAlert(project.id, alertDefinition, alertService, []);

// Create a transaction older than the alert criteria
await createTransactionRecord(app.get(PrismaService), project, {
date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
});
const tx1 = (
await createTransactionRecord(app.get(PrismaService), project, {
date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
})
)[0];

const response = await request(app.getHttpServer())
.get(`/external/transactions/by-alert?alertId=${alert.id}`)
Expand Down Expand Up @@ -710,7 +714,7 @@ describe('#TransactionControllerExternal', () => {
alertService,
);

const alert = await createAlert(otherProject.id, alertDefinition, alertService);
const alert = await createAlert(otherProject.id, alertDefinition, alertService, []);

const response = await request(app.getHttpServer())
.get(`/external/transactions/by-alert?alertId=${alert.id}`)
Expand All @@ -726,24 +730,26 @@ describe('#TransactionControllerExternal', () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);

// Create transactions at different times
await createTransactionRecord(app.get(PrismaService), project, { date: fifteenDaysAgo });
await createTransactionRecord(app.get(PrismaService), project, { date: tenDaysAgo });
await createTransactionRecord(app.get(PrismaService), project, { date: fiveDaysAgo });
const tx1 = (
await createTransactionRecord(app.get(PrismaService), project, {
date: fifteenDaysAgo,
})
)[0];

alertDefinition = await createAlertDefinition(
project.id,
getAlertDefinitionWithTimeOptions('days', 15) as any,
alertService,
);

const alert = await createAlert(project.id, alertDefinition, alertService);
const alert = await createAlert(project.id, alertDefinition, alertService, [tx1!]);

const response = await request(app.getHttpServer())
.get(`/external/transactions/by-alert?alertId=${alert.id}`)
.set('authorization', `Bearer ${API_KEY}`);

expect(response.status).toBe(200);
expect(response.body).toHaveLength(3);
expect(response.body).toHaveLength(1);

// Verify that all returned transactions are within the last 15 days
response.body.forEach((transaction: any) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
GetTransactionsDto,
} from '@/transaction/dtos/get-transactions.dto';
import { TransactionCreatedDto } from '@/transaction/dtos/transaction-created.dto';
import { PaymentMethod } from '@prisma/client';
import { MonitoringType, PaymentMethod } from '@prisma/client';
import { isEmpty } from 'lodash';
import { TransactionEntityMapper } from './transaction.mapper';
import { DataInvestigationService } from '@/data-analytics/data-investigation.service';
Expand Down Expand Up @@ -292,13 +292,6 @@ export class TransactionControllerExternal {

@Get('/by-alert')
// @UseCustomerAuthGuard()
@swagger.ApiOkResponse({ description: 'Returns an array of transactions.' })
@swagger.ApiQuery({ name: 'businessId', description: 'Filter by business ID.', required: false })
@swagger.ApiQuery({
name: 'counterpartyId',
description: 'Filter by counterparty ID.',
required: false,
})
@swagger.ApiQuery({
name: 'startDate',
type: Date,
Expand Down Expand Up @@ -339,13 +332,20 @@ export class TransactionControllerExternal {
@Query() filters: GetTransactionsByAlertDto,
@CurrentProject() projectId: types.TProjectId,
) {
const alert = await this.alertService.getAlertWithDefinition(filters.alertId, projectId);
const alert = await this.alertService.getAlertWithDefinition(
filters.alertId,
projectId,
MonitoringType.transaction_monitoring,
);

if (!alert) {
throw new errors.NotFoundException(`Alert with id ${filters.alertId} not found`);
}

if (!alert.alertDefinition) {
if (
!alert.alertDefinition ||
alert.alertDefinition.monitoringType !== MonitoringType.transaction_monitoring
) {
throw new errors.NotFoundException(`Alert definition not found for alert ${alert.id}`);
}

Expand Down Expand Up @@ -423,8 +423,10 @@ export class TransactionControllerExternal {
alert: NonNullable<Awaited<ReturnType<AlertService['getAlertWithDefinition']>>>;
filters: Pick<GetTransactionsByAlertDto, 'startDate' | 'endDate' | 'page' | 'orderBy'>;
}) {
const subject = this.dataInvestigationService.buildSubjectFilterCompetabilityByAlert(alert);

return this.service.getTransactions(projectId, filters, {
where: alert.executionDetails.filters,
where: { ...alert.executionDetails.filters, ...subject },
include: {
counterpartyBeneficiary: {
select: {
Expand Down
1 change: 0 additions & 1 deletion services/workflows-service/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"sourceRoot": "/", // Shortens file paths on Sentry
"exclude": [
"node_modules",
"prisma/migrations",
"prisma/schema.prisma",
"test",
"dist",
Expand Down

0 comments on commit 4e6945e

Please sign in to comment.