305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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<typeof Logger.prototype.error>;
|
|
|
|
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<Invoice>,
|
|
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."
|
|
)
|
|
);
|
|
});
|
|
});
|