diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d703048209..5bb140afa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21071,7 +21071,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.54.0)(typescript@4.9.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.54.0)(typescript@4.9.5) debug: 3.2.7 eslint: 8.54.0 eslint-import-resolver-node: 0.3.9 @@ -21236,7 +21236,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.54.0)(typescript@4.9.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.54.0)(typescript@4.9.5) array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 diff --git a/services/workflows-service/prisma/data-migrations b/services/workflows-service/prisma/data-migrations index 59b3de37c2..0e199894d2 160000 --- a/services/workflows-service/prisma/data-migrations +++ b/services/workflows-service/prisma/data-migrations @@ -1 +1 @@ -Subproject commit 59b3de37c295c337d398cebe1f0a95f93208eb9d +Subproject commit 0e199894d25f205daa956cd5c516a4663676aab8 diff --git a/services/workflows-service/prisma/migrations/20240415161621_add_monitoring_type/migration.sql b/services/workflows-service/prisma/migrations/20240415161621_add_monitoring_type/migration.sql new file mode 100644 index 0000000000..3176d98e9e --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240415161621_add_monitoring_type/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "MonitoringType" AS ENUM ('transaction_monitoring', 'ongoing_merchant_monitoring'); + +-- AlterTable with default in order to avoid the error for existing data +ALTER TABLE "AlertDefinition" ADD COLUMN "monitoringType" "MonitoringType" NOT NULL DEFAULT 'transaction_monitoring'; diff --git a/services/workflows-service/prisma/migrations/20240415161704_remove_alert_definition_monitoring_default_type/migration.sql b/services/workflows-service/prisma/migrations/20240415161704_remove_alert_definition_monitoring_default_type/migration.sql new file mode 100644 index 0000000000..e01cef4eb9 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240415161704_remove_alert_definition_monitoring_default_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AlertDefinition" ALTER COLUMN "monitoringType" DROP DEFAULT; diff --git a/services/workflows-service/prisma/migrations/20240415183939_associate_alert_with_business_for_ongoing_report/migration.sql b/services/workflows-service/prisma/migrations/20240415183939_associate_alert_with_business_for_ongoing_report/migration.sql new file mode 100644 index 0000000000..4b03dbbc80 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240415183939_associate_alert_with_business_for_ongoing_report/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Alert" ADD COLUMN "businessId" TEXT; + +-- AddForeignKey +ALTER TABLE "Alert" ADD CONSTRAINT "Alert_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/services/workflows-service/prisma/migrations/20240421105728_add_business_report_report_id/migration.sql b/services/workflows-service/prisma/migrations/20240421105728_add_business_report_report_id/migration.sql new file mode 100644 index 0000000000..1b08a1d122 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240421105728_add_business_report_report_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "BusinessReport" ADD COLUMN "reportId" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "BusinessReport_reportId_idx" ON "BusinessReport"("reportId"); diff --git a/services/workflows-service/prisma/migrations/20240502085516_add_risk_score_to_business_report/migration.sql b/services/workflows-service/prisma/migrations/20240502085516_add_risk_score_to_business_report/migration.sql new file mode 100644 index 0000000000..8c30bc573e --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240502085516_add_risk_score_to_business_report/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `riskScore` to the `BusinessReport` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "BusinessReport" ADD COLUMN "riskScore" INTEGER NOT NULL; + +-- CreateIndex +CREATE INDEX "BusinessReport_riskScore_idx" ON "BusinessReport"("riskScore"); diff --git a/services/workflows-service/prisma/migrations/20240502123851_add_alert_additional_info/migration.sql b/services/workflows-service/prisma/migrations/20240502123851_add_alert_additional_info/migration.sql new file mode 100644 index 0000000000..7c66235edb --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240502123851_add_alert_additional_info/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Alert" ADD COLUMN "additionalInfo" JSONB; diff --git a/services/workflows-service/prisma/migrations/20240516085653_add_unique_report_id/migration.sql b/services/workflows-service/prisma/migrations/20240516085653_add_unique_report_id/migration.sql new file mode 100644 index 0000000000..0b20e693f6 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20240516085653_add_unique_report_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[reportId]` on the table `BusinessReport` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "BusinessReport_reportId_idx"; + +-- CreateIndex +CREATE UNIQUE INDEX "BusinessReport_reportId_key" ON "BusinessReport"("reportId"); diff --git a/services/workflows-service/prisma/schema.prisma b/services/workflows-service/prisma/schema.prisma index cab5cd8dd2..ce8aab35dc 100644 --- a/services/workflows-service/prisma/schema.prisma +++ b/services/workflows-service/prisma/schema.prisma @@ -145,6 +145,7 @@ model Business { project Project @relation(fields: [projectId], references: [id]) Counterparty Counterparty[] businessReports BusinessReport[] + alerts Alert[] @@unique([projectId, correlationId]) @@index([companyName]) @@ -707,10 +708,16 @@ enum AlertStatus { completed @map("300") } +enum MonitoringType { + transaction_monitoring + ongoing_merchant_monitoring +} + model AlertDefinition { id String @id @default(cuid()) crossEnvKey String? @unique - correlationId String + correlationId String + monitoringType MonitoringType name String enabled Boolean @default(true) description String? @@ -758,6 +765,7 @@ model Alert { severity AlertSeverity? executionDetails Json + additionalInfo Json? assignee User? @relation(fields: [assigneeId], references: [id], onUpdate: Cascade, onDelete: NoAction) assigneeId String? @@ -772,6 +780,9 @@ model Alert { counterpartyId String? counterparty Counterparty? @relation(fields: [counterpartyId], references: [id]) + businessId String? + business Business? @relation(fields: [businessId], references: [id]) + @@index([assigneeId]) @@index([projectId]) @@index([alertDefinitionId]) @@ -830,19 +841,22 @@ model Counterparty { model BusinessReport { id String @id @default(cuid()) type BusinessReportType + reportId String @unique report Json + riskScore Int businessId String - projectId String + business Business @relation(fields: [businessId], references: [id]) - business Business @relation(fields: [businessId], references: [id]) - project Project @relation(fields: [projectId], references: [id]) + projectId String + project Project @relation(fields: [projectId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([createdAt]) @@index([businessId]) @@index([projectId]) + @@index([riskScore]) @@index([type]) } diff --git a/services/workflows-service/scripts/alerts/generate-alerts.ts b/services/workflows-service/scripts/alerts/generate-alerts.ts index d6ab4b97e2..f10e466554 100644 --- a/services/workflows-service/scripts/alerts/generate-alerts.ts +++ b/services/workflows-service/scripts/alerts/generate-alerts.ts @@ -1,9 +1,10 @@ import { ALERT_DEDUPE_STRATEGY_DEFAULT, + daysToMinutesConverter, + MerchantAlertLabel, SEVEN_DAYS, THREE_DAYS, TWENTY_ONE_DAYS, - daysToMinutesConverter, } from '@/alert/consts'; import { TDedupeStrategy } from '@/alert/types'; import { AggregateType, TIME_UNITS } from '@/data-analytics/consts'; @@ -14,6 +15,7 @@ import { AlertSeverity, AlertState, AlertStatus, + MonitoringType, PaymentMethod, Prisma, PrismaClient, @@ -358,6 +360,7 @@ export const ALERT_DEFINITIONS = { description: 'High Percentage of Chargebacks - High percentage of chargebacks over a set period of time', dedupeStrategy: { + mute: false, cooldownTimeframeInMinutes: daysToMinutesConverter(TWENTY_ONE_DAYS), }, inlineRule: { @@ -369,7 +372,7 @@ export const ALERT_DEFINITIONS = { subjectColumn: 'counterpartyOriginatorId', minimumCount: 3, minimumPercentage: 50, - timeAmount: 21, + timeAmount: TWENTY_ONE_DAYS, timeUnit: TIME_UNITS.days, }, }, @@ -537,16 +540,74 @@ export const ALERT_DEFINITIONS = { }, } as const satisfies Record[0]>; +export const MERCHANT_MONITORING_ALERT_DEFINITIONS = { + MERCHANT_ONGOING_RISK_ALERT_RISK_INCREASE: { + enabled: true, + defaultSeverity: AlertSeverity.low, + monitoringType: MonitoringType.ongoing_merchant_monitoring, + description: 'Monitor ongoing risk changes', + inlineRule: { + id: 'MERCHANT_ONGOING_RISK_ALERT_RISK_INCREASE', + fnName: 'checkMerchantOngoingAlert', + subjects: ['businessId', 'projectId'], + options: { + increaseRiskScore: 20, + }, + }, + }, + MERCHANT_ONGOING_RISK_ALERT_THRESHOLD: { + enabled: true, + defaultSeverity: AlertSeverity.high, + monitoringType: MonitoringType.ongoing_merchant_monitoring, + description: 'Monitor ongoing risk changes', + inlineRule: { + id: 'MERCHANT_ONGOING_RISK_ALERT_THRESHOLD', + fnName: 'checkMerchantOngoingAlert', + subjects: ['businessId', 'projectId'], + options: { + maxRiskScoreThreshold: 60, + }, + }, + }, + MERCHANT_ONGOING_RISK_ALERT_PERCENTAGE: { + enabled: true, + defaultSeverity: AlertSeverity.medium, + monitoringType: MonitoringType.ongoing_merchant_monitoring, + description: 'Monitor ongoing risk changes', + inlineRule: { + id: 'MERCHANT_ONGOING_RISK_ALERT_PERCENTAGE', + fnName: 'checkMerchantOngoingAlert', + subjects: ['businessId', 'projectId'], + options: { + increaseRiskScorePercentage: 30, + }, + }, + }, +} as const satisfies Partial< + Record< + keyof typeof MerchantAlertLabel | string, + { + inlineRule: InlineRule & InputJsonValue; + monitoringType: MonitoringType; + defaultSeverity: AlertSeverity; + enabled?: boolean; + description?: string; + } + > +>; + export const getAlertDefinitionCreateData = ( { inlineRule, defaultSeverity, description, + monitoringType = MonitoringType.transaction_monitoring, enabled = false, dedupeStrategy = ALERT_DEDUPE_STRATEGY_DEFAULT, }: { inlineRule: InlineRule; defaultSeverity: AlertSeverity; + monitoringType?: MonitoringType; enabled: boolean; dedupeStrategy?: Partial; description?: string; @@ -564,6 +625,7 @@ export const getAlertDefinitionCreateData = ( ...ALERT_DEDUPE_STRATEGY_DEFAULT, ...(dedupeStrategy ?? {}), }, + monitoringType: monitoringType ?? MonitoringType.transaction_monitoring, inlineRule, correlationId: id, name: id, @@ -587,6 +649,9 @@ export const generateAlertDefinitions = async ( createdBy = 'SYSTEM', alertsDef = ALERT_DEFINITIONS, }: { + alertsDefConfiguration?: + | typeof ALERT_DEFINITIONS + | typeof MERCHANT_MONITORING_ALERT_DEFINITIONS; createdBy?: string; project: Project; alertsDef?: Partial; @@ -627,18 +692,34 @@ export const generateAlertDefinitions = async ( }), ); -const generateFakeAlert = ({ - severity, - counterparyIds, - agentUserIds, -}: { - severity: AlertSeverity; - counterparyIds: string[]; - agentUserIds: string[]; -}): Omit => { - const counterypartyId = faker.helpers.arrayElement( - counterparyIds.map(id => ({ counterpartyId: id })), - ); +const generateFakeAlert = ( + options: { + severity: AlertSeverity; + agentUserIds: string[]; + } & ( + | { + counterpartyIds: string[]; + } + | { + businessIds: string[]; + } + ), +): Omit => { + const { severity, agentUserIds } = options; + + let businessId: { businessId: string } | {} = {}; + let counterpartyId: { counterpartyId: string } | {} = {}; + + if ('businessIds' in options) { + // For merchant monitoring alerts + businessId = faker.helpers.arrayElement(options.businessIds.map(id => ({ businessId: id }))); + } else if ('counterpartyIds' in options) { + // For transaction alerts + counterpartyId = faker.helpers.arrayElement( + options.counterpartyIds.map(id => ({ counterpartyId: id })), + ); + } + // In chance of 1 to 5, assign an agent to the alert const assigneeId = faker.datatype.number({ min: 1, max: 5 }) === 1 @@ -658,29 +739,39 @@ const generateFakeAlert = ({ } as InputJsonValue, severity, assigneeId, - ...counterypartyId, + ...counterpartyId, + ...businessId, }; }; -export const generateFakeAlertsAndDefinitions = async ( +export const seedTransactionsAlerts = async ( prisma: PrismaClient, { project, - counterparyIds, + businessIds, + counterpartyIds, agentUserIds, }: { project: Project; - counterparyIds: string[]; + businessIds: string[]; + counterpartyIds: string[]; agentUserIds: string[]; }, ) => { - const alertDefinitions = await generateAlertDefinitions(prisma, { + const transactionsAlertDef = await generateAlertDefinitions(prisma, { + alertsDefConfiguration: ALERT_DEFINITIONS, project, createdBy: faker.internet.userName(), }); - await Promise.all( - alertDefinitions.map(alertDefinition => + const merchantMonitoringAlertDef = await generateAlertDefinitions(prisma, { + alertsDefConfiguration: MERCHANT_MONITORING_ALERT_DEFINITIONS, + project, + createdBy: faker.internet.userName(), + }); + + await Promise.all([ + ...transactionsAlertDef.map(alertDefinition => prisma.alert.createMany({ data: Array.from( { @@ -690,7 +781,7 @@ export const generateFakeAlertsAndDefinitions = async ( alertDefinitionId: alertDefinition.id, projectId: project.id, ...generateFakeAlert({ - counterparyIds, + counterpartyIds, agentUserIds, severity: faker.helpers.arrayElement(Object.values(AlertSeverity)), }), @@ -699,5 +790,5 @@ export const generateFakeAlertsAndDefinitions = async ( skipDuplicates: true, }), ), - ); + ]); }; diff --git a/services/workflows-service/scripts/seed.ts b/services/workflows-service/scripts/seed.ts index 45343716f6..1ab681810b 100644 --- a/services/workflows-service/scripts/seed.ts +++ b/services/workflows-service/scripts/seed.ts @@ -1,4 +1,4 @@ -import { hashKey } from './../src/customer/api-key/utils'; +import { hashKey } from '../src/customer/api-key/utils'; import { faker } from '@faker-js/faker'; import { Business, Customer, EndUser, Prisma, PrismaClient, Project } from '@prisma/client'; import { hash } from 'bcrypt'; @@ -34,7 +34,7 @@ import { import { generateTransactions } from './alerts/generate-transactions'; import { generateKycManualReviewRuntimeAndToken } from './workflows/runtime/geneate-kyc-manual-review-runtime-and-token'; import { Type } from '@sinclair/typebox'; -import { generateFakeAlertsAndDefinitions as generateFakeAlertDefinitions } from './alerts/generate-alerts'; +import { seedTransactionsAlerts } from './alerts/generate-alerts'; const BCRYPT_SALT: string | number = 10; @@ -148,14 +148,6 @@ async function seed() { const [adminUser, ...agentUsers] = await createUsers({ project1, project2 }, client); - await generateFakeAlertDefinitions(client, { - project: project1, - counterparyIds: [...ids1, ...ids2] - .map(({ counterpartyOriginatorId }) => counterpartyOriginatorId) - .filter(Boolean) as string[], - agentUserIds: agentUsers.map(({ id }) => id), - }); - const kycManualMachineId = 'MANUAL_REVIEW_0002zpeid7bq9aaa'; const kybManualMachineId = 'MANUAL_REVIEW_0002zpeid7bq9bbb'; const manualMachineVersion = 1; @@ -986,6 +978,14 @@ async function seed() { }); }); + await seedTransactionsAlerts(client, { + project: project1, + businessIds: businessRiskIds, + counterpartyIds: [...ids1, ...ids2] + .map(({ counterpartyOriginatorId }) => counterpartyOriginatorId) + .filter(Boolean) as string[], + agentUserIds: agentUsers.map(({ id }) => id), + }); // TODO: create business with enduser attched to them // await client.business.create({ // data: { diff --git a/services/workflows-service/src/alert-definition/alert-definition.module.ts b/services/workflows-service/src/alert-definition/alert-definition.module.ts index e34fb610fd..4fe8b7fe06 100644 --- a/services/workflows-service/src/alert-definition/alert-definition.module.ts +++ b/services/workflows-service/src/alert-definition/alert-definition.module.ts @@ -7,6 +7,6 @@ import { AlertDefinitionService } from '@/alert-definition/alert-definition.serv @Module({ imports: [PrismaModule, ProjectModule], providers: [AlertDefinitionService, AlertDefinitionRepository], - exports: [AlertDefinitionService], + exports: [AlertDefinitionService, AlertDefinitionRepository], }) export class AlertDefinitionModule {} diff --git a/services/workflows-service/src/alert-definition/alert-definition.repository.ts b/services/workflows-service/src/alert-definition/alert-definition.repository.ts index 6034688033..b821aa9a8e 100644 --- a/services/workflows-service/src/alert-definition/alert-definition.repository.ts +++ b/services/workflows-service/src/alert-definition/alert-definition.repository.ts @@ -11,10 +11,10 @@ export class AlertDefinitionRepository { protected readonly scopeService: ProjectScopeService, ) {} - async findByAlertId>( + async findByAlertId( alertId: string, projectIds: TProjectIds, - args?: Prisma.SelectSubset>, + args?: Omit, ) { return this.findFirst( { @@ -49,22 +49,25 @@ export class AlertDefinitionRepository { async findMany( args: Prisma.SelectSubset, projectIds: TProjectIds, + { orderBy }: { orderBy?: Prisma.AlertDefinitionOrderByWithRelationInput } = {}, ): Promise { const queryArgs = this.scopeService.scopeFindMany(args, projectIds); - return await this.prisma.alertDefinition.findMany(queryArgs); + return await this.prisma.alertDefinition.findMany({ + ...queryArgs, + orderBy, + }); } - async findById>( + async findById( id: string, - args: Prisma.SelectSubset>, + args: Omit, projectIds: TProjectIds, ): Promise { const queryArgs = this.scopeService.scopeFindOne( { ...args, where: { - ...(args as Prisma.AlertDefinitionFindFirstOrThrowArgs)?.where, id, }, }, @@ -84,9 +87,9 @@ export class AlertDefinitionRepository { }); } - async deleteById>( + async deleteById( id: string, - args: Prisma.SelectSubset>, + args: Omit, projectIds: TProjectIds, ): Promise { return await this.prisma.alertDefinition.delete( diff --git a/services/workflows-service/src/alert/alert.controller.external.ts b/services/workflows-service/src/alert/alert.controller.external.ts index 5fd67aec73..e9e09c2b70 100644 --- a/services/workflows-service/src/alert/alert.controller.external.ts +++ b/services/workflows-service/src/alert/alert.controller.external.ts @@ -10,12 +10,17 @@ import type { AuthenticatedEntity, TProjectId } from '@/types'; import * as common from '@nestjs/common'; import { Res } from '@nestjs/common'; import * as swagger from '@nestjs/swagger'; -import { Alert, AlertDefinition } from '@prisma/client'; +import { Alert, AlertDefinition, MonitoringType } from '@prisma/client'; import * as errors from '../errors'; import { AlertAssigneeUniqueDto, AlertUpdateResponse } from './dtos/assign-alert.dto'; import { CreateAlertDefinitionDto } from './dtos/create-alert-definition.dto'; import { FindAlertsDto, FindAlertsSchema } from './dtos/get-alerts.dto'; -import { BulkStatus, TAlertResponse, TBulkAssignAlertsResponse } from './types'; +import { + BulkStatus, + TAlertMerchantResponse, + TAlertTransactionResponse, + TBulkAssignAlertsResponse, +} from './types'; import { AlertDecisionDto } from './dtos/decision-alert.dto'; import { UserData } from '@/user/user-data.decorator'; import { AlertDefinitionService } from '@/alert-definition/alert-definition.service'; @@ -46,53 +51,58 @@ export class AlertControllerExternal { } @common.Get('/') - @swagger.ApiOkResponse({ type: Array }) // TODO: Set type + @swagger.ApiOkResponse({ type: Array }) // TODO: Set type @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) @common.UsePipes(new ZodValidationPipe(FindAlertsSchema, 'query')) async list(@common.Query() findAlertsDto: FindAlertsDto, @ProjectIds() projectIds: TProjectId[]) { - const alerts = await this.alertService.getAlerts(findAlertsDto, projectIds, { - include: { - alertDefinition: { - select: { - correlationId: true, - description: true, + const alerts = await this.alertService.getAlerts( + findAlertsDto, + MonitoringType.transaction_monitoring, + projectIds, + { + include: { + alertDefinition: { + select: { + correlationId: true, + description: true, + }, }, - }, - assignee: { - select: { - id: true, - firstName: true, - lastName: true, - avatarUrl: true, + assignee: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, }, - }, - counterparty: { - select: { - id: true, - business: { - select: { - id: true, - correlationId: true, - companyName: true, + counterparty: { + select: { + id: true, + business: { + select: { + id: true, + correlationId: true, + companyName: true, + }, }, - }, - endUser: { - select: { - id: true, - correlationId: true, - firstName: true, - lastName: true, + endUser: { + select: { + id: true, + correlationId: true, + firstName: true, + lastName: true, + }, }, }, }, }, }, - }); + ); return alerts.map(alert => { const { alertDefinition, assignee, counterparty, state, ...alertWithoutDefinition } = - alert as TAlertResponse; + alert as TAlertTransactionResponse; return { ...alertWithoutDefinition, @@ -123,6 +133,75 @@ export class AlertControllerExternal { }); } + @common.Get('/business-report') + @swagger.ApiOkResponse({ type: Array }) // TODO: Set type + @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) + @common.UsePipes(new ZodValidationPipe(FindAlertsSchema, 'query')) + async listBusinessReportAlerts( + @common.Query() findAlertsDto: FindAlertsDto, + @ProjectIds() projectIds: TProjectId[], + ) { + const alerts = await this.alertService.getAlerts( + findAlertsDto, + MonitoringType.ongoing_merchant_monitoring, + projectIds, + { + include: { + alertDefinition: { + select: { + correlationId: true, + description: true, + }, + }, + assignee: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + business: { + select: { + id: true, + companyName: true, + businessReports: true, + }, + }, + }, + }, + ); + + return alerts.map(alert => { + const { + alertDefinition, + assignee, + business, + state, + executionDetails: _, + ...alertWithoutDefinition + } = alert as TAlertMerchantResponse; + + return { + ...alertWithoutDefinition, + correlationId: alertDefinition.correlationId, + assignee: assignee + ? { + id: assignee?.id, + fullName: `${assignee?.firstName} ${assignee?.lastName}`, + avatarUrl: assignee?.avatarUrl, + } + : null, + alertDetails: alertDefinition.description, + subject: { + ...business, + }, + decision: state, + }; + }); + } + @common.Patch('assign') @common.UseGuards(ProjectAssigneeGuard) @swagger.ApiOkResponse({ type: [AlertUpdateResponse] }) diff --git a/services/workflows-service/src/alert/alert.module.ts b/services/workflows-service/src/alert/alert.module.ts index 5f264aabcd..0c2497de08 100644 --- a/services/workflows-service/src/alert/alert.module.ts +++ b/services/workflows-service/src/alert/alert.module.ts @@ -23,9 +23,11 @@ import { ProjectModule } from '@/project/project.module'; import { UserRepository } from '@/user/user.repository'; import { AlertDefinitionModule } from '@/alert-definition/alert-definition.module'; import { SentryModule } from '@/sentry/sentry.module'; +import { BusinessReportModule } from '@/business-report/business-report.module'; @Module({ imports: [ + BusinessReportModule, DataAnalyticsModule, ACLModule, PrismaModule, @@ -61,7 +63,7 @@ import { SentryModule } from '@/sentry/sentry.module'; UserRepository, PasswordService, ], - exports: [ACLModule, AlertService, WebhookEventEmitterService], + exports: [ACLModule, AlertRepository, AlertService, WebhookEventEmitterService], }) export class AlertModule { constructor( diff --git a/services/workflows-service/src/alert/alert.service.intg.test.ts b/services/workflows-service/src/alert/alert.service.intg.test.ts index d73e11720f..3e83ac82ff 100644 --- a/services/workflows-service/src/alert/alert.service.intg.test.ts +++ b/services/workflows-service/src/alert/alert.service.intg.test.ts @@ -1,4 +1,17 @@ -import { PrismaService } from './../prisma/prisma.service'; +import { AlertDefinitionRepository } from '@/alert-definition/alert-definition.repository'; +import { AlertRepository } from '@/alert/alert.repository'; +import { AlertService } from '@/alert/alert.service'; +import { BusinessReportRepository } from '@/business-report/business-report.repository'; +import { BusinessReportService } from '@/business-report/business-report.service'; +import { DataAnalyticsService } from '@/data-analytics/data-analytics.service'; +import { ProjectScopeService } from '@/project/project-scope.service'; +import { createCustomer } from '@/test/helpers/create-customer'; +import { createProject } from '@/test/helpers/create-project'; +import { cleanupDatabase, tearDownDatabase } from '@/test/helpers/database-helper'; +import { commonTestingModules } from '@/test/helpers/nest-app-helper'; +import { TransactionFactory } from '@/transaction/test-utils/transaction-factory'; +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; import { AlertDefinition, Counterparty, @@ -8,22 +21,12 @@ import { TransactionDirection, TransactionRecordType, } from '@prisma/client'; -import { cleanupDatabase, tearDownDatabase } from '@/test/helpers/database-helper'; -import { createCustomer } from '@/test/helpers/create-customer'; -import { faker } from '@faker-js/faker'; -import { createProject } from '@/test/helpers/create-project'; -import { TransactionFactory } from '@/transaction/test-utils/transaction-factory'; -import { AlertService } from '@/alert/alert.service'; -import { commonTestingModules } from '@/test/helpers/nest-app-helper'; -import { DataAnalyticsService } from '@/data-analytics/data-analytics.service'; -import { ProjectScopeService } from '@/project/project-scope.service'; -import { Test } from '@nestjs/testing'; -import { AlertRepository } from '@/alert/alert.repository'; -import { AlertDefinitionRepository } from '@/alert-definition/alert-definition.repository'; import { ALERT_DEFINITIONS, getAlertDefinitionCreateData, } from '../../scripts/alerts/generate-alerts'; +import { PrismaService } from './../prisma/prisma.service'; + type AsyncTransactionFactoryCallback = ( transactionFactory: TransactionFactory, ) => Promise; @@ -35,7 +38,7 @@ const createTransactionsWithCounterpartyAsync = async ( ) => { const counteryparty = await createCounterparty(prismaService, project); - let baseTransactionFactory = new TransactionFactory({ + const baseTransactionFactory = new TransactionFactory({ prisma: prismaService, projectId: counteryparty.projectId, }) @@ -63,6 +66,8 @@ describe('AlertService', () => { ProjectScopeService, AlertRepository, AlertDefinitionRepository, + BusinessReportService, + BusinessReportRepository, AlertService, ], }).compile(); @@ -1471,6 +1476,7 @@ describe('AlertService', () => { const createCounterparty = async (prismaService: PrismaService, proj?: Pick) => { const correlationId = faker.datatype.uuid(); + if (!proj) { const customer = await createCustomer( prismaService, diff --git a/services/workflows-service/src/alert/alert.service.ts b/services/workflows-service/src/alert/alert.service.ts index 37c6323a9e..b5a3dcee7b 100644 --- a/services/workflows-service/src/alert/alert.service.ts +++ b/services/workflows-service/src/alert/alert.service.ts @@ -3,18 +3,26 @@ import { AppLoggerService } from '@/common/app-logger/app-logger.service'; import * as errors from '@/errors'; import { PrismaService } from '@/prisma/prisma.service'; import { isFkConstraintError } from '@/prisma/prisma.util'; -import { ObjectValues, TProjectId } from '@/types'; +import { InputJsonValue, ObjectValues, TProjectId } from '@/types'; import { Injectable } from '@nestjs/common'; -import { Alert, AlertDefinition, AlertState, AlertStatus } from '@prisma/client'; +import { + Alert, + AlertDefinition, + AlertSeverity, + AlertState, + AlertStatus, + BusinessReport, + MonitoringType, +} from '@prisma/client'; import { CreateAlertDefinitionDto } from './dtos/create-alert-definition.dto'; import { FindAlertsDto } from './dtos/get-alerts.dto'; -import { DataAnalyticsService } from '@/data-analytics/data-analytics.service'; import { AlertDefinitionRepository } from '@/alert-definition/alert-definition.repository'; -import { InlineRule } from '@/data-analytics/types'; import _ from 'lodash'; import { AlertExecutionStatus } from './consts'; import { computeHash } from '@/common/utils/sign/sign'; import { TDedupeStrategy, TExecutionDetails } from './types'; +import { CheckRiskScoreOptions, InlineRule } from '@/data-analytics/types'; +import { DataAnalyticsService } from '@/data-analytics/data-analytics.service'; const DEFAULT_DEDUPE_STRATEGIES = { cooldownTimeframeInMinutes: 60 * 24, @@ -71,6 +79,7 @@ export class AlertService { async getAlerts( findAlertsDto: FindAlertsDto, + monitoringType: MonitoringType, projectIds: TProjectId[], args?: Omit< Parameters[0], @@ -88,6 +97,7 @@ export class AlertService { in: findAlertsDto.filter?.status, }, alertDefinition: { + monitoringType, correlationId: { in: findAlertsDto.filter?.correlationIds, }, @@ -114,15 +124,17 @@ export class AlertService { } // Function to retrieve all alert definitions - async getAllAlertDefinitions(): Promise { + async getAlertDefinitions(options: { type: MonitoringType }): Promise { return await this.prisma.alertDefinition.findMany({ - where: { enabled: true }, + where: { enabled: true, monitoringType: options.type }, }); } // Function to perform alert checks for each alert definition async checkAllAlerts() { - const alertDefinitions = await this.getAllAlertDefinitions(); + const alertDefinitions = await this.getAlertDefinitions({ + type: MonitoringType.transaction_monitoring, + }); for (const definition of alertDefinitions) { try { @@ -140,8 +152,57 @@ export class AlertService { } } - // Specific alert check logic based on the definition - private async checkAlert(alertDefinition: AlertDefinition) { + async checkOngoingMonitoringAlert(businessReport: BusinessReport, businessCompanyName: string) { + const alertDefinitions = await this.alertDefinitionRepository.findMany( + { + where: { + enabled: true, + monitoringType: MonitoringType.ongoing_merchant_monitoring, + }, + }, + [businessReport.projectId], + ); + + const alertDefinitionsCheck = alertDefinitions.map(async alertDefinition => { + const alertResultData = await this.dataAnalyticsService.checkMerchantOngoingAlert( + businessReport, + (alertDefinition.inlineRule as InlineRule).options as CheckRiskScoreOptions, + alertDefinition.defaultSeverity, + ); + + if (alertResultData) { + const { id: businessReportId, businessId, projectId } = businessReport; + const subjects = { businessId, projectId }; + + const subjectArray = Object.entries(subjects).map(([key, value]) => ({ + [key]: value, + })); + + const createAlertReference = this.createAlert; + + return [ + alertDefinition, + subjectArray, + { subjectArray }, + { + ...alertResultData, + businessReportId, + businessCompanyName, + }, + ] satisfies Parameters; + } + }); + + const evaluatedRulesResults = (await Promise.all(alertDefinitionsCheck)).filter(Boolean); + + const alertArgs = evaluatedRulesResults[0]; + + if (alertArgs) { + return await this.createAlert(...alertArgs); + } + } + + private async checkAlert(alertDefinition: AlertDefinition, ...args: any[]) { const unknownData: unknown = alertDefinition.inlineRule; const inlineRule: InlineRule = unknownData as InlineRule; @@ -229,7 +290,8 @@ export class AlertService { alertDef: AlertDefinition, subject: Array<{ [key: string]: unknown }>, executionRow: Record, - ): Promise { + additionalInfo?: Record, + ) { return this.alertRepository.create({ data: { alertDefinitionId: alertDef.id, @@ -238,12 +300,14 @@ export class AlertService { dataTimestamp: new Date(), state: AlertState.triggered, status: AlertStatus.new, + additionalInfo: additionalInfo, executionDetails: { checkpoint: { hash: computeHash(executionRow), }, + subject: Object.assign({}, ...(subject || [])), executionRow, - } satisfies TExecutionDetails, + } satisfies TExecutionDetails as InputJsonValue, ...Object.assign({}, ...(subject || [])), }, }); @@ -325,6 +389,11 @@ export class AlertService { }, }, [projectId], + { + orderBy: { + defaultSeverity: 'desc', + }, + }, ); return alertDefinitions.map(({ correlationId }) => correlationId); @@ -340,4 +409,25 @@ export class AlertService { [projectId], ); } + + orderedBySeverity(a: AlertSeverity, b: AlertSeverity) { + const alertSeverityToNumber = (severity: AlertSeverity) => { + switch (severity) { + case AlertSeverity.high: + return 3; + case AlertSeverity.medium: + return 2; + case AlertSeverity.low: + return 1; + default: + return 0; + } + }; + + if (a === b) { + return 0; + } + + return alertSeverityToNumber(a) < alertSeverityToNumber(b) ? 1 : -1; + } } diff --git a/services/workflows-service/src/alert/consts.ts b/services/workflows-service/src/alert/consts.ts index bef6f880b4..d15e76e336 100644 --- a/services/workflows-service/src/alert/consts.ts +++ b/services/workflows-service/src/alert/consts.ts @@ -14,3 +14,9 @@ export const AlertExecutionStatus = { SKIPPED: 'SKIPPED', FAILED: 'FAILED', } as const; + +export const MerchantAlertLabel = { + MERCHANT_ONGOING_RISK_ALERT_THRESHOLD: 'MERCHANT_ONGOING_RISK_ALERT_THRESHOLD', + MERCHANT_ONGOING_RISK_ALERT_PERCENTAGE: 'MERCHANT_ONGOING_RISK_ALERT_PERCENTAGE', + MERCHANT_ONGOING_RISK_ALERT_RISK_INCREASE: 'MERCHANT_ONGOING_RISK_ALERT_RISK_INCREASE', +} as const; diff --git a/services/workflows-service/src/alert/types.ts b/services/workflows-service/src/alert/types.ts index 8454ab8829..dbbeca2adc 100644 --- a/services/workflows-service/src/alert/types.ts +++ b/services/workflows-service/src/alert/types.ts @@ -4,6 +4,7 @@ export type TExecutionDetails = { checkpoint: { hash: string; }; + subject: Array>; executionRow: unknown; }; @@ -22,12 +23,19 @@ export type TBulkStatus = (typeof BulkStatus)[keyof typeof BulkStatus]; export type TAlertResponse = Alert & { alertDefinition: Pick; assignee: Pick; +}; + +export type TAlertTransactionResponse = TAlertResponse & { counterparty: { business: Pick; endUser: Pick; }; }; +export type TAlertMerchantResponse = TAlertResponse & { + business: Pick; +}; + export type TAlertUpdateResponse = Array<{ alertId: string; status: string; diff --git a/services/workflows-service/src/app.module.ts b/services/workflows-service/src/app.module.ts index 674f600657..b892538cdc 100644 --- a/services/workflows-service/src/app.module.ts +++ b/services/workflows-service/src/app.module.ts @@ -63,6 +63,7 @@ import { CronModule } from '@/workflow/cron/cron.module'; EndUserModule, CustomerModule, TransactionModule, + BusinessReportModule, AlertModule, BusinessModule, ProjectModule, diff --git a/services/workflows-service/src/business-report/business-report.controller.internal.ts b/services/workflows-service/src/business-report/business-report.controller.internal.ts index d5b37cfa1a..21398984c1 100644 --- a/services/workflows-service/src/business-report/business-report.controller.internal.ts +++ b/services/workflows-service/src/business-report/business-report.controller.internal.ts @@ -6,7 +6,8 @@ import { AppLoggerService } from '@/common/app-logger/app-logger.service'; import * as errors from '@/errors'; import { CurrentProject } from '@/common/decorators/current-project.decorator'; import { BusinessReportService } from '@/business-report/business-report.service'; -import { GetBusinessReportsDto } from '@/business-report/get-business-reports.dto'; +import { GetBusinessReportDto } from '@/business-report/dto/get-business-report.dto'; +import { GetBusinessReportsDto } from '@/business-report/dto/get-business-reports.dto'; @common.Controller('internal/business-reports') @swagger.ApiExcludeController() @@ -21,14 +22,38 @@ export class BusinessReportControllerInternal { @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) async getLatestBusinessReport( @CurrentProject() currentProjectId: TProjectId, - @Query() searchQueryParams: GetBusinessReportsDto, + @Query() searchQueryParams: GetBusinessReportDto, ) { - return await this.businessReportService.findFirst( + return await this.businessReportService.findFirstOrThrow( { where: { businessId: searchQueryParams.businessId, type: searchQueryParams.type, }, + orderBy: { + createdAt: 'desc', + }, + }, + [currentProjectId], + ); + } + + @common.Get() + @swagger.ApiOkResponse({ type: [String] }) + @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) + async getBusinessReports( + @CurrentProject() currentProjectId: TProjectId, + @Query() searchQueryParams: GetBusinessReportsDto, + ) { + return await this.businessReportService.findMany( + { + where: { + businessId: searchQueryParams.businessId, + ...(searchQueryParams.type ? { type: searchQueryParams.type } : {}), + }, + orderBy: { + createdAt: 'desc', + }, }, [currentProjectId], ); diff --git a/services/workflows-service/src/business-report/business-report.repository.ts b/services/workflows-service/src/business-report/business-report.repository.ts index 8ebd14fe4d..4508bfd980 100644 --- a/services/workflows-service/src/business-report/business-report.repository.ts +++ b/services/workflows-service/src/business-report/business-report.repository.ts @@ -17,6 +17,17 @@ export class BusinessReportRepository { return await this.prisma.businessReport.create(args); } + async update( + args: Prisma.SelectSubset, + ) { + return await this.prisma.businessReport.update(args); + } + async updateMany( + args: Prisma.SelectSubset, + ) { + return await this.prisma.businessReport.updateMany(args); + } + async findMany( args: Prisma.SelectSubset, projectIds: TProjectIds, @@ -26,12 +37,12 @@ export class BusinessReportRepository { ); } - async findFirst( + async findFirstOrThrow( args: Prisma.SelectSubset, projectIds: TProjectIds, ) { - return await this.prisma.businessReport.findFirst( - this.scopeService.scopeFindMany(args, projectIds), + return await this.prisma.businessReport.findFirstOrThrow( + this.scopeService.scopeFindFirst(args, projectIds), ); } } diff --git a/services/workflows-service/src/business-report/business-report.service.ts b/services/workflows-service/src/business-report/business-report.service.ts index 3a9bc007ca..6ba2c4bc81 100644 --- a/services/workflows-service/src/business-report/business-report.service.ts +++ b/services/workflows-service/src/business-report/business-report.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { TProjectIds } from '@/types'; import { BusinessReportRepository } from '@/business-report/business-report.repository'; +import { GetBusinessReportDto } from './dto/get-business-report.dto'; +import { toPrismaOrderByGeneric } from '@/workflow/utils/toPrismaOrderBy'; @Injectable() export class BusinessReportService { @@ -20,10 +22,68 @@ export class BusinessReportService { return await this.businessReportRepository.findMany(args, projectIds); } - async findFirst( + async upsert( + args: Prisma.SelectSubset, + projectIds: NonNullable, + ) { + if (!args.where.id) { + return await this.businessReportRepository.create({ data: args.create }); + } + + await this.businessReportRepository.updateMany({ + where: { + id: args.where.id, + project: { id: { in: projectIds } }, + }, + data: args.update, + }); + + return await this.businessReportRepository.findFirstOrThrow( + { + where: { + id: args.where.id, + }, + }, + projectIds, + ); + } + + async findFirstOrThrow( args: Prisma.SelectSubset, projectIds: TProjectIds, ) { - return await this.businessReportRepository.findFirst(args, projectIds); + return await this.businessReportRepository.findFirstOrThrow(args, projectIds); + } + + async findManyWithFilters( + getTransactionsParameters: GetBusinessReportDto, + projectId: string, + options?: Prisma.BusinessReportFindManyArgs, + ) { + const args: Prisma.BusinessReportFindManyArgs = {}; + + if (getTransactionsParameters.page?.number && getTransactionsParameters.page?.size) { + // Temporary fix for pagination (class transformer issue) + const size = parseInt(getTransactionsParameters.page.size as unknown as string, 10); + const number = parseInt(getTransactionsParameters.page.number as unknown as string, 10); + + args.take = size; + args.skip = size * (number - 1); + } + + if (getTransactionsParameters.orderBy) { + args.orderBy = toPrismaOrderByGeneric(getTransactionsParameters.orderBy); + } + + return await this.businessReportRepository.findMany( + { + ...options, + where: { + businessId: getTransactionsParameters.businessId, + }, + ...args, + }, + [projectId], + ); } } diff --git a/services/workflows-service/src/business-report/dto/get-business-report.dto.ts b/services/workflows-service/src/business-report/dto/get-business-report.dto.ts new file mode 100644 index 0000000000..59d314fb46 --- /dev/null +++ b/services/workflows-service/src/business-report/dto/get-business-report.dto.ts @@ -0,0 +1,28 @@ +import { PageDto } from '@/common/dto'; +import { ApiProperty } from '@nestjs/swagger'; +import { BusinessReportType } from '@prisma/client'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class GetBusinessReportDto { + @IsOptional() + @IsString() + businessId?: string; + + @ApiProperty({ + required: true, + }) + @IsIn(Object.values(BusinessReportType)) + type!: BusinessReportType; + + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: 'Column to sort by and direction separated by a colon', + examples: [{ value: 'createdAt:asc' }, { value: 'status:asc' }], + }) + orderBy?: `${string}:asc` | `${string}:desc`; + + @ApiProperty({ type: PageDto }) + page!: PageDto; +} diff --git a/services/workflows-service/src/business-report/get-business-reports.dto.ts b/services/workflows-service/src/business-report/dto/get-business-reports.dto.ts similarity index 68% rename from services/workflows-service/src/business-report/get-business-reports.dto.ts rename to services/workflows-service/src/business-report/dto/get-business-reports.dto.ts index 1db0d3247b..2e90f72b93 100644 --- a/services/workflows-service/src/business-report/get-business-reports.dto.ts +++ b/services/workflows-service/src/business-report/dto/get-business-reports.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { BusinessReportType } from '@prisma/client'; -import { IsIn, IsNotEmpty, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class GetBusinessReportsDto { @ApiProperty({ @@ -11,8 +11,9 @@ export class GetBusinessReportsDto { businessId!: string; @ApiProperty({ - required: true, + required: false, }) @IsIn(Object.values(BusinessReportType)) - type!: BusinessReportType; + @IsOptional() + type?: BusinessReportType; } diff --git a/services/workflows-service/src/collection-flow/collection-flow.module.ts b/services/workflows-service/src/collection-flow/collection-flow.module.ts index 88d45a1f41..b67b0fd9e0 100644 --- a/services/workflows-service/src/collection-flow/collection-flow.module.ts +++ b/services/workflows-service/src/collection-flow/collection-flow.module.ts @@ -36,6 +36,8 @@ import { SalesforceIntegrationRepository } from '@/salesforce/salesforce-integra import { CollectionFlowEndUserController } from '@/collection-flow/controllers/collection-flow.end-user.controller'; import { TranslationService } from '@/providers/translation/translation.service'; import { BusinessReportModule } from '@/business-report/business-report.module'; +import { AlertModule } from '@/alert/alert.module'; +import { DataAnalyticsModule } from '@/data-analytics/data-analytics.module'; @Module({ imports: [ @@ -46,6 +48,8 @@ import { BusinessReportModule } from '@/business-report/business-report.module'; TokenAuthModule, UiDefinitionModule, BusinessReportModule, + AlertModule, + DataAnalyticsModule, ], controllers: [ ColectionFlowController, diff --git a/services/workflows-service/src/data-analytics/data-analytics.module.ts b/services/workflows-service/src/data-analytics/data-analytics.module.ts index 0fbd549d10..6344c53c3b 100644 --- a/services/workflows-service/src/data-analytics/data-analytics.module.ts +++ b/services/workflows-service/src/data-analytics/data-analytics.module.ts @@ -5,9 +5,10 @@ import { DataAnalyticsService } from '@/data-analytics/data-analytics.service'; import { DataAnalyticsControllerExternal } from '@/data-analytics/data-analytics.controller.external'; import { PrismaModule } from '@/prisma/prisma.module'; import { ProjectScopeService } from '@/project/project-scope.service'; +import { BusinessReportModule } from '@/business-report/business-report.module'; @Module({ - imports: [ACLModule, PrismaModule], + imports: [ACLModule, PrismaModule, BusinessReportModule], controllers: [DataAnalyticsControllerInternal, DataAnalyticsControllerExternal], providers: [DataAnalyticsService, ProjectScopeService], exports: [ACLModule, DataAnalyticsService], diff --git a/services/workflows-service/src/data-analytics/data-analytics.service.ts b/services/workflows-service/src/data-analytics/data-analytics.service.ts index 78798ae094..c51e940fab 100644 --- a/services/workflows-service/src/data-analytics/data-analytics.service.ts +++ b/services/workflows-service/src/data-analytics/data-analytics.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '@/prisma/prisma.service'; import { + CheckRiskScoreOptions, + HighTransactionTypePercentage, + HighVelocityHistoricAverageOptions, InlineRule, - TransactionsAgainstDynamicRulesType, TCustomersTransactionTypeOptions, - HighTransactionTypePercentage, - TPeerGroupTransactionAverageOptions, TDormantAccountOptions, - HighVelocityHistoricAverageOptions, + TPeerGroupTransactionAverageOptions, + TransactionsAgainstDynamicRulesType, } from './types'; import { AggregateType, TIME_UNITS } from './consts'; -import { Prisma } from '@prisma/client'; +import { AlertSeverity, BusinessReport, BusinessReportType, Prisma } from '@prisma/client'; import { AppLoggerService } from '@/common/app-logger/app-logger.service'; import { isEmpty } from 'lodash'; @@ -67,6 +68,118 @@ export class DataAnalyticsService { throw new Error(`No evaluation function found for rule name: ${(inlineRule as InlineRule).id}`); } + async checkMerchantOngoingAlert( + businessReport: BusinessReport, + { + increaseRiskScorePercentage, + increaseRiskScore, + maxRiskScoreThreshold, + }: CheckRiskScoreOptions, + alertSeverity: AlertSeverity, + ) { + const { report, businessId, projectId } = businessReport; + + if ( + !( + report as { + data: { + previousReport?: unknown; + }; + } + ).data.previousReport + ) { + return; + } + + const { + data: { + summary: { riskScore: currentRiskScore }, + previousReport: { + summary: { riskScore: previousRiskScore }, + reportType: previousReportType, + }, + }, + } = report as { + data: { + summary: { + riskScore: number; + }; + previousReport: { + summary: { + riskScore: number; + }; + reportType: BusinessReportType; + }; + }; + }; + + if (previousReportType !== BusinessReportType.ONGOING_MERCHANT_REPORT_T1) { + this.logger.warn(`Previous report type is not ONGOING_MERCHANT_REPORT_T1`); + + return; + } + + if (currentRiskScore < previousRiskScore) { + return; + } + + if (!(maxRiskScoreThreshold || increaseRiskScore || increaseRiskScorePercentage)) { + this.logger.warn(`Rule for ${businessId} ${projectId} missing required options`, { + maxRiskScoreThreshold, + increaseRiskScore, + increaseRiskScorePercentage, + }); + + return; + } + + let ruleResult: + | { + severity: AlertSeverity; + alertReason: string; + } + | undefined; + + if (maxRiskScoreThreshold && currentRiskScore >= maxRiskScoreThreshold) { + ruleResult = { + severity: alertSeverity, + alertReason: `The risk score has exceeded the threshold of ${maxRiskScoreThreshold}`, + }; + } + + if (increaseRiskScore && currentRiskScore - previousRiskScore >= increaseRiskScore) { + ruleResult = { + severity: alertSeverity, + alertReason: `The risk score has been increased by more than ${increaseRiskScore} from previous monitoring`, + }; + } + + if ( + increaseRiskScorePercentage && + ((currentRiskScore - previousRiskScore) / previousRiskScore) * 100 >= + increaseRiskScorePercentage + ) { + ruleResult = { + severity: alertSeverity, + alertReason: `The risk score has been significantly increased from previous monitoring`, + }; + } + + if (!ruleResult) { + return; + } + + const executionDetails = { + businessId: businessId, + projectId: projectId, + riskScore: currentRiskScore, + previousRiskScore, + ...ruleResult, + }; + + return executionDetails; + } + async evaluateTransactionsAgainstDynamicRules({ projectId, amountThreshold, @@ -270,7 +383,7 @@ export class DataAnalyticsService { count(tr."id") AS "totalTransactionAllTime" FROM "TransactionRecord" AS "tr" - WHERE + WHERE tr."projectId" = ${projectId} AND tr."counterpartyBeneficiaryId" IS NOT NULL GROUP BY @@ -282,7 +395,7 @@ export class DataAnalyticsService { transactions WHERE "totalTransactionAllTime" > 1 - AND "totalTransactionWithinSixMonths" = 1; + AND "totalTransactionWithinSixMonths" = 1; `; return await this._executeQuery>>(query); diff --git a/services/workflows-service/src/data-analytics/types.ts b/services/workflows-service/src/data-analytics/types.ts index a87f29c76e..815b11aff6 100644 --- a/services/workflows-service/src/data-analytics/types.ts +++ b/services/workflows-service/src/data-analytics/types.ts @@ -30,6 +30,10 @@ export type InlineRule = { fnName: 'evaluateDormantAccount'; options: Omit; } + | { + fnName: 'checkMerchantOngoingAlert'; + options: CheckRiskScoreOptions; + } | { fnName: 'evaluateHighVelocityHistoricAverage'; options: Omit; @@ -94,6 +98,12 @@ export type TransactionLimitHistoricAverageOptions = { transactionFactor: number; }; +export type CheckRiskScoreOptions = { + increaseRiskScorePercentage?: number; + increaseRiskScore?: number; + maxRiskScoreThreshold?: number; +}; + export type TPeerGroupTransactionAverageOptions = TransactionLimitHistoricAverageOptions & { customerType?: string; timeUnit?: TimeUnit; diff --git a/services/workflows-service/src/transaction/transaction.controller.external.intg.test.ts b/services/workflows-service/src/transaction/transaction.controller.external.intg.test.ts index 6245cea766..fffdf28b5f 100644 --- a/services/workflows-service/src/transaction/transaction.controller.external.intg.test.ts +++ b/services/workflows-service/src/transaction/transaction.controller.external.intg.test.ts @@ -434,9 +434,7 @@ describe('#TransactionControllerExternal', () => { const successfulTransaction = (response.body as any[]).find( ({ status }) => status === BulkStatus.SUCCESS, ); - const failedTransaction = (response.body as any[]).find( - ({ status }) => status === BulkStatus.FAILED, - ); + expect(successfulTransaction).toEqual({ status: BulkStatus.SUCCESS, data: { id: expect.any(String), correlationId: transaction.correlationId }, @@ -448,6 +446,11 @@ describe('#TransactionControllerExternal', () => { }, }); expect(transactionRecord?.id).toEqual(successfulTransaction.data.id); + + const failedTransaction = (response.body as any[]).find( + ({ status }) => status === BulkStatus.FAILED, + ); + expect(failedTransaction).toEqual({ status: BulkStatus.FAILED, error: diff --git a/services/workflows-service/src/transaction/transaction.service.ts b/services/workflows-service/src/transaction/transaction.service.ts index cedd1718b3..d55d423174 100644 --- a/services/workflows-service/src/transaction/transaction.service.ts +++ b/services/workflows-service/src/transaction/transaction.service.ts @@ -36,12 +36,13 @@ export class TransactionService { []; for (const transactionPayload of mappedTransactions) { + const correlationId = transactionPayload.transactionCorrelationId; try { const transaction = await this.repository.create({ data: transactionPayload }); response.push({ id: transaction.id, - correlationId: transaction.transactionCorrelationId, + correlationId, }); } catch (error) { if (mappedTransactions.length === 1) { diff --git a/services/workflows-service/src/webhooks/webhooks.module.ts b/services/workflows-service/src/webhooks/webhooks.module.ts index 3e0b11c5f3..bc8db1d51b 100644 --- a/services/workflows-service/src/webhooks/webhooks.module.ts +++ b/services/workflows-service/src/webhooks/webhooks.module.ts @@ -31,6 +31,9 @@ import { WebhooksController } from '@/webhooks/webhooks.controller'; import { WebhooksService } from '@/webhooks/webhooks.service'; import { BusinessService } from '@/business/business.service'; import { BusinessReportModule } from '@/business-report/business-report.module'; +import { AlertModule } from '@/alert/alert.module'; +import { DataAnalyticsModule } from '@/data-analytics/data-analytics.module'; +import { AlertDefinitionModule } from '@/alert-definition/alert-definition.module'; @Module({ controllers: [WebhooksController], @@ -43,6 +46,9 @@ import { BusinessReportModule } from '@/business-report/business-report.module'; CustomerModule, BusinessReportModule, WorkflowDefinitionModule, + AlertModule, + DataAnalyticsModule, + AlertDefinitionModule, ], providers: [ WorkflowService, diff --git a/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts b/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts index ad6c09a853..7786aa3216 100644 --- a/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts +++ b/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts @@ -71,7 +71,7 @@ export class OngoingMonitoringCron { const lastReceivedReport = await this.findLastBusinessReport(business, projectIds); if (!lastReceivedReport) { - this.logger.error(`No initial report found for business: ${business.id}`); + this.logger.debug(`No initial report found for business: ${business.id}`); continue; } @@ -92,7 +92,7 @@ export class OngoingMonitoringCron { workflowDefinitionId: workflowDefinition.id, currentProjectId: business.projectId, projectIds: projectIds, - lastReportId: (lastReceivedReport.report as { reportId: string }).reportId, + lastReportId: lastReceivedReport.reportId, checkTypes: processConfig?.checkTypes, reportType: this.processFeatureName, }); diff --git a/services/workflows-service/src/workflow/hook-callback-handler.service.ts b/services/workflows-service/src/workflow/hook-callback-handler.service.ts index 2b85481fda..35f3ed24b5 100644 --- a/services/workflows-service/src/workflow/hook-callback-handler.service.ts +++ b/services/workflows-service/src/workflow/hook-callback-handler.service.ts @@ -13,10 +13,19 @@ import { BusinessReportType, Customer, WorkflowRuntimeData } from '@prisma/clien import fs from 'fs'; import { get, isObject, set } from 'lodash'; import * as tmp from 'tmp'; +import { AlertService } from '@/alert/alert.service'; import { EndUserService } from '@/end-user/end-user.service'; import { z } from 'zod'; import { EndUserActiveMonitoringsSchema } from '@/end-user/end-user.schema'; +const ReportWithRiskScoreSchema = z + .object({ + summary: z.object({ + riskScore: z.number(), + }), + }) + .passthrough(); + @Injectable() export class HookCallbackHandlerService { constructor( @@ -24,6 +33,7 @@ export class HookCallbackHandlerService { protected readonly customerService: CustomerService, protected readonly businessReportService: BusinessReportService, protected readonly businessService: BusinessService, + protected readonly alertService: AlertService, private readonly logger: AppLoggerService, private readonly endUserService: EndUserService, ) {} @@ -76,7 +86,13 @@ export class HookCallbackHandlerService { if (processName === 'merchant-audit-report') { return await this.prepareMerchantAuditReportContext( - data, + data as { + reportData: Record; + base64Pdf: string; + reportId: string; + reportType: string; + comparedToReportId?: string; + }, workflowRuntime, resultDestinationPath, currentProjectId, @@ -113,7 +129,8 @@ export class HookCallbackHandlerService { const customer = await this.customerService.getByProjectId(currentProjectId); const { context } = workflowRuntime; - const { reportData, base64Pdf, reportId, reportType } = data; + const { reportData: unvalidatedReportData, base64Pdf, reportId, reportType } = data; + const reportData = ReportWithRiskScoreSchema; const { documents, pdfReportBallerineFileId } = await this.__peristPDFReportDocumentWithWorkflowDocuments({ @@ -123,44 +140,113 @@ export class HookCallbackHandlerService { base64PDFString: base64Pdf as string, }); + const reportRiskScore = + ReportWithRiskScoreSchema.parse(unvalidatedReportData).summary.riskScore; + const business = await this.businessService.getByCorrelationId(context.entity.id, [ currentProjectId, ]); if (!business) throw new BadRequestException('Business not found.'); - await this.businessReportService.create({ - data: { - type: reportType as BusinessReportType, - report: { - reportFileId: pdfReportBallerineFileId, - data: reportData as InputJsonValue, - reportId: reportId as string, + const currentReportId = reportId as string; + const existantBusinessReport = await this.businessReportService.findFirstOrThrow( + { + where: { + businessId: business.id, + reportId: currentReportId, }, - businessId: business.id, - projectId: currentProjectId, }, - }); + [currentProjectId], + ); + + const businessReport = await this.businessReportService.upsert( + { + create: { + type: reportType as BusinessReportType, + riskScore: reportRiskScore as number, + report: { + reportFileId: pdfReportBallerineFileId, + data: reportData as InputJsonValue, + }, + reportId: currentReportId, + businessId: business.id, + projectId: currentProjectId, + }, + update: { + type: reportType as BusinessReportType, + riskScore: reportRiskScore, + report: { + reportFileId: pdfReportBallerineFileId, + data: reportData as InputJsonValue, + }, + }, + where: { + id: existantBusinessReport?.id, + }, + }, + [currentProjectId], + ); set(workflowRuntime.context, resultDestinationPath, { reportData }); workflowRuntime.context.documents = documents; + this.alertService + .checkOngoingMonitoringAlert(businessReport, business.companyName) + .then(() => { + this.logger.debug(`Alert Tested for ${currentReportId}}`); + }) + .catch(error => { + this.logger.error(error); + }); + return context; } async prepareMerchantAuditReportContext( - data: AnyRecord, + data: Record, workflowRuntime: WorkflowRuntimeData, resultDestinationPath: string, currentProjectId: TProjectId, ) { - const { reportData, base64Pdf, reportId, reportType } = data; + const { reportData, base64Pdf, reportId, reportType, comparedToReportId } = z + .object({ + reportData: ReportWithRiskScoreSchema, + base64Pdf: z.string(), + reportId: z.string(), + reportType: z.string(), + comparedToReportId: z.string().optional(), + }) + .parse(data); + const { context } = workflowRuntime; const businessId = context.entity.id as string; const customer = await this.customerService.getByProjectId(currentProjectId); + if (comparedToReportId) { + const comparedToReport = await this.businessReportService.findFirstOrThrow( + { + where: { + businessId, + reportId: comparedToReportId, + }, + }, + [currentProjectId], + ); + + if (!comparedToReport) { + throw new BadRequestException('Compared to report not found.'); + } + + reportData.previousReport = { + summary: (comparedToReport.report as { data: { summary: { summary: unknown } } }).data + .summary, + reportType: comparedToReport.type, + }; + } + const { pdfReportBallerineFileId } = await this.__peristPDFReportDocumentWithWorkflowDocuments({ context, customer, @@ -174,12 +260,16 @@ export class HookCallbackHandlerService { reportId, } as Record; + const reportRiskScore = reportData.summary.riskScore; + await this.businessReportService.create({ data: { type: reportType as BusinessReportType, report: reportContent, businessId: businessId, + reportId: reportId as string, projectId: currentProjectId, + riskScore: reportRiskScore, }, }); @@ -396,7 +486,7 @@ export class HookCallbackHandlerService { const documentImages: AnyRecord[] = []; for (const image of data.images as Array<{ context?: string; content: string }>) { - const tmpFile = tmp.fileSync().name; + const tmpFile = tmp.fileSync({ keep: false }).name; const base64ImageContent = image.content.split(',')[1]; const buffer = Buffer.from(base64ImageContent as string, 'base64'); const fileType = await getFileMetadata({ diff --git a/services/workflows-service/src/workflow/schemas/zod-schemas.ts b/services/workflows-service/src/workflow/schemas/zod-schemas.ts index 1f1aa5d91a..ab496eb2d5 100644 --- a/services/workflows-service/src/workflow/schemas/zod-schemas.ts +++ b/services/workflows-service/src/workflow/schemas/zod-schemas.ts @@ -1,6 +1,6 @@ import { SubscriptionSchema } from '@/common/types'; -import { WorkflowDefinitionConfigThemeSchema } from '@ballerine/common'; import { z } from 'zod'; +import { WorkflowDefinitionConfigThemeSchema } from '@ballerine/common'; export const ConfigSchema = z .object({ diff --git a/services/workflows-service/src/workflow/workflow.module.ts b/services/workflows-service/src/workflow/workflow.module.ts index 2ae793b1a1..64e6295856 100644 --- a/services/workflows-service/src/workflow/workflow.module.ts +++ b/services/workflows-service/src/workflow/workflow.module.ts @@ -38,6 +38,9 @@ import { WorkflowControllerInternal } from '@/workflow/workflow.controller.inter import { WorkflowService } from '@/workflow/workflow.service'; import { HttpModule } from '@nestjs/axios'; import { forwardRef, Module } from '@nestjs/common'; +import { AlertModule } from '@/alert/alert.module'; +import { DataAnalyticsModule } from '@/data-analytics/data-analytics.module'; +import { AlertDefinitionModule } from '@/alert-definition/alert-definition.module'; @Module({ controllers: [WorkflowControllerExternal, WorkflowControllerInternal], @@ -50,7 +53,10 @@ import { forwardRef, Module } from '@nestjs/common'; CustomerModule, BusinessReportModule, WorkflowDefinitionModule, + AlertModule, BusinessModule, + DataAnalyticsModule, + AlertDefinitionModule, ], providers: [ WorkflowDefinitionRepository,