diff --git a/src/shared/i18n/de/support-issue.json b/src/shared/i18n/de/support-issue.json new file mode 100644 index 0000000000..572355bf99 --- /dev/null +++ b/src/shared/i18n/de/support-issue.json @@ -0,0 +1,4 @@ +{ + "bot_hint": "Wenn dir diese Nachricht nicht weiterhilft, kannst du einfach nochmal eine Nachricht hier schreiben und wirst automatisch an einen Support Mitarbeiter übergeben, der sich den Fall anschaut.", + "monero_not_displayed": "❓ Die Kryptolieferung wird in der Cake Wallet nicht angezeigt:\n\n⓵ Verbindung prüfen:\n▪️ Gehe zum Guthaben-Bildschirm in der Cake Wallet\n▪️ überprüfe, ob die Leiste oben eines der folgenden anzeigt\n🟢 „Synchronisiert“ („Synchronised“)\n🟠 „Connecting“ („Verbindung wird hergestellt“)\n🔴 „Blöcke verbleibend“ („blocks remaining“)\nhttps://docs.cakewallet.com/faq/funds-not-appearing\n\n⓶ Wallet synchronisieren, falls Blöcke verbleiben:\n▪️ Lass die App geöffnet\n▪️ bleibe auf dem Guthaben-Bildschirm\n▫️ bis die verbleibenden Blöcke auf 0 reduziert sind.\n\n⓷ Blockchain scannen (Neuscan ab Datum):\n▪️ Falls die Synchronisation das Problem nicht löst\n▪️ starte einen Scan der Blockchain-Blöcke\n👉 1–2 Tage vor der Kryptolieferung.\nhttps://docs.cakewallet.com/features/advanced/rescan-wallet\n\n⓸ Überprüfen:\nDeine Kryptolieferung sollte nach diesen Schritten\nin der Wallet angezeigt werden.\n\n⓹ Cake Wallet Support kontaktieren\n(falls ⓵ – ⓸ nicht helfen)\nhttps://docs.cakewallet.com/support\n\n🫵 Du kannst auch die „Cake Wallet In-App Support“-Funktion nutzen,\num direkt über die App mit dem Support-Team zu chatten:\n📲 Öffne dazu das Menü und wähle:\nSupport ➔ Live-Support, um dort Kontakt aufzunehmen." +} diff --git a/src/shared/i18n/en/support-issue.json b/src/shared/i18n/en/support-issue.json new file mode 100644 index 0000000000..2276ac2a9f --- /dev/null +++ b/src/shared/i18n/en/support-issue.json @@ -0,0 +1,4 @@ +{ + "bot_hint": "If this message does not help you, simply write another message here and you will be automatically transferred to a support employee who will look into the issue.", + "monero_not_displayed": "❓ The crypto delivery is not displayed in the Cake Wallet:\n\n⓵ Check Connection:\n▪️ Go to the balance screen in the Cake Wallet\n▪️ Check if the bar at the top shows one of the following\n🟢 „Synchronised“\n🟠 „Connecting“\n🔴 „blocks remaining“\nhttps://docs.cakewallet.com/faq/funds-not-appearing\n\n⓶ Synchronise the wallet if the bar shows blocks remaining:\nTo do this,\n▪️ Leave the app open\n▪️ and stay on the balance screen\n▫️ until the remaining blocks decrease to 0.\n\n⓷ Scan the blockchain (Rescan from date):\n▪️ If the synchronisation does not solve the problem\n▪️ start the scan of the blockchain blocks\n👉 1–2 days before the crypto delivery.\nhttps://docs.cakewallet.com/features/advanced/rescan-wallet\n\n⓸ Check:\nYour crypto delivery should appear in the wallet after these steps.\n\n⓹ Contact Cake Wallet Support\n(if ⓵ – ⓸ does not work)\nhttps://docs.cakewallet.com/support\n\n🫵 You may also use the „Cake Wallet In-App Support“ function\nto chat with the support team directly through the app:\n📲 Open the menu and select:\nSupport ➔ Live Support to get in touch there. ☝️" +} diff --git a/src/shared/i18n/es/support-issue.json b/src/shared/i18n/es/support-issue.json new file mode 100644 index 0000000000..2276ac2a9f --- /dev/null +++ b/src/shared/i18n/es/support-issue.json @@ -0,0 +1,4 @@ +{ + "bot_hint": "If this message does not help you, simply write another message here and you will be automatically transferred to a support employee who will look into the issue.", + "monero_not_displayed": "❓ The crypto delivery is not displayed in the Cake Wallet:\n\n⓵ Check Connection:\n▪️ Go to the balance screen in the Cake Wallet\n▪️ Check if the bar at the top shows one of the following\n🟢 „Synchronised“\n🟠 „Connecting“\n🔴 „blocks remaining“\nhttps://docs.cakewallet.com/faq/funds-not-appearing\n\n⓶ Synchronise the wallet if the bar shows blocks remaining:\nTo do this,\n▪️ Leave the app open\n▪️ and stay on the balance screen\n▫️ until the remaining blocks decrease to 0.\n\n⓷ Scan the blockchain (Rescan from date):\n▪️ If the synchronisation does not solve the problem\n▪️ start the scan of the blockchain blocks\n👉 1–2 days before the crypto delivery.\nhttps://docs.cakewallet.com/features/advanced/rescan-wallet\n\n⓸ Check:\nYour crypto delivery should appear in the wallet after these steps.\n\n⓹ Contact Cake Wallet Support\n(if ⓵ – ⓸ does not work)\nhttps://docs.cakewallet.com/support\n\n🫵 You may also use the „Cake Wallet In-App Support“ function\nto chat with the support team directly through the app:\n📲 Open the menu and select:\nSupport ➔ Live Support to get in touch there. ☝️" +} diff --git a/src/shared/i18n/fr/support-issue.json b/src/shared/i18n/fr/support-issue.json new file mode 100644 index 0000000000..2276ac2a9f --- /dev/null +++ b/src/shared/i18n/fr/support-issue.json @@ -0,0 +1,4 @@ +{ + "bot_hint": "If this message does not help you, simply write another message here and you will be automatically transferred to a support employee who will look into the issue.", + "monero_not_displayed": "❓ The crypto delivery is not displayed in the Cake Wallet:\n\n⓵ Check Connection:\n▪️ Go to the balance screen in the Cake Wallet\n▪️ Check if the bar at the top shows one of the following\n🟢 „Synchronised“\n🟠 „Connecting“\n🔴 „blocks remaining“\nhttps://docs.cakewallet.com/faq/funds-not-appearing\n\n⓶ Synchronise the wallet if the bar shows blocks remaining:\nTo do this,\n▪️ Leave the app open\n▪️ and stay on the balance screen\n▫️ until the remaining blocks decrease to 0.\n\n⓷ Scan the blockchain (Rescan from date):\n▪️ If the synchronisation does not solve the problem\n▪️ start the scan of the blockchain blocks\n👉 1–2 days before the crypto delivery.\nhttps://docs.cakewallet.com/features/advanced/rescan-wallet\n\n⓸ Check:\nYour crypto delivery should appear in the wallet after these steps.\n\n⓹ Contact Cake Wallet Support\n(if ⓵ – ⓸ does not work)\nhttps://docs.cakewallet.com/support\n\n🫵 You may also use the „Cake Wallet In-App Support“ function\nto chat with the support team directly through the app:\n📲 Open the menu and select:\nSupport ➔ Live Support to get in touch there. ☝️" +} diff --git a/src/shared/i18n/it/support-issue.json b/src/shared/i18n/it/support-issue.json new file mode 100644 index 0000000000..2276ac2a9f --- /dev/null +++ b/src/shared/i18n/it/support-issue.json @@ -0,0 +1,4 @@ +{ + "bot_hint": "If this message does not help you, simply write another message here and you will be automatically transferred to a support employee who will look into the issue.", + "monero_not_displayed": "❓ The crypto delivery is not displayed in the Cake Wallet:\n\n⓵ Check Connection:\n▪️ Go to the balance screen in the Cake Wallet\n▪️ Check if the bar at the top shows one of the following\n🟢 „Synchronised“\n🟠 „Connecting“\n🔴 „blocks remaining“\nhttps://docs.cakewallet.com/faq/funds-not-appearing\n\n⓶ Synchronise the wallet if the bar shows blocks remaining:\nTo do this,\n▪️ Leave the app open\n▪️ and stay on the balance screen\n▫️ until the remaining blocks decrease to 0.\n\n⓷ Scan the blockchain (Rescan from date):\n▪️ If the synchronisation does not solve the problem\n▪️ start the scan of the blockchain blocks\n👉 1–2 days before the crypto delivery.\nhttps://docs.cakewallet.com/features/advanced/rescan-wallet\n\n⓸ Check:\nYour crypto delivery should appear in the wallet after these steps.\n\n⓹ Contact Cake Wallet Support\n(if ⓵ – ⓸ does not work)\nhttps://docs.cakewallet.com/support\n\n🫵 You may also use the „Cake Wallet In-App Support“ function\nto chat with the support team directly through the app:\n📲 Open the menu and select:\nSupport ➔ Live Support to get in touch there. ☝️" +} diff --git a/src/shared/i18n/pt/support-issue.json b/src/shared/i18n/pt/support-issue.json new file mode 100644 index 0000000000..2276ac2a9f --- /dev/null +++ b/src/shared/i18n/pt/support-issue.json @@ -0,0 +1,4 @@ +{ + "bot_hint": "If this message does not help you, simply write another message here and you will be automatically transferred to a support employee who will look into the issue.", + "monero_not_displayed": "❓ The crypto delivery is not displayed in the Cake Wallet:\n\n⓵ Check Connection:\n▪️ Go to the balance screen in the Cake Wallet\n▪️ Check if the bar at the top shows one of the following\n🟢 „Synchronised“\n🟠 „Connecting“\n🔴 „blocks remaining“\nhttps://docs.cakewallet.com/faq/funds-not-appearing\n\n⓶ Synchronise the wallet if the bar shows blocks remaining:\nTo do this,\n▪️ Leave the app open\n▪️ and stay on the balance screen\n▫️ until the remaining blocks decrease to 0.\n\n⓷ Scan the blockchain (Rescan from date):\n▪️ If the synchronisation does not solve the problem\n▪️ start the scan of the blockchain blocks\n👉 1–2 days before the crypto delivery.\nhttps://docs.cakewallet.com/features/advanced/rescan-wallet\n\n⓸ Check:\nYour crypto delivery should appear in the wallet after these steps.\n\n⓹ Contact Cake Wallet Support\n(if ⓵ – ⓸ does not work)\nhttps://docs.cakewallet.com/support\n\n🫵 You may also use the „Cake Wallet In-App Support“ function\nto chat with the support team directly through the app:\n📲 Open the menu and select:\nSupport ➔ Live Support to get in touch there. ☝️" +} diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index d606f053b4..7bca628646 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -87,6 +87,7 @@ export enum Process { AML_RECHECK_MAIL_RESET = 'AmlRecheckMailReset', ZANO_ASSET_WHITELIST = 'ZanoAssetWhitelist', TRADE_APPROVAL_DATE = 'TradeApprovalDate', + SUPPORT_BOT = 'SupportBot', } const safetyProcesses: Process[] = [ diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts new file mode 100644 index 0000000000..83f0743c58 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts @@ -0,0 +1,165 @@ +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; +import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from 'src/integration/blockchain/shared/evm/interfaces'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { PayInType } from '../../../../entities/crypto-input.entity'; +import { PayInEntry } from '../../../../interfaces'; +import { RegisterStrategy } from './register.strategy'; + +export interface PayInCitreaServiceInterface { + getHistory(address: string, fromBlock: number): Promise<[EvmCoinHistoryEntry[], EvmTokenHistoryEntry[]]>; +} + +export abstract class CitreaBaseStrategy extends RegisterStrategy { + protected readonly logger = new DfxLogger(CitreaBaseStrategy); + + private readonly paymentDepositAddress: string; + + protected abstract getOwnAddresses(): string[]; + + constructor( + protected readonly payInCitreaService: PayInCitreaServiceInterface, + protected readonly transactionRequestService: TransactionRequestService, + ) { + super(); + this.paymentDepositAddress = EvmUtil.createWallet({ seed: Config.payment.evmSeed, index: 0 }).address; + } + + // --- JOBS --- // + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) + async checkPayInEntries(): Promise { + const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( + Util.hoursBefore(1), + this.blockchain, + ); + + await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain))); + } + + async pollAddress(depositAddress: BlockchainAddress): Promise { + if (depositAddress.blockchain !== this.blockchain) + throw new Error(`Invalid blockchain: ${depositAddress.blockchain}`); + + return this.processNewPayInEntries([depositAddress]); + } + + private async processNewPayInEntries(depositAddresses: BlockchainAddress[]): Promise { + const log = this.createNewLogObject(); + + const newEntries: PayInEntry[] = []; + + for (const depositAddress of depositAddresses) { + const lastCheckedBlockHeight = await this.getLastCheckedBlockHeight(depositAddress); + + newEntries.push(...(await this.getNewEntries(depositAddress, lastCheckedBlockHeight))); + } + + if (newEntries?.length) { + await this.createPayInsAndSave(newEntries, log); + } + + this.printInputLog(log, 'omitted', this.blockchain); + } + + private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { + return this.payInRepository + .findOne({ + select: ['id', 'blockHeight'], + where: { address: depositAddress }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }) + .then((input) => input?.blockHeight ?? 0); + } + + private async getNewEntries( + depositAddress: BlockchainAddress, + lastCheckedBlockHeight: number, + ): Promise { + const fromBlock = lastCheckedBlockHeight + 1; + const [coinTransactions, tokenTransactions] = await this.payInCitreaService.getHistory( + depositAddress.address, + fromBlock, + ); + + const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + + const coinEntries = this.mapCoinTransactionsToEntries(coinTransactions, depositAddress, supportedAssets); + const tokenEntries = this.mapTokenTransactionsToEntries(tokenTransactions, depositAddress, supportedAssets); + + return [...coinEntries, ...tokenEntries]; + } + + private mapCoinTransactionsToEntries( + transactions: EvmCoinHistoryEntry[], + depositAddress: BlockchainAddress, + supportedAssets: Asset[], + ): PayInEntry[] { + const ownAddresses = this.getOwnAddresses(); + const relevantTransactions = transactions.filter( + (t) => + t.to.toLowerCase() === depositAddress.address.toLowerCase() && !Util.includesIgnoreCase(ownAddresses, t.from), + ); + + const coinAsset = supportedAssets.find((a) => a.type === AssetType.COIN); + + return relevantTransactions.map((tx) => ({ + senderAddresses: tx.from, + receiverAddress: depositAddress, + txId: tx.hash, + txType: this.getTxType(depositAddress.address), + txSequence: 0, + blockHeight: parseInt(tx.blockNumber), + amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value), 15), + asset: coinAsset, + })); + } + + private mapTokenTransactionsToEntries( + transactions: EvmTokenHistoryEntry[], + depositAddress: BlockchainAddress, + supportedAssets: Asset[], + ): PayInEntry[] { + const ownAddresses = this.getOwnAddresses(); + const relevantTransactions = transactions.filter( + (t) => + t.to.toLowerCase() === depositAddress.address.toLowerCase() && !Util.includesIgnoreCase(ownAddresses, t.from), + ); + + const entries: PayInEntry[] = []; + const txGroups = Util.groupBy(relevantTransactions, 'hash'); + + for (const txGroup of txGroups.values()) { + for (let i = 0; i < txGroup.length; i++) { + const tx = txGroup[i]; + + const asset = this.assetService.getByChainIdSync(supportedAssets, this.blockchain, tx.contractAddress); + const decimals = tx.tokenDecimal ? parseInt(tx.tokenDecimal) : asset?.decimals; + + entries.push({ + senderAddresses: tx.from, + receiverAddress: depositAddress, + txId: tx.hash, + txType: this.getTxType(depositAddress.address), + txSequence: i, + blockHeight: parseInt(tx.blockNumber), + amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value, decimals), 15), + asset, + }); + } + } + + return entries; + } + + private getTxType(depositAddress: string): PayInType { + return Util.equalsIgnoreCase(this.paymentDepositAddress, depositAddress) ? PayInType.PAYMENT : PayInType.DEPOSIT; + } +} diff --git a/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts index cba68615af..e6e17e4038 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts @@ -1,166 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; -import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from 'src/integration/blockchain/shared/evm/interfaces'; -import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; -import { BlockchainAddress } from 'src/shared/models/blockchain-address'; -import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { Process } from 'src/shared/services/process.service'; -import { DfxCron } from 'src/shared/utils/cron'; -import { Util } from 'src/shared/utils/util'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; -import { PayInType } from '../../../entities/crypto-input.entity'; -import { PayInEntry } from '../../../interfaces'; import { PayInCitreaTestnetService } from '../../../services/payin-citrea-testnet.service'; -import { RegisterStrategy } from './base/register.strategy'; +import { CitreaBaseStrategy } from './base/citrea.strategy'; @Injectable() -export class CitreaTestnetStrategy extends RegisterStrategy { - protected readonly logger = new DfxLogger(CitreaTestnetStrategy); - - private readonly paymentDepositAddress: string; - +export class CitreaTestnetStrategy extends CitreaBaseStrategy { constructor( - private readonly payInCitreaTestnetService: PayInCitreaTestnetService, - private readonly transactionRequestService: TransactionRequestService, + payInCitreaTestnetService: PayInCitreaTestnetService, + transactionRequestService: TransactionRequestService, ) { - super(); - this.paymentDepositAddress = EvmUtil.createWallet({ seed: Config.payment.evmSeed, index: 0 }).address; + super(payInCitreaTestnetService, transactionRequestService); } get blockchain(): Blockchain { return Blockchain.CITREA_TESTNET; } - // --- JOBS --- // - @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) - async checkPayInEntries(): Promise { - const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( - Util.hoursBefore(1), - this.blockchain, - ); - - await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain))); - } - - async pollAddress(depositAddress: BlockchainAddress): Promise { - if (depositAddress.blockchain !== this.blockchain) - throw new Error(`Invalid blockchain: ${depositAddress.blockchain}`); - - return this.processNewPayInEntries([depositAddress]); - } - - private async processNewPayInEntries(depositAddresses: BlockchainAddress[]): Promise { - const log = this.createNewLogObject(); - - const newEntries: PayInEntry[] = []; - - for (const depositAddress of depositAddresses) { - const lastCheckedBlockHeight = await this.getLastCheckedBlockHeight(depositAddress); - - newEntries.push(...(await this.getNewEntries(depositAddress, lastCheckedBlockHeight))); - } - - if (newEntries?.length) { - await this.createPayInsAndSave(newEntries, log); - } - - this.printInputLog(log, 'omitted', this.blockchain); - } - - private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { - return this.payInRepository - .findOne({ - select: ['id', 'blockHeight'], - where: { address: depositAddress }, - order: { blockHeight: 'DESC' }, - loadEagerRelations: false, - }) - .then((input) => input?.blockHeight ?? 0); - } - - private async getNewEntries( - depositAddress: BlockchainAddress, - lastCheckedBlockHeight: number, - ): Promise { - const fromBlock = lastCheckedBlockHeight + 1; - const [coinTransactions, tokenTransactions] = await this.payInCitreaTestnetService.getHistory( - depositAddress.address, - fromBlock, - ); - - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); - - const coinEntries = this.mapCoinTransactionsToEntries(coinTransactions, depositAddress, supportedAssets); - const tokenEntries = this.mapTokenTransactionsToEntries(tokenTransactions, depositAddress, supportedAssets); - - return [...coinEntries, ...tokenEntries]; - } - - private mapCoinTransactionsToEntries( - transactions: EvmCoinHistoryEntry[], - depositAddress: BlockchainAddress, - supportedAssets: Asset[], - ): PayInEntry[] { - const relevantTransactions = transactions.filter( - (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), - ); - - const coinAsset = supportedAssets.find((a) => a.type === AssetType.COIN); - - return relevantTransactions.map((tx) => ({ - senderAddresses: tx.from, - receiverAddress: depositAddress, - txId: tx.hash, - txType: this.getTxType(depositAddress.address), - txSequence: 0, - blockHeight: parseInt(tx.blockNumber), - amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value), 15), - asset: coinAsset, - })); - } - - private mapTokenTransactionsToEntries( - transactions: EvmTokenHistoryEntry[], - depositAddress: BlockchainAddress, - supportedAssets: Asset[], - ): PayInEntry[] { - const relevantTransactions = transactions.filter( - (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), - ); - - const entries: PayInEntry[] = []; - const txGroups = Util.groupBy(relevantTransactions, 'hash'); - - for (const txGroup of txGroups.values()) { - for (let i = 0; i < txGroup.length; i++) { - const tx = txGroup[i]; - - const asset = this.assetService.getByChainIdSync(supportedAssets, this.blockchain, tx.contractAddress); - const decimals = tx.tokenDecimal ? parseInt(tx.tokenDecimal) : asset?.decimals; - - entries.push({ - senderAddresses: tx.from, - receiverAddress: depositAddress, - txId: tx.hash, - txType: this.getTxType(depositAddress.address), - txSequence: i, - blockHeight: parseInt(tx.blockNumber), - amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value, decimals), 15), - asset, - }); - } - } - - return entries; - } - - private getTxType(depositAddress: string): PayInType { - return Util.equalsIgnoreCase(this.paymentDepositAddress, depositAddress) ? PayInType.PAYMENT : PayInType.DEPOSIT; - } - protected getOwnAddresses(): string[] { return [Config.blockchain.citreaTestnet.citreaTestnetWalletAddress]; } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts index 05e65e314f..6a617c0bcd 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts @@ -1,166 +1,20 @@ import { Injectable } from '@nestjs/common'; -import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; -import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from 'src/integration/blockchain/shared/evm/interfaces'; -import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; -import { BlockchainAddress } from 'src/shared/models/blockchain-address'; -import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { Process } from 'src/shared/services/process.service'; -import { DfxCron } from 'src/shared/utils/cron'; -import { Util } from 'src/shared/utils/util'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; -import { PayInType } from '../../../entities/crypto-input.entity'; -import { PayInEntry } from '../../../interfaces'; import { PayInCitreaService } from '../../../services/payin-citrea.service'; -import { RegisterStrategy } from './base/register.strategy'; +import { CitreaBaseStrategy } from './base/citrea.strategy'; @Injectable() -export class CitreaStrategy extends RegisterStrategy { - protected readonly logger = new DfxLogger(CitreaStrategy); - - private readonly paymentDepositAddress: string; - - constructor( - private readonly payInCitreaService: PayInCitreaService, - private readonly transactionRequestService: TransactionRequestService, - ) { - super(); - this.paymentDepositAddress = EvmUtil.createWallet({ seed: Config.payment.evmSeed, index: 0 }).address; +export class CitreaStrategy extends CitreaBaseStrategy { + constructor(payInCitreaService: PayInCitreaService, transactionRequestService: TransactionRequestService) { + super(payInCitreaService, transactionRequestService); } get blockchain(): Blockchain { return Blockchain.CITREA; } - // --- JOBS --- // - @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) - async checkPayInEntries(): Promise { - const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( - Util.hoursBefore(1), - this.blockchain, - ); - - await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain))); - } - - async pollAddress(depositAddress: BlockchainAddress): Promise { - if (depositAddress.blockchain !== this.blockchain) - throw new Error(`Invalid blockchain: ${depositAddress.blockchain}`); - - return this.processNewPayInEntries([depositAddress]); - } - - private async processNewPayInEntries(depositAddresses: BlockchainAddress[]): Promise { - const log = this.createNewLogObject(); - - const newEntries: PayInEntry[] = []; - - for (const depositAddress of depositAddresses) { - const lastCheckedBlockHeight = await this.getLastCheckedBlockHeight(depositAddress); - - newEntries.push(...(await this.getNewEntries(depositAddress, lastCheckedBlockHeight))); - } - - if (newEntries?.length) { - await this.createPayInsAndSave(newEntries, log); - } - - this.printInputLog(log, 'omitted', this.blockchain); - } - - private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { - return this.payInRepository - .findOne({ - select: ['id', 'blockHeight'], - where: { address: depositAddress }, - order: { blockHeight: 'DESC' }, - loadEagerRelations: false, - }) - .then((input) => input?.blockHeight ?? 0); - } - - private async getNewEntries( - depositAddress: BlockchainAddress, - lastCheckedBlockHeight: number, - ): Promise { - const fromBlock = lastCheckedBlockHeight + 1; - const [coinTransactions, tokenTransactions] = await this.payInCitreaService.getHistory( - depositAddress.address, - fromBlock, - ); - - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); - - const coinEntries = this.mapCoinTransactionsToEntries(coinTransactions, depositAddress, supportedAssets); - const tokenEntries = this.mapTokenTransactionsToEntries(tokenTransactions, depositAddress, supportedAssets); - - return [...coinEntries, ...tokenEntries]; - } - - private mapCoinTransactionsToEntries( - transactions: EvmCoinHistoryEntry[], - depositAddress: BlockchainAddress, - supportedAssets: Asset[], - ): PayInEntry[] { - const relevantTransactions = transactions.filter( - (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), - ); - - const coinAsset = supportedAssets.find((a) => a.type === AssetType.COIN); - - return relevantTransactions.map((tx) => ({ - senderAddresses: tx.from, - receiverAddress: depositAddress, - txId: tx.hash, - txType: this.getTxType(depositAddress.address), - txSequence: 0, - blockHeight: parseInt(tx.blockNumber), - amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value), 15), - asset: coinAsset, - })); - } - - private mapTokenTransactionsToEntries( - transactions: EvmTokenHistoryEntry[], - depositAddress: BlockchainAddress, - supportedAssets: Asset[], - ): PayInEntry[] { - const relevantTransactions = transactions.filter( - (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), - ); - - const entries: PayInEntry[] = []; - const txGroups = Util.groupBy(relevantTransactions, 'hash'); - - for (const txGroup of txGroups.values()) { - for (let i = 0; i < txGroup.length; i++) { - const tx = txGroup[i]; - - const asset = this.assetService.getByChainIdSync(supportedAssets, this.blockchain, tx.contractAddress); - const decimals = tx.tokenDecimal ? parseInt(tx.tokenDecimal) : asset?.decimals; - - entries.push({ - senderAddresses: tx.from, - receiverAddress: depositAddress, - txId: tx.hash, - txType: this.getTxType(depositAddress.address), - txSequence: i, - blockHeight: parseInt(tx.blockNumber), - amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value, decimals), 15), - asset, - }); - } - } - - return entries; - } - - private getTxType(depositAddress: string): PayInType { - return Util.equalsIgnoreCase(this.paymentDepositAddress, depositAddress) ? PayInType.PAYMENT : PayInType.DEPOSIT; - } - protected getOwnAddresses(): string[] { return [Config.blockchain.citrea.citreaWalletAddress]; } diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index a672dc2f87..396cbe5a5b 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -26,7 +26,6 @@ export enum RealUnitUserType { export enum RealUnitRegistrationStatus { COMPLETED = 'completed', PENDING_REVIEW = 'pending_review', - MANUAL_REVIEW_DATA_MISMATCH = 'manual_review_data_mismatch', FORWARDING_FAILED = 'forwarding_failed', } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 0ff4d524e3..8f612174e1 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -210,8 +210,7 @@ export class RealUnitService { const currencyName = dto.currency ?? 'CHF'; // 1. Registration required - const hasRegistration = userData.getNonFailedStepWith(KycStepName.REALUNIT_REGISTRATION); - if (!hasRegistration) { + if (!this.hasRegistrationForWallet(userData, user.address)) { throw new RegistrationRequiredException(); } @@ -396,9 +395,25 @@ export class RealUnitService { throw new BadRequestException('Email does not match registered email'); } - // duplicate check - if (userData.getNonFailedStepWith(KycStepName.REALUNIT_REGISTRATION)) { - throw new BadRequestException('RealUnit registration already exists'); + if (this.hasRegistrationForWallet(userData, dto.walletAddress)) { + throw new BadRequestException('RealUnit registration already exists for this wallet'); + } + + // validate personal data + const hasExistingData = userData.firstname != null; + if (hasExistingData && !this.isPersonalDataMatching(userData, dto)) { + throw new BadRequestException('Personal data does not match existing data'); + } + + // save personal data + if (!hasExistingData) { + await this.userDataService.updatePersonalData(userData, dto.kycData); + await this.userDataService.updateUserDataInternal(userData, { + nationality: await this.countryService.getCountryWithSymbol(dto.nationality), + birthday: new Date(dto.birthday), + language: dto.lang && (await this.languageService.getLanguageBySymbol(dto.lang)), + tin: dto.countryAndTINs?.length ? JSON.stringify(dto.countryAndTINs) : undefined, + }); } // store data with internal review @@ -409,29 +424,10 @@ export class RealUnitService { dto, ); - const hasExistingData = userData.firstname != null; - if (hasExistingData) { - const dataMatches = this.isPersonalDataMatching(userData, dto); - if (!dataMatches) { - await this.kycService.saveKycStepUpdate(kycStep.manualReview('Existing KYC data does not match')); - return RealUnitRegistrationStatus.MANUAL_REVIEW_DATA_MISMATCH; - } - } else { - await this.userDataService.updatePersonalData(userData, dto.kycData); - } - // forward to Aktionariat const success = await this.forwardRegistration(kycStep, dto); if (!success) return RealUnitRegistrationStatus.FORWARDING_FAILED; - // only update after successful forward - await this.userDataService.updateUserDataInternal(userData, { - nationality: await this.countryService.getCountryWithSymbol(dto.nationality), - birthday: new Date(dto.birthday), - language: dto.lang && (await this.languageService.getLanguageBySymbol(dto.lang)), - tin: dto.countryAndTINs?.length ? JSON.stringify(dto.countryAndTINs) : undefined, - }); - return RealUnitRegistrationStatus.COMPLETED; } @@ -571,6 +567,16 @@ export class RealUnitService { if (!success) throw new BadRequestException('Failed to forward registration to Aktionariat'); } + private hasRegistrationForWallet(userData: UserData, walletAddress: string): boolean { + return userData + .getStepsWith(KycStepName.REALUNIT_REGISTRATION) + .filter((s) => !(s.isFailed || s.isCanceled)) + .some((s) => { + const result = s.getResult(); + return result?.walletAddress && Util.equalsIgnoreCase(result.walletAddress, walletAddress); + }); + } + private isPersonalDataMatching(userData: UserData, dto: RealUnitRegistrationDto): boolean { const kycData = dto.kycData; @@ -656,8 +662,7 @@ export class RealUnitService { const currencyName = dto.currency ?? 'CHF'; // 1. Registration required - const hasRegistration = userData.getNonFailedStepWith(KycStepName.REALUNIT_REGISTRATION); - if (!hasRegistration) { + if (!this.hasRegistrationForWallet(userData, user.address)) { throw new RegistrationRequiredException(); } diff --git a/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts b/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts index 8952b051f3..1624586429 100644 --- a/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts +++ b/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts @@ -6,6 +6,11 @@ import { SupportIssueType, } from '../enums/support-issue.enum'; +export enum SupportMessageTranslationKey { + BOT_HINT = 'support-issue.bot_hint', + MONERO_NOT_DISPLAYED = 'support-issue.monero_not_displayed', +} + export class SupportMessageDto { @ApiProperty() id: number; diff --git a/src/subdomains/supporting/support-issue/entities/support-message.entity.ts b/src/subdomains/supporting/support-issue/entities/support-message.entity.ts index 690c8eeb50..fddadc979e 100644 --- a/src/subdomains/supporting/support-issue/entities/support-message.entity.ts +++ b/src/subdomains/supporting/support-issue/entities/support-message.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, ManyToOne } from 'typeorm'; import { SupportIssue } from './support-issue.entity'; export const CustomerAuthor = 'Customer'; +export const AutoResponder = 'AutoResponder'; @Entity() export class SupportMessage extends IEntity { diff --git a/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts b/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts new file mode 100644 index 0000000000..80a79dfbec --- /dev/null +++ b/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { FindOptionsWhere, In, IsNull, MoreThan, Not } from 'typeorm'; +import { MailFactory } from '../../notification/factories/mail.factory'; +import { SupportMessageTranslationKey } from '../dto/support-issue.dto'; +import { SupportIssue } from '../entities/support-issue.entity'; +import { AutoResponder } from '../entities/support-message.entity'; +import { SupportIssueInternalState, SupportIssueReason, SupportIssueType } from '../enums/support-issue.enum'; +import { SupportIssueRepository } from '../repositories/support-issue.repository'; +import { SupportIssueService } from './support-issue.service'; + +@Injectable() +export class SupportIssueJobService { + constructor( + private readonly supportIssueRepo: SupportIssueRepository, + private readonly supportIssueService: SupportIssueService, + private readonly mailFactory: MailFactory, + ) {} + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.SUPPORT_BOT, timeout: 1800 }) + async sendAutoResponses() { + await this.moneroComplete(); + } + + async moneroComplete(): Promise { + await this.sendAutoResponse(SupportMessageTranslationKey.MONERO_NOT_DISPLAYED, { + type: SupportIssueType.TRANSACTION_ISSUE, + reason: In([SupportIssueReason.FUNDS_NOT_RECEIVED, SupportIssueReason.TRANSACTION_MISSING]), + transaction: { + buyCrypto: { id: Not(IsNull()), isComplete: true, amlCheck: CheckStatus.PASS, outputAsset: { name: 'XMR' } }, + }, + created: MoreThan(Util.daysBefore(2)), + }); + } + + private async sendAutoResponse( + translationKey: SupportMessageTranslationKey, + where: FindOptionsWhere, + ): Promise { + const entities = await this.supportIssueRepo.find({ + where: { + state: SupportIssueInternalState.CREATED, + messages: { author: Not(AutoResponder) }, + ...where, + }, + }); + + for (const entity of entities) { + const lang = entity.userData.language.symbol.toLowerCase(); + const message = this.mailFactory.translate(translationKey, lang); + const botHint = this.mailFactory.translate(SupportMessageTranslationKey.BOT_HINT, lang); + await this.supportIssueService.createMessageInternal(entity, { + message: `Hi ${entity.userData.firstname ?? entity.name}\n\n${message}\n\n${botHint}\n\nFreundliche Grüsse / Kind Regards DFX Bot`, + author: AutoResponder, + }); + await this.supportIssueService.updateIssueInternal(entity, { + state: SupportIssueInternalState.PENDING, + }); + } + } +} 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 ad29ba869a..7cc029f9e9 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -24,7 +24,7 @@ import { SupportIssueDtoMapper } from '../dto/support-issue-dto.mapper'; import { SupportIssueDto, SupportMessageDto } from '../dto/support-issue.dto'; import { UpdateSupportIssueDto } from '../dto/update-support-issue.dto'; import { SupportIssue } from '../entities/support-issue.entity'; -import { CustomerAuthor, SupportMessage } from '../entities/support-message.entity'; +import { AutoResponder, CustomerAuthor, SupportMessage } from '../entities/support-message.entity'; import { Department } from '../enums/department.enum'; import { SupportIssueInternalState } from '../enums/support-issue.enum'; import { SupportLogType } from '../enums/support-log.enum'; @@ -161,6 +161,10 @@ export class SupportIssueService { const entity = await this.supportIssueRepo.findOneBy({ id }); if (!entity) throw new NotFoundException('Support issue not found'); + return this.updateIssueInternal(entity, dto); + } + + async updateIssueInternal(entity: SupportIssue, dto: UpdateSupportIssueDto): Promise { Object.assign(entity, dto); await this.supportLogService.createSupportLog(entity.userData, { @@ -182,8 +186,11 @@ export class SupportIssueService { return this.createMessageInternal(issue, { ...dto, author: CustomerAuthor }); } - async createMessageSupport(id: number, dto: CreateSupportMessageDto): Promise { - const issue = await this.supportIssueRepo.findOne({ where: { id }, relations: { userData: { wallet: true } } }); + async createMessageSupport(issueId: number, dto: CreateSupportMessageDto): Promise { + const issue = await this.supportIssueRepo.findOne({ + where: { id: issueId }, + relations: { userData: { wallet: true } }, + }); if (!issue) throw new NotFoundException('Support issue not found'); return this.createMessageInternal(issue, dto); @@ -235,7 +242,7 @@ export class SupportIssueService { // --- HELPER METHODS --- // - private async createMessageInternal(issue: SupportIssue, dto: CreateSupportMessageDto): Promise { + async createMessageInternal(issue: SupportIssue, dto: CreateSupportMessageDto): Promise { if (!dto.author) throw new BadRequestException('Author for message is missing'); if (dto.message?.length > 4000) throw new BadRequestException('Message has too many characters'); @@ -259,6 +266,8 @@ export class SupportIssueService { if (dto.author !== CustomerAuthor) { await this.supportIssueRepo.update(...issue.setClerk(dto.author)); await this.supportIssueNotificationService.newSupportMessage(entity); + } else if (issue.clerk === AutoResponder) { + await this.supportIssueRepo.update(...issue.setClerk(null)); } if ( diff --git a/src/subdomains/supporting/support-issue/support-issue.module.ts b/src/subdomains/supporting/support-issue/support-issue.module.ts index 6f4be733b7..d0943b25cf 100644 --- a/src/subdomains/supporting/support-issue/support-issue.module.ts +++ b/src/subdomains/supporting/support-issue/support-issue.module.ts @@ -20,6 +20,7 @@ import { SupportMessageRepository } from './repositories/support-message.reposit import { LimitRequestNotificationService } from './services/limit-request-notification.service'; import { LimitRequestService } from './services/limit-request.service'; import { SupportDocumentService } from './services/support-document.service'; +import { SupportIssueJobService } from './services/support-issue-job.service'; import { SupportIssueNotificationService } from './services/support-issue-notification.service'; import { SupportIssueService } from './services/support-issue.service'; import { SupportLogService } from './services/support-log.service'; @@ -54,6 +55,7 @@ import { SupportIssueController } from './support-issue.controller'; SupportDocumentService, SupportLogRepository, SupportLogService, + SupportIssueJobService, ], exports: [SupportIssueService, LimitRequestService, SupportLogService], })