From 6aad6225be4003afcb097b999184fd72b307ebaf Mon Sep 17 00:00:00 2001 From: Yannick1712 <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:02:46 +0100 Subject: [PATCH] [DEV-4502] userData SpecialExternalPhoneCallDate --- src/integration/sift/dto/sift.dto.ts | 1 + .../core/aml/enums/aml-error.enum.ts | 4 +-- .../core/aml/enums/aml-reason.enum.ts | 1 + .../core/aml/services/aml-helper.service.ts | 6 +++-- .../core/aml/services/aml.service.ts | 26 ++++++++++++++----- .../buy-crypto-preparation.service.ts | 3 ++- .../user-data/dto/update-user-data.dto.ts | 9 +++++++ .../user/models/user-data/user-data.entity.ts | 19 ++++++++++++++ .../models/user-data/user-data.service.ts | 3 +++ .../supporting/payment/dto/transaction.dto.ts | 1 + 10 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/integration/sift/dto/sift.dto.ts b/src/integration/sift/dto/sift.dto.ts index 29157744b1..77a439374b 100644 --- a/src/integration/sift/dto/sift.dto.ts +++ b/src/integration/sift/dto/sift.dto.ts @@ -1040,6 +1040,7 @@ export const SiftAmlDeclineMap: { [method in AmlReason]: DeclineCategory } = { [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: DeclineCategory.RISKY, [AmlReason.NAME_TOO_SHORT]: DeclineCategory.OTHER, [AmlReason.ASSET_INPUT_NOT_ALLOWED]: DeclineCategory.INVALID, + [AmlReason.MANUAL_CHECK_SPECIAL_EXTERNAL_PHONE]: DeclineCategory.RISKY, }; export interface ScoreRsponse { diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index d84786af6a..1812acf22e 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -293,12 +293,12 @@ export const AmlErrorResult: { [AmlError.BIC_PHONE_VERIFICATION_NEEDED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.PENDING, - amlReason: AmlReason.MANUAL_CHECK_PHONE, + amlReason: AmlReason.MANUAL_CHECK_SPECIAL_EXTERNAL_PHONE, }, [AmlError.IBAN_PHONE_VERIFICATION_NEEDED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.PENDING, - amlReason: AmlReason.MANUAL_CHECK_PHONE, + amlReason: AmlReason.MANUAL_CHECK_SPECIAL_EXTERNAL_PHONE, }, [AmlError.BANK_RELEASE_DATE_MISSING]: { type: AmlErrorType.SINGLE, diff --git a/src/subdomains/core/aml/enums/aml-reason.enum.ts b/src/subdomains/core/aml/enums/aml-reason.enum.ts index 5ae8a68e02..f8e29895fa 100644 --- a/src/subdomains/core/aml/enums/aml-reason.enum.ts +++ b/src/subdomains/core/aml/enums/aml-reason.enum.ts @@ -42,6 +42,7 @@ export enum AmlReason { INTERMEDIARY_WITHOUT_SENDER = 'IntermediaryWithoutSender', NAME_TOO_SHORT = 'NameTooShort', ASSET_INPUT_NOT_ALLOWED = 'AssetInputNotAllowed', + MANUAL_CHECK_SPECIAL_EXTERNAL_PHONE = 'ManualCheckSpecialExternalPhone', } export const KycAmlReasons = [ diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 64197b5571..0ad828d411 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -299,7 +299,8 @@ export class AmlHelperService { errors.push(AmlError.IBAN_BLACKLISTED); if ( - !entity.userData.phoneCallCheckDate && + (!entity.userData.phoneCallSpecialExternalCheckDate || + !entity.userData.phoneCallSpecialExternalCheckValuesObject?.includes(entity.bankTx.bic)) && entity.userData.isPersonalAccount && phoneCallList.some((b) => b.matches([SpecialExternalAccountType.AML_PHONE_CALL_NEEDED_BIC_BUY], entity.bankTx.bic), @@ -307,7 +308,8 @@ export class AmlHelperService { ) errors.push(AmlError.BIC_PHONE_VERIFICATION_NEEDED); if ( - !entity.userData.phoneCallCheckDate && + (!entity.userData.phoneCallSpecialExternalCheckDate || + !entity.userData.phoneCallSpecialExternalCheckValuesObject?.includes(entity.bankTx.iban)) && entity.userData.isPersonalAccount && phoneCallList.some( (b) => diff --git a/src/subdomains/core/aml/services/aml.service.ts b/src/subdomains/core/aml/services/aml.service.ts index c4f23c1a0e..82c67a54c5 100644 --- a/src/subdomains/core/aml/services/aml.service.ts +++ b/src/subdomains/core/aml/services/aml.service.ts @@ -5,6 +5,8 @@ import { CountryService } from 'src/shared/models/country/country.service'; import { IpLogService } from 'src/shared/models/ip-log/ip-log.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; +import { KycLogType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; +import { KycLogService } from 'src/subdomains/generic/kyc/services/kyc-log.service'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { NameCheckService } from 'src/subdomains/generic/kyc/services/name-check.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; @@ -43,16 +45,28 @@ export class AmlService { private readonly transactionService: TransactionService, private readonly ipLogService: IpLogService, private readonly kycService: KycService, + private readonly kycLogService: KycLogService, ) {} - async postProcessing(entity: BuyFiat | BuyCrypto, last30dVolume: number | undefined): Promise { + async postProcessing( + entity: BuyFiat | BuyCrypto, + last30dVolume: number | undefined, + isFirstRun = false, + ): Promise { if (entity.cryptoInput) await this.payInService.updatePayInAction(entity.cryptoInput.id, entity.amlCheck); - if ( - [CheckStatus.PENDING, CheckStatus.GSHEET].includes(entity.amlCheck) && - entity.amlReason === AmlReason.VIDEO_IDENT_NEEDED - ) - await this.userDataService.checkOrTriggerVideoIdent(entity.userData); + if ([CheckStatus.PENDING, CheckStatus.GSHEET].includes(entity.amlCheck)) { + if (entity.amlReason === AmlReason.VIDEO_IDENT_NEEDED) + await this.userDataService.checkOrTriggerVideoIdent(entity.userData); + if (isFirstRun && entity.amlReason === AmlReason.MANUAL_CHECK_SPECIAL_EXTERNAL_PHONE) { + await this.kycLogService.createLogInternal( + entity.userData, + KycLogType.KYC, + `Reset phoneCallSpecialExternalCheckDate ${entity.userData.phoneCallSpecialExternalCheckDate}`, + ); + await this.userDataService.updateUserDataInternal(entity.userData, { phoneCallSpecialExternalCheckDate: null }); + } + } if (entity.amlCheck === CheckStatus.PASS) { if (entity.user.status === UserStatus.NA) await this.userService.activateUser(entity.user, entity.userData); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index 5d52682a97..3f5923babe 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -96,6 +96,7 @@ export class BuyCryptoPreparationService { if (entity.cryptoInput && !entity.cryptoInput.isConfirmed) continue; const amlCheckBefore = entity.amlCheck; + const isFirstRun = entity.amlCheck == null; const inputCurrency = entity.cryptoInput?.asset ?? (await this.fiatService.getFiatByName(entity.inputAsset)); const inputReferenceCurrency = @@ -192,7 +193,7 @@ export class BuyCryptoPreparationService { ), ); - await this.amlService.postProcessing(entity, last30dVolume); + await this.amlService.postProcessing(entity, last30dVolume, isFirstRun); if (amlCheckBefore !== entity.amlCheck) await this.buyCryptoWebhookService.triggerWebhook(entity); diff --git a/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts b/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts index c82e300656..0534405e4b 100644 --- a/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts +++ b/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts @@ -328,4 +328,13 @@ export class UpdateUserDataDto { @IsOptional() @IsEnum(PhoneCallStatus) phoneCallStatus?: PhoneCallStatus; + + @IsOptional() + @IsDate() + @Type(() => Date) + phoneCallSpecialExternalCheckDate?: Date; + + @IsOptional() + @IsString() + phoneCallSpecialExternalCheckValue?: string; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 02fa3f1225..dc5f8ebe5b 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -248,6 +248,12 @@ export class UserData extends IEntity { @Column({ type: 'datetime2', nullable: true }) phoneCallIpCountryCheckDate?: Date; + @Column({ type: 'datetime2', nullable: true }) + phoneCallSpecialExternalCheckDate?: Date; + + @Column({ length: 256, nullable: true }) + phoneCallSpecialExternalCheckValues?: string; // already checked semicolon separated iban's, bic's and blz's + @Column({ length: 256, nullable: true }) phoneCallTimes: string; // PhoneCallPreferredTimes array @@ -526,6 +532,19 @@ export class UserData extends IEntity { return this.phoneCallTimes ? (this.phoneCallTimes?.split(';') as PhoneCallPreferredTime[]) : []; } + get phoneCallSpecialExternalCheckValuesObject(): string[] { + return this.phoneCallSpecialExternalCheckValues?.split(';'); + } + + addPhoneCallSpecialExternalCheckValue(specialAccountValue: string): void { + const existing = this.phoneCallSpecialExternalCheckValuesObject; + if (existing?.includes(specialAccountValue)) return; + + this.phoneCallSpecialExternalCheckValues = existing + ? `${this.phoneCallSpecialExternalCheckValues};${specialAccountValue}` + : specialAccountValue; + } + get hasValidNameCheckDate(): boolean { return this.lastNameCheckDate && Util.daysDiff(this.lastNameCheckDate) <= Config.amlCheckLastNameCheckValidity; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index c48789205d..5823d68a76 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -300,6 +300,9 @@ export class UserDataService { dto = await this.loadRelationsAndVerify({ id: userData.id, ...dto }, dto); + if (dto.phoneCallSpecialExternalCheckValue) + userData.addPhoneCallSpecialExternalCheckValue(dto.phoneCallSpecialExternalCheckValue); + if (dto.bankTransactionVerification === CheckStatus.PASS) { // cancel a pending video ident, if ident is completed const identCompleted = userData.hasCompletedStep(KycStepName.IDENT); diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index 54487ed854..f8ae48141c 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -117,6 +117,7 @@ export const TransactionReasonMapper: { [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: TransactionReason.BANK_NOT_ALLOWED, [AmlReason.NAME_TOO_SHORT]: TransactionReason.KYC_DATA_NEEDED, [AmlReason.ASSET_INPUT_NOT_ALLOWED]: TransactionReason.ASSET_NOT_AVAILABLE, + [AmlReason.MANUAL_CHECK_SPECIAL_EXTERNAL_PHONE]: TransactionReason.PHONE_VERIFICATION_NEEDED, }; export class UnassignedTransactionDto {