From 12040111887261bc8a0dd2af612177c60e809495 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:26:03 +0100 Subject: [PATCH 1/2] fix: included rent exemption fee for solana refund (#2947) --- .../payment/services/transaction-helper.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index cd00bdf80b..1b7f00094b 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -427,7 +427,12 @@ export class TransactionHelper implements OnModuleInit { }); const dfxFeeAmount = inputAmount * chargebackFee.rate + price.convert(chargebackFee.fixed); - const networkFeeAmount = price.convert(chargebackFee.network); + + let networkFeeAmount = price.convert(chargebackFee.network); + + if (isAsset(inputCurrency) && inputCurrency.blockchain === Blockchain.SOLANA) + networkFeeAmount += await this.getSolanaRentExemptionFee(inputCurrency); + const bankFeeAmount = refundEntity.paymentMethodIn === FiatPaymentMethod.BANK ? price.convert( @@ -654,6 +659,13 @@ export class TransactionHelper implements OnModuleInit { return price.convert(fee); } + private async getSolanaRentExemptionFee(asset: Asset): Promise { + if (asset.type !== AssetType.COIN) return 0; + + const price = await this.pricingService.getPrice(asset, PriceCurrency.CHF, PriceValidity.ANY); + return price.convert(Config.blockchain.solana.minimalCoinAccountRent) * 1.05; // 5% buffer for rounding + } + private async getTronCreateAccountFee(user: User, asset: Asset): Promise { const tronService = this.blockchainRegistryService.getService(asset.blockchain) as TronService; From 197be34a9012670c880b2b0ffe0b096f7b7f17c4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:26:19 +0100 Subject: [PATCH 2/2] fix: match FiatOutput to BankTx via endToEndId fallback (#2950) * fix: match FiatOutput to BankTx via endToEndId fallback LiqManagement FiatOutputs (e.g. transfers to Kraken/exchanges) have no remittanceInfo set, causing the matching in searchOutgoingBankTx() to fail even though the BankTx exists with matching endToEndId. Changes: - Add getBankTxByEndToEndId() to BankTxService - Extend getMatchingBankTx() to try endToEndId as fallback when remittanceInfo is not set or doesn't match This fixes FiatOutputs stuck in isComplete=false despite successful Yapeal transmission and existing DBIT BankTx entries. * refactor: add DBIT filter, logging and tests for endToEndId matching - Filter getBankTxByEndToEndId to only return DEBIT transactions (prevents matching incoming CRDT transactions by accident) - Add info log when matching via endToEndId for debugging - Add 3 unit tests for searchOutgoingBankTx: - Match via remittanceInfo - Match via endToEndId fallback (LiqManagement case) - Reject if BankTx created before FiatOutput.isReadyDate * feat: set BankTxType.INTERNAL for LiqManagement FiatOutputs When matching LiqManagement FiatOutputs, set the BankTx type to INTERNAL instead of leaving it as Pending. This provides clearer categorization for financial reporting and prevents the BankTx from being picked up by other assignment processes. * fix: removed log --------- Co-authored-by: David May --- .../bank-tx/services/bank-tx.service.ts | 8 +++ .../__tests__/fiat-output-job.service.spec.ts | 62 ++++++++++++++++++- .../fiat-output/fiat-output-job.service.ts | 19 +++++- 3 files changed, 84 insertions(+), 5 deletions(-) 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 e92b81fddc..01c4c4b5b5 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 @@ -386,6 +386,14 @@ export class BankTxService implements OnModuleInit { .getOne(); } + async getBankTxByEndToEndId(endToEndId: string): Promise { + return this.bankTxRepo.findOne({ + where: { endToEndId, creditDebitIndicator: BankTxIndicator.DEBIT }, + relations: { transaction: true }, + order: { id: 'DESC' }, + }); + } + async getBankTxByTransactionId(transactionId: number, relations?: FindOptionsRelations): Promise { return this.bankTxRepo.findOne({ where: { transaction: { id: transactionId } }, relations }); } diff --git a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts index d57ab8403e..e7b8264ef1 100644 --- a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts @@ -16,7 +16,7 @@ import { createCustomSell } from 'src/subdomains/core/sell-crypto/route/__mocks_ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { BankTxRepeatService } from '../../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return.service'; -import { createDefaultBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; +import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; import { createCustomBank, yapealEUR } from '../../bank/bank/__mocks__/bank.entity.mock'; import { BankService } from '../../bank/bank/bank.service'; import { IbanBankName } from '../../bank/bank/dto/bank.dto'; @@ -107,7 +107,7 @@ describe('FiatOutputJobService', () => { id: 2, type: FiatOutputType.BANK_TX_REPEAT, isComplete: false, - bankTx: createDefaultBankTx(), + bankTx: createCustomBankTx({}), }), createCustomFiatOutput({ id: 3, @@ -474,4 +474,62 @@ describe('FiatOutputJobService', () => { expect(updateCalls[1][0]).toBe(2); }); }); + + describe('searchOutgoingBankTx', () => { + it('should match FiatOutput via remittanceInfo', async () => { + const bankTx = createCustomBankTx({ id: 100, created: new Date('2024-01-01') }); + const fiatOutput = createCustomFiatOutput({ + id: 1, + remittanceInfo: 'DFX-123', + isComplete: false, + isReadyDate: new Date('2024-01-01'), + }); + + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); + jest.spyOn(bankTxService, 'getBankTxByRemittanceInfo').mockResolvedValue(bankTx); + + await service['searchOutgoingBankTx'](); + + expect(bankTxService.getBankTxByRemittanceInfo).toHaveBeenCalledWith('DFX-123'); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(1, expect.objectContaining({ isComplete: true, bankTx })); + }); + + it('should match FiatOutput via endToEndId when remittanceInfo is not set', async () => { + const bankTx = createCustomBankTx({ id: 200, created: new Date('2024-01-01') }); + const fiatOutput = createCustomFiatOutput({ + id: 2, + endToEndId: 'E2E-79057', + remittanceInfo: undefined, + isComplete: false, + isReadyDate: new Date('2024-01-01'), + type: FiatOutputType.LIQ_MANAGEMENT, + }); + + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); + jest.spyOn(bankTxService, 'getBankTxByRemittanceInfo').mockResolvedValue(null); + jest.spyOn(bankTxService, 'getBankTxByEndToEndId').mockResolvedValue(bankTx); + + await service['searchOutgoingBankTx'](); + + expect(bankTxService.getBankTxByEndToEndId).toHaveBeenCalledWith('E2E-79057'); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(2, expect.objectContaining({ isComplete: true, bankTx })); + }); + + it('should not match if BankTx created before FiatOutput isReadyDate', async () => { + const bankTx = createCustomBankTx({ id: 300, created: new Date('2024-01-01') }); + const fiatOutput = createCustomFiatOutput({ + id: 3, + endToEndId: 'E2E-79058', + isComplete: false, + isReadyDate: new Date('2024-01-02'), // after BankTx.created + }); + + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); + jest.spyOn(bankTxService, 'getBankTxByEndToEndId').mockResolvedValue(bankTx); + + await service['searchOutgoingBankTx'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index b6efc350fd..44b5d9b6ff 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -1,7 +1,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; -import { Config } from 'src/config/config'; import { isLiechtensteinBankHoliday } from 'src/config/bank-holiday.config'; +import { Config } from 'src/config/config'; import { Pain001Payment } from 'src/integration/bank/services/iso20022.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { AzureStorageService } from 'src/integration/infrastructure/azure-storage.service'; @@ -86,9 +86,19 @@ export class FiatOutputJobService { // --- HELPER METHODS --- // private async getMatchingBankTx(entity: FiatOutput): Promise { - if (!entity.remittanceInfo) return undefined; + // Try remittanceInfo first + if (entity.remittanceInfo) { + const bankTx = await this.bankTxService.getBankTxByRemittanceInfo(entity.remittanceInfo); + if (bankTx) return bankTx; + } - return this.bankTxService.getBankTxByRemittanceInfo(entity.remittanceInfo); + // Fallback to endToEndId (used for Yapeal LiqManagement payments) + if (entity.endToEndId) { + const bankTx = await this.bankTxService.getBankTxByEndToEndId(entity.endToEndId); + if (bankTx) return bankTx; + } + + return undefined; } private async getPayoutAccount(entity: FiatOutput, country: Country): Promise<{ accountIban: string; bank: Bank }> { @@ -435,6 +445,9 @@ export class FiatOutputJobService { case FiatOutputType.BANK_TX_RETURN: return this.bankTxService.updateInternal(bankTx, { type: BankTxType.BANK_TX_RETURN_CHARGEBACK }); + + case FiatOutputType.LIQ_MANAGEMENT: + return this.bankTxService.updateInternal(bankTx, { type: BankTxType.INTERNAL }); } } }