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
33 changes: 33 additions & 0 deletions migration/1784117860216-AddBankTxTypeCreatedIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Backs the monthly bank-cost aggregation (BankTxService.getBankTxFee), which runs every minute from
* the financial-log cron and filters bank_tx by (type = BANK_ACCOUNT_FEE, created >= from). Without a
* matching composite index the query degrades to a full scan over a table that grows continuously.
*
* bank_tx carries no primary key in production (MSSQL->Postgres cutover drift), so this migration adds
* ONLY a secondary index: no foreign key, no column alter, nothing that requires a unique constraint.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddBankTxTypeCreatedIndex1784117860216 {
name = 'AddBankTxTypeCreatedIndex1784117860216';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_bank_tx_type_created" ON "bank_tx" ("type", "created")`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_bank_tx_type_created"`);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { createMock } from '@golevelup/ts-jest';
import { ConfigService } from 'src/config/config';
import { OlkypayService } from 'src/integration/bank/services/olkypay.service';
import { YapealService } from 'src/integration/bank/services/yapeal.service';
import { createCustomPrice } from 'src/integration/exchange/dto/__mocks__/price.dto.mock';
import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock';
import { FiatService } from 'src/shared/models/fiat/fiat.service';
import { SettingService } from 'src/shared/models/setting/setting.service';
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 { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service';
import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service';
import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service';
import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service';
import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service';
import { TransactionNotificationService } from 'src/subdomains/supporting/payment/services/transaction-notification.service';
import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service';
import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service';
import { BankTxRepeatService } from '../../bank-tx-repeat/bank-tx-repeat.service';
import { BankTxReturnService } from '../../bank-tx-return/bank-tx-return.service';
import { BankTxIndicator } from '../entities/bank-tx.entity';
import { BankTxBatchRepository } from '../repositories/bank-tx-batch.repository';
import { BankTxRepository } from '../repositories/bank-tx.repository';
import { BankTxFrickService } from '../services/bank-tx-frick.service';
import { BankTxService } from '../services/bank-tx.service';
import { SepaParser } from '../services/sepa-parser.service';

// one raw aggregate row as returned by the GROUP BY currency, creditDebitIndicator query
interface FeeAggregate {
currency: string;
creditDebitIndicator: BankTxIndicator;
amount: string; // Postgres returns SUM() as a string
}

describe('BankTxService', () => {
let service: BankTxService;

let bankTxRepo: BankTxRepository;
let pricingService: PricingService;
let fiatService: FiatService;

let qb: any;

const from = new Date('2026-07-01');

// Distinct per-currency CHF prices. Price.convert divides the amount by the price
// (getPrice(fiat, CHF).convert(x) = x / price), so a wrong currency->price mapping or an
// inverted (multiply) direction produces a different total and turns these tests red.
const chfPriceByCurrency: Record<string, number> = { EUR: 0.8, USD: 2 };

beforeAll(() => {
new ConfigService(); // sets module-level Config (defaultVolumeDecimal read by the CHF conversion)
});

beforeEach(() => {
jest.clearAllMocks();

bankTxRepo = createMock<BankTxRepository>();
pricingService = createMock<PricingService>();
fiatService = createMock<FiatService>();

// one chainable query builder mock serves both queries: the legacy chargeAmountChf sum
// (getRawOne) and the per-currency/direction BankAccountFee aggregation (getRawMany).
qb = {
select: jest.fn(() => qb),
addSelect: jest.fn(() => qb),
where: jest.fn(() => qb),
andWhere: jest.fn(() => qb),
groupBy: jest.fn(() => qb),
addGroupBy: jest.fn(() => qb),
getRawOne: jest.fn(),
getRawMany: jest.fn(),
};
(bankTxRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);

(fiatService.getFiatByName as jest.Mock).mockImplementation((name: string) => createCustomFiat({ name }));

service = new BankTxService(
bankTxRepo,
createMock<BankTxBatchRepository>(),
createMock<BuyCryptoService>(),
createMock<NotificationService>(),
createMock<SettingService>(),
createMock<OlkypayService>(),
createMock<BankTxFrickService>(),
createMock<BankTxReturnService>(),
createMock<BankTxRepeatService>(),
createMock<BuyService>(),
createMock<BankService>(),
createMock<YapealService>(),
createMock<TransactionService>(),
createMock<SpecialExternalAccountService>(),
createMock<SepaParser>(),
createMock<BankDataService>(),
createMock<VirtualIbanService>(),
createMock<TransactionNotificationService>(),
pricingService,
fiatService,
);
});

function mockLegacyFee(fee: number | null): void {
qb.getRawOne.mockResolvedValue({ fee });
}

function mockFeeAggregates(rows: FeeAggregate[]): void {
qb.getRawMany.mockResolvedValue(rows);
}

// per-currency prices from chfPriceByCurrency (distinct so mapping/direction errors are caught)
function mockChfPrices(): void {
(pricingService.getPrice as jest.Mock).mockImplementation(async (fiat: { name: string }) =>
createCustomPrice({ source: fiat.name, target: 'CHF', price: chfPriceByCurrency[fiat.name] }),
);
}

it('converts each DBIT currency aggregate with its own price and sums the result', async () => {
mockLegacyFee(null);
mockFeeAggregates([
{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' },
{ currency: 'USD', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '200' },
]);
mockChfPrices();

// EUR 100 / 0.8 = 125 CHF; USD 200 / 2 = 100 CHF; total 225 CHF
// (a multiply direction -> 80 + 400 = 480; a swapped currency->price -> 125 + 250 = 375)
await expect(service.getBankTxFee(from)).resolves.toBe(225);

expect(fiatService.getFiatByName).toHaveBeenCalledWith('EUR');
expect(fiatService.getFiatByName).toHaveBeenCalledWith('USD');
expect(pricingService.getPrice).toHaveBeenCalledTimes(2);
});

it('nets a CRDT refund against a DBIT fee in the same currency before converting', async () => {
mockLegacyFee(null);
mockFeeAggregates([
{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' },
{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.CREDIT, amount: '30' },
]);
mockChfPrices();

// net EUR 70 / 0.8 = 87.5 CHF (converted once, after netting)
await expect(service.getBankTxFee(from)).resolves.toBe(87.5);

expect(pricingService.getPrice).toHaveBeenCalledTimes(1);
});

it('adds the legacy chargeAmountChf sum on top of the converted BankAccountFee aggregates', async () => {
mockLegacyFee(5);
mockFeeAggregates([{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' }]);
mockChfPrices();

// legacy 5 + EUR 100 / 0.8 (125) = 130
await expect(service.getBankTxFee(from)).resolves.toBe(130);
});

it('rounds the assembled total to the default volume decimals', async () => {
mockLegacyFee(0.1);
mockFeeAggregates([{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '0.2' }]);
// price 1 so the converted amount is exactly 0.2 and only the final sum needs rounding
(pricingService.getPrice as jest.Mock).mockResolvedValue(
createCustomPrice({ source: 'EUR', target: 'CHF', price: 1 }),
);

// legacy 0.1 + EUR 0.2 = 0.30000000000000004 in float -> must be rounded to 0.3
await expect(service.getBankTxFee(from)).resolves.toBe(0.3);
});

it('fails loud when the price is unavailable', async () => {
mockLegacyFee(null);
mockFeeAggregates([{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' }]);
(pricingService.getPrice as jest.Mock).mockRejectedValue(new Error('No valid price'));

await expect(service.getBankTxFee(from)).rejects.toThrow();
});

it('returns only the legacy sum when there are no BankAccountFee aggregates', async () => {
mockLegacyFee(5);
mockFeeAggregates([]);

await expect(service.getBankTxFee(from)).resolves.toBe(5);

expect(pricingService.getPrice).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
} from '@nestjs/common';
import { CronExpression } from '@nestjs/schedule';
import { Observable, Subject } from 'rxjs';
import { Config } from 'src/config/config';
import { YapealService } from 'src/integration/bank/services/yapeal.service';
import { FiatService } from 'src/shared/models/fiat/fiat.service';
import { SettingService } from 'src/shared/models/setting/setting.service';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { Process } from 'src/shared/services/process.service';
Expand All @@ -26,6 +28,11 @@ import { MailContext, MailType } from 'src/subdomains/supporting/notification/en
import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service';
import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entities/special-external-account.entity';
import { TransactionNotificationService } from 'src/subdomains/supporting/payment/services/transaction-notification.service';
import {
PriceCurrency,
PriceValidity,
PricingService,
} from 'src/subdomains/supporting/pricing/services/pricing.service';
import {
DeepPartial,
FindOptionsRelations,
Expand Down Expand Up @@ -114,6 +121,8 @@ export class BankTxService implements OnModuleInit {
private readonly virtualIbanService: VirtualIbanService,
@Inject(forwardRef(() => TransactionNotificationService))
private readonly transactionNotificationService: TransactionNotificationService,
private readonly pricingService: PricingService,
private readonly fiatService: FiatService,
) {}

onModuleInit() {
Expand Down Expand Up @@ -430,14 +439,62 @@ export class BankTxService implements OnModuleInit {
]);
}

// Bank fees come from two sources: legacy per-tx charges (chargeAmountChf) plus dedicated BankAccountFee rows.
// Charge fields ceased with the bank migration in Dec 2025; fees arrive as dedicated BankAccountFee rows since,
// carrying the amount in the account currency (no amountChf column), so they are aggregated per currency and
// converted to CHF.
async getBankTxFee(from: Date): Promise<number> {
const { fee } = await this.bankTxRepo
.createQueryBuilder('bankTx')
.select('SUM(bankTx.chargeAmountChf)', 'fee')
.where('bankTx.created >= :from', { from })
.getRawOne<{ fee: number }>();

return fee ?? 0;
let totalFeeChf = fee ?? 0;

// Aggregate the dedicated BankAccountFee rows in the database (summed per currency and direction)
// rather than hydrating every row: this runs every minute from the financial-log cron and the table
// grows continuously. Column references use the alias.property form so TypeORM quotes the camelCase
// identifiers correctly for Postgres.
const feeAggregates = await this.bankTxRepo
.createQueryBuilder('bankTx')
.select('bankTx.currency', 'currency')
.addSelect('bankTx.creditDebitIndicator', 'creditDebitIndicator')
.addSelect('SUM(bankTx.amount)', 'amount')
.where('bankTx.type = :type', { type: BankTxType.BANK_ACCOUNT_FEE })
.andWhere('bankTx.created >= :from', { from })
.groupBy('bankTx.currency')
.addGroupBy('bankTx.creditDebitIndicator')
.getRawMany<{ currency: string; creditDebitIndicator: string; amount: string }>();

const amountByCurrency = new Map<string, number>();
for (const row of feeAggregates) {
const groupAmount = Number(row.amount);

let signedAmount: number;
if (row.creditDebitIndicator === BankTxIndicator.DEBIT) {
signedAmount = groupAmount;
} else if (row.creditDebitIndicator === BankTxIndicator.CREDIT) {
signedAmount = -groupAmount;
} else {
this.logger.error(
`BankAccountFee aggregate (currency ${row.currency}) has unexpected creditDebitIndicator ${row.creditDebitIndicator}, skipping`,
);
continue;
}

amountByCurrency.set(row.currency, (amountByCurrency.get(row.currency) ?? 0) + signedAmount);
}

for (const [currency, amount] of amountByCurrency) {
if (!amount) continue;

const fiat = await this.fiatService.getFiatByName(currency);
const price = await this.pricingService.getPrice(fiat, PriceCurrency.CHF, PriceValidity.ANY);
totalFeeChf += price.convert(amount, Config.defaultVolumeDecimal);
}

return Util.round(totalFeeChf, Config.defaultVolumeDecimal);
}

async getRecentBankToBankTx(fromIban: string, toIban: string): Promise<BankTx[]> {
Expand Down
53 changes: 53 additions & 0 deletions src/subdomains/supporting/log/__tests__/log-job.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,59 @@ describe('LogJobService', () => {
expect(result.minus.scrypt).toBeUndefined();
expect(result.minus.mexc).toBeUndefined();
});

it('flows the bank tx fee into minus.bank and the totals', async () => {
setupEmpty();
jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([] as any);
jest.spyOn(bankTxService, 'getBankTxFee').mockResolvedValue(4196 as any);

const result = await (service as any).getChangeLog();

expect(result.minus.bank).toBe(4196);
expect(result.minus.total).toBe(4196);
expect(result.total).toBe(-4196);
});
});

describe('FinancialChangesLog isolation (reporting failure must not arm the equity safety mode)', () => {
// a healthy, finite book comfortably above the minimum -> the equity path leaves safety mode off
function setup() {
jest.spyOn(service as any, 'getTradingLog').mockResolvedValue({});
jest.spyOn(service as any, 'getAssetLog').mockResolvedValue({});
jest
.spyOn(service as any, 'getBalancesByFinancialType')
.mockReturnValue({ EUR: { plusBalance: 5000, plusBalanceChf: 5000, minusBalance: 0, minusBalanceChf: 0 } });
jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([] as any);
jest.spyOn(settingService, 'getObj').mockResolvedValue(100 as any);
jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 });
jest
.spyOn(logService, 'maxEntity')
.mockResolvedValue({ message: JSON.stringify({ balancesTotal: { totalBalanceChf: 5000 } }) } as any);
return jest.spyOn(logService, 'create').mockResolvedValue({} as any);
}

it('writes the FinancialDataLog, keeps safety mode off, logs the error and omits the changes entry when getChangeLog throws', async () => {
const errorSpy = jest.spyOn(service['logger'], 'error');
const createSpy = setup();
// a transient reporting-price failure while building the changes log
jest.spyOn(service as any, 'getChangeLog').mockRejectedValue(new Error('No valid price'));

await service.saveTradingLog();

// the equity path ran and still persisted the FinancialDataLog entry for this minute
const dataLog = createSpy.mock.calls.find(([dto]) => dto.subsystem === 'FinancialDataLog');
expect(dataLog).toBeDefined();

// the reporting-price failure did NOT arm the equity safety mode: the healthy book set it to false
// (and the outer catch, which would set it true, never ran)
expect(processService.setSafetyModeActive).toHaveBeenCalledWith(false);
expect(processService.setSafetyModeActive).not.toHaveBeenCalledWith(true);

// the failure is logged and this minute's changes entry is omitted (absence, not a wrong value)
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('financial changes log'), expect.any(Error));
const changesLog = createSpy.mock.calls.find(([dto]) => dto.subsystem === 'FinancialChangesLog');
expect(changesLog).toBeUndefined();
});
});

describe('saveTradingLog (referral-credit liability)', () => {
Expand Down
Loading