From a8019eebcbfa9ebcb6a0868a9cecd22bd3bded23 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:16:45 +0200 Subject: [PATCH 1/2] fix(buy-crypto): complete bank chargebacks from chargebackOutput (#3906) * fix(buy-crypto): complete bank chargebacks from chargebackOutput Bank-based BuyCrypto chargebacks were never marked complete: chargebackFillUp re-matched the outgoing bankTx via getBankTxByRemittanceInfo, which regularly failed, leaving isComplete=false. Such orders kept counting as a pending liability (pendingInputAmount), permanently depressing the financial-data-log total balance. Complete the order deterministically once its chargebackOutput (FiatOutput) is executed, taking chargebackBankTx from chargebackOutput.bankTx. The bankTx type is already set to BUY_CRYPTO_RETURN by fiat-output-job.setBankTxType, so the fragile remittance re-match is removed. Adds unit tests. * test(buy-crypto): cover chargebackFillUp webhook-failure resilience Add a multi-entity test asserting the per-entity try/catch lets the loop continue completing remaining orders when triggerWebhook rejects for one. * test(buy-crypto): sort imports, assert chargebackOutput.bankTx relation is loaded * test(buy-crypto): make chargebackFillUp query-guard structure-agnostic Rename the guard test to its honest scope and assert FindOperator instances instead of TypeORM-internal shapes, decoupling from typeorm internals. * test(buy-crypto): assert filter operator direction and model real stuck chargeback Check FindOperator .type (isNull / not) so an inverted-operator regression fails, and set outputAmount: null in fixtures to mirror a real AML-failed, not-paid-out chargeback. * fix(buy-crypto): load cryptoInput relation in chargebackFillUp triggerWebhook reads cryptoInput; load it explicitly so webhook correctness does not implicitly depend on the query filter. * test(buy-crypto): assert inner Not(IsNull()) operator and clarify fixture comment Verify the nested IsNull via FindOperator.child so an inverted inner operator fails, and neutralize the misleading outputAmount comment. * fix(buy-crypto): load bankTx relation for complete chargeback webhook payload triggerWebhook reads bankTx.iban as sourceAccount; load it alongside cryptoInput so the completion webhook payload is complete. --- .../buy-crypto-preparation.service.spec.ts | 161 ++++++++++++++++++ .../buy-crypto-preparation.service.ts | 22 ++- 2 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts new file mode 100644 index 0000000000..7b0f9e8f19 --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -0,0 +1,161 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SiftService } from 'src/integration/sift/services/sift.service'; +import { CountryService } from 'src/shared/models/country/country.service'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomBuyCrypto } from '../../entities/__mocks__/buy-crypto.entity.mock'; +import { BuyCryptoStatus } from '../../entities/buy-crypto.entity'; +import { BuyCryptoRepository } from '../../repositories/buy-crypto.repository'; +import { BuyCryptoNotificationService } from '../buy-crypto-notification.service'; +import { BuyCryptoPreparationService } from '../buy-crypto-preparation.service'; +import { BuyCryptoWebhookService } from '../buy-crypto-webhook.service'; +import { BuyCryptoService } from '../buy-crypto.service'; + +describe('BuyCryptoPreparationService', () => { + let service: BuyCryptoPreparationService; + + let buyCryptoRepo: BuyCryptoRepository; + let transactionHelper: TransactionHelper; + let pricingService: PricingService; + let fiatService: FiatService; + let buyCryptoService: BuyCryptoService; + let amlService: AmlService; + let siftService: SiftService; + let countryService: CountryService; + let bankService: BankService; + let buyCryptoWebhookService: BuyCryptoWebhookService; + let buyCryptoNotificationService: BuyCryptoNotificationService; + let virtualIbanService: VirtualIbanService; + let transactionService: TransactionService; + + beforeEach(async () => { + buyCryptoRepo = createMock(); + transactionHelper = createMock(); + pricingService = createMock(); + fiatService = createMock(); + buyCryptoService = createMock(); + amlService = createMock(); + siftService = createMock(); + countryService = createMock(); + bankService = createMock(); + buyCryptoWebhookService = createMock(); + buyCryptoNotificationService = createMock(); + virtualIbanService = createMock(); + transactionService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + BuyCryptoPreparationService, + { provide: BuyCryptoRepository, useValue: buyCryptoRepo }, + { provide: TransactionHelper, useValue: transactionHelper }, + { provide: PricingService, useValue: pricingService }, + { provide: FiatService, useValue: fiatService }, + { provide: BuyCryptoService, useValue: buyCryptoService }, + { provide: AmlService, useValue: amlService }, + { provide: SiftService, useValue: siftService }, + { provide: CountryService, useValue: countryService }, + { provide: BankService, useValue: bankService }, + { provide: BuyCryptoWebhookService, useValue: buyCryptoWebhookService }, + { provide: BuyCryptoNotificationService, useValue: buyCryptoNotificationService }, + { provide: VirtualIbanService, useValue: virtualIbanService }, + { provide: TransactionService, useValue: transactionService }, + ], + }).compile(); + + service = module.get(BuyCryptoPreparationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('chargebackFillUp', () => { + it('should complete an order whose chargebackOutput is complete and take the chargebackBankTx from it', async () => { + const chargebackBankTx = { id: 42 } as any; + + const entity = createCustomBuyCrypto({ + id: 1, + amlCheck: CheckStatus.FAIL, + isComplete: false, + outputAmount: null, // mirrors production shape of a refunded chargeback (no crypto output) + chargebackOutput: { isComplete: true, bankTx: chargebackBankTx } as any, + }); + + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([entity]); + + await service.chargebackFillUp(); + + expect(buyCryptoRepo.update).toHaveBeenCalledTimes(1); + expect(buyCryptoRepo.update).toHaveBeenCalledWith(entity.id, { + chargebackBankTx, + isComplete: true, + status: BuyCryptoStatus.COMPLETE, + }); + expect(buyCryptoWebhookService.triggerWebhook).toHaveBeenCalledTimes(1); + expect(buyCryptoWebhookService.triggerWebhook).toHaveBeenCalledWith(entity); + }); + + it('should query with the chargebackOutput-complete filter and load the bankTx relation', async () => { + const findSpy = jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([]); + + await service.chargebackFillUp(); + + // assert the filter selects only refunded-and-settled orders, incl. operator direction + const where = findSpy.mock.calls[0][0].where as any; + expect(where.amlCheck).toBe(CheckStatus.FAIL); + expect(where.isComplete).toBe(false); + expect(where.chargebackBankTx.type).toBe('isNull'); // IsNull(): not yet linked + expect(where.chargebackOutput.isComplete).toBe(true); + expect(where.chargebackOutput.bankTx.id.type).toBe('not'); // Not(...): outer operator + expect(where.chargebackOutput.bankTx.id.child.type).toBe('isNull'); // Not(IsNull()): refund already settled + + // the fix reads entity.chargebackOutput.bankTx, so that relation must be loaded + const relations = findSpy.mock.calls[0][0].relations as any; + expect(relations.chargebackOutput.bankTx).toBe(true); + + // empty result set → nothing is completed + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(buyCryptoWebhookService.triggerWebhook).not.toHaveBeenCalled(); + }); + + it('should continue completing remaining orders even if a webhook fails for one of them', async () => { + const entity1 = createCustomBuyCrypto({ + id: 1, + amlCheck: CheckStatus.FAIL, + isComplete: false, + outputAmount: null, + chargebackOutput: { isComplete: true, bankTx: { id: 42 } } as any, + }); + + const entity2 = createCustomBuyCrypto({ + id: 2, + amlCheck: CheckStatus.FAIL, + isComplete: false, + outputAmount: null, + chargebackOutput: { isComplete: true, bankTx: { id: 43 } } as any, + }); + + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([entity1, entity2]); + jest + .spyOn(buyCryptoWebhookService, 'triggerWebhook') + .mockRejectedValueOnce(new Error('webhook down')) + .mockResolvedValue(undefined); + + await service.chargebackFillUp(); + + expect(buyCryptoRepo.update).toHaveBeenCalledTimes(2); + expect(buyCryptoRepo.update).toHaveBeenCalledWith(entity1.id, expect.objectContaining({ isComplete: true })); + expect(buyCryptoRepo.update).toHaveBeenCalledWith(entity2.id, expect.objectContaining({ isComplete: true })); + expect(buyCryptoWebhookService.triggerWebhook).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index 98e8b1e003..b7c821b4ce 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -13,8 +13,6 @@ import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycStatus, RiskStatus, UserDataStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; -import { BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; -import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; import { CardBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; @@ -53,7 +51,6 @@ export class BuyCryptoPreparationService { private readonly bankService: BankService, private readonly buyCryptoWebhookService: BuyCryptoWebhookService, private readonly buyCryptoNotificationService: BuyCryptoNotificationService, - private readonly bankTxService: BankTxService, private readonly virtualIbanService: VirtualIbanService, private readonly transactionService: TransactionService, ) {} @@ -524,22 +521,23 @@ export class BuyCryptoPreparationService { where: { chargebackBankTx: IsNull(), amlCheck: CheckStatus.FAIL, - bankTx: { id: Not(IsNull()) }, isComplete: false, - chargebackRemittanceInfo: Not(IsNull()), - chargebackOutput: { id: Not(IsNull()) }, + chargebackOutput: { isComplete: true, bankTx: { id: Not(IsNull()) } }, + }, + relations: { + transaction: { user: { wallet: true }, userData: true }, + chargebackOutput: { bankTx: true }, + // cryptoInput and bankTx are read by triggerWebhook (tx type / sourceAccount); + // load explicitly for a complete payload instead of relying on the filter + cryptoInput: true, + bankTx: true, }, - relations: { transaction: { user: { wallet: true }, userData: true }, cryptoInput: true }, }); for (const entity of entities) { try { - const bankTx = await this.bankTxService.getBankTxByRemittanceInfo(entity.chargebackRemittanceInfo); - if (!bankTx) continue; - - await this.bankTxService.updateInternal(bankTx, { type: BankTxType.BUY_CRYPTO_RETURN }); await this.buyCryptoRepo.update(entity.id, { - chargebackBankTx: bankTx, + chargebackBankTx: entity.chargebackOutput.bankTx, isComplete: true, status: BuyCryptoStatus.COMPLETE, }); From 04f43dbac5c7d1cc64448fe4d71b7da14225e074 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:35:05 +0200 Subject: [PATCH 2/2] fix(log): mark high totalBalanceChf FinancialDataLog entries of 2026-06-18 as invalid (#3916) --- ...-InvalidateHighTotalBalanceLogs20260618.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 migration/1781766233000-InvalidateHighTotalBalanceLogs20260618.js diff --git a/migration/1781766233000-InvalidateHighTotalBalanceLogs20260618.js b/migration/1781766233000-InvalidateHighTotalBalanceLogs20260618.js new file mode 100644 index 0000000000..1a9995c567 --- /dev/null +++ b/migration/1781766233000-InvalidateHighTotalBalanceLogs20260618.js @@ -0,0 +1,65 @@ +// FinancialDataLog entries created on 2026-06-18 carry a totalBalanceChf that is +// implausibly high (> 60 000 CHF) compared to the expected operating-equity range, +// yet they were stamped valid=true. This is a recurrence of the same transient +// accounting spike handled for 2026-06-15 (see InvalidateHighTotalBalanceLogs +// 1781527084203), 2026-06-16 (see InvalidateHighTotalBalanceLogs 1781598468039) and +// 2026-06-17 (see InvalidateHighTotalBalanceLogs 1781685299000): plusBalanceChf +// jumped ahead of the corresponding minusBalanceChf booking, so deltas at the +// elevated baseline stayed within financeLogTotalBalanceChangeLimit and the invalid +// flag was never set. This migration marks those entries invalid so monitoring +// dashboards and anomaly alerts reflect the period correctly. +// +// Threshold : totalBalanceChf > 60 000 (well above the normal operating band; raised +// from the 50 000 used on the previous days to target this day's spike) +// Scope : entries created on 2026-06-18 (UTC), no upper bound so every +// affected row of the day is covered regardless of sub-second timing. +// +// Env-guarded: the COUNT pre-check makes up() a no-op where no rows match +// (staging/dev). down() re-stamps valid=true for the same window/threshold; it +// cannot distinguish rows already invalid before up() ran, so it may over-restore +// a small number of entries — accepted for this one-shot fix. +module.exports = class InvalidateHighTotalBalanceLogs1781766233000 { + name = 'InvalidateHighTotalBalanceLogs1781766233000'; + + async up(queryRunner) { + const [{ count }] = await queryRunner.query(` + SELECT COUNT(*) AS count FROM log + WHERE subsystem = 'FinancialDataLog' + AND created >= '2026-06-18T00:00:00Z' + AND created < '2026-06-19T00:00:00Z' + AND (message::jsonb -> 'balancesTotal' ->> 'totalBalanceChf')::numeric > 60000 + AND valid = true + `); + if (parseInt(count) === 0) return; + + await queryRunner.query(` + UPDATE log SET valid = false + WHERE subsystem = 'FinancialDataLog' + AND created >= '2026-06-18T00:00:00Z' + AND created < '2026-06-19T00:00:00Z' + AND (message::jsonb -> 'balancesTotal' ->> 'totalBalanceChf')::numeric > 60000 + AND valid = true + `); + } + + async down(queryRunner) { + const [{ count }] = await queryRunner.query(` + SELECT COUNT(*) AS count FROM log + WHERE subsystem = 'FinancialDataLog' + AND created >= '2026-06-18T00:00:00Z' + AND created < '2026-06-19T00:00:00Z' + AND (message::jsonb -> 'balancesTotal' ->> 'totalBalanceChf')::numeric > 60000 + AND valid = false + `); + if (parseInt(count) === 0) return; + + await queryRunner.query(` + UPDATE log SET valid = true + WHERE subsystem = 'FinancialDataLog' + AND created >= '2026-06-18T00:00:00Z' + AND created < '2026-06-19T00:00:00Z' + AND (message::jsonb -> 'balancesTotal' ->> 'totalBalanceChf')::numeric > 60000 + AND valid = false + `); + } +};