Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
`);
}
};
Original file line number Diff line number Diff line change
@@ -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<BuyCryptoRepository>();
transactionHelper = createMock<TransactionHelper>();
pricingService = createMock<PricingService>();
fiatService = createMock<FiatService>();
buyCryptoService = createMock<BuyCryptoService>();
amlService = createMock<AmlService>();
siftService = createMock<SiftService>();
countryService = createMock<CountryService>();
bankService = createMock<BankService>();
buyCryptoWebhookService = createMock<BuyCryptoWebhookService>();
buyCryptoNotificationService = createMock<BuyCryptoNotificationService>();
virtualIbanService = createMock<VirtualIbanService>();
transactionService = createMock<TransactionService>();

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>(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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
) {}
Expand Down Expand Up @@ -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,
});
Expand Down
Loading