diff --git a/src/subdomains/core/aml/dto/manual-aml-check.dto.ts b/src/subdomains/core/aml/dto/manual-aml-check.dto.ts new file mode 100644 index 0000000000..9829bcc211 --- /dev/null +++ b/src/subdomains/core/aml/dto/manual-aml-check.dto.ts @@ -0,0 +1,17 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { AmlReason } from '../enums/aml-reason.enum'; +import { CheckStatus } from '../enums/check-status.enum'; + +export class ManualAmlCheckDto { + @IsNotEmpty() + @IsEnum(CheckStatus) + amlCheck: CheckStatus; + + @IsOptional() + @IsEnum(AmlReason) + amlReason?: AmlReason; + + @IsNotEmpty() + @IsString() + responsible: string; +} diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 30c74e084c..43ecce0e8d 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -83,6 +83,27 @@ export const DelayResultError = [ AmlError.BANK_RELEASE_DATE_MISSING, ]; +// Keep in sync with packages/core/src/definitions/compliance.ts (ManualPassWhitelistErrors / canManualPass) +export const ManualPassWhitelistErrors: AmlError[] = [ + AmlError.PHONE_VERIFICATION_NEEDED, + AmlError.IP_PHONE_VERIFICATION_NEEDED, + AmlError.BIC_PHONE_VERIFICATION_NEEDED, + AmlError.IBAN_PHONE_VERIFICATION_NEEDED, + AmlError.IP_COUNTRY_MISMATCH, + AmlError.TRADE_APPROVAL_DATE_MISSING, + AmlError.USER_DATA_FAILED_CALL, + AmlError.USER_DATA_REJECTED_CALL, + AmlError.REFERRAL_NO_TRADE_HISTORY, +]; + +export function canManualPass(comment: string | null | undefined): boolean { + const errors = (comment ?? '') + .split(';') + .map((e) => e.trim()) + .filter(Boolean); + return errors.length > 0 && errors.every((e) => ManualPassWhitelistErrors.includes(e as AmlError)); +} + export enum AmlErrorType { SINGLE = 'Single', // Only one error may occur MULTI = 'Multi', // All errors must have the same amlCheck diff --git a/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts b/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts index 6e36bef904..282687d3d3 100644 --- a/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts +++ b/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts @@ -5,6 +5,7 @@ import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { RefundInternalDto } from '../../history/dto/refund-internal.dto'; +import { ManualAmlCheckDto } from '../../aml/dto/manual-aml-check.dto'; import { UpdateBuyCryptoDto } from './dto/update-buy-crypto.dto'; import { BuyCrypto } from './entities/buy-crypto.entity'; import { BuyCryptoWebhookService } from './services/buy-crypto-webhook.service'; @@ -65,4 +66,12 @@ export class BuyCryptoController { async resetAmlCheck(@Param('id') id: string): Promise { return this.buyCryptoService.resetAmlCheck(+id); } + + @Put(':id/amlCheck') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async manualPassAmlCheck(@Param('id') id: string, @Body() dto: ManualAmlCheckDto): Promise { + return this.buyCryptoService.manualPassAmlCheck(+id, dto); + } } diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index bd974e7fdb..50664c7b23 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -53,12 +53,14 @@ import { TransactionRequestService } from 'src/subdomains/supporting/payment/ser import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { Between, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { canManualPass } from '../../../aml/enums/aml-error.enum'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { Buy } from '../../routes/buy/buy.entity'; import { BuyRepository } from '../../routes/buy/buy.repository'; import { BuyService } from '../../routes/buy/buy.service'; import { BuyHistoryDto } from '../../routes/buy/dto/buy-history.dto'; +import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { UpdateBuyCryptoDto } from '../dto/update-buy-crypto.dto'; import { BuyCrypto, BuyCryptoEditableAmlCheck, BuyCryptoStatus } from '../entities/buy-crypto.entity'; import { BuyCryptoRepository } from '../repositories/buy-crypto.repository'; @@ -682,6 +684,24 @@ export class BuyCryptoService { if (fiatOutputId) await this.fiatOutputService.delete(fiatOutputId); } + async manualPassAmlCheck(id: number, dto: ManualAmlCheckDto): Promise { + const entity = await this.buyCryptoRepo.findOneBy({ id }); + if (!entity) throw new NotFoundException('BuyCrypto not found'); + if (entity.isComplete || entity.chargebackAllowedDateUser) + throw new BadRequestException('BuyCrypto is already complete or chargeback initiated'); + if ([CheckStatus.PASS, CheckStatus.FAIL].includes(entity.amlCheck)) + throw new BadRequestException('BuyCrypto amlCheck is already finalized'); + if (dto.amlCheck === CheckStatus.PASS && !canManualPass(entity.comment)) + throw new BadRequestException('Manual pass only allowed when all errors are phone-related'); + + return this.update(id, { + amlCheck: dto.amlCheck, + amlResponsible: dto.responsible, + amlReason: dto.amlCheck === CheckStatus.PASS ? AmlReason.NA : dto.amlReason, + priceDefinitionAllowedDate: dto.amlCheck === CheckStatus.PASS ? new Date() : undefined, + } as UpdateBuyCryptoDto); + } + async getUserVolume( userIds: number[], dateFrom: Date = new Date(0), diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts index 3d2d7a0859..c3ba8195cb 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts @@ -6,6 +6,7 @@ import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { RefundInternalDto } from '../../history/dto/refund-internal.dto'; import { BuyFiat } from './buy-fiat.entity'; +import { ManualAmlCheckDto } from '../../aml/dto/manual-aml-check.dto'; import { UpdateBuyFiatDto } from './dto/update-buy-fiat.dto'; import { BuyFiatService } from './services/buy-fiat.service'; @@ -61,4 +62,12 @@ export class BuyFiatController { async resetAmlCheck(@Param('id') id: string): Promise { return this.buyFiatService.resetAmlCheck(+id); } + + @Put(':id/amlCheck') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async manualPassAmlCheck(@Param('id') id: string, @Body() dto: ManualAmlCheckDto): Promise { + return this.buyFiatService.manualPassAmlCheck(+id, dto); + } } diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index 925582e953..a8cdcb6434 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -27,6 +27,7 @@ import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/su import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; import { Between, FindOptionsRelations, In, MoreThan } from 'typeorm'; import { FiatOutputService } from '../../../../supporting/fiat-output/fiat-output.service'; +import { canManualPass } from '../../../aml/enums/aml-error.enum'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { BuyCryptoService } from '../../../buy-crypto/process/services/buy-crypto.service'; @@ -39,6 +40,7 @@ import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; import { BuyFiat, BuyFiatEditableAmlCheck } from '../buy-fiat.entity'; import { BuyFiatRepository } from '../buy-fiat.repository'; +import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { UpdateBuyFiatDto } from '../dto/update-buy-fiat.dto'; import { BuyFiatNotificationService } from './buy-fiat-notification.service'; @@ -415,6 +417,24 @@ export class BuyFiatService { } } + async manualPassAmlCheck(id: number, dto: ManualAmlCheckDto): Promise { + const entity = await this.buyFiatRepo.findOneBy({ id }); + if (!entity) throw new NotFoundException('BuyFiat not found'); + if (entity.isComplete || entity.chargebackAllowedDateUser) + throw new BadRequestException('BuyFiat is already complete or chargeback initiated'); + if ([CheckStatus.PASS, CheckStatus.FAIL].includes(entity.amlCheck)) + throw new BadRequestException('BuyFiat amlCheck is already finalized'); + if (dto.amlCheck === CheckStatus.PASS && !canManualPass(entity.comment)) + throw new BadRequestException('Manual pass only allowed when all errors are phone-related'); + + return this.update(id, { + amlCheck: dto.amlCheck, + amlResponsible: dto.responsible, + amlReason: dto.amlCheck === CheckStatus.PASS ? AmlReason.NA : dto.amlReason, + priceDefinitionAllowedDate: dto.amlCheck === CheckStatus.PASS ? new Date() : undefined, + } as UpdateBuyFiatDto); + } + async updateVolumes(start = 1, end = 100000): Promise { const sellIds = await this.buyFiatRepo .find({