add bitcoin support to storefront invoice view

Refactor invoice view building to share logic across XMR and BTC, including BIP21 QR codes and payment detail validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-06 00:41:57 +02:00
co-authored by Cursor
parent ced92ead37
commit cf92212226
4 changed files with 107 additions and 27 deletions
@@ -1,15 +1,19 @@
import { InternalServerErrorException } from '@nestjs/common';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
import { BTC_ATOMIC_PER_BTC } from '../../consts/btcAtomicPerBtc';
import type { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoiceBtcDetails } from '../../modules/payment/entities/InvoiceBtcDetails';
import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { InvoiceReason } from '../../modules/payment/types/InvoiceReason';
import type { BtcDetailsOverrides, MoneroDetailsOverrides } from './types/ToStorefrontInvoiceViewTestTypes';
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const oneBtcAtomic = BTC_ATOMIC_PER_BTC.toString();
const paymentAddress = '4StorefrontInvoiceViewTestAddress';
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
@@ -21,10 +25,6 @@ const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =
...overrides
}) as InvoicePayment;
type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
({
paymentAddressIndex: 1,
@@ -33,6 +33,13 @@ const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMone
...overrides
}) as InvoiceMoneroDetails;
const buildBtcDetails = (overrides: BtcDetailsOverrides = {}): InvoiceBtcDetails =>
({
fiatPerBtcAtCreation: 60_000,
requiredConfirmations: 1,
...overrides
}) as InvoiceBtcDetails;
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
reason: InvoiceReason.Checkout,
@@ -77,10 +84,21 @@ describe('toStorefrontInvoiceView', () => {
await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException);
});
it('throws for unsupported payment methods', async () => {
const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod });
it('throws when bitcoin details are missing', async () => {
const invoice = buildInvoice({
paymentMethod: PaymentMethod.Btc,
moneroDetails: null,
btcDetails: null,
expectedTotalAtomic: oneBtcAtomic
});
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: btc');
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Invoice is missing Bitcoin payment details');
});
it('throws for unsupported payment methods', async () => {
const invoice = buildInvoice({ paymentMethod: 'eth' as PaymentMethod, moneroDetails: null });
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: eth');
});
});
@@ -124,6 +142,27 @@ describe('toStorefrontInvoiceView', () => {
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`);
});
it('maps bitcoin invoices and builds a bip21 qr code', async () => {
const invoice = buildInvoice({
paymentMethod: PaymentMethod.Btc,
moneroDetails: null,
btcDetails: buildBtcDetails(),
expectedTotalAtomic: oneBtcAtomic,
paymentAddress: 'bc1qstorefronttest'
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
cryptoCurrency: 'BTC',
expectedTotalCrypto: '1.00000000',
paymentAddress: 'bc1qstorefronttest'
});
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(
'bitcoin:bc1qstorefronttest?amount=1.00000000'
);
});
it('formats expiry as seconds only when under one minute remains', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:00:45.000Z')