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 => ({ txHash: 'tx-hash-1', amountAtomic: '200000000', confirmations: 1, subaddrIndex: 3, ...overrides }); const buildInvoice = (overrides: Partial = {}): 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; 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) => callback(manager) ) }; walletRpcClient = { getIncomingTransfers: jest.fn().mockResolvedValue([]) }; configService = { get: jest.fn().mockReturnValue({ minByMethod: { [PaymentMethod.Xmr]: minIncomingAtomic } }) }; service = new InvoicePaymentService( invoiceRepo as unknown as Repository, 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(); }); }); });