import { Logger, ServiceUnavailableException } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import type { Repository } from 'typeorm'; import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient'; import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { Invoice } from '../entities/Invoice'; import { InvoiceReason } from '../types/InvoiceReason'; import { PaymentMethod } from '../types/PaymentMethod'; import { InvoiceService } from './InvoiceService'; const confirmationTiers = [{ upToTotalFiat: 1000, minConfirmations: 1 }]; describe('InvoiceService', () => { let service: InvoiceService; let invoiceRepo: { create: jest.Mock; save: jest.Mock; }; let configService: { get: jest.Mock; }; let moneroWalletRpcClient: { createAddress: jest.Mock; }; let bitcoinWalletRpcClient: { createAddress: jest.Mock; }; let exchangeRateService: { getLiveFiatPerCrypto: jest.Mock; }; let errorLogSpy: jest.SpiedFunction; beforeEach(() => { errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); invoiceRepo = { create: jest.fn(data => ({ id: 'invoice-1', ...data })), save: jest.fn(async (invoice: Invoice) => invoice) }; configService = { get: jest.fn((key: string) => { if (key === 'shopSettings') { return { shopFiatCurrency: 'USD' }; } if (key === 'shopSettings.monero') { return { confirmationTiers }; } if (key === 'shopSettings.bitcoin') { return { confirmationTiers }; } if (key === 'order') { return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; } return undefined; }) }; moneroWalletRpcClient = { createAddress: jest.fn().mockResolvedValue({ address: '4MoneroPaymentAddressExample', address_index: 12 }) }; bitcoinWalletRpcClient = { createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress') }; exchangeRateService = { getLiveFiatPerCrypto: jest.fn().mockReturnValue(150) }; service = new InvoiceService( invoiceRepo as unknown as Repository, configService as unknown as ConfigService, moneroWalletRpcClient as unknown as MoneroWalletRpcClient, bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient, exchangeRateService as unknown as ExchangeRateService ); }); afterEach(() => { errorLogSpy.mockRestore(); }); const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) => service.issueInvoice({ paymentMethod, reason: InvoiceReason.Checkout, contextId: 'session-uuid', amountFiat: 15 }); it('throws when the live XMR rate is unavailable for checkout invoices', async () => { exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null); await expect(issueCheckoutInvoice()).rejects.toThrow( new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") ); expect(moneroWalletRpcClient.createAddress).not.toHaveBeenCalled(); }); it('throws and logs when wallet address allocation fails for checkout invoices', async () => { moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); await expect(issueCheckoutInvoice()).rejects.toThrow( new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") ); expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Monero payment address')); expect(invoiceRepo.save).not.toHaveBeenCalled(); }); it('creates a checkout invoice with converted totals and monero details', async () => { const invoice = await issueCheckoutInvoice(); expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); expect(invoiceRepo.create).toHaveBeenCalledWith( expect.objectContaining({ reason: InvoiceReason.Checkout, paymentMethod: PaymentMethod.Xmr, amountFiat: 15, fiatCurrency: 'USD', paymentAddress: '4MoneroPaymentAddressExample', expectedTotalAtomic: '100000000000', expiresAt: expect.any(Date), moneroDetails: { paymentAddressIndex: 12, fiatPerXmrAtCreation: 150, requiredConfirmations: 1 } }) ); expect(invoiceRepo.save).toHaveBeenCalled(); expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 })); }); it('uses a higher confirmation tier for larger checkout amounts', async () => { configService.get.mockImplementation((key: string) => { if (key === 'shopSettings') { return { shopFiatCurrency: 'USD' }; } if (key === 'shopSettings.monero') { return { confirmationTiers: [ { upToTotalFiat: 10, minConfirmations: 1 }, { upToTotalFiat: 100, minConfirmations: 5 }, { minConfirmations: 10 } ] }; } if (key === 'shopSettings.bitcoin') { return { confirmationTiers }; } if (key === 'order') { return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; } return undefined; }); await service.issueInvoice({ paymentMethod: PaymentMethod.Xmr, reason: InvoiceReason.Checkout, contextId: 'session-large', amountFiat: 75 }); expect(invoiceRepo.create).toHaveBeenCalledWith( expect.objectContaining({ moneroDetails: expect.objectContaining({ requiredConfirmations: 5 }) }) ); }); it('uses shipping-specific messages and address labels for shipping invoices', async () => { exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null); await expect( service.issueInvoice({ paymentMethod: PaymentMethod.Xmr, reason: InvoiceReason.Shipping, contextId: 'order-1', amountFiat: 5 }) ).rejects.toThrow( new ServiceUnavailableException( "We can't quote shipping in XMR right now. Please try again in a few minutes." ) ); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150); moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); await expect( service.issueInvoice({ paymentMethod: PaymentMethod.Xmr, reason: InvoiceReason.Shipping, contextId: 'order-1', amountFiat: 5 }) ).rejects.toThrow( new ServiceUnavailableException( "We can't take shipping payments right now. Please try again in a few minutes." ) ); moneroWalletRpcClient.createAddress.mockResolvedValue({ address: '4ShippingPaymentAddressExample', address_index: 3 }); await service.issueInvoice({ paymentMethod: PaymentMethod.Xmr, reason: InvoiceReason.Shipping, contextId: 'order-1', amountFiat: 5 }); expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1'); }); it('throws when the live BTC rate is unavailable for checkout invoices', async () => { exchangeRateService.getLiveFiatPerCrypto.mockImplementation( (method: PaymentMethod) => (method === PaymentMethod.Btc ? null : 150) ); await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow( new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") ); expect(bitcoinWalletRpcClient.createAddress).not.toHaveBeenCalled(); }); it('throws and logs when Bitcoin address allocation fails for checkout invoices', async () => { exchangeRateService.getLiveFiatPerCrypto.mockImplementation( (method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150) ); bitcoinWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow( new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") ); expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Bitcoin payment address')); expect(invoiceRepo.save).not.toHaveBeenCalled(); }); it('creates a checkout invoice with converted totals and bitcoin details', async () => { exchangeRateService.getLiveFiatPerCrypto.mockImplementation( (method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150) ); const invoice = await issueCheckoutInvoice(PaymentMethod.Btc); expect(bitcoinWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); expect(invoiceRepo.create).toHaveBeenCalledWith( expect.objectContaining({ reason: InvoiceReason.Checkout, paymentMethod: PaymentMethod.Btc, amountFiat: 15, fiatCurrency: 'USD', paymentAddress: 'bc1qtestpaymentaddress', expectedTotalAtomic: '25000', expiresAt: expect.any(Date), btcDetails: { fiatPerBtcAtCreation: 60_000, requiredConfirmations: 1 } }) ); expect(invoiceRepo.save).toHaveBeenCalled(); expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 })); }); it('uses BTC-specific shipping rate messages', async () => { exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null); await expect( service.issueInvoice({ paymentMethod: PaymentMethod.Btc, reason: InvoiceReason.Shipping, contextId: 'order-1', amountFiat: 5 }) ).rejects.toThrow( new ServiceUnavailableException( "We can't quote shipping in BTC right now. Please try again in a few minutes." ) ); }); });