This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,368 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment';
import { PaymentMethod } from '../types/PaymentMethod';
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
import { InvoicePaymentService } from './InvoicePaymentService';
const minIncomingAtomic = '100000000';
const buildTransfer = (
overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
): MoneroWalletRpcIncomingTransfer => ({
txHash: 'tx-hash-1',
amountAtomic: '200000000',
confirmations: 1,
subaddrIndex: 3,
...overrides
});
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
id: 'invoice-1',
paymentMethod: PaymentMethod.Xmr,
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 },
payments: [],
...overrides
}) as Invoice;
describe('InvoicePaymentService', () => {
let service: InvoicePaymentServiceTest;
let invoiceRepo: {
createQueryBuilder: jest.Mock;
};
let pollQueryBuilder: {
innerJoinAndSelect: jest.Mock;
where: jest.Mock;
andWhere: jest.Mock;
getMany: jest.Mock;
};
let walletRpcClient: {
getIncomingTransfers: jest.Mock;
};
let configService: {
get: jest.Mock;
};
let dataSource: {
transaction: jest.Mock;
};
let transactionalInvoiceRepo: {
createQueryBuilder: jest.Mock;
};
let transactionalInvoiceQueryBuilder: {
leftJoinAndSelect: jest.Mock;
where: jest.Mock;
setLock: jest.Mock;
getOne: jest.Mock;
};
let paymentRepo: {
update: jest.Mock;
createQueryBuilder: jest.Mock;
};
let insertQueryBuilder: {
insert: jest.Mock;
values: jest.Mock;
orIgnore: jest.Mock;
execute: jest.Mock;
};
let manager: EntityManager;
let processInvoiceSpy: jest.SpyInstance;
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
beforeEach(() => {
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
pollQueryBuilder = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([])
};
invoiceRepo = {
createQueryBuilder: jest.fn().mockReturnValue(pollQueryBuilder)
};
transactionalInvoiceQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
setLock: jest.fn().mockReturnThis(),
getOne: jest.fn()
};
transactionalInvoiceRepo = {
createQueryBuilder: jest.fn().mockReturnValue(transactionalInvoiceQueryBuilder)
};
insertQueryBuilder = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
orIgnore: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined)
};
paymentRepo = {
update: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
};
manager = {
getRepository: jest.fn((entity: { name: string }) => {
if (entity.name === Invoice.name) {
return transactionalInvoiceRepo;
}
if (entity.name === InvoicePayment.name) {
return paymentRepo;
}
throw new Error(`Unexpected repository: ${entity.name}`);
})
} as unknown as EntityManager;
dataSource = {
transaction: jest.fn(async (callback: (entityManager: EntityManager) => Promise<void>) =>
callback(manager)
)
};
walletRpcClient = {
getIncomingTransfers: jest.fn().mockResolvedValue([])
};
configService = {
get: jest.fn().mockReturnValue({
minByMethod: {
[PaymentMethod.Xmr]: minIncomingAtomic
}
})
};
service = new InvoicePaymentService(
invoiceRepo as unknown as Repository<Invoice>,
dataSource as unknown as DataSource,
walletRpcClient as unknown as MoneroWalletRpcClient,
configService as unknown as ConfigService
) as unknown as InvoicePaymentServiceTest;
processInvoiceSpy = jest.spyOn(service, 'processInvoice').mockResolvedValue(undefined);
});
afterEach(() => {
processInvoiceSpy.mockRestore();
errorLogSpy.mockRestore();
});
describe('pollInvoices', () => {
it('returns early when there are no open invoices', async () => {
pollQueryBuilder.getMany.mockResolvedValue([]);
await service.pollInvoices();
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('returns early when incoming transfers cannot be fetched', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]);
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
await service.pollInvoices();
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('routes transfers to each invoice by subaddress index', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({
id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}),
buildInvoice({
id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
})
]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
]);
await service.pollInvoices();
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 })
]);
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 })
]);
});
it('continues processing other invoices when one invoice fails', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ id: 'invoice-1' }),
buildInvoice({ id: 'invoice-2' })
]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
processInvoiceSpy.mockRestore();
processInvoiceSpy = jest
.spyOn(service, 'processInvoice')
.mockRejectedValueOnce(new Error('invoice-1 failed'))
.mockResolvedValueOnce(undefined);
await service.pollInvoices();
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({
id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}),
buildInvoice({
id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
})
]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
await service.pollInvoices();
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
});
describe('processInvoice', () => {
beforeEach(() => {
processInvoiceSpy.mockRestore();
});
it('does nothing when the invoice is missing inside the transaction', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
await service.processInvoice('invoice-1', [buildTransfer()]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('skips transfers below the configured minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
await service.processInvoice('invoice-1', [
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' })
]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
await service.processInvoice('invoice-1', [
buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 })
]);
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' },
txHash: 'min-tx',
amountAtomic: minIncomingAtomic,
confirmations: 1
});
});
it('processes a mixed batch of dust, new, and existing transfers', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({
payments: [
{
id: 'payment-1',
txHash: 'known-tx',
amountAtomic: '200000000',
confirmations: 1
}
] as InvoicePayment[]
})
);
await service.processInvoice('invoice-1', [
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }),
buildTransfer({ txHash: 'known-tx', confirmations: 4 }),
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' },
txHash: 'new-tx',
amountAtomic: '300000000',
confirmations: 2
});
expect(insertQueryBuilder.execute).toHaveBeenCalledTimes(1);
});
it('inserts a new payment for transfers at or above the minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
await service.processInvoice('invoice-1', [transfer]);
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' },
txHash: 'new-tx',
amountAtomic: '200000000',
confirmations: 2
});
expect(insertQueryBuilder.orIgnore).toHaveBeenCalled();
expect(insertQueryBuilder.execute).toHaveBeenCalled();
});
it('updates confirmations for an existing payment when they change', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({
payments: [
{
id: 'payment-1',
txHash: 'known-tx',
amountAtomic: '200000000',
confirmations: 1
}
] as InvoicePayment[]
})
);
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('does not update an existing payment when confirmations are unchanged', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({
payments: [
{
id: 'payment-1',
txHash: 'known-tx',
amountAtomic: '200000000',
confirmations: 3
}
] as InvoicePayment[]
})
);
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]);
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});
});