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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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<ExchangeTxTable>;
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([]);
});
});
15 changes: 13 additions & 2 deletions src/integration/exchange/services/exchange-tx.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,19 @@ export class ExchangeTxService implements OnModuleInit {
}
}

async getExchangeTx(from: Date, relations?: FindOptionsRelations<ExchangeTx>): Promise<ExchangeTx[]> {
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<ExchangeTx>): Promise<ExchangeTx> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BuyCryptoTable>;
let transactionRepo: Repository<TransactionTable>;
let cryptoInputRepo: Repository<CryptoInputTable>;
let paymentLinkPaymentRepo: Repository<PaymentLinkPaymentTable>;
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 });
});
});
Loading