wire bitcoin invoice creation and payment polling

This commit is contained in:
2026-09-04 14:11:54 +02:00
parent cae5b4fe50
commit b4618cf3b1
7 changed files with 448 additions and 70 deletions
@@ -1,6 +1,7 @@
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';
@@ -19,7 +20,10 @@ describe('InvoiceService', () => {
let configService: {
get: jest.Mock;
};
let walletRpcClient: {
let moneroWalletRpcClient: {
createAddress: jest.Mock;
};
let bitcoinWalletRpcClient: {
createAddress: jest.Mock;
};
let exchangeRateService: {
@@ -45,6 +49,10 @@ describe('InvoiceService', () => {
return { confirmationTiers };
}
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
}
@@ -53,13 +61,17 @@ describe('InvoiceService', () => {
})
};
walletRpcClient = {
moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample',
address_index: 12
})
};
bitcoinWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
};
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
@@ -67,7 +79,8 @@ describe('InvoiceService', () => {
service = new InvoiceService(
invoiceRepo as unknown as Repository<Invoice>,
configService as unknown as ConfigService,
walletRpcClient as unknown as MoneroWalletRpcClient,
moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
exchangeRateService as unknown as ExchangeRateService
);
});
@@ -76,9 +89,9 @@ describe('InvoiceService', () => {
errorLogSpy.mockRestore();
});
const issueCheckoutInvoice = () =>
const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) =>
service.issueInvoice({
paymentMethod: PaymentMethod.Xmr,
paymentMethod,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 15
@@ -91,11 +104,11 @@ describe('InvoiceService', () => {
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
);
expect(walletRpcClient.createAddress).not.toHaveBeenCalled();
expect(moneroWalletRpcClient.createAddress).not.toHaveBeenCalled();
});
it('throws and logs when wallet address allocation fails for checkout invoices', async () => {
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
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.")
@@ -108,7 +121,7 @@ describe('InvoiceService', () => {
it('creates a checkout invoice with converted totals and monero details', async () => {
const invoice = await issueCheckoutInvoice();
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
reason: InvoiceReason.Checkout,
@@ -145,6 +158,10 @@ describe('InvoiceService', () => {
};
}
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
}
@@ -185,7 +202,7 @@ describe('InvoiceService', () => {
);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(
service.issueInvoice({
@@ -200,7 +217,7 @@ describe('InvoiceService', () => {
)
);
walletRpcClient.createAddress.mockResolvedValue({
moneroWalletRpcClient.createAddress.mockResolvedValue({
address: '4ShippingPaymentAddressExample',
address_index: 3
});
@@ -212,6 +229,76 @@ describe('InvoiceService', () => {
amountFiat: 5
});
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
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."
)
);
});
});