Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[bal-3193] - backward compatbility for previous alert's subject #2898

Merged
merged 5 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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];
liorzam marked this conversation as resolved.
Show resolved Hide resolved

// 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, {
liorzam marked this conversation as resolved.
Show resolved Hide resolved
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,
);
liorzam marked this conversation as resolved.
Show resolved Hide resolved

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
Loading