From 2ed4770c342ca389bcfe01a45b15b9eb25378949 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:55:15 +0200 Subject: [PATCH] Aggregate the finance change log in SQL instead of loading the month's entities getChangeLog runs every minute and loaded every buy-crypto, buy-fiat, exchange and payout row of the current month as fully hydrated entities, only to reduce them to a few fee sums. Eager relations expand a single buy-crypto row to 481 columns across 15 LEFT JOINs: 3225 rows amount to 9 MB and ~1.55 M field values per run. The database answers that in 58 ms; deserialising it in the pg driver took up to 7926 ms and kept the event loop saturated. Replace the four loaders with SQL aggregates that return only the totals. Semantics are preserved: cryptoInput stays LEFT-joined so rows without one keep counting as regular fees, the payout fee COALESCEs both columns the way the entity getter relied on JS null coercion, and an empty period maps NULL to 0. Cover the aggregates with pg-mem suites, since a mocked repository executes no SQL. --- .../__tests__/exchange-tx.service.pg.spec.ts | 189 ++++++++++++++++++ .../exchange/services/exchange-tx.service.ts | 15 +- .../__tests__/buy-crypto.service.pg.spec.ts | 168 ++++++++++++++++ .../process/services/buy-crypto.service.ts | 22 +- .../__tests__/buy-fiat.service.pg.spec.ts | 174 ++++++++++++++++ .../process/services/buy-fiat.service.ts | 20 +- .../log/__tests__/log-job.service.spec.ts | 66 ++++-- .../supporting/log/log-job.service.ts | 78 +++----- .../__tests__/payout.service.pg.spec.ts | 160 +++++++++++++++ .../services/__tests__/payout.service.spec.ts | 46 +---- .../payout/services/payout.service.ts | 18 +- 11 files changed, 837 insertions(+), 119 deletions(-) create mode 100644 src/integration/exchange/services/__tests__/exchange-tx.service.pg.spec.ts create mode 100644 src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.pg.spec.ts create mode 100644 src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat.service.pg.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout.service.pg.spec.ts diff --git a/src/integration/exchange/services/__tests__/exchange-tx.service.pg.spec.ts b/src/integration/exchange/services/__tests__/exchange-tx.service.pg.spec.ts new file mode 100644 index 0000000000..060f935b27 --- /dev/null +++ b/src/integration/exchange/services/__tests__/exchange-tx.service.pg.spec.ts @@ -0,0 +1,189 @@ +import { DataType, newDb } from 'pg-mem'; +import { Column, DataSource, Entity, PrimaryColumn, Repository } from 'typeorm'; +import { ExchangeName } from '../../enums/exchange.enum'; +import { ExchangeTxType } from '../../entities/exchange-tx.entity'; +import { ExchangeTxService } from '../exchange-tx.service'; + +// mirrors only the columns getExchangeTxFee touches, under the real table name — the full entity +// cannot be registered standalone +@Entity({ name: 'exchange_tx' }) +class ExchangeTxTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'timestamp' }) + created: Date; + + @Column({ type: 'varchar' }) + exchange: string; + + @Column({ type: 'varchar' }) + type: string; + + @Column({ type: 'float', nullable: true }) + feeAmountChf?: number; +} + +// runs getExchangeTxFee against a Postgres-semantics engine (pg-mem): the netting of rebates and the +// grouping used to happen in JS and was covered there; now it is SQL, and a mocked repository would +// execute none of it +describe('ExchangeTxService.getExchangeTxFee (postgres semantics)', () => { + let dataSource: DataSource; + let repo: Repository; + let service: ExchangeTxService; + + const from = new Date('2026-07-01T00:00:00Z'); + + beforeAll(async () => { + const db = newDb(); + // TypeORM runs SELECT version() / current_database() on connect; pg-mem does not ship them + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + + dataSource = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [ExchangeTxTable], + synchronize: true, + })) as DataSource; + await dataSource.initialize(); + repo = dataSource.getRepository(ExchangeTxTable); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + beforeEach(async () => { + await repo.clear(); + + // this query needs none of the constructor's collaborators; building off the prototype keeps the + // test from breaking when an unrelated dependency is added + service = Object.create(ExchangeTxService.prototype) as ExchangeTxService; + (service as any).exchangeTxRepo = repo; + }); + + function feeOf( + result: { exchange: ExchangeName; type: ExchangeTxType; fee: number }[], + exchange: ExchangeName, + type: ExchangeTxType, + ): number { + return result.find((r) => r.exchange === exchange && r.type === type)?.fee ?? 0; + } + + it('nets fees per exchange and type, including negative rebates', async () => { + await repo.save([ + // Scrypt trading: 240 minus a 20 rebate -> 220 net + { + id: 1, + created: new Date('2026-07-02'), + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + feeAmountChf: 240, + }, + { + id: 2, + created: new Date('2026-07-03'), + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + feeAmountChf: -20, + }, + { + id: 3, + created: new Date('2026-07-04'), + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.WITHDRAWAL, + feeAmountChf: 10, + }, + { + id: 4, + created: new Date('2026-07-05'), + exchange: ExchangeName.MEXC, + type: ExchangeTxType.TRADE, + feeAmountChf: 18, + }, + ]); + + const result = await service.getExchangeTxFee(from); + + expect(feeOf(result, ExchangeName.SCRYPT, ExchangeTxType.TRADE)).toBe(220); + expect(feeOf(result, ExchangeName.SCRYPT, ExchangeTxType.WITHDRAWAL)).toBe(10); + expect(feeOf(result, ExchangeName.MEXC, ExchangeTxType.TRADE)).toBe(18); + // one row per occurring combination, not per transaction + expect(result).toHaveLength(3); + }); + + it('reports 0 instead of NULL for a group whose fees are all unset', async () => { + await repo.save([ + { + id: 1, + created: new Date('2026-07-02'), + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.DEPOSIT, + feeAmountChf: null, + }, + { + id: 2, + created: new Date('2026-07-03'), + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.DEPOSIT, + feeAmountChf: null, + }, + ]); + + const result = await service.getExchangeTxFee(from); + + expect(result).toEqual([{ exchange: ExchangeName.BINANCE, type: ExchangeTxType.DEPOSIT, fee: 0 }]); + }); + + it('ignores an unset fee but keeps the rest of its group', async () => { + await repo.save([ + { + id: 1, + created: new Date('2026-07-02'), + exchange: ExchangeName.KRAKEN, + type: ExchangeTxType.TRADE, + feeAmountChf: null, + }, + { + id: 2, + created: new Date('2026-07-03'), + exchange: ExchangeName.KRAKEN, + type: ExchangeTxType.TRADE, + feeAmountChf: 12, + }, + ]); + + const result = await service.getExchangeTxFee(from); + + expect(feeOf(result, ExchangeName.KRAKEN, ExchangeTxType.TRADE)).toBe(12); + }); + + it('excludes transactions created before the given date', async () => { + await repo.save([ + { + id: 1, + created: new Date('2026-06-30'), + exchange: ExchangeName.KRAKEN, + type: ExchangeTxType.TRADE, + feeAmountChf: 99, + }, + { + id: 2, + created: new Date('2026-07-02'), + exchange: ExchangeName.KRAKEN, + type: ExchangeTxType.TRADE, + feeAmountChf: 1, + }, + ]); + + const result = await service.getExchangeTxFee(from); + + expect(feeOf(result, ExchangeName.KRAKEN, ExchangeTxType.TRADE)).toBe(1); + }); + + it('returns an empty list when there are no transactions in the period', async () => { + const result = await service.getExchangeTxFee(from); + + expect(result).toEqual([]); + }); +}); diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index c3cb9d82bd..0731644f04 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -207,8 +207,19 @@ export class ExchangeTxService implements OnModuleInit { } } - async getExchangeTx(from: Date, relations?: FindOptionsRelations): Promise { - return this.exchangeTxRepo.find({ where: { created: MoreThan(from) }, relations }); + // Grouped in SQL rather than loading every transaction of the period and filtering per exchange/type + // in memory — the caller (FinanceLog job) runs every minute and only needs the totals. Returns one + // row per exchange/type combination that actually occurred; absent combinations are simply no fees. + async getExchangeTxFee(from: Date): Promise<{ exchange: ExchangeName; type: ExchangeTxType; fee: number }[]> { + return this.exchangeTxRepo + .createQueryBuilder('exchangeTx') + .select('exchangeTx.exchange', 'exchange') + .addSelect('exchangeTx.type', 'type') + .addSelect('COALESCE(SUM(exchangeTx.feeAmountChf), 0)', 'fee') + .where('exchangeTx.created > :from', { from }) + .groupBy('exchangeTx.exchange') + .addGroupBy('exchangeTx.type') + .getRawMany<{ exchange: ExchangeName; type: ExchangeTxType; fee: number }>(); } async getLastExchangeTx(exchange: ExchangeName, relations?: FindOptionsRelations): Promise { diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.pg.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.pg.spec.ts new file mode 100644 index 0000000000..c9c517404e --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.pg.spec.ts @@ -0,0 +1,168 @@ +import { DataType, newDb } from 'pg-mem'; +import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; +import { BuyCryptoService } from '../buy-crypto.service'; + +// The real entities cannot be registered standalone (relations pull in the whole entity graph), so +// these tables mirror only what getBuyCryptoFee touches, under the real table names. The relations are +// ManyToOne rather than the real OneToOne: both produce the same `LEFT JOIN x ON x.id = base.fkId`, +// and ManyToOne keeps the fixtures free of uniqueness constraints the query does not depend on. +@Entity({ name: 'payment_link_payment' }) +class PaymentLinkPaymentTable { + @PrimaryColumn() + id: number; +} + +@Entity({ name: 'crypto_input' }) +class CryptoInputTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'int', nullable: true }) + paymentLinkPaymentId?: number; + + @ManyToOne(() => PaymentLinkPaymentTable, { nullable: true, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'paymentLinkPaymentId' }) + paymentLinkPayment?: PaymentLinkPaymentTable; +} + +@Entity({ name: 'transaction' }) +class TransactionTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'timestamp' }) + created: Date; +} + +@Entity({ name: 'buy_crypto' }) +class BuyCryptoTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'float', nullable: true }) + totalFeeAmountChf?: number; + + @Column({ type: 'int' }) + transactionId: number; + + @Column({ type: 'int', nullable: true }) + cryptoInputId?: number; + + @ManyToOne(() => TransactionTable, { nullable: false, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'transactionId' }) + transaction: TransactionTable; + + @ManyToOne(() => CryptoInputTable, { nullable: true, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'cryptoInputId' }) + cryptoInput?: CryptoInputTable; +} + +// runs getBuyCryptoFee against a Postgres-semantics engine (pg-mem). The split between regular and +// payment-link fees used to be a JS filter over loaded entities; as SQL it depends on two LEFT joins +// and a CASE, none of which a mocked repository would execute +describe('BuyCryptoService.getBuyCryptoFee (postgres semantics)', () => { + let dataSource: DataSource; + let buyCryptoRepo: Repository; + let transactionRepo: Repository; + let cryptoInputRepo: Repository; + let paymentLinkPaymentRepo: Repository; + let service: BuyCryptoService; + + const from = new Date('2026-07-01T00:00:00Z'); + + beforeAll(async () => { + const db = newDb(); + // TypeORM runs SELECT version() / current_database() on connect; pg-mem does not ship them + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + + dataSource = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [BuyCryptoTable, TransactionTable, CryptoInputTable, PaymentLinkPaymentTable], + synchronize: true, + })) as DataSource; + await dataSource.initialize(); + + buyCryptoRepo = dataSource.getRepository(BuyCryptoTable); + transactionRepo = dataSource.getRepository(TransactionTable); + cryptoInputRepo = dataSource.getRepository(CryptoInputTable); + paymentLinkPaymentRepo = dataSource.getRepository(PaymentLinkPaymentTable); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + beforeEach(async () => { + await buyCryptoRepo.clear(); + await cryptoInputRepo.clear(); + await paymentLinkPaymentRepo.clear(); + await transactionRepo.clear(); + + // the constructor takes ~20 collaborators this query needs none of; building the instance off the + // prototype keeps the test from breaking every time an unrelated dependency is added + service = Object.create(BuyCryptoService.prototype) as BuyCryptoService; + (service as any).buyCryptoRepo = buyCryptoRepo; + }); + + it('splits the fees into regular and payment-link, keeping rows without a crypto input as regular', async () => { + await transactionRepo.save([ + { id: 1, created: new Date('2026-07-02') }, + { id: 2, created: new Date('2026-07-03') }, + { id: 3, created: new Date('2026-07-04') }, + ]); + await paymentLinkPaymentRepo.save([{ id: 500 }]); + await cryptoInputRepo.save([ + { id: 10, paymentLinkPaymentId: 500 }, + { id: 11, paymentLinkPaymentId: null }, + ]); + await buyCryptoRepo.save([ + // bank purchase: no crypto input at all -> regular (this is the majority of rows in prod) + { id: 1, transactionId: 1, cryptoInputId: null, totalFeeAmountChf: 100 }, + // crypto input without a payment link -> regular + { id: 2, transactionId: 2, cryptoInputId: 11, totalFeeAmountChf: 20 }, + // crypto input with a payment link -> paymentLink + { id: 3, transactionId: 3, cryptoInputId: 10, totalFeeAmountChf: 3 }, + ]); + + const result = await service.getBuyCryptoFee(from); + + expect(result).toEqual({ regular: 120, paymentLink: 3 }); + }); + + it('ignores an unset fee instead of turning the sum into NULL', async () => { + await transactionRepo.save([ + { id: 1, created: new Date('2026-07-02') }, + { id: 2, created: new Date('2026-07-03') }, + ]); + await buyCryptoRepo.save([ + { id: 1, transactionId: 1, cryptoInputId: null, totalFeeAmountChf: null }, + { id: 2, transactionId: 2, cryptoInputId: null, totalFeeAmountChf: 8 }, + ]); + + const result = await service.getBuyCryptoFee(from); + + expect(result).toEqual({ regular: 8, paymentLink: 0 }); + }); + + it('filters on the transaction date, not the buy-crypto row', async () => { + await transactionRepo.save([ + { id: 1, created: new Date('2026-06-30') }, + { id: 2, created: new Date('2026-07-02') }, + ]); + await buyCryptoRepo.save([ + { id: 1, transactionId: 1, cryptoInputId: null, totalFeeAmountChf: 99 }, + { id: 2, transactionId: 2, cryptoInputId: null, totalFeeAmountChf: 1 }, + ]); + + const result = await service.getBuyCryptoFee(from); + + expect(result).toEqual({ regular: 1, paymentLink: 0 }); + }); + + it('returns zeros when there are no transactions in the period', async () => { + const result = await service.getBuyCryptoFee(from); + + expect(result).toEqual({ regular: 0, paymentLink: 0 }); + }); +}); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 2b6a6d4b32..1ae6742a93 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -60,7 +60,7 @@ import { TransactionHelper } from 'src/subdomains/supporting/payment/services/tr import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { Between, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { Between, FindOptionsRelations, In, IsNull, Not } from 'typeorm'; import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { AmlSourceType } from '../../../aml/entities/transaction-aml-check.entity'; import { canManualPass, ManualPassBlacklistErrors } from '../../../aml/enums/aml-error.enum'; @@ -745,8 +745,24 @@ export class BuyCryptoService implements OnModuleInit { return this.buyCryptoRepo.findOne({ where: { transaction: { id: transactionId } }, relations }); } - async getBuyCrypto(from: Date, relations?: FindOptionsRelations): Promise { - return this.buyCryptoRepo.find({ where: { transaction: { created: MoreThan(from) } }, relations }); + // Summed in SQL on purpose: the caller (FinanceLog job) runs every minute and only needs two totals. + // Loading the entities instead pulled ~9 MB / 1.5 M field values per run through the pg driver — the + // eager relations expand one row to 481 columns — which is what saturated the API's event loop. + // cryptoInput is LEFT-joined because most buy_crypto rows have none (bank purchases); they belong to + // `regular`, matching the previous `!p.cryptoInput?.paymentLinkPayment` filter. + async getBuyCryptoFee(from: Date): Promise<{ regular: number; paymentLink: number }> { + const { regular, paymentLink } = await this.buyCryptoRepo + .createQueryBuilder('buyCrypto') + .select('SUM(CASE WHEN paymentLinkPayment.id IS NULL THEN buyCrypto.totalFeeAmountChf END)', 'regular') + .addSelect('SUM(CASE WHEN paymentLinkPayment.id IS NOT NULL THEN buyCrypto.totalFeeAmountChf END)', 'paymentLink') + .innerJoin('buyCrypto.transaction', 'transaction') + .leftJoin('buyCrypto.cryptoInput', 'cryptoInput') + .leftJoin('cryptoInput.paymentLinkPayment', 'paymentLinkPayment') + .where('transaction.created > :from', { from }) + .getRawOne<{ regular: number; paymentLink: number }>(); + + // SUM over an empty result set is NULL — no transactions this period means no fees + return { regular: regular ?? 0, paymentLink: paymentLink ?? 0 }; } async updateVolumes(start = 1, end = 100000): Promise { diff --git a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat.service.pg.spec.ts b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat.service.pg.spec.ts new file mode 100644 index 0000000000..4c1c6cd1ca --- /dev/null +++ b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat.service.pg.spec.ts @@ -0,0 +1,174 @@ +import { DataType, newDb } from 'pg-mem'; +import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; +import { BuyFiatService } from '../buy-fiat.service'; + +// Mirrors only what getBuyFiatFee touches, under the real table names — see the equivalent comment in +// buy-crypto.service.pg.spec.ts on why the relations are ManyToOne here. +@Entity({ name: 'payment_link_payment' }) +class PaymentLinkPaymentTable { + @PrimaryColumn() + id: number; +} + +@Entity({ name: 'crypto_input' }) +class CryptoInputTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'int', nullable: true }) + paymentLinkPaymentId?: number; + + @ManyToOne(() => PaymentLinkPaymentTable, { nullable: true, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'paymentLinkPaymentId' }) + paymentLinkPayment?: PaymentLinkPaymentTable; +} + +@Entity({ name: 'transaction' }) +class TransactionTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'timestamp' }) + created: Date; +} + +@Entity({ name: 'buy_fiat' }) +class BuyFiatTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'float', nullable: true }) + totalFeeAmountChf?: number; + + @Column({ type: 'int' }) + transactionId: number; + + // nullable here although the real column is NOT NULL: the query LEFT-joins it on purpose, and the + // orphan case below pins that a row can never silently drop out of the total + @Column({ type: 'int', nullable: true }) + cryptoInputId?: number; + + @ManyToOne(() => TransactionTable, { nullable: false, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'transactionId' }) + transaction: TransactionTable; + + @ManyToOne(() => CryptoInputTable, { nullable: true, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'cryptoInputId' }) + cryptoInput?: CryptoInputTable; +} + +// runs getBuyFiatFee against a Postgres-semantics engine (pg-mem); a mocked repository would execute +// neither the joins nor the CASE the regular/payment-link split is built on +describe('BuyFiatService.getBuyFiatFee (postgres semantics)', () => { + let dataSource: DataSource; + let buyFiatRepo: Repository; + let transactionRepo: Repository; + let cryptoInputRepo: Repository; + let paymentLinkPaymentRepo: Repository; + let service: BuyFiatService; + + const from = new Date('2026-07-01T00:00:00Z'); + + beforeAll(async () => { + const db = newDb(); + // TypeORM runs SELECT version() / current_database() on connect; pg-mem does not ship them + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + + dataSource = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [BuyFiatTable, TransactionTable, CryptoInputTable, PaymentLinkPaymentTable], + synchronize: true, + })) as DataSource; + await dataSource.initialize(); + + buyFiatRepo = dataSource.getRepository(BuyFiatTable); + transactionRepo = dataSource.getRepository(TransactionTable); + cryptoInputRepo = dataSource.getRepository(CryptoInputTable); + paymentLinkPaymentRepo = dataSource.getRepository(PaymentLinkPaymentTable); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + beforeEach(async () => { + await buyFiatRepo.clear(); + await cryptoInputRepo.clear(); + await paymentLinkPaymentRepo.clear(); + await transactionRepo.clear(); + + // the constructor takes ~20 collaborators this query needs none of; see buy-crypto.service.pg.spec.ts + service = Object.create(BuyFiatService.prototype) as BuyFiatService; + (service as any).buyFiatRepo = buyFiatRepo; + }); + + it('splits the fees into regular and payment-link', async () => { + await transactionRepo.save([ + { id: 1, created: new Date('2026-07-02') }, + { id: 2, created: new Date('2026-07-03') }, + ]); + await paymentLinkPaymentRepo.save([{ id: 500 }]); + await cryptoInputRepo.save([ + { id: 10, paymentLinkPaymentId: 500 }, + { id: 11, paymentLinkPaymentId: null }, + ]); + await buyFiatRepo.save([ + { id: 1, transactionId: 1, cryptoInputId: 11, totalFeeAmountChf: 20 }, + { id: 2, transactionId: 2, cryptoInputId: 10, totalFeeAmountChf: 7 }, + ]); + + const result = await service.getBuyFiatFee(from); + + expect(result).toEqual({ regular: 20, paymentLink: 7 }); + }); + + // cryptoInput is NOT NULL in the schema, so this should never happen — but the LEFT join means such + // a row still reaches the total instead of vanishing from the finance log + it('counts a row whose crypto input is missing as regular', async () => { + await transactionRepo.save([{ id: 1, created: new Date('2026-07-02') }]); + await buyFiatRepo.save([{ id: 1, transactionId: 1, cryptoInputId: null, totalFeeAmountChf: 5 }]); + + const result = await service.getBuyFiatFee(from); + + expect(result).toEqual({ regular: 5, paymentLink: 0 }); + }); + + it('ignores an unset fee instead of turning the sum into NULL', async () => { + await transactionRepo.save([ + { id: 1, created: new Date('2026-07-02') }, + { id: 2, created: new Date('2026-07-03') }, + ]); + await cryptoInputRepo.save([{ id: 10, paymentLinkPaymentId: null }]); + await buyFiatRepo.save([ + { id: 1, transactionId: 1, cryptoInputId: 10, totalFeeAmountChf: null }, + { id: 2, transactionId: 2, cryptoInputId: 10, totalFeeAmountChf: 8 }, + ]); + + const result = await service.getBuyFiatFee(from); + + expect(result).toEqual({ regular: 8, paymentLink: 0 }); + }); + + it('filters on the transaction date, not the buy-fiat row', async () => { + await transactionRepo.save([ + { id: 1, created: new Date('2026-06-30') }, + { id: 2, created: new Date('2026-07-02') }, + ]); + await cryptoInputRepo.save([{ id: 10, paymentLinkPaymentId: null }]); + await buyFiatRepo.save([ + { id: 1, transactionId: 1, cryptoInputId: 10, totalFeeAmountChf: 99 }, + { id: 2, transactionId: 2, cryptoInputId: 10, totalFeeAmountChf: 1 }, + ]); + + const result = await service.getBuyFiatFee(from); + + expect(result).toEqual({ regular: 1, paymentLink: 0 }); + }); + + it('returns zeros when there are no transactions in the period', async () => { + const result = await service.getBuyFiatFee(from); + + expect(result).toEqual({ regular: 0, paymentLink: 0 }); + }); +}); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index ca453e3f0f..73980f652e 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -37,7 +37,7 @@ import { PayoutOrderContext } from 'src/subdomains/supporting/payout/entities/pa import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/support-log.enum'; import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; -import { Between, FindOptionsRelations, In, IsNull, MoreThan } from 'typeorm'; +import { Between, FindOptionsRelations, In, IsNull } from 'typeorm'; import { FiatOutputService } from '../../../../supporting/fiat-output/fiat-output.service'; import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { AmlSourceType } from '../../../aml/entities/transaction-aml-check.entity'; @@ -371,8 +371,22 @@ export class BuyFiatService implements OnModuleInit { }); } - async getBuyFiat(from: Date, relations?: FindOptionsRelations): Promise { - return this.buyFiatRepo.find({ where: { transaction: { created: MoreThan(from) } }, relations }); + // Summed in SQL for the same reason as BuyCryptoService.getBuyCryptoFee — see the comment there. + // cryptoInput is non-nullable on BuyFiat, but stays a LEFT join so a row could never silently drop + // out of the total; such a row counts as `regular`. + async getBuyFiatFee(from: Date): Promise<{ regular: number; paymentLink: number }> { + const { regular, paymentLink } = await this.buyFiatRepo + .createQueryBuilder('buyFiat') + .select('SUM(CASE WHEN paymentLinkPayment.id IS NULL THEN buyFiat.totalFeeAmountChf END)', 'regular') + .addSelect('SUM(CASE WHEN paymentLinkPayment.id IS NOT NULL THEN buyFiat.totalFeeAmountChf END)', 'paymentLink') + .innerJoin('buyFiat.transaction', 'transaction') + .leftJoin('buyFiat.cryptoInput', 'cryptoInput') + .leftJoin('cryptoInput.paymentLinkPayment', 'paymentLinkPayment') + .where('transaction.created > :from', { from }) + .getRawOne<{ regular: number; paymentLink: number }>(); + + // SUM over an empty result set is NULL — no transactions this period means no fees + return { regular: regular ?? 0, paymentLink: paymentLink ?? 0 }; } async triggerWebhookManual(id: number): Promise { diff --git a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts index daf001fa7a..c3022ec471 100644 --- a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts @@ -39,6 +39,7 @@ import { DashboardFinancialService } from '../../dashboard/dashboard-financial.s import { createCustomFiatOutput } from '../../fiat-output/__mocks__/fiat-output.entity.mock'; import { createCustomCryptoInput } from '../../payin/entities/__mocks__/crypto-input.entity.mock'; import { PayInService } from '../../payin/services/payin.service'; +import { PayoutOrderContext } from '../../payout/entities/payout-order.entity'; import { PayoutService } from '../../payout/services/payout.service'; import { LogJobService } from '../log-job.service'; import { LogService } from '../log.service'; @@ -128,27 +129,31 @@ describe('LogJobService', () => { }); describe('getChangeLog (Scrypt & MEXC exchange fees)', () => { + // The per-source sums now come from SQL aggregates; these tests cover how getChangeLog assembles + // them. That the aggregates themselves sum correctly (rebates, NULL fees, grouping) is covered + // against real Postgres semantics in the *.pg.spec.ts files of the respective services. function setupEmpty() { - jest.spyOn(buyFiatService, 'getBuyFiat').mockResolvedValue([] as any); - jest.spyOn(buyCryptoService, 'getBuyCrypto').mockResolvedValue([] as any); + jest.spyOn(buyFiatService, 'getBuyFiatFee').mockResolvedValue({ regular: 0, paymentLink: 0 }); + jest.spyOn(buyCryptoService, 'getBuyCryptoFee').mockResolvedValue({ regular: 0, paymentLink: 0 }); jest.spyOn(tradingOrderService, 'getTradingOrderYield').mockResolvedValue({ fee: 0, profit: 0 } as any); - jest.spyOn(payoutService, 'getPayoutOrders').mockResolvedValue([] as any); + jest.spyOn(payoutService, 'getPayoutOrderFee').mockResolvedValue([]); jest.spyOn(bankTxService, 'getBankTxFee').mockResolvedValue(0 as any); jest.spyOn(payInService, 'getPayInFee').mockResolvedValue(0 as any); jest.spyOn(refRewardService, 'getRefRewardVolume').mockResolvedValue(0 as any); } - it('sums Scrypt and MEXC trade+withdrawal fees (sign-aware, incl. rebates) into minus and total', async () => { + it('sums Scrypt and MEXC trade+withdrawal fees into minus and total', async () => { setupEmpty(); - jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([ - // Scrypt: trade 240 minus a 20 rebate = 220 net trading, plus a 10 withdrawal -> total 230 - createCustomExchangeTx({ exchange: ExchangeName.SCRYPT, type: ExchangeTxType.TRADE, feeAmountChf: 240 }), - createCustomExchangeTx({ exchange: ExchangeName.SCRYPT, type: ExchangeTxType.TRADE, feeAmountChf: -20 }), - createCustomExchangeTx({ exchange: ExchangeName.SCRYPT, type: ExchangeTxType.WITHDRAWAL, feeAmountChf: 10 }), + jest.spyOn(exchangeTxService, 'getExchangeTxFee').mockResolvedValue([ + // Scrypt: 220 net trading (240 minus a 20 rebate, netted by the aggregate) + 10 withdrawal -> 230 + { exchange: ExchangeName.SCRYPT, type: ExchangeTxType.TRADE, fee: 220 }, + { exchange: ExchangeName.SCRYPT, type: ExchangeTxType.WITHDRAWAL, fee: 10 }, // MEXC: trade 18 + withdrawal 5 -> total 23 - createCustomExchangeTx({ exchange: ExchangeName.MEXC, type: ExchangeTxType.TRADE, feeAmountChf: 18 }), - createCustomExchangeTx({ exchange: ExchangeName.MEXC, type: ExchangeTxType.WITHDRAWAL, feeAmountChf: 5 }), - ] as any); + { exchange: ExchangeName.MEXC, type: ExchangeTxType.TRADE, fee: 18 }, + { exchange: ExchangeName.MEXC, type: ExchangeTxType.WITHDRAWAL, fee: 5 }, + // a combination no other block reads must not leak into any total + { exchange: ExchangeName.MEXC, type: ExchangeTxType.DEPOSIT, fee: 7 }, + ]); const result = await (service as any).getChangeLog(); @@ -160,7 +165,7 @@ describe('LogJobService', () => { it('omits the Scrypt and MEXC blocks when there are no such exchange fees', async () => { setupEmpty(); - jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([] as any); + jest.spyOn(exchangeTxService, 'getExchangeTxFee').mockResolvedValue([]); const result = await (service as any).getChangeLog(); @@ -168,9 +173,42 @@ describe('LogJobService', () => { expect(result.minus.mexc).toBeUndefined(); }); + it('splits the buy fees into buyCrypto, buyFiat and the shared paymentLink block', async () => { + setupEmpty(); + jest.spyOn(exchangeTxService, 'getExchangeTxFee').mockResolvedValue([]); + jest.spyOn(buyCryptoService, 'getBuyCryptoFee').mockResolvedValue({ regular: 100, paymentLink: 3 }); + jest.spyOn(buyFiatService, 'getBuyFiatFee').mockResolvedValue({ regular: 20, paymentLink: 7 }); + + const result = await (service as any).getChangeLog(); + + expect(result.plus.buyCrypto).toBe(100); + expect(result.plus.buyFiat).toBe(20); + // both sources feed the same paymentLink figure + expect(result.plus.paymentLink).toBe(10); + expect(result.plus.total).toBe(130); + expect(result.total).toBe(130); + }); + + it('separates the ref payout fee from all other payout contexts', async () => { + setupEmpty(); + jest.spyOn(exchangeTxService, 'getExchangeTxFee').mockResolvedValue([]); + jest.spyOn(payoutService, 'getPayoutOrderFee').mockResolvedValue([ + { context: PayoutOrderContext.REF_PAYOUT, fee: 5 }, + { context: PayoutOrderContext.BUY_CRYPTO, fee: 11 }, + { context: PayoutOrderContext.MANUAL, fee: 4 }, + ]); + + const result = await (service as any).getChangeLog(); + + // ref fee goes to the ref block, every other context to the blockchain tx-out block + expect(result.minus.ref.fee).toBe(5); + expect(result.minus.blockchain.tx.out).toBe(15); + expect(result.minus.total).toBe(20); + }); + it('flows the bank tx fee into minus.bank and the totals', async () => { setupEmpty(); - jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([] as any); + jest.spyOn(exchangeTxService, 'getExchangeTxFee').mockResolvedValue([]); jest.spyOn(bankTxService, 'getBankTxFee').mockResolvedValue(4196 as any); const result = await (service as any).getChangeLog(); diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index f85f7037b0..6ab7cf7e12 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -25,11 +25,9 @@ import { } from 'src/subdomains/core/liquidity-management/enums'; import { LiquidityManagementPipelineService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service'; import { PaymentBalanceService } from 'src/subdomains/core/payment-link/services/payment-balance.service'; -import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; -import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; import { TradingOrderService } from 'src/subdomains/core/trading/services/trading-order.service'; import { TradingRuleService } from 'src/subdomains/core/trading/services/trading-rule.service'; import { BankTxRepeat } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; @@ -41,9 +39,8 @@ import { BankTxService } from '../bank-tx/bank-tx/services/bank-tx.service'; import { BankService } from '../bank/bank/bank.service'; import { IbanBankName } from '../bank/bank/dto/bank.dto'; import { DashboardFinancialService } from '../dashboard/dashboard-financial.service'; -import { CryptoInput } from '../payin/entities/crypto-input.entity'; import { PayInService } from '../payin/services/payin.service'; -import { PayoutOrder, PayoutOrderContext } from '../payout/entities/payout-order.entity'; +import { PayoutOrderContext } from '../payout/entities/payout-order.entity'; import { PayoutService } from '../payout/services/payout.service'; import { AssetLog, @@ -1181,57 +1178,42 @@ export class LogJobService { const firstDayOfMonth = Util.firstDayOfMonth(); // plus amounts - const buyFiats = await this.buyFiatService.getBuyFiat(firstDayOfMonth, { - cryptoInput: { paymentLinkPayment: true }, - }); - const buyCryptos = await this.buyCryptoService.getBuyCrypto(firstDayOfMonth, { - cryptoInput: { paymentLinkPayment: true }, - }); + // All four sources aggregate in SQL. Loading the entities and summing in JS cost ~9 MB and 1.5 M + // field values per minute for buy_crypto alone, which is what kept the event loop saturated. + const buyFiatFees = await this.buyFiatService.getBuyFiatFee(firstDayOfMonth); + const buyCryptoFees = await this.buyCryptoService.getBuyCryptoFee(firstDayOfMonth); const { fee: tradingOrderFee, profit: tradingOrderProfit } = await this.tradingOrderService.getTradingOrderYield(firstDayOfMonth); - const buyFiatFee = this.getFeeAmount(buyFiats.filter((b) => !b.cryptoInput.paymentLinkPayment)); - const paymentLinkFee = this.getFeeAmount([ - ...buyFiats.filter((p) => p.cryptoInput.paymentLinkPayment), - ...buyCryptos.filter((p) => p.cryptoInput?.paymentLinkPayment), - ]); - const buyCryptoFee = this.getFeeAmount(buyCryptos.filter((b) => !b.cryptoInput?.paymentLinkPayment)); + const buyFiatFee = buyFiatFees.regular; + const paymentLinkFee = buyFiatFees.paymentLink + buyCryptoFees.paymentLink; + const buyCryptoFee = buyCryptoFees.regular; // minus amounts - const exchangeTx = await this.exchangeTxService.getExchangeTx(firstDayOfMonth); - const payoutOrders = await this.payoutService.getPayoutOrders(firstDayOfMonth); + const exchangeFees = await this.exchangeTxService.getExchangeTxFee(firstDayOfMonth); + const payoutOrderFees = await this.payoutService.getPayoutOrderFee(firstDayOfMonth); const bankTxFee = await this.bankTxService.getBankTxFee(firstDayOfMonth); - const krakenTxWithdrawFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.KRAKEN && e.type === ExchangeTxType.WITHDRAWAL), - ); - const krakenTxTradingFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.KRAKEN && e.type === ExchangeTxType.TRADE), - ); - const binanceTxWithdrawFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.BINANCE && e.type === ExchangeTxType.WITHDRAWAL), - ); - const binanceTxTradingFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.BINANCE && e.type === ExchangeTxType.TRADE), - ); - const scryptTxWithdrawFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.SCRYPT && e.type === ExchangeTxType.WITHDRAWAL), - ); - const scryptTxTradingFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.SCRYPT && e.type === ExchangeTxType.TRADE), - ); - const mexcTxWithdrawFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.MEXC && e.type === ExchangeTxType.WITHDRAWAL), - ); - const mexcTxTradingFee = this.getFeeAmount( - exchangeTx.filter((e) => e.exchange === ExchangeName.MEXC && e.type === ExchangeTxType.TRADE), - ); + + // a combination the period has no transactions for contributes no fees + const exchangeFee = (exchange: ExchangeName, type: ExchangeTxType): number => + exchangeFees.find((f) => f.exchange === exchange && f.type === type)?.fee ?? 0; + + const krakenTxWithdrawFee = exchangeFee(ExchangeName.KRAKEN, ExchangeTxType.WITHDRAWAL); + const krakenTxTradingFee = exchangeFee(ExchangeName.KRAKEN, ExchangeTxType.TRADE); + const binanceTxWithdrawFee = exchangeFee(ExchangeName.BINANCE, ExchangeTxType.WITHDRAWAL); + const binanceTxTradingFee = exchangeFee(ExchangeName.BINANCE, ExchangeTxType.TRADE); + const scryptTxWithdrawFee = exchangeFee(ExchangeName.SCRYPT, ExchangeTxType.WITHDRAWAL); + const scryptTxTradingFee = exchangeFee(ExchangeName.SCRYPT, ExchangeTxType.TRADE); + const mexcTxWithdrawFee = exchangeFee(ExchangeName.MEXC, ExchangeTxType.WITHDRAWAL); + const mexcTxTradingFee = exchangeFee(ExchangeName.MEXC, ExchangeTxType.TRADE); const cryptoInputFee = await this.payInService.getPayInFee(firstDayOfMonth); const refRewards = await this.refRewardService.getRefRewardVolume(firstDayOfMonth); - const payoutOrderRefFee = this.getFeeAmount( - payoutOrders.filter((p) => p.context === PayoutOrderContext.REF_PAYOUT), + const payoutOrderRefFee = payoutOrderFees.find((p) => p.context === PayoutOrderContext.REF_PAYOUT)?.fee ?? 0; + const payoutOrderFee = Util.sumObjValue( + payoutOrderFees.filter((p) => p.context !== PayoutOrderContext.REF_PAYOUT), + 'fee', ); - const payoutOrderFee = this.getFeeAmount(payoutOrders.filter((p) => p.context !== PayoutOrderContext.REF_PAYOUT)); const totalKrakenFee = krakenTxWithdrawFee + krakenTxTradingFee; const totalBinanceFee = binanceTxWithdrawFee + binanceTxTradingFee; @@ -1324,12 +1306,6 @@ export class LogJobService { return tx.length ? tx.map((t) => t.id).join(';') : undefined; } - private getFeeAmount( - tx: (BuyCrypto | BuyFiat | BankTx | ExchangeTx | RefReward | TradingOrder | CryptoInput | PayoutOrder)[], - ): number { - return tx.reduce((sum, tx) => sum + (tx.feeAmountChf ?? 0), 0); - } - private getPendingAmounts( assets: Asset[], pendingTx: (BuyCrypto | BuyFiat | BankTx | BankTxReturn | BankTxRepeat)[], diff --git a/src/subdomains/supporting/payout/services/__tests__/payout.service.pg.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout.service.pg.spec.ts new file mode 100644 index 0000000000..9e83954aad --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout.service.pg.spec.ts @@ -0,0 +1,160 @@ +import { DataType, newDb } from 'pg-mem'; +import { Column, DataSource, Entity, PrimaryColumn, Repository } from 'typeorm'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutService } from '../payout.service'; + +// the real PayoutOrder entity cannot be registered standalone (relations pull in the whole entity +// graph), so this table mirrors only the columns getPayoutOrderFee touches — under the real table name +@Entity({ name: 'payout_order' }) +class PayoutOrderTable { + @PrimaryColumn() + id: number; + + @Column({ type: 'timestamp' }) + created: Date; + + @Column({ type: 'varchar' }) + context: string; + + @Column({ type: 'float', nullable: true }) + preparationFeeAmountChf?: number; + + @Column({ type: 'float', nullable: true }) + payoutFeeAmountChf?: number; +} + +// runs getPayoutOrderFee against a Postgres-semantics engine (pg-mem), because a mocked repository +// never executes SQL: a wrong COALESCE, a missing GROUP BY or a flipped date comparison would go +// unnoticed, and this aggregate feeds the FinanceLog +describe('PayoutService.getPayoutOrderFee (postgres semantics)', () => { + let dataSource: DataSource; + let repo: Repository; + let service: PayoutService; + + const from = new Date('2026-07-01T00:00:00Z'); + + beforeAll(async () => { + const db = newDb(); + // TypeORM runs SELECT version() / current_database() on connect; pg-mem does not ship them + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + + dataSource = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [PayoutOrderTable], + synchronize: true, + })) as DataSource; + await dataSource.initialize(); + repo = dataSource.getRepository(PayoutOrderTable); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + beforeEach(async () => { + await repo.clear(); + + // the constructor takes six collaborators this query needs none of; building the instance off the + // prototype keeps the test from breaking every time an unrelated dependency is added + service = Object.create(PayoutService.prototype) as PayoutService; + (service as any).payoutOrderRepo = repo; + }); + + function feeOf(result: { context: PayoutOrderContext; fee: number }[], context: PayoutOrderContext): number { + return result.find((r) => r.context === context)?.fee ?? 0; + } + + it('groups the fees by context and adds up both fee columns', async () => { + await repo.save([ + { + id: 1, + created: new Date('2026-07-05'), + context: PayoutOrderContext.REF_PAYOUT, + preparationFeeAmountChf: 1, + payoutFeeAmountChf: 2, + }, + { + id: 2, + created: new Date('2026-07-06'), + context: PayoutOrderContext.REF_PAYOUT, + preparationFeeAmountChf: 0.5, + payoutFeeAmountChf: 0.5, + }, + { + id: 3, + created: new Date('2026-07-07'), + context: PayoutOrderContext.BUY_CRYPTO, + preparationFeeAmountChf: 10, + payoutFeeAmountChf: 5, + }, + ]); + + const result = await service.getPayoutOrderFee(from); + + expect(feeOf(result, PayoutOrderContext.REF_PAYOUT)).toBe(4); + expect(feeOf(result, PayoutOrderContext.BUY_CRYPTO)).toBe(15); + expect(result).toHaveLength(2); + }); + + // the previous JS getter relied on `null + x === x`; COALESCE must reproduce that exactly, not + // turn the row into NULL (which would swallow the other, present fee) — 2 such rows exist in prod + it('counts a row with only one fee column set, instead of dropping it', async () => { + await repo.save([ + { + id: 1, + created: new Date('2026-07-05'), + context: PayoutOrderContext.BUY_CRYPTO, + preparationFeeAmountChf: 7, + payoutFeeAmountChf: null, + }, + { + id: 2, + created: new Date('2026-07-06'), + context: PayoutOrderContext.BUY_CRYPTO, + preparationFeeAmountChf: null, + payoutFeeAmountChf: 3, + }, + { + id: 3, + created: new Date('2026-07-07'), + context: PayoutOrderContext.BUY_CRYPTO, + preparationFeeAmountChf: null, + payoutFeeAmountChf: null, + }, + ]); + + const result = await service.getPayoutOrderFee(from); + + expect(feeOf(result, PayoutOrderContext.BUY_CRYPTO)).toBe(10); + }); + + it('excludes orders created before the given date', async () => { + await repo.save([ + { + id: 1, + created: new Date('2026-06-30'), + context: PayoutOrderContext.BUY_CRYPTO, + preparationFeeAmountChf: 99, + payoutFeeAmountChf: 99, + }, + { + id: 2, + created: new Date('2026-07-02'), + context: PayoutOrderContext.BUY_CRYPTO, + preparationFeeAmountChf: 1, + payoutFeeAmountChf: 1, + }, + ]); + + const result = await service.getPayoutOrderFee(from); + + expect(feeOf(result, PayoutOrderContext.BUY_CRYPTO)).toBe(2); + }); + + it('returns an empty list when nothing was paid out in the period', async () => { + const result = await service.getPayoutOrderFee(from); + + expect(result).toEqual([]); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts index d1a27439f5..8be6636ff6 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts @@ -4,7 +4,7 @@ import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/_ import * as processServiceModule from 'src/shared/services/process.service'; import { Util } from 'src/shared/utils/util'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; -import { In, LessThan, MoreThan } from 'typeorm'; +import { In, LessThan } from 'typeorm'; import { RetryPayoutDto } from '../../dto/retry-payout.dto'; import { createCustomPayoutOrder } from '../../entities/__mocks__/payout-order.entity.mock'; import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../entities/payout-order.entity'; @@ -525,48 +525,8 @@ describe('PayoutService', () => { }); }); - describe('#getPayoutOrders(...)', () => { - let service: PayoutService; - let payoutOrderRepo: PayoutOrderRepository; - - beforeEach(() => { - payoutOrderRepo = mock(); - - service = new PayoutService( - mock(), - mock(), - payoutOrderRepo, - mock(), - mock(), - mock(), - ); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('queries orders created after the given date and forwards the requested relations', async () => { - const from = new Date('2026-01-01'); - const relations = { asset: true }; - const orders = [createCustomPayoutOrder({ id: 100 })]; - const findSpy = jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue(orders); - - const result = await service.getPayoutOrders(from, relations); - - expect(findSpy).toHaveBeenCalledWith({ where: { created: MoreThan(from) }, relations }); - expect(result).toBe(orders); - }); - - it('queries orders without relations when none are provided', async () => { - const from = new Date('2026-01-01'); - const findSpy = jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue([]); - - await service.getPayoutOrders(from); - - expect(findSpy).toHaveBeenCalledWith({ where: { created: MoreThan(from) }, relations: undefined }); - }); - }); + // getPayoutOrderFee replaced getPayoutOrders here; its SQL aggregate is covered against real + // Postgres semantics in payout.service.pg.spec.ts, which a mocked repository cannot verify. describe('#doPayout(...)', () => { let service: PayoutService; diff --git a/src/subdomains/supporting/payout/services/payout.service.ts b/src/subdomains/supporting/payout/services/payout.service.ts index c078f8ef3a..5fe274f4d1 100644 --- a/src/subdomains/supporting/payout/services/payout.service.ts +++ b/src/subdomains/supporting/payout/services/payout.service.ts @@ -7,7 +7,7 @@ import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; -import { FindOptionsRelations, In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; +import { In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { MailRequest } from '../../notification/interfaces'; import { RetryPayoutDto } from '../dto/retry-payout.dto'; import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../entities/payout-order.entity'; @@ -33,8 +33,20 @@ export class PayoutService { //*** PUBLIC API ***// - async getPayoutOrders(from: Date, relations?: FindOptionsRelations): Promise { - return this.payoutOrderRepo.find({ where: { created: MoreThan(from) }, relations }); + // Grouped in SQL rather than loading every order of the period — the caller (FinanceLog job) runs + // every minute and only needs the totals per context. The two COALESCEs mirror the feeAmountChf + // getter, where a NULL fee column contributes 0 to the sum (JS `null + x === x`). + async getPayoutOrderFee(from: Date): Promise<{ context: PayoutOrderContext; fee: number }[]> { + return this.payoutOrderRepo + .createQueryBuilder('payoutOrder') + .select('payoutOrder.context', 'context') + .addSelect( + 'COALESCE(SUM(COALESCE(payoutOrder.preparationFeeAmountChf, 0) + COALESCE(payoutOrder.payoutFeeAmountChf, 0)), 0)', + 'fee', + ) + .where('payoutOrder.created > :from', { from }) + .groupBy('payoutOrder.context') + .getRawMany<{ context: PayoutOrderContext; fee: number }>(); } async doPayout(request: PayoutRequest): Promise {