diff --git a/migration/1770470071165-AddUserDataVerificationCallTime.js b/migration/1770470071165-AddUserDataVerificationCallTime.js new file mode 100644 index 0000000000..48ae8a5240 --- /dev/null +++ b/migration/1770470071165-AddUserDataVerificationCallTime.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddUserDataVerificationCallTime1770470071165 { + name = 'AddUserDataVerificationCallTime1770470071165' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_data" ADD "phoneCallTimes" nvarchar(256)`); + await queryRunner.query(`ALTER TABLE "user_data" ADD "phoneCallStatus" nvarchar(256)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_data" DROP COLUMN "phoneCallStatus"`); + await queryRunner.query(`ALTER TABLE "user_data" DROP COLUMN "phoneCallTimes"`); + } +} diff --git a/src/integration/blockchain/api/services/blockchain-transaction.service.ts b/src/integration/blockchain/api/services/blockchain-transaction.service.ts index cb78ed2c4d..a6b0d3ecb5 100644 --- a/src/integration/blockchain/api/services/blockchain-transaction.service.ts +++ b/src/integration/blockchain/api/services/blockchain-transaction.service.ts @@ -102,18 +102,14 @@ export class BlockchainTransactionService { const fromTokenAccount = await SolanaToken.getAssociatedTokenAddress(mintPublicKey, fromPublicKey); const toTokenAccount = await SolanaToken.getAssociatedTokenAddress(mintPublicKey, toPublicKey); - const isTokenAccountAvailable = await this.solanaClient.checkTokenAccount(toAddress, mintAddress); - - if (!isTokenAccountAvailable) { - transaction.add( - SolanaToken.createAssociatedTokenAccountInstruction( - fromPublicKey, - toTokenAccount, - toPublicKey, - mintPublicKey, - ), - ); - } + transaction.add( + SolanaToken.createAssociatedTokenAccountIdempotentInstruction( + fromPublicKey, + toTokenAccount, + toPublicKey, + mintPublicKey, + ), + ); transaction.add( SolanaToken.createTransferInstruction( diff --git a/src/integration/blockchain/solana/solana-client.ts b/src/integration/blockchain/solana/solana-client.ts index 2a5019ff19..482166bc1d 100644 --- a/src/integration/blockchain/solana/solana-client.ts +++ b/src/integration/blockchain/solana/solana-client.ts @@ -229,15 +229,16 @@ export class SolanaClient extends BlockchainClient { const fromTokenAccount = await SolanaToken.getAssociatedTokenAddress(mintPublicKey, fromPublicKey); const toTokenAccount = await SolanaToken.getAssociatedTokenAddress(mintPublicKey, toPublicKey); - const isTokenAccountAvailable = await this.checkTokenAccount(toAddress, mintAddress); - const transaction = new Solana.Transaction(); - if (!isTokenAccountAvailable) { - transaction.add( - SolanaToken.createAssociatedTokenAccountInstruction(fromPublicKey, toTokenAccount, toPublicKey, mintPublicKey), - ); - } + transaction.add( + SolanaToken.createAssociatedTokenAccountIdempotentInstruction( + fromPublicKey, + toTokenAccount, + toPublicKey, + mintPublicKey, + ), + ); transaction.add( SolanaToken.createTransferInstruction( diff --git a/src/integration/sift/dto/sift.dto.ts b/src/integration/sift/dto/sift.dto.ts index d657d12cb6..29157744b1 100644 --- a/src/integration/sift/dto/sift.dto.ts +++ b/src/integration/sift/dto/sift.dto.ts @@ -1034,6 +1034,7 @@ export const SiftAmlDeclineMap: { [method in AmlReason]: DeclineCategory } = { [AmlReason.MANUAL_CHECK_PHONE]: DeclineCategory.RISKY, [AmlReason.MANUAL_CHECK_IP_PHONE]: DeclineCategory.RISKY, [AmlReason.MANUAL_CHECK_IP_COUNTRY_PHONE]: DeclineCategory.RISKY, + [AmlReason.MANUAL_CHECK_PHONE_FAILED]: DeclineCategory.RISKY, [AmlReason.BANK_RELEASE_PENDING]: DeclineCategory.OTHER, [AmlReason.VIRTUAL_IBAN_USER_MISMATCH]: DeclineCategory.RISKY, [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: DeclineCategory.RISKY, diff --git a/src/shared/i18n/de/mail.json b/src/shared/i18n/de/mail.json index ee4cdc2066..ebebbd5e9c 100644 --- a/src/shared/i18n/de/mail.json +++ b/src/shared/i18n/de/mail.json @@ -91,9 +91,10 @@ "test_only": "Test", "kyc_data_needed": "Für die Transaktion werden deine KYC Daten benötigt", "bank_tx_needed": "Um diese Transaktion auszuführen ist zuvor eine Banktransaktion erforderlich", - "manual_check_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", - "manual_check_ip_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", - "manual_check_ip_country_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", + "manual_check_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen. Du kannst einen erneuten Anruf und eine bevorzugte Uhrzeit für das Telefongespräch selber hier beantragen: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen. Du kannst einen erneuten Anruf und eine bevorzugte Uhrzeit für das Telefongespräch selber hier beantragen: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_country_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen. Du kannst einen erneuten Anruf und eine bevorzugte Uhrzeit für das Telefongespräch selber hier beantragen: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_phone_rejected": "Das Telefonat war nicht erfolgreich oder wurde abgelehnt", "merge_incomplete": "Die Email Bestätigung wurde nicht akzeptiert", "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten.", "name_too_short": "Dein Name ist zu kurz für die Bankverarbeitung. Banken benötigen mindestens 4 Buchstaben im Namen des Kontoinhabers.", @@ -216,7 +217,7 @@ "line1": "Wir haben deine Einzahlung erhalten.", "line2": "Wir werden uns in Kürze auf der Telefonnummer {phone} bei dir melden.", "line3": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", - "line4": "", + "line4": "Du kannst eine bevorzugte Uhrzeit für das Telefongespräch selber hier angeben: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Wenn du stattdessen eine Rückzahlung anfordern möchtest:
[url:Klick hier]" }, "merge_incomplete": { @@ -243,7 +244,7 @@ "line1": "Wir haben deine Einzahlung erhalten.", "line2": "Wir werden uns in Kürze auf der Telefonnummer {phone} bei dir melden.", "line3": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", - "line4": "", + "line4": "Du kannst eine bevorzugte Uhrzeit für das Telefongespräch selber hier angeben: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Wenn du stattdessen eine Rückzahlung anfordern möchtest:
[url:Klick hier]" }, "manual_check_ip_country_phone": { @@ -252,7 +253,7 @@ "line1": "Wir haben deine Einzahlung erhalten.", "line2": "Wir werden uns in Kürze auf der Telefonnummer {phone} bei dir melden.", "line3": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", - "line4": "", + "line4": "Du kannst eine bevorzugte Uhrzeit für das Telefongespräch selber hier angeben: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Wenn du stattdessen eine Rückzahlung anfordern möchtest:
[url:Klick hier]" } }, diff --git a/src/shared/i18n/en/mail.json b/src/shared/i18n/en/mail.json index c311bc8d6d..bf74208214 100644 --- a/src/shared/i18n/en/mail.json +++ b/src/shared/i18n/en/mail.json @@ -91,9 +91,10 @@ "test_only": "Test", "kyc_data_needed": "Your KYC data is required for the transaction", "bank_tx_needed": "A bank transaction is required before this transaction can be carried out", - "manual_check_phone": "We were unable to reach you at the phone number you provided", - "manual_check_ip_phone": "We were unable to reach you at the phone number you provided", - "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided", + "manual_check_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_phone_rejected": "The phone call was unsuccessful or rejected", "merge_incomplete": "The email confirmation was not accepted", "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction.", "name_too_short": "Your name is too short for bank processing. Banks require at least 4 letters in the account holder name.", @@ -216,7 +217,7 @@ "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "", + "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", "line5": "If you would like to request a refund instead:
[url:click here]" }, "merge_incomplete": { @@ -243,7 +244,7 @@ "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "", + "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", "line5": "If you would like to request a refund instead:
[url:click here]" }, "manual_check_ip_country_phone": { @@ -252,7 +253,7 @@ "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "", + "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", "line5": "If you would like to request a refund instead:
[url:click here]" } }, diff --git a/src/shared/i18n/es/mail.json b/src/shared/i18n/es/mail.json index 22fa89af89..111c77fec1 100644 --- a/src/shared/i18n/es/mail.json +++ b/src/shared/i18n/es/mail.json @@ -91,9 +91,10 @@ "test_only": "Prueba", "kyc_data_needed": "Sus datos KYC son necesarios para la transacción", "bank_tx_needed": "Para poder realizar esta operación es necesaria una transacción bancaria", - "manual_check_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", - "manual_check_ip_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", - "manual_check_ip_country_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", + "manual_check_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó. Aquí puede solicitar una llamada y elegir la hora que prefiera para recibirla: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó. Aquí puede solicitar una llamada y elegir la hora que prefiera para recibirla: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_country_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó. Aquí puede solicitar una llamada y elegir la hora que prefiera para recibirla: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_phone_rejected": "La llamada telefónica no se ha podido realizar o ha sido rechazada.", "merge_incomplete": "El correo electrónico de confirmación no fue aceptado", "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción.", "name_too_short": "Tu nombre es demasiado corto para el procesamiento bancario. Los bancos requieren al menos 4 letras en el nombre del titular de la cuenta.", @@ -216,7 +217,7 @@ "line1": "Hemos recibido su depósito.", "line2": "Nos pondremos en contacto con usted en breve en el {phone}.", "line3": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", - "line4": "", + "line4": "Aquí puede especificar la hora que prefiera para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Si desea solicitar un reembolso en su lugar:
[url:haga clic aquí]" }, "merge_incomplete": { @@ -243,7 +244,7 @@ "line1": "Hemos recibido su depósito.", "line2": "Nos pondremos en contacto con usted en breve en el {phone}.", "line3": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", - "line4": "", + "line4": "Aquí puede especificar la hora que prefiera para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Si desea solicitar un reembolso en su lugar:
[url:haga clic aquí]" }, "manual_check_ip_country_phone": { @@ -252,7 +253,7 @@ "line1": "Hemos recibido su depósito.", "line2": "Nos pondremos en contacto con usted en breve en el {phone}.", "line3": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", - "line4": "", + "line4": "Aquí puede especificar la hora que prefiera para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Si desea solicitar un reembolso en su lugar:
[url:haga clic aquí]" } }, diff --git a/src/shared/i18n/fr/mail.json b/src/shared/i18n/fr/mail.json index d61645fecc..9343213558 100644 --- a/src/shared/i18n/fr/mail.json +++ b/src/shared/i18n/fr/mail.json @@ -91,9 +91,10 @@ "test_only": "Test", "kyc_data_needed": "Vos données KYC sont nécessaires pour la transaction", "bank_tx_needed": "Une transaction bancaire est nécessaire pour que cette opération puisse être effectuée", - "manual_check_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", - "manual_check_ip_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", - "manual_check_ip_country_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", + "manual_check_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni. Vous pouvez demander ici à être rappelé et indiquer l'heure à laquelle vous souhaitez recevoir l'appel: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni. Vous pouvez demander ici à être rappelé et indiquer l'heure à laquelle vous souhaitez recevoir l'appel: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_country_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni. Vous pouvez demander ici à être rappelé et indiquer l'heure à laquelle vous souhaitez recevoir l'appel: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_phone_rejected": "L'appel téléphonique n'a pas abouti ou a été rejeté", "merge_incomplete": "L'e-mail de confirmation n'a pas été accepté", "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction.", "name_too_short": "Votre nom est trop court pour le traitement bancaire. Les banques exigent au moins 4 lettres dans le nom du titulaire du compte.", @@ -216,7 +217,7 @@ "line1": "Nous avons bien reçu votre acompte.", "line2": "Nous vous contacterons sous peu au {phone}.", "line3": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", - "line4": "", + "line4": "Vous pouvez indiquer ici l'heure à laquelle vous souhaitez recevoir l'appel téléphonique: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Si vous souhaitez demander un remboursement:
[url:cliquez ici]" }, "merge_incomplete": { @@ -243,7 +244,7 @@ "line1": "Nous avons bien reçu votre acompte.", "line2": "Nous vous contacterons sous peu au {phone}.", "line3": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", - "line4": "", + "line4": "Vous pouvez indiquer ici l'heure à laquelle vous souhaitez recevoir l'appel téléphonique: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Si vous souhaitez demander un remboursement:
[url:cliquez ici]" }, "manual_check_ip_country_phone": { @@ -252,7 +253,7 @@ "line1": "Nous avons bien reçu votre acompte.", "line2": "Nous vous contacterons sous peu au {phone}.", "line3": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", - "line4": "", + "line4": "Vous pouvez indiquer ici l'heure à laquelle vous souhaitez recevoir l'appel téléphonique: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Si vous souhaitez demander un remboursement:
[url:cliquez ici]" } }, diff --git a/src/shared/i18n/it/mail.json b/src/shared/i18n/it/mail.json index edfbf0cb8f..6583dc282c 100644 --- a/src/shared/i18n/it/mail.json +++ b/src/shared/i18n/it/mail.json @@ -91,9 +91,10 @@ "test_only": "Test", "kyc_data_needed": "I dati KYC sono necessari per la transazione", "bank_tx_needed": "Per effettuare questa transazione è necessaria una transazione bancaria", - "manual_check_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", - "manual_check_ip_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", - "manual_check_ip_country_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", + "manual_check_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito. Puoi richiedere tu stesso una richiamata e indicare l'orario che preferisci per la telefonata qui: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito. Puoi richiedere tu stesso una richiamata e indicare l'orario che preferisci per la telefonata qui: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_country_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito. Puoi richiedere tu stesso una richiamata e indicare l'orario che preferisci per la telefonata qui: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_phone_rejected": "La telefonata non è andata a buon fine o è stata rifiutata.", "merge_incomplete": "L'e-mail di conferma non è stata accettata", "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione.", "name_too_short": "Il tuo nome è troppo corto per l'elaborazione bancaria. Le banche richiedono almeno 4 lettere nel nome del titolare del conto.", @@ -216,7 +217,7 @@ "line1": "Abbiamo ricevuto il tuo deposito.", "line2": "Ti contatteremo a breve al numero {phone}.", "line3": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", - "line4": "", + "line4": "Qui puoi specificare l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Se invece si desidera richiedere un rimborso:
[url:clicca qui]" }, "merge_incomplete": { @@ -243,7 +244,7 @@ "line1": "Abbiamo ricevuto il tuo deposito.", "line2": "Ti contatteremo a breve al numero {phone}.", "line3": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", - "line4": "", + "line4": "Qui puoi specificare l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Se invece si desidera richiedere un rimborso:
[url:clicca qui]" }, "manual_check_ip_country_phone": { @@ -252,7 +253,7 @@ "line1": "Abbiamo ricevuto il tuo deposito.", "line2": "Ti contatteremo a breve al numero {phone}.", "line3": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", - "line4": "", + "line4": "Qui puoi specificare l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", "line5": "Se invece si desidera richiedere un rimborso:
[url:clicca qui]" } }, diff --git a/src/shared/i18n/pt/mail.json b/src/shared/i18n/pt/mail.json index dafefe0831..ac5f1c71c4 100644 --- a/src/shared/i18n/pt/mail.json +++ b/src/shared/i18n/pt/mail.json @@ -91,9 +91,10 @@ "test_only": "Test", "kyc_data_needed": "Your KYC data is required for the transaction", "bank_tx_needed": "A bank transaction is required before this transaction can be carried out", - "manual_check_phone": "We were unable to reach you at the phone number you provided", - "manual_check_ip_phone": "We were unable to reach you at the phone number you provided", - "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided", + "manual_check_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", + "manual_check_phone_rejected": "The phone call was unsuccessful or rejected", "merge_incomplete": "The email confirmation was not accepted", "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação.", "name_too_short": "O seu nome é muito curto para o processamento bancário. Os bancos exigem pelo menos 4 letras no nome do titular da conta.", @@ -216,7 +217,7 @@ "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "", + "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", "line5": "If you would like to request a refund instead:
[url:click here]" }, "merge_incomplete": { @@ -243,7 +244,7 @@ "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "", + "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", "line5": "If you would like to request a refund instead:
[url:click here]" }, "manual_check_ip_country_phone": { @@ -252,7 +253,7 @@ "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "", + "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", "line5": "If you would like to request a refund instead:
[url:click here]" } }, diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index e8839c8210..d84786af6a 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -64,6 +64,7 @@ export enum AmlError { IP_BLACKLISTED_WITHOUT_KYC = 'IpBlacklistedWithoutKyc', BANK_RELEASE_DATE_MISSING = 'BankReleaseDateMissing', IP_COUNTRY_MISMATCH = 'IpCountryMismatch', + USER_DATA_FAILED_CALL = 'UserDataFailedCall', TRADE_APPROVAL_DATE_MISSING = 'TradeApprovalDateMissing', BANK_TX_CUSTOMER_NAME_MISSING = 'BankTxCustomerNameMissing', FORCE_MANUAL_CHECK = 'ForceManualCheck', @@ -314,6 +315,11 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK_IP_COUNTRY_PHONE, }, + [AmlError.USER_DATA_FAILED_CALL]: { + type: AmlErrorType.CRUCIAL, + amlCheck: CheckStatus.FAIL, + amlReason: AmlReason.MANUAL_CHECK_PHONE_FAILED, + }, [AmlError.TRADE_APPROVAL_DATE_MISSING]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.PENDING, diff --git a/src/subdomains/core/aml/enums/aml-reason.enum.ts b/src/subdomains/core/aml/enums/aml-reason.enum.ts index 1bdeadcb68..5ae8a68e02 100644 --- a/src/subdomains/core/aml/enums/aml-reason.enum.ts +++ b/src/subdomains/core/aml/enums/aml-reason.enum.ts @@ -36,6 +36,7 @@ export enum AmlReason { MANUAL_CHECK_PHONE = 'ManualCheckPhone', MANUAL_CHECK_IP_PHONE = 'ManualCheckIpPhone', MANUAL_CHECK_IP_COUNTRY_PHONE = 'ManualCheckIpCountryPhone', + MANUAL_CHECK_PHONE_FAILED = 'ManualCheckPhoneFailed', BANK_RELEASE_PENDING = 'BankReleasePending', VIRTUAL_IBAN_USER_MISMATCH = 'VirtualIbanUserMismatch', INTERMEDIARY_WITHOUT_SENDER = 'IntermediaryWithoutSender', diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 515a441c85..64197b5571 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -7,7 +7,12 @@ import { Util } from 'src/shared/utils/util'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { BankData, BankDataVerificationError } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; -import { KycLevel, KycType, UserDataStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { + KycLevel, + KycType, + PhoneCallStatus, + UserDataStatus, +} from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; @@ -60,7 +65,11 @@ export class AmlHelperService { !entity.userData.tradeApprovalDate && !entity.wallet.autoTradeApproval ) - errors.push(AmlError.TRADE_APPROVAL_DATE_MISSING); + errors.push( + [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + ? AmlError.USER_DATA_FAILED_CALL + : AmlError.TRADE_APPROVAL_DATE_MISSING, + ); if (entity.inputReferenceAmount < minVolume * 0.9) errors.push(AmlError.MIN_VOLUME_NOT_REACHED); if (entity.user.isBlocked) errors.push(AmlError.USER_BLOCKED); if (entity.user.isDeleted) errors.push(AmlError.USER_DELETED); @@ -92,7 +101,11 @@ export class AmlHelperService { errors.push(AmlError.YEARLY_LIMIT_WO_KYC_REACHED); if (entity.userData.hasIpRisk && !entity.userData.phoneCallIpCheckDate) { if (entity.userData.kycLevel >= KycLevel.LEVEL_50) { - errors.push(AmlError.IP_PHONE_VERIFICATION_NEEDED); + errors.push( + [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + ? AmlError.USER_DATA_FAILED_CALL + : AmlError.IP_PHONE_VERIFICATION_NEEDED, + ); } else { errors.push(AmlError.IP_BLACKLISTED_WITHOUT_KYC); } @@ -198,7 +211,11 @@ export class AmlHelperService { ![l, entity.userData.country.symbol].every((c) => Config.allowedBorderRegions.includes(c)), ) ) - errors.push(AmlError.IP_COUNTRY_MISMATCH); + errors.push( + [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + ? AmlError.USER_DATA_FAILED_CALL + : AmlError.IP_COUNTRY_MISMATCH, + ); if ( entity.userData.hasSuspiciousMail && @@ -224,7 +241,11 @@ export class AmlHelperService { entity.userData.isPersonalAccount && Util.yearsDiff(entity.userData.birthday) > 55 ) - errors.push(AmlError.PHONE_VERIFICATION_NEEDED); + errors.push( + [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + ? AmlError.USER_DATA_FAILED_CALL + : AmlError.PHONE_VERIFICATION_NEEDED, + ); if (entity.bankTx) { // bank @@ -469,7 +490,11 @@ export class AmlHelperService { case AmlRule.RULE_16: if (entity instanceof BuyCrypto && entity.userData.isPersonalAccount && !entity.userData.phoneCallCheckDate) - errors.push(AmlError.PHONE_VERIFICATION_NEEDED); + errors.push( + [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + ? AmlError.USER_DATA_FAILED_CALL + : AmlError.PHONE_VERIFICATION_NEEDED, + ); break; } diff --git a/src/subdomains/generic/support/dto/user-data-support.dto.ts b/src/subdomains/generic/support/dto/user-data-support.dto.ts index cd9ff2bf56..db52de21d8 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -48,12 +48,31 @@ export class TransactionSupportInfo { created: Date; } +export class RecommendationUserInfo { + id: number; + firstname?: string; + surname?: string; +} + +export class RecommendationEntry { + id: number; + recommended: RecommendationUserInfo; + isConfirmed?: boolean; + confirmationDate?: Date; + created: Date; +} + export class KycStepSupportInfo { id: number; name: string; type?: string; status: string; sequenceNumber: number; + result?: string; + comment?: string; + recommender?: RecommendationUserInfo; + recommended?: RecommendationUserInfo; + allRecommendations?: RecommendationEntry[]; created: Date; } @@ -125,6 +144,32 @@ export class KycFileYearlyStats { highestFileNr: number; } +export class RecommendationGraphNode { + id: number; + firstname?: string; + surname?: string; + kycStatus?: string; + kycLevel?: number; + tradeApprovalDate?: Date; +} + +export class RecommendationGraphEdge { + id: number; + recommenderId: number; + recommendedId: number; + method: string; + type: string; + isConfirmed?: boolean; + confirmationDate?: Date; + created: Date; +} + +export class RecommendationGraph { + nodes: RecommendationGraphNode[]; + edges: RecommendationGraphEdge[]; + rootId: number; +} + export class UserDataSupportInfoDetails { userData: UserData; kycFiles: KycFile[]; diff --git a/src/subdomains/generic/support/support.controller.ts b/src/subdomains/generic/support/support.controller.ts index 28eb262d01..5c2ac4d77f 100644 --- a/src/subdomains/generic/support/support.controller.ts +++ b/src/subdomains/generic/support/support.controller.ts @@ -20,6 +20,7 @@ import { TransactionListQuery } from './dto/transaction-list-query.dto'; import { KycFileListEntry, KycFileYearlyStats, + RecommendationGraph, TransactionListEntry, UserDataSupportInfoDetails, UserDataSupportInfoResult, @@ -63,6 +64,14 @@ export class SupportController { return this.supportService.getTransactionList(query); } + @Get('recommendation-graph/:id') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async getRecommendationGraph(@Param('id') id: string): Promise { + return this.supportService.getRecommendationGraph(+id); + } + @Get(':id') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 6f22bd6ac2..1b6e7d2249 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -27,10 +27,13 @@ import { Transaction } from 'src/subdomains/supporting/payment/entities/transact import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { KycStep } from '../kyc/entities/kyc-step.entity'; +import { KycStepName } from '../kyc/enums/kyc-step-name.enum'; import { KycFileService } from '../kyc/services/kyc-file.service'; import { KycService } from '../kyc/services/kyc.service'; import { BankData } from '../user/models/bank-data/bank-data.entity'; import { BankDataService } from '../user/models/bank-data/bank-data.service'; +import { Recommendation } from '../user/models/recommendation/recommendation.entity'; +import { RecommendationService } from '../user/models/recommendation/recommendation.service'; import { UserData } from '../user/models/user-data/user-data.entity'; import { UserDataService } from '../user/models/user-data/user-data.service'; import { User } from '../user/models/user/user.entity'; @@ -44,6 +47,11 @@ import { KycFileListEntry, KycFileYearlyStats, KycStepSupportInfo, + RecommendationEntry, + RecommendationGraph, + RecommendationGraphEdge, + RecommendationGraphNode, + RecommendationUserInfo, SellSupportInfo, TransactionListEntry, TransactionSupportInfo, @@ -83,6 +91,7 @@ export class SupportService { @Inject(forwardRef(() => TransactionHelper)) private readonly transactionHelper: TransactionHelper, private readonly settingService: SettingService, + private readonly recommendationService: RecommendationService, ) {} async getUserDataDetails(id: number): Promise { @@ -100,10 +109,34 @@ export class SupportService { this.sellService.getSellsByUserDataId(id), ]); + // Load recommendation data for Recommendation steps + const recommendationStepIds = kycSteps.filter((s) => s.name === KycStepName.RECOMMENDATION).map((s) => s.id); + const recommendations = await this.recommendationService.getRecommendationsByKycStepIdsOrUserDataId( + recommendationStepIds, + id, + ); + // Map by kycStepId first, then fall back to first recommendation for this userData + const recommendationByStep = new Map(recommendations.filter((r) => r.kycStep?.id).map((r) => [r.kycStep.id, r])); + const fallbackRecommendation = recommendations[0]; + + // Load all recommendations by the recommender (to show the full network) + const recommenderId = fallbackRecommendation?.recommender?.id; + const allByRecommender = recommenderId + ? await this.recommendationService.getAllRecommendationsByRecommenderId(recommenderId) + : []; + return { userData, kycFiles, - kycSteps: kycSteps.map((s) => this.toKycStepSupportInfo(s)), + kycSteps: kycSteps.map((s) => + this.toKycStepSupportInfo( + s, + s.name === KycStepName.RECOMMENDATION + ? (recommendationByStep.get(s.id) ?? fallbackRecommendation) + : undefined, + s.name === KycStepName.RECOMMENDATION ? allByRecommender : undefined, + ), + ), transactions: transactions.map((t) => this.toTransactionSupportInfo(t)), users: users.map((u) => this.toUserSupportInfo(u)), bankDatas: bankDatas.map((b) => this.toBankDataSupportInfo(b)), @@ -207,13 +240,33 @@ export class SupportService { }; } - private toKycStepSupportInfo(step: KycStep): KycStepSupportInfo { + private toKycStepSupportInfo( + step: KycStep, + recommendation?: Recommendation, + allByRecommender?: Recommendation[], + ): KycStepSupportInfo { + const toUserInfo = (ud?: UserData): RecommendationUserInfo | undefined => + ud ? { id: ud.id, firstname: ud.firstname, surname: ud.surname } : undefined; + + const toEntry = (r: Recommendation): RecommendationEntry => ({ + id: r.id, + recommended: toUserInfo(r.recommended) ?? { id: 0 }, + isConfirmed: r.isConfirmed, + confirmationDate: r.confirmationDate, + created: r.created, + }); + return { id: step.id, name: step.name, type: step.type, status: step.status, sequenceNumber: step.sequenceNumber, + result: step.result, + comment: step.comment, + recommender: toUserInfo(recommendation?.recommender), + recommended: toUserInfo(recommendation?.recommended), + allRecommendations: allByRecommender?.map(toEntry), created: step.created, }; } @@ -269,6 +322,62 @@ export class SupportService { }; } + async getRecommendationGraph(userDataId: number): Promise { + const MAX_NODES = 500; + const visitedUsers = new Set(); + const visitedRecs = new Map(); + const queue: number[] = [userDataId]; + + // BFS: traverse all connected recommendations in both directions (capped) + while (queue.length > 0 && visitedUsers.size < MAX_NODES) { + const currentId = queue.shift(); + if (visitedUsers.has(currentId)) continue; + visitedUsers.add(currentId); + + // Find all recommendations where this user is recommender OR recommended + const [asRecommender, asRecommended] = await Promise.all([ + this.recommendationService.getAllRecommendationsByRecommenderId(currentId), + this.recommendationService.getRecommendationsByRecommendedId(currentId), + ]); + + for (const rec of [...asRecommender, ...asRecommended]) { + if (visitedRecs.has(rec.id)) continue; + visitedRecs.set(rec.id, rec); + + if (rec.recommender?.id && !visitedUsers.has(rec.recommender.id)) queue.push(rec.recommender.id); + if (rec.recommended?.id && !visitedUsers.has(rec.recommended.id)) queue.push(rec.recommended.id); + } + } + + // Batch-load all user data + const allUserIds = [...visitedUsers]; + const userDatas = await this.userDataService.getUserDataByIds(allUserIds); + + const nodes: RecommendationGraphNode[] = userDatas.map((ud) => ({ + id: ud.id, + firstname: ud.firstname, + surname: ud.surname, + kycStatus: ud.kycStatus, + kycLevel: ud.kycLevel, + tradeApprovalDate: ud.tradeApprovalDate, + })); + + const edges: RecommendationGraphEdge[] = [...visitedRecs.values()] + .filter((r) => r.recommender?.id && r.recommended?.id) + .map((r) => ({ + id: r.id, + recommenderId: r.recommender.id, + recommendedId: r.recommended.id, + method: r.method, + type: r.type, + isConfirmed: r.isConfirmed, + confirmationDate: r.confirmationDate, + created: r.created, + })); + + return { nodes, edges, rootId: userDataId }; + } + async searchUserDataByKey(query: UserDataSupportQuery): Promise { const searchResult = await this.getUserDatasByKey(query.key); const bankTx = [ComplianceSearchType.IBAN, ComplianceSearchType.VIRTUAL_IBAN].includes(searchResult.type) diff --git a/src/subdomains/generic/user/models/recommendation/recommendation.entity.ts b/src/subdomains/generic/user/models/recommendation/recommendation.entity.ts index 344ee34313..ea0baadf49 100644 --- a/src/subdomains/generic/user/models/recommendation/recommendation.entity.ts +++ b/src/subdomains/generic/user/models/recommendation/recommendation.entity.ts @@ -64,7 +64,7 @@ export class Recommendation extends IEntity { } get url(): string { - return `${Config.frontend.services}/recommendation`; + return `${Config.frontend.services}/account?a=recommendation`; } get loginUrl(): string { diff --git a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts index c0500cf1c8..96ebcfcdf8 100644 --- a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts +++ b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts @@ -283,6 +283,46 @@ export class RecommendationService { return this.updateRecommendationInternal(entity, { isConfirmed: true, confirmationDate: new Date() }); } + async getRecommendationsByKycStepIdsOrUserDataId( + kycStepIds: number[], + userDataId: number, + ): Promise { + const results: Recommendation[] = []; + + // Search by kycStepId + if (kycStepIds.length) { + const byStep = await this.recommendationRepo.find({ + where: kycStepIds.map((id) => ({ kycStep: { id } })), + relations: { recommender: true, recommended: true }, + }); + results.push(...byStep); + } + + // Also search by recommendedId (for mail invitations where kycStepId is null) + const byRecommended = await this.recommendationRepo.find({ + where: { recommended: { id: userDataId } }, + relations: { recommender: true, recommended: true }, + }); + results.push(...byRecommended.filter((r) => !results.some((e) => e.id === r.id))); + + return results; + } + + async getAllRecommendationsByRecommenderId(recommenderId: number): Promise { + return this.recommendationRepo.find({ + where: { recommender: { id: recommenderId } }, + relations: { recommended: true, recommender: true }, + order: { id: 'DESC' }, + }); + } + + async getRecommendationsByRecommendedId(recommendedId: number): Promise { + return this.recommendationRepo.find({ + where: { recommended: { id: recommendedId } }, + relations: { recommended: true, recommender: true }, + }); + } + // --- NOTIFICATIONS --- // private async sendInvitationMail(entity: Recommendation): Promise { try { 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 9050e3a94d..ccc329943a 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 @@ -41,6 +41,8 @@ import { LegalEntity, LimitPeriod, Moderator, + PhoneCallPreferredTime, + PhoneCallStatus, RiskStatus, SignatoryPower, UserDataStatus, @@ -246,6 +248,12 @@ export class UserData extends IEntity { @Column({ type: 'datetime2', nullable: true }) phoneCallIpCountryCheckDate?: Date; + @Column({ length: 256, nullable: true }) + phoneCallTimes: string; // PhoneCallPreferredTimes array + + @Column({ length: 256, nullable: true }) + phoneCallStatus: PhoneCallStatus; + @Column({ type: 'datetime2', nullable: true }) tradeApprovalDate?: Date; @@ -498,6 +506,12 @@ export class UserData extends IEntity { const update: Partial = { language: dto.language ?? this.language, currency: dto.currency ?? this.currency, + phoneCallTimes: dto.preferredPhoneTimes ? dto.preferredPhoneTimes.join(';') : undefined, + phoneCallStatus: dto.acceptCall + ? PhoneCallStatus.REPEAT + : dto.acceptCall === false + ? PhoneCallStatus.USER_REJECTED + : undefined, }; Object.assign(this, update); @@ -505,6 +519,10 @@ export class UserData extends IEntity { return [this.id, update]; } + get phoneCallTimesObject(): PhoneCallPreferredTime[] { + return this.phoneCallTimes ? (this.phoneCallTimes?.split(';') as PhoneCallPreferredTime[]) : []; + } + get hasValidNameCheckDate(): boolean { return this.lastNameCheckDate && Util.daysDiff(this.lastNameCheckDate) <= Config.amlCheckLastNameCheckValidity; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.enum.ts b/src/subdomains/generic/user/models/user-data/user-data.enum.ts index b8ab98e9b7..a1474b1105 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.enum.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.enum.ts @@ -15,6 +15,26 @@ export enum RiskStatus { RELEASED = 'Released', } +export enum PhoneCallPreferredTime { + H_9_TO_10 = 'H9To10', + H_10_TO_11 = 'H10To11', + H_11_TO_12 = 'H11To12', + H_12_TO_13 = 'H12To13', + H_13_TO_14 = 'H13To14', + H_14_TO_15 = 'H14To15', + H_15_TO_16 = 'H15To16', + H_9_TO_16 = 'H9To16', +} + +export enum PhoneCallStatus { + REPEAT = 'Repeat', + USER_REJECTED = 'UserRejected', + UNAVAILABLE = 'Unavailable', + FAILED = 'Failed', + COMPLETED = 'Completed', + SUSPICIOUS = 'Suspicious', +} + export enum KycLevel { // automatic levels LEVEL_0 = 0, // nothing 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 bd42d91d55..254256a158 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 @@ -66,7 +66,7 @@ import { UpdateUserDataDto } from './dto/update-user-data.dto'; import { KycIdentificationType } from './kyc-identification-type.enum'; import { UserDataNotificationService } from './user-data-notification.service'; import { UserData } from './user-data.entity'; -import { KycLevel, TradeApprovalReason, UserDataStatus } from './user-data.enum'; +import { KycLevel, PhoneCallStatus, TradeApprovalReason, UserDataStatus } from './user-data.enum'; import { UserDataRepository } from './user-data.repository'; export const MergedPrefix = 'Merged into '; @@ -139,6 +139,11 @@ export class UserDataService { : this.userDataRepo.findOne(request); } + async getUserDataByIds(ids: number[]): Promise { + if (!ids.length) return []; + return this.userDataRepo.find({ where: { id: In(ids) } }); + } + async getByKycHashOrThrow(kycHash: string, relations?: FindOptionsRelations): Promise { if (!Config.formats.kycHash.test(kycHash)) throw new UnauthorizedException('Invalid KYC hash'); @@ -618,6 +623,8 @@ export class UserDataService { surname: transliterate(dto.lastName), }; + if (userData.verifiedName) update.verifiedName = `${update.firstname} ${update.surname}`; + await this.userDataRepo.update(userData.id, update); Object.assign(userData, update); @@ -785,6 +792,10 @@ export class UserDataService { // create KYC step if (createStep) { + if (!userData.kycSteps) { + userData.kycSteps = await this.kycService.getStepsByUserData(userData.id); + } + await this.kycService.createCustomKycStep(userData, KycStepName.PHONE_CHANGE, ReviewStatus.COMPLETED, { phone, previousPhone, @@ -840,6 +851,15 @@ export class UserDataService { if (!dto.currency) throw new BadRequestException('Currency not found'); } + if ( + userData.phoneCallStatus && + ![PhoneCallStatus.UNAVAILABLE, PhoneCallStatus.USER_REJECTED, PhoneCallStatus.REPEAT].includes( + userData.phoneCallStatus, + ) && + (dto.acceptCall || dto.acceptCall === false) + ) + throw new BadRequestException('Phone call status is already set'); + // check phone if (dto.phone && dto.phone !== userData.phone) { await this.updatePhone(userData, dto.phone); diff --git a/src/subdomains/generic/user/models/user/dto/update-user.dto.ts b/src/subdomains/generic/user/models/user/dto/update-user.dto.ts index 444db51b6b..12ff67526b 100644 --- a/src/subdomains/generic/user/models/user/dto/update-user.dto.ts +++ b/src/subdomains/generic/user/models/user/dto/update-user.dto.ts @@ -1,11 +1,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsEmail, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsEmail, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; import { EntityDto } from 'src/shared/dto/entity.dto'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { Language } from 'src/shared/models/language/language.entity'; import { Util } from 'src/shared/utils/util'; import { DfxPhoneTransform, IsDfxPhone } from '../../user-data/is-dfx-phone.validator'; +import { PhoneCallPreferredTime } from '../../user-data/user-data.enum'; export class UpdateUserDto { @ApiPropertyOptional() @@ -28,6 +38,16 @@ export class UpdateUserDto { @ValidateNested() @Type(() => EntityDto) currency?: Fiat; + + @ApiPropertyOptional({ type: String, isArray: true }) + @IsOptional() + @IsArray() + preferredPhoneTimes?: PhoneCallPreferredTime[]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + acceptCall?: boolean; } export class UpdateUserMailDto { diff --git a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts index f38232442d..02c31a8f22 100644 --- a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts +++ b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts @@ -11,6 +11,7 @@ import { UserData } from '../../user-data/user-data.entity'; import { User } from '../user.entity'; import { UserProfileDto } from './user-profile.dto'; import { ReferralDto, UserAddressDto, UserV2Dto, VolumesDto } from './user-v2.dto'; +import { PhoneCallStatusMapper } from './user.dto'; export class UserDtoMapper { static mapUser(userData: UserData, activeUserId?: number): UserV2Dto { @@ -28,6 +29,8 @@ export class UserDtoMapper { hash: userData.kycHash, level: userData.kycLevelDisplay, dataComplete: userData.isDataComplete, + phoneCallStatus: userData.phoneCallStatus ? PhoneCallStatusMapper[userData.phoneCallStatus] : undefined, + preferredPhoneTimes: userData.phoneCallTimesObject, }, volumes: this.mapVolumes(userData), addresses: userData.users diff --git a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts index e329dbd886..a59c9e527b 100644 --- a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts +++ b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts @@ -9,9 +9,9 @@ import { FiatDto } from 'src/shared/models/fiat/dto/fiat.dto'; import { LanguageDto } from 'src/shared/models/language/dto/language.dto'; import { HistoryFilterKey } from 'src/subdomains/core/history/dto/history-filter.dto'; import { AccountType } from '../../user-data/account-type.enum'; -import { KycLevel } from '../../user-data/user-data.enum'; +import { KycLevel, PhoneCallPreferredTime } from '../../user-data/user-data.enum'; import { RefPayoutFrequency } from '../user.enum'; -import { TradingLimit, VolumeInformation } from './user.dto'; +import { TradingLimit, UserPhoneCallStatus, VolumeInformation } from './user.dto'; export class VolumesDto { @ApiProperty({ type: VolumeInformation, description: 'Total buy volume in CHF' }) @@ -104,6 +104,12 @@ export class UserKycDto { @ApiProperty() dataComplete: boolean; + + @ApiProperty({ enum: PhoneCallPreferredTime, isArray: true }) + preferredPhoneTimes: PhoneCallPreferredTime[]; + + @ApiProperty({ enum: UserPhoneCallStatus }) + phoneCallStatus: UserPhoneCallStatus; } export class UserPaymentLinkDto { diff --git a/src/subdomains/generic/user/models/user/dto/user.dto.ts b/src/subdomains/generic/user/models/user/dto/user.dto.ts index d73b86e376..6ff3686308 100644 --- a/src/subdomains/generic/user/models/user/dto/user.dto.ts +++ b/src/subdomains/generic/user/models/user/dto/user.dto.ts @@ -3,10 +3,29 @@ import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { LanguageDto } from 'src/shared/models/language/dto/language.dto'; import { HistoryFilterKey } from 'src/subdomains/core/history/dto/history-filter.dto'; import { AccountType } from '../../user-data/account-type.enum'; -import { KycLevel, KycState, KycStatus, LimitPeriod } from '../../user-data/user-data.enum'; +import { KycLevel, KycState, KycStatus, LimitPeriod, PhoneCallStatus } from '../../user-data/user-data.enum'; import { UserStatus } from '../user.enum'; import { LinkedUserOutDto } from './linked-user.dto'; +export enum UserPhoneCallStatus { + ACCEPTED = 'Accepted', + REJECTED = 'Rejected', + UNAVAILABLE = 'Unavailable', + COMPLETED = 'Completed', + FAILED = 'Failed', +} + +export const PhoneCallStatusMapper: { + [key in PhoneCallStatus]: UserPhoneCallStatus; +} = { + [PhoneCallStatus.REPEAT]: UserPhoneCallStatus.ACCEPTED, + [PhoneCallStatus.USER_REJECTED]: UserPhoneCallStatus.REJECTED, + [PhoneCallStatus.UNAVAILABLE]: UserPhoneCallStatus.UNAVAILABLE, + [PhoneCallStatus.FAILED]: UserPhoneCallStatus.FAILED, + [PhoneCallStatus.COMPLETED]: UserPhoneCallStatus.COMPLETED, + [PhoneCallStatus.SUSPICIOUS]: UserPhoneCallStatus.FAILED, +}; + export class VolumeInformation { @ApiProperty() total: number; diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 7a242ff9f6..29098ce6e2 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -307,7 +307,10 @@ export class UserService { } async updateUserV1(id: number, dto: UpdateUserDto): Promise { - const user = await this.userRepo.findOne({ where: { id }, relations: { userData: { users: true }, wallet: true } }); + const user = await this.userRepo.findOne({ + where: { id }, + relations: { userData: { users: true }, wallet: true }, + }); if (!user) throw new NotFoundException('User not found'); // update diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index ef9f43b675..54487ed854 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -111,6 +111,7 @@ export const TransactionReasonMapper: { [AmlReason.MANUAL_CHECK_PHONE]: TransactionReason.PHONE_VERIFICATION_NEEDED, [AmlReason.MANUAL_CHECK_IP_PHONE]: TransactionReason.PHONE_VERIFICATION_NEEDED, [AmlReason.MANUAL_CHECK_IP_COUNTRY_PHONE]: TransactionReason.PHONE_VERIFICATION_NEEDED, + [AmlReason.MANUAL_CHECK_PHONE_FAILED]: TransactionReason.PHONE_VERIFICATION_NEEDED, [AmlReason.BANK_RELEASE_PENDING]: TransactionReason.BANK_RELEASE_PENDING, [AmlReason.VIRTUAL_IBAN_USER_MISMATCH]: TransactionReason.UNKNOWN, [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: TransactionReason.BANK_NOT_ALLOWED, diff --git a/src/subdomains/supporting/support-issue/enums/support-issue.enum.ts b/src/subdomains/supporting/support-issue/enums/support-issue.enum.ts index 236ab3890e..5074f7a84e 100644 --- a/src/subdomains/supporting/support-issue/enums/support-issue.enum.ts +++ b/src/subdomains/supporting/support-issue/enums/support-issue.enum.ts @@ -9,6 +9,7 @@ export enum SupportIssueInternalState { export enum SupportIssueType { GENERIC_ISSUE = 'GenericIssue', TRANSACTION_ISSUE = 'TransactionIssue', + VERIFICATION_CALL = 'VerificationCall', KYC_ISSUE = 'KycIssue', LIMIT_REQUEST = 'LimitRequest', PARTNERSHIP_REQUEST = 'PartnershipRequest', @@ -17,13 +18,20 @@ export enum SupportIssueType { } export enum SupportIssueReason { + // general OTHER = 'Other', + + // support DATA_REQUEST = 'DataRequest', // transaction issue FUNDS_NOT_RECEIVED = 'FundsNotReceived', TRANSACTION_MISSING = 'TransactionMissing', + // verification call + REJECT_CALL = 'RejectCall', + REPEAT_CALL = 'RepeatCall', + // notification of changes issue NAME_CHANGED = 'NameChanged', ADDRESS_CHANGED = 'AddressChanged', diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 0f85f3b0e2..94c73f310e 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -8,11 +8,11 @@ import { import { Config } from 'src/config/config'; import { BlobContent } from 'src/integration/infrastructure/azure-storage.service'; import { UserRole } from 'src/shared/auth/user-role.enum'; -import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { Util } from 'src/shared/utils/util'; import { ContentType } from 'src/subdomains/generic/kyc/enums/content-type.enum'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { PhoneCallStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { FindOptionsWhere, In, IsNull, MoreThan, Not } from 'typeorm'; import { TransactionRequestType } from '../../payment/entities/transaction-request.entity'; @@ -27,8 +27,7 @@ import { SupportIssueDto, SupportIssueInternalDataDto, SupportMessageDto } from import { UpdateSupportIssueDto } from '../dto/update-support-issue.dto'; import { SupportIssue } from '../entities/support-issue.entity'; import { AutoResponder, CustomerAuthor, SupportMessage } from '../entities/support-message.entity'; -import { Department } from '../enums/department.enum'; -import { SupportIssueInternalState } from '../enums/support-issue.enum'; +import { SupportIssueInternalState, SupportIssueReason, SupportIssueType } from '../enums/support-issue.enum'; import { SupportLogType } from '../enums/support-log.enum'; import { SupportIssueRepository } from '../repositories/support-issue.repository'; import { SupportMessageRepository } from '../repositories/support-message.repository'; @@ -50,7 +49,6 @@ export class SupportIssueService { private readonly transactionRequestService: TransactionRequestService, private readonly supportLogService: SupportLogService, private readonly bankDataService: BankDataService, - private readonly fiatService: FiatService, ) {} async createTransactionRequestIssue(dto: CreateSupportIssueBaseDto): Promise { @@ -145,9 +143,22 @@ export class SupportIssueService { } // create limit request - if (dto.limitRequest) { - newIssue.department = Department.COMPLIANCE; + if (dto.limitRequest) newIssue.limitRequest = await this.limitRequestService.increaseLimitInternal(dto.limitRequest, userData); + + if ( + !userData.phoneCallStatus && + dto.type === SupportIssueType.VERIFICATION_CALL && + [SupportIssueReason.REJECT_CALL, SupportIssueReason.REPEAT_CALL].includes(dto.reason) + ) { + await this.userDataService.updateUserDataInternal(userData, { + phoneCallStatus: + dto.reason === SupportIssueReason.REJECT_CALL + ? PhoneCallStatus.USER_REJECTED + : dto.reason === SupportIssueReason.REPEAT_CALL + ? PhoneCallStatus.REPEAT + : undefined, + }); } } diff --git a/src/subdomains/supporting/support-issue/support-issue.controller.ts b/src/subdomains/supporting/support-issue/support-issue.controller.ts index 16edfa1c00..387c0c6f2e 100644 --- a/src/subdomains/supporting/support-issue/support-issue.controller.ts +++ b/src/subdomains/supporting/support-issue/support-issue.controller.ts @@ -16,6 +16,7 @@ import { UpdateSupportIssueDto } from './dto/update-support-issue.dto'; import { SupportIssue } from './entities/support-issue.entity'; import { CustomerAuthor } from './entities/support-message.entity'; import { Department } from './enums/department.enum'; +import { SupportIssueType } from './enums/support-issue.enum'; import { SupportIssueService } from './services/support-issue.service'; @ApiTags('Support') @@ -30,7 +31,14 @@ export class SupportIssueController { @GetJwt() jwt: JwtPayload | undefined, @Body() dto: CreateSupportIssueDto, ): Promise { - const input: CreateSupportIssueDto = { ...dto, author: CustomerAuthor, department: Department.SUPPORT }; + const input: CreateSupportIssueDto = { + ...dto, + author: CustomerAuthor, + department: + dto.type === SupportIssueType.VERIFICATION_CALL || dto.limitRequest + ? Department.COMPLIANCE + : Department.SUPPORT, + }; return jwt?.account ? this.supportIssueService.createIssue(jwt.account, input) : this.supportIssueService.createTransactionRequestIssue(input);