Skip to content

Commit

Permalink
[DEV-3154] chargeback unassigned tx (#1792)
Browse files Browse the repository at this point in the history
* [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
  • Loading branch information
Yannick1712 authored Dec 20, 2024
1 parent 3c64a4e commit d7f8fcb
Show file tree
Hide file tree
Showing 18 changed files with 362 additions and 88 deletions.
35 changes: 35 additions & 0 deletions migration/1733327830775-addBankTxReturnChargebackCols.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
}
1 change: 1 addition & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ export class Configuration {
refreshToken: process.env.REVOLUT_REFRESH_TOKEN,
clientAssertion: process.env.REVOLUT_CLIENT_ASSERTION,
},
forexFee: 0.02,
};

giroCode = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions src/subdomains/core/buy-crypto/routes/swap/swap.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
Expand Down
204 changes: 138 additions & 66 deletions src/subdomains/core/history/controllers/transaction.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
) {}

Expand Down Expand Up @@ -281,75 +286,113 @@ export class TransactionController {
@UseGuards(AuthGuard(), new RoleGuard(UserRole.ACCOUNT))
async getTransactionRefund(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise<RefundDataDto> {
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);

Expand All @@ -365,6 +408,8 @@ export class TransactionController {
@Body() dto: TransactionRefundDto,
): Promise<void> {
const transaction = await this.transactionService.getTransactionById(+id, {
bankTx: { transaction: true },
bankTxReturn: true,
user: { userData: true },
buyCrypto: {
transaction: { user: { userData: true } },
Expand All @@ -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');
Expand All @@ -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,
Expand All @@ -413,6 +474,17 @@ export class TransactionController {

// --- HELPER METHODS --- //

private async validateIban(iban: string): Promise<boolean> {
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;
}
Expand Down
25 changes: 21 additions & 4 deletions src/subdomains/core/transaction/transaction-util.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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';
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';
Expand All @@ -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');
Expand All @@ -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)
Expand Down
Loading

0 comments on commit d7f8fcb

Please sign in to comment.