From d7f8fcb0f21ce32438ccf2d1174a77818129a181 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:29:57 +0100 Subject: [PATCH] [DEV-3154] chargeback unassigned tx (#1792) * [DEV-3154] chargeback unassigned tx * [DEV-3154] fix linter error * [DEV-3154] fix code * [DEV-3154] Refactoring * [DEV-3154] add migration * [DEV-3154] fix code * [DEV-3154] fix code 2 * [DEV-3154] Refactoring 2 * [DEV-3154] add user check --- ...327830775-addBankTxReturnChargebackCols.js | 35 +++ src/config/config.ts | 1 + .../process/services/buy-crypto.service.ts | 3 +- .../buy-crypto/routes/swap/swap.service.ts | 2 + .../controllers/transaction.controller.ts | 204 ++++++++++++------ .../transaction/transaction-util.service.ts | 25 ++- .../user/models/user-data/user-data.entity.ts | 4 + .../bank-tx-return.controller.ts | 11 +- .../bank-tx-return/bank-tx-return.entity.ts | 59 ++++- .../bank-tx-return/bank-tx-return.service.ts | 58 ++++- .../supporting/bank-tx/bank-tx.module.ts | 4 + .../bank-tx/services/bank-tx.service.ts | 8 +- .../fiat-output/dto/create-fiat-output.dto.ts | 4 + .../fiat-output/fiat-output.entity.ts | 4 + .../fiat-output/fiat-output.module.ts | 4 +- .../fiat-output/fiat-output.service.ts | 11 +- .../payment/entities/transaction.entity.ts | 4 +- .../transaction-notification.service.ts | 9 +- 18 files changed, 362 insertions(+), 88 deletions(-) create mode 100644 migration/1733327830775-addBankTxReturnChargebackCols.js diff --git a/migration/1733327830775-addBankTxReturnChargebackCols.js b/migration/1733327830775-addBankTxReturnChargebackCols.js new file mode 100644 index 0000000000..7b3397a7c1 --- /dev/null +++ b/migration/1733327830775-addBankTxReturnChargebackCols.js @@ -0,0 +1,35 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class addBankTxReturnChargebackCols1733327830775 { + name = 'addBankTxReturnChargebackCols1733327830775' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackDate" datetime2`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackRemittanceInfo" nvarchar(256)`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackAllowedDate" datetime2`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackAllowedDateUser" datetime2`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackAmount" float`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackAllowedBy" nvarchar(256)`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackIban" nvarchar(256)`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "chargebackOutputId" int`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "userDataId" int`); + await queryRunner.query(`CREATE UNIQUE INDEX "REL_58d88e2efe1c42023dd12f11ac" ON "dbo"."bank_tx_return" ("chargebackOutputId") WHERE "chargebackOutputId" IS NOT NULL`); + await queryRunner.query(`ALTER TABLE "bank_tx_return" ADD CONSTRAINT "FK_58d88e2efe1c42023dd12f11aca" FOREIGN KEY ("chargebackOutputId") REFERENCES "fiat_output"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "bank_tx_return" ADD CONSTRAINT "FK_a7125bcc9a433258dc395d6c867" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP CONSTRAINT "FK_a7125bcc9a433258dc395d6c867"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP CONSTRAINT "FK_58d88e2efe1c42023dd12f11aca"`); + await queryRunner.query(`DROP INDEX "REL_58d88e2efe1c42023dd12f11ac" ON "dbo"."bank_tx_return"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "userDataId"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackOutputId"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackIban"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackAllowedBy"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackAmount"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackAllowedDateUser"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackAllowedDate"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackRemittanceInfo"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "chargebackDate"`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index 84074bbdab..00d9b99cf0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -526,6 +526,7 @@ export class Configuration { refreshToken: process.env.REVOLUT_REFRESH_TOKEN, clientAssertion: process.env.REVOLUT_CLIENT_ASSERTION, }, + forexFee: 0.02, }; giroCode = { diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 3308b37030..3ab454e6b6 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -6,6 +6,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { Config } from 'src/config/config'; import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; import { TransactionStatus } from 'src/integration/sift/dto/sift.dto'; @@ -95,7 +96,7 @@ export class BuyCryptoService { const buy = await this.getBuy(buyId); - const forexFee = bankTx.txCurrency === bankTx.currency ? 0 : 0.02; + const forexFee = bankTx.txCurrency === bankTx.currency ? 0 : Config.bank.forexFee; // create bank data if (bankTx.senderAccount && !DisabledProcess(Process.AUTO_CREATE_BANK_DATA)) { diff --git a/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts b/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts index f91a889b20..6244745288 100644 --- a/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts +++ b/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts @@ -40,10 +40,12 @@ export class SwapService { private readonly userService: UserService, private readonly depositService: DepositService, private readonly userDataService: UserDataService, + @Inject(forwardRef(() => PayInService)) private readonly payInService: PayInService, @Inject(forwardRef(() => BuyCryptoService)) private readonly buyCryptoService: BuyCryptoService, private readonly buyCryptoWebhookService: BuyCryptoWebhookService, + @Inject(forwardRef(() => TransactionUtilService)) private readonly transactionUtilService: TransactionUtilService, private readonly routeService: RouteService, ) {} diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 971ab4f7f7..11b6ec4080 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -36,6 +36,9 @@ import { FiatDtoMapper } from 'src/shared/models/fiat/dto/fiat-dto.mapper'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { Util } from 'src/shared/utils/util'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { BankTxReturnService } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service'; import { BankTx, BankTxType, @@ -93,6 +96,8 @@ export class TransactionController { private readonly buyCryptoService: BuyCryptoService, private readonly feeService: FeeService, private readonly transactionUtilService: TransactionUtilService, + private readonly userDataService: UserDataService, + private readonly bankTxReturnService: BankTxReturnService, private readonly specialExternalAccountService: SpecialExternalAccountService, ) {} @@ -281,75 +286,113 @@ export class TransactionController { @UseGuards(AuthGuard(), new RoleGuard(UserRole.ACCOUNT)) async getTransactionRefund(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { const transaction = await this.transactionService.getTransactionById(+id, { + bankTx: true, + bankTxReturn: true, user: { userData: true }, buyCrypto: { cryptoInput: { route: { user: true } }, bankTx: true, checkoutTx: true }, buyFiat: { cryptoInput: { route: { user: true } } }, }); - if ( - !transaction || - (!(transaction.targetEntity instanceof BuyCrypto) && !(transaction.targetEntity instanceof BuyFiat)) - ) + if (!transaction || transaction.targetEntity instanceof RefReward) throw new NotFoundException('Transaction not found'); - if (jwt.account !== transaction.userData.id) - throw new ForbiddenException('You can only refund your own transaction'); - if (![CheckStatus.FAIL, CheckStatus.PENDING].includes(transaction.targetEntity.amlCheck)) - throw new BadRequestException('You can only refund failed or pending transactions'); - if (transaction.targetEntity.chargebackAmount) - throw new BadRequestException('You can only refund a transaction once'); - if (transaction.targetEntity?.cryptoInput?.txType === PayInType.PAYMENT) - throw new BadRequestException('You cannot refund payment transactions'); - - const networkFeeAmount = transaction.targetEntity.cryptoInput - ? await this.feeService.getBlockchainFee(transaction.targetEntity.cryptoInput.asset, false) - : 0; - - const bankFeeAmount = transaction.targetEntity.cryptoInput - ? 0 - : transaction.targetEntity.inputAmount - transaction.targetEntity.bankTx.amount; - - const totalFeeAmount = networkFeeAmount + bankFeeAmount; - - if (totalFeeAmount >= transaction.targetEntity.inputAmount) - throw new BadRequestException('Transaction fee is too expensive'); - - const refundAsset = transaction.targetEntity.cryptoInput?.asset - ? AssetDtoMapper.toDto(transaction.targetEntity.cryptoInput?.asset) - : FiatDtoMapper.toDto(await this.fiatService.getFiatByName(transaction.targetEntity.inputAsset)); - - let refundTarget = null; - - if (transaction.targetEntity instanceof BuyCrypto) { - try { - const multiAccountIbans = await this.specialExternalAccountService.getMultiAccountIbans(); - - refundTarget = transaction.targetEntity.checkoutTx - ? `${transaction.targetEntity.checkoutTx.cardBin}****${transaction.targetEntity.checkoutTx.cardLast4}` - : IbanTools.validateIBAN(transaction.targetEntity.bankTx?.iban).valid && - !multiAccountIbans.includes(transaction.targetEntity.bankTx?.iban) && - (await this.transactionUtilService.validateChargebackIban(transaction.targetEntity.bankTx.iban)) - ? transaction.targetEntity.bankTx.iban - : transaction.targetEntity.chargebackIban; - } catch (_) { - refundTarget = transaction.targetEntity.chargebackIban; - } + + let refundData: RefundDataDto; + + // Unassigned transaction + if ( + !(transaction.targetEntity instanceof BuyFiat) && + (transaction.targetEntity instanceof BankTxReturn || !transaction.targetEntity) + ) { + if (!transaction.bankTx || !BankTxTypeUnassigned(transaction.bankTx.type)) + throw new NotFoundException('Transaction not found'); + + const bankDatas = await this.bankDataService.getValidBankDatasForUser(jwt.account); + if (!bankDatas.map((b) => b.iban).includes(transaction.bankTx.senderAccount)) + throw new ForbiddenException('You can only refund your own transaction'); + + if (transaction.targetEntity?.chargebackAmount) + throw new BadRequestException('You can only refund a transaction once'); + + const bankFeeAmount = transaction.bankTx.chargeAmount; + + const inputAmount = transaction.bankTx.amount + transaction.bankTx.chargeAmount; + + if (bankFeeAmount >= inputAmount) throw new BadRequestException('Transaction fee is too expensive'); + + const refundAsset = FiatDtoMapper.toDto(await this.fiatService.getFiatByName(transaction.bankTx.currency)); + + const refundTarget = (await this.validateIban(transaction.bankTx?.iban)) + ? transaction.bankTx.iban + : transaction.targetEntity?.chargebackIban; + + refundData = { + expiryDate: Util.secondsAfter(Config.transactionRefundExpirySeconds), + refundAmount: Util.roundReadable(inputAmount - bankFeeAmount, true), + fee: { + network: 0, + bank: Util.roundReadable(bankFeeAmount, true), + }, + refundAsset, + refundTarget, + }; } else { - refundTarget = transaction.targetEntity.chargebackAddress; - } + // Assigned transaction + if (jwt.account !== transaction.userData.id) + throw new ForbiddenException('You can only refund your own transaction'); + if (![CheckStatus.FAIL, CheckStatus.PENDING].includes(transaction.targetEntity.amlCheck)) + throw new BadRequestException('You can only refund failed or pending transactions'); + if (transaction.targetEntity.chargebackAmount) + throw new BadRequestException('You can only refund a transaction once'); + if (transaction.targetEntity?.cryptoInput?.txType === PayInType.PAYMENT) + throw new BadRequestException('You cannot refund payment transactions'); + + const networkFeeAmount = transaction.targetEntity.cryptoInput + ? await this.feeService.getBlockchainFee(transaction.targetEntity.cryptoInput.asset, false) + : 0; + + const bankFeeAmount = transaction.targetEntity.cryptoInput + ? 0 + : transaction.targetEntity.inputAmount - transaction.targetEntity.bankTx.amount; + + const totalFeeAmount = networkFeeAmount + bankFeeAmount; + + if (totalFeeAmount >= transaction.targetEntity.inputAmount) + throw new BadRequestException('Transaction fee is too expensive'); + + const refundAsset = transaction.targetEntity.cryptoInput?.asset + ? AssetDtoMapper.toDto(transaction.targetEntity.cryptoInput?.asset) + : FiatDtoMapper.toDto(await this.fiatService.getFiatByName(transaction.targetEntity.inputAsset)); + + let refundTarget = null; + + if (transaction.targetEntity instanceof BuyCrypto) { + try { + refundTarget = transaction.targetEntity.checkoutTx + ? `${transaction.targetEntity.checkoutTx.cardBin}****${transaction.targetEntity.checkoutTx.cardLast4}` + : (await this.validateIban(transaction.targetEntity.bankTx?.iban)) + ? transaction.targetEntity.bankTx.iban + : transaction.targetEntity.chargebackIban; + } catch (_) { + refundTarget = transaction.targetEntity.chargebackIban; + } + } else { + refundTarget = transaction.targetEntity.chargebackAddress; + } - const refundData: RefundDataDto = { - expiryDate: Util.secondsAfter(Config.transactionRefundExpirySeconds), - refundAmount: Util.roundReadable( - transaction.targetEntity.inputAmount - totalFeeAmount, - !transaction.targetEntity.cryptoInput, - ), - fee: { - network: Util.roundReadable(networkFeeAmount, !transaction.targetEntity.cryptoInput), - bank: Util.roundReadable(bankFeeAmount, !transaction.targetEntity.cryptoInput), - }, - refundAsset, - refundTarget, - }; + refundData = { + expiryDate: Util.secondsAfter(Config.transactionRefundExpirySeconds), + refundAmount: Util.roundReadable( + transaction.targetEntity.inputAmount - totalFeeAmount, + !transaction.targetEntity.cryptoInput, + ), + fee: { + network: Util.roundReadable(networkFeeAmount, !transaction.targetEntity.cryptoInput), + bank: Util.roundReadable(bankFeeAmount, !transaction.targetEntity.cryptoInput), + }, + refundAsset, + refundTarget, + }; + } this.refundList.set(transaction.id, refundData); @@ -365,6 +408,8 @@ export class TransactionController { @Body() dto: TransactionRefundDto, ): Promise { const transaction = await this.transactionService.getTransactionById(+id, { + bankTx: { transaction: true }, + bankTxReturn: true, user: { userData: true }, buyCrypto: { transaction: { user: { userData: true } }, @@ -375,13 +420,15 @@ export class TransactionController { buyFiat: { transaction: { user: { userData: true } }, cryptoInput: { route: { user: true } } }, }); - if ( - !transaction || - (!(transaction.targetEntity instanceof BuyCrypto) && !(transaction.targetEntity instanceof BuyFiat)) - ) + if (!transaction || transaction.targetEntity instanceof RefReward) throw new NotFoundException('Transaction not found'); - if (jwt.account !== transaction.userData.id) + if (transaction.targetEntity && jwt.account !== transaction.userData.id) throw new ForbiddenException('You can only refund your own transaction'); + if (!transaction.targetEntity) { + const bankDatas = await this.bankDataService.getValidBankDatasForUser(jwt.account); + if (!bankDatas.map((b) => b.iban).includes(transaction.bankTx.senderAccount)) + throw new ForbiddenException('You can only refund your own transaction'); + } const refundData = this.refundList.get(transaction.id); if (!refundData) throw new BadRequestException('Request refund data first'); @@ -390,6 +437,20 @@ export class TransactionController { const refundDto = { chargebackAmount: refundData.refundAmount, chargebackAllowedDateUser: new Date() }; + if (!transaction.targetEntity) { + const userData = await this.userDataService.getUserData(jwt.account); + transaction.bankTxReturn = await this.bankTxService + .updateInternal(transaction.bankTx, { type: BankTxType.BANK_TX_RETURN }, userData) + .then((b) => b.bankTxReturn); + } + + if (transaction.targetEntity instanceof BankTxReturn) { + return this.bankTxReturnService.refundBankTx(transaction.targetEntity, { + refundIban: refundData.refundTarget ?? dto.refundTarget, + ...refundDto, + }); + } + if (transaction.targetEntity instanceof BuyFiat) return this.buyFiatService.refundBuyFiatInternal(transaction.targetEntity, { refundUserAddress: dto.refundTarget, @@ -413,6 +474,17 @@ export class TransactionController { // --- HELPER METHODS --- // + private async validateIban(iban: string): Promise { + if (!iban) return false; + + const multiAccountIbans = await this.specialExternalAccountService.getMultiAccountIbans(); + return ( + IbanTools.validateIBAN(iban).valid && + !multiAccountIbans.includes(iban) && + (await this.transactionUtilService.validateChargebackIban(iban)) + ); + } + private isRefundDataValid(refundData: RefundDataDto): boolean { return Util.secondsDiff(refundData.expiryDate) <= 0; } diff --git a/src/subdomains/core/transaction/transaction-util.service.ts b/src/subdomains/core/transaction/transaction-util.service.ts index cafac4efac..cd0baf64d1 100644 --- a/src/subdomains/core/transaction/transaction-util.service.ts +++ b/src/subdomains/core/transaction/transaction-util.service.ts @@ -1,4 +1,11 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, + forwardRef, +} from '@nestjs/common'; import { BigNumber } from 'ethers/lib/ethers'; import * as IbanTools from 'ibantools'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; @@ -6,6 +13,7 @@ import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto import { AssetService } from 'src/shared/models/asset/asset.service'; import { BlockchainAddress } from 'src/shared/models/blockchain-address'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; import { BankAccountService } from 'src/subdomains/supporting/bank/bank-account/bank-account.service'; import { CryptoInput, PayInType } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; @@ -30,13 +38,14 @@ export class TransactionUtilService { constructor( private readonly assetService: AssetService, private readonly blockchainRegistry: BlockchainRegistryService, + @Inject(forwardRef(() => PayInService)) private readonly payInService: PayInService, private readonly bankAccountService: BankAccountService, private readonly specialExternalAccountService: SpecialExternalAccountService, ) {} - static validateRefund(entity: BuyCrypto | BuyFiat, dto: RefundValidation): void { - if (entity.cryptoInput) { + static validateRefund(entity: BuyCrypto | BuyFiat | BankTxReturn, dto: RefundValidation): void { + if (!(entity instanceof BankTxReturn) && entity.cryptoInput) { if (!dto.refundUser) throw new NotFoundException('Your refund user was not found'); if (entity.userData.id !== dto.refundUser.userData.id) throw new ForbiddenException('You can only refund to your own addresses'); @@ -60,9 +69,17 @@ export class TransactionUtilService { entity.chargebackAllowedDate || entity.chargebackDate || (entity instanceof BuyFiat && entity.chargebackTxId) || - (entity instanceof BuyCrypto && (entity.chargebackCryptoTxId || entity.chargebackBankTx)) + (entity instanceof BuyCrypto && (entity.chargebackCryptoTxId || entity.chargebackBankTx)) || + (entity instanceof BankTxReturn && (entity.chargebackRemittanceInfo || entity.chargebackBankTx)) ) throw new BadRequestException('Transaction is already returned'); + + if (entity instanceof BankTxReturn) { + if (dto.chargebackAmount && dto.chargebackAmount > entity.bankTx.amount) + throw new BadRequestException('You can not refund more than the input amount'); + return; + } + if (![CheckStatus.FAIL, CheckStatus.PENDING].includes(entity.amlCheck) || entity.outputAmount) throw new BadRequestException('Only failed or pending transactions are refundable'); if (dto.chargebackAmount && dto.chargebackAmount > entity.inputAmount) 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 2bbb53813d..0dd5349e54 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 @@ -13,6 +13,7 @@ import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { KycStepName, KycStepType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; import { User, UserStatus } from 'src/subdomains/generic/user/models/user/user.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; import { Column, Entity, Generated, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { UserDataRelation } from '../user-data-relation/user-data-relation.entity'; @@ -349,6 +350,9 @@ export class UserData extends IEntity { @OneToMany(() => BankData, (bankData) => bankData.userData) bankDatas: BankData[]; + @OneToMany(() => BankTxReturn, (bankTxReturn) => bankTxReturn.userData) + bankTxReturns: BankTxReturn[]; + @OneToMany(() => SupportIssue, (supportIssue) => supportIssue.userData) supportIssues: SupportIssue[]; diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts index c1bf7a8a4f..49e9167614 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Put, Param, Body, UseGuards } from '@nestjs/common'; +import { Body, Controller, Param, Post, Put, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { RefundInternalDto } from 'src/subdomains/core/history/dto/refund-internal.dto'; import { BankTxReturn } from './bank-tx-return.entity'; import { BankTxReturnService } from './bank-tx-return.service'; import { UpdateBankTxReturnDto } from './dto/update-bank-tx-return.dto'; @@ -19,4 +20,12 @@ export class BankTxReturnController { async update(@Param('id') id: string, @Body() dto: UpdateBankTxReturnDto): Promise { return this.bankTxReturnService.update(+id, dto); } + + @Post(':id/refund') + @ApiBearerAuth() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + @ApiExcludeEndpoint() + async refundBuyCrypto(@Param('id') id: string, @Body() dto: RefundInternalDto): Promise { + return this.bankTxReturnService.refundBankTxReturn(+id, dto); + } } diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts index 47799a0a3b..81a2b00838 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts @@ -1,7 +1,9 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; -import { IEntity } from 'src/shared/models/entity'; -import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; +import { IEntity, UpdateResult } from 'src/shared/models/entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; import { BankService } from '../../bank/bank/bank.service'; +import { FiatOutput } from '../../fiat-output/fiat-output.entity'; import { Transaction } from '../../payment/entities/transaction.entity'; import { BankTx } from '../bank-tx/entities/bank-tx.entity'; @@ -19,6 +21,10 @@ export class BankTxReturn extends IEntity { @JoinColumn() transaction?: Transaction; + @OneToOne(() => FiatOutput, { nullable: true }) + @JoinColumn() + chargebackOutput: FiatOutput; + @Column({ length: 256, nullable: true }) info?: string; @@ -31,6 +37,30 @@ export class BankTxReturn extends IEntity { @Column({ type: 'float', nullable: true }) amountInUsd?: number; + @Column({ type: 'datetime2', nullable: true }) + chargebackDate: Date; + + @Column({ length: 256, nullable: true }) + chargebackRemittanceInfo: string; + + @Column({ type: 'datetime2', nullable: true }) + chargebackAllowedDate: Date; + + @Column({ type: 'datetime2', nullable: true }) + chargebackAllowedDateUser: Date; + + @Column({ type: 'float', nullable: true }) + chargebackAmount: number; + + @Column({ length: 256, nullable: true }) + chargebackAllowedBy: string; + + @Column({ length: 256, nullable: true }) + chargebackIban: string; + + @ManyToOne(() => UserData, (userData) => userData.bankTxReturns, { nullable: true, eager: true }) + userData: UserData; + //*** METHODS ***// pendingInputAmount(asset: Asset): number { @@ -47,4 +77,29 @@ export class BankTxReturn extends IEntity { pendingOutputAmount(_: Asset): number { return 0; } + + chargebackFillUp( + chargebackIban: string, + chargebackAmount: number, + chargebackAllowedDate: Date, + chargebackAllowedDateUser: Date, + chargebackAllowedBy: string, + chargebackOutput?: FiatOutput, + chargebackRemittanceInfo?: string, + ): UpdateResult { + const update: Partial = { + chargebackDate: chargebackAllowedDate ? new Date() : null, + chargebackAllowedDate, + chargebackAllowedDateUser, + chargebackIban, + chargebackAmount, + chargebackOutput, + chargebackAllowedBy, + chargebackRemittanceInfo, + }; + + Object.assign(this, update); + + return [this.id, update]; + } } diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts index dcf969eb4a..2b03679b37 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts @@ -1,6 +1,10 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Util } from 'src/shared/utils/util'; +import { BankTxRefund, RefundInternalDto } from 'src/subdomains/core/history/dto/refund-internal.dto'; +import { TransactionUtilService } from 'src/subdomains/core/transaction/transaction-util.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { IsNull } from 'typeorm'; +import { FiatOutputService } from '../../fiat-output/fiat-output.service'; import { TransactionTypeInternal } from '../../payment/entities/transaction.entity'; import { TransactionService } from '../../payment/services/transaction.service'; import { BankTx, BankTxType } from '../bank-tx/entities/bank-tx.entity'; @@ -15,9 +19,11 @@ export class BankTxReturnService { private readonly bankTxReturnRepo: BankTxReturnRepository, private readonly bankTxRepo: BankTxRepository, private readonly transactionService: TransactionService, + private readonly transactionUtilService: TransactionUtilService, + private readonly fiatOutputService: FiatOutputService, ) {} - async create(bankTx: BankTx): Promise { + async create(bankTx: BankTx, userData?: UserData): Promise { let entity = await this.bankTxReturnRepo.findOneBy({ bankTx: { id: bankTx.id } }); if (entity) throw new BadRequestException('BankTx already used'); @@ -25,13 +31,13 @@ export class BankTxReturnService { type: TransactionTypeInternal.BANK_TX_RETURN, }); - entity = this.bankTxReturnRepo.create({ bankTx, transaction }); + entity = this.bankTxReturnRepo.create({ bankTx, transaction, userData }); return this.bankTxReturnRepo.save(entity); } async update(id: number, dto: UpdateBankTxReturnDto): Promise { - const entity = await this.bankTxReturnRepo.findOne({ where: { id }, relations: ['chargebackBankTx'] }); + const entity = await this.bankTxReturnRepo.findOne({ where: { id }, relations: { chargebackBankTx: true } }); if (!entity) throw new NotFoundException('BankTxReturn not found'); const update = this.bankTxReturnRepo.create(dto); @@ -60,4 +66,50 @@ export class BankTxReturnService { relations: { chargebackBankTx: true, bankTx: true }, }); } + + async refundBankTxReturn(buyCryptoId: number, dto: RefundInternalDto): Promise { + const bankTxReturn = await this.bankTxReturnRepo.findOne({ + where: { id: buyCryptoId }, + relations: { transaction: { user: { userData: true } }, bankTx: true }, + }); + + if (!bankTxReturn) throw new NotFoundException('BankTxReturn not found'); + + return this.refundBankTx(bankTxReturn, { + refundIban: dto.refundIban, + chargebackAmount: dto.chargebackAmount, + chargebackAllowedDate: dto.chargebackAllowedDate, + chargebackAllowedBy: dto.chargebackAllowedBy, + }); + } + + async refundBankTx(bankTxReturn: BankTxReturn, dto: BankTxRefund): Promise { + const chargebackAmount = dto.chargebackAmount ?? bankTxReturn.chargebackAmount; + const chargebackIban = dto.refundIban ?? bankTxReturn.chargebackIban; + + if (!chargebackIban) throw new BadRequestException('You have to define a chargebackIban'); + + TransactionUtilService.validateRefund(bankTxReturn, { + refundIban: chargebackIban, + chargebackAmount, + }); + + if (!(await this.transactionUtilService.validateChargebackIban(chargebackIban))) + throw new BadRequestException('IBAN not valid or BIC not available'); + + if (dto.chargebackAllowedDate && chargebackAmount) { + dto.chargebackOutput = await this.fiatOutputService.createInternal('BankTxReturn', { bankTxReturn }); + } + + await this.bankTxReturnRepo.update( + ...bankTxReturn.chargebackFillUp( + chargebackIban, + chargebackAmount, + dto.chargebackAllowedDate, + dto.chargebackAllowedDateUser, + dto.chargebackAllowedBy, + dto.chargebackOutput, + ), + ); + } } diff --git a/src/subdomains/supporting/bank-tx/bank-tx.module.ts b/src/subdomains/supporting/bank-tx/bank-tx.module.ts index f1b2b4bf8c..94dd13e1c6 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx.module.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx.module.ts @@ -3,12 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BankIntegrationModule } from 'src/integration/bank/bank.module'; import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module'; +import { TransactionUtilModule } from 'src/subdomains/core/transaction/transaction-util.module'; import { UserModule } from 'src/subdomains/generic/user/user.module'; import { BankTxRepeatController } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.controller'; import { BankTxRepeat } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; import { BankTxRepeatRepository } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.repository'; import { BankTxRepeatService } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankModule } from '../bank/bank.module'; +import { FiatOutputModule } from '../fiat-output/fiat-output.module'; import { NotificationModule } from '../notification/notification.module'; import { TransactionModule } from '../payment/transaction.module'; import { PricingModule } from '../pricing/pricing.module'; @@ -36,6 +38,8 @@ import { SepaParser } from './bank-tx/services/sepa-parser.service'; BankModule, TransactionModule, PricingModule, + TransactionUtilModule, + forwardRef(() => FiatOutputModule), ], controllers: [BankTxController, BankTxReturnController, BankTxRepeatController], diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 6a4f524967..55ebf3b5c1 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -15,6 +15,7 @@ import { Lock } from 'src/shared/utils/lock'; import { Util } from 'src/shared/utils/util'; import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; @@ -75,6 +76,7 @@ export class BankTxService { private readonly notificationService: NotificationService, private readonly settingService: SettingService, private readonly olkyService: OlkypayService, + @Inject(forwardRef(() => BankTxReturnService)) private readonly bankTxReturnService: BankTxReturnService, private readonly bankTxRepeatService: BankTxRepeatService, private readonly buyService: BuyService, @@ -175,6 +177,10 @@ export class BankTxService { }, }); if (!bankTx) throw new NotFoundException('BankTx not found'); + return this.updateInternal(bankTx, dto); + } + + async updateInternal(bankTx: BankTx, dto: UpdateBankTxDto, userData?: UserData): Promise { if (dto.type && dto.type != bankTx.type) { if (BankTxTypeCompleted(bankTx.type)) throw new ConflictException('BankTx type already set'); @@ -185,7 +191,7 @@ export class BankTxService { await this.buyCryptoService.createFromBankTx(bankTx, dto.buyId); break; case BankTxType.BANK_TX_RETURN: - await this.bankTxReturnService.create(bankTx); + bankTx.bankTxReturn = await this.bankTxReturnService.create(bankTx, userData); break; case BankTxType.BANK_TX_REPEAT: await this.bankTxRepeatService.create(bankTx); diff --git a/src/subdomains/supporting/fiat-output/dto/create-fiat-output.dto.ts b/src/subdomains/supporting/fiat-output/dto/create-fiat-output.dto.ts index 2e73cc83d4..f551c2b846 100644 --- a/src/subdomains/supporting/fiat-output/dto/create-fiat-output.dto.ts +++ b/src/subdomains/supporting/fiat-output/dto/create-fiat-output.dto.ts @@ -10,6 +10,10 @@ export class CreateFiatOutputDto { @IsNumber() buyCryptoId?: number; + @IsOptional() + @IsNumber() + bankTxReturnId?: number; + @IsNotEmpty() @IsString() type: string; diff --git a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts index b2ca32704f..dd46dc7c6d 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts @@ -3,6 +3,7 @@ import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-c import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm'; import { BankTx } from '../bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxReturn } from '../bank-tx/bank-tx-return/bank-tx-return.entity'; export enum TransactionCharge { BEN = 'BEN', @@ -18,6 +19,9 @@ export class FiatOutput extends IEntity { @OneToOne(() => BuyCrypto, (buyCrypto) => buyCrypto.chargebackOutput, { nullable: true }) buyCrypto?: BuyCrypto; + @OneToOne(() => BankTxReturn, (bankTxReturn) => bankTxReturn.chargebackOutput, { nullable: true }) + bankTxReturn?: BankTxReturn; + @OneToOne(() => BankTx, { nullable: true }) @JoinColumn() bankTx?: BankTx; diff --git a/src/subdomains/supporting/fiat-output/fiat-output.module.ts b/src/subdomains/supporting/fiat-output/fiat-output.module.ts index 40a67cad48..aae061ba3c 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.module.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; @@ -10,7 +10,7 @@ import { FiatOutputRepository } from '../fiat-output/fiat-output.repository'; import { FiatOutputService } from '../fiat-output/fiat-output.service'; @Module({ - imports: [TypeOrmModule.forFeature([FiatOutput]), SharedModule, BankTxModule], + imports: [TypeOrmModule.forFeature([FiatOutput]), SharedModule, forwardRef(() => BankTxModule)], controllers: [FiatOutputController], providers: [FiatOutputRepository, BuyFiatRepository, BuyCryptoRepository, FiatOutputService], diff --git a/src/subdomains/supporting/fiat-output/fiat-output.service.ts b/src/subdomains/supporting/fiat-output/fiat-output.service.ts index f11e1b3787..dbd56f14ab 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.service.ts @@ -8,6 +8,7 @@ import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repo import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { IsNull, Not } from 'typeorm'; +import { BankTxReturn } from '../bank-tx/bank-tx-return/bank-tx-return.entity'; import { BankTx } from '../bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from '../bank-tx/bank-tx/services/bank-tx.service'; import { PayInStatus } from '../payin/entities/crypto-input.entity'; @@ -51,11 +52,13 @@ export class FiatOutputService { } async create(dto: CreateFiatOutputDto): Promise { - if (dto.buyCryptoId || dto.buyFiatId) { + if (dto.buyCryptoId || dto.buyFiatId || dto.bankTxReturnId) { const existing = await this.fiatOutputRepo.exists({ where: dto.buyCryptoId ? { buyCrypto: { id: dto.buyCryptoId }, type: dto.type } - : { buyFiats: { id: dto.buyFiatId }, type: dto.type }, + : dto.buyFiatId + ? { buyFiats: { id: dto.buyFiatId }, type: dto.type } + : { bankTxReturn: { id: dto.bankTxReturnId }, type: dto.type }, }); if (existing) throw new BadRequestException('FiatOutput already exists'); } @@ -82,9 +85,9 @@ export class FiatOutputService { async createInternal( type: string, - { buyCrypto, buyFiats }: { buyCrypto?: BuyCrypto; buyFiats?: BuyFiat[] }, + { buyCrypto, buyFiats, bankTxReturn }: { buyCrypto?: BuyCrypto; buyFiats?: BuyFiat[]; bankTxReturn?: BankTxReturn }, ): Promise { - const entity = this.fiatOutputRepo.create({ type, buyCrypto, buyFiats }); + const entity = this.fiatOutputRepo.create({ type, buyCrypto, buyFiats, bankTxReturn }); return this.fiatOutputRepo.save(entity); } diff --git a/src/subdomains/supporting/payment/entities/transaction.entity.ts b/src/subdomains/supporting/payment/entities/transaction.entity.ts index d4652a7dff..4bda29230a 100644 --- a/src/subdomains/supporting/payment/entities/transaction.entity.ts +++ b/src/subdomains/supporting/payment/entities/transaction.entity.ts @@ -157,8 +157,8 @@ export class Transaction extends IEntity { } } - get targetEntity(): BuyCrypto | BuyFiat | RefReward | undefined { - return this.buyCrypto ?? this.buyFiat ?? this.refReward ?? undefined; + get targetEntity(): BuyCrypto | BuyFiat | RefReward | BankTxReturn | undefined { + return this.buyCrypto ?? this.buyFiat ?? this.refReward ?? this.bankTxReturn ?? undefined; } get userData(): UserData { diff --git a/src/subdomains/supporting/payment/services/transaction-notification.service.ts b/src/subdomains/supporting/payment/services/transaction-notification.service.ts index 205c0c6b44..6278aef02e 100644 --- a/src/subdomains/supporting/payment/services/transaction-notification.service.ts +++ b/src/subdomains/supporting/payment/services/transaction-notification.service.ts @@ -3,7 +3,8 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { Lock } from 'src/shared/utils/lock'; -import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { In, IsNull } from 'typeorm'; import { BankTxIndicator, BankTxUnassignedTypes } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; @@ -51,7 +52,11 @@ export class TransactionNotificationService { for (const entity of entities) { try { - if (!entity.targetEntity || entity.targetEntity instanceof RefReward) continue; + if ( + !entity.targetEntity || + (!(entity.targetEntity instanceof BuyCrypto) && !(entity.targetEntity instanceof BuyFiat)) + ) + continue; if (entity.userData?.mail) await this.notificationService.sendMail({