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 {