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
@@ -0,0 +1,3 @@
import type Decimal from 'decimal.js';
export type CryptoAtomicConverter = (amountAtomic: string, rounding?: Decimal.Rounding) => string;
@@ -1,15 +1,19 @@
import { InternalServerErrorException } from '@nestjs/common'; import { InternalServerErrorException } from '@nestjs/common';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr'; 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 { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoiceBtcDetails } from '../../modules/payment/entities/InvoiceBtcDetails';
import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails'; import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { InvoiceReason } from '../../modules/payment/types/InvoiceReason'; import { InvoiceReason } from '../../modules/payment/types/InvoiceReason';
import type { BtcDetailsOverrides, MoneroDetailsOverrides } from './types/ToStorefrontInvoiceViewTestTypes';
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo'; import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl'; import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView'; import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString(); const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const oneBtcAtomic = BTC_ATOMIC_PER_BTC.toString();
const paymentAddress = '4StorefrontInvoiceViewTestAddress'; const paymentAddress = '4StorefrontInvoiceViewTestAddress';
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment => const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
@@ -21,10 +25,6 @@ const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =
...overrides ...overrides
}) as InvoicePayment; }) as InvoicePayment;
type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails => const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
({ ({
paymentAddressIndex: 1, paymentAddressIndex: 1,
@@ -33,6 +33,13 @@ const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMone
...overrides ...overrides
}) as InvoiceMoneroDetails; }) as InvoiceMoneroDetails;
const buildBtcDetails = (overrides: BtcDetailsOverrides = {}): InvoiceBtcDetails =>
({
fiatPerBtcAtCreation: 60_000,
requiredConfirmations: 1,
...overrides
}) as InvoiceBtcDetails;
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice => const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({ ({
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
@@ -77,10 +84,21 @@ describe('toStorefrontInvoiceView', () => {
await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException); await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException);
}); });
it('throws for unsupported payment methods', async () => { it('throws when bitcoin details are missing', async () => {
const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod }); 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`); 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 () => { it('formats expiry as seconds only when under one minute remains', async () => {
const invoice = buildInvoice({ const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:00:45.000Z') expiresAt: new Date('2026-06-01T12:00:45.000Z')
@@ -2,13 +2,16 @@ import Decimal from 'decimal.js';
import { InternalServerErrorException } from '@nestjs/common'; import { InternalServerErrorException } from '@nestjs/common';
import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView'; import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView';
import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView'; import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView';
import type { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter';
import type { Invoice } from '../../modules/payment/entities/Invoice'; import type { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import dayjs from '../../plugins/dayjs'; import dayjs from '../../plugins/dayjs';
import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl'; import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl';
import { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc';
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr'; import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
import { subtractAtomic } from '../atomic/subtractAtomic'; import { subtractAtomic } from '../atomic/subtractAtomic';
import { paymentMethodLabel } from '../../consts/paymentMethodLabel';
import { deriveInvoiceState } from './deriveInvoiceState'; import { deriveInvoiceState } from './deriveInvoiceState';
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus'; import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage'; import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
@@ -19,17 +22,44 @@ import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic
export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => { export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
switch (invoice.paymentMethod) { switch (invoice.paymentMethod) {
case PaymentMethod.Xmr: case PaymentMethod.Xmr:
return toXmrInvoiceView(invoice); if (!invoice.moneroDetails) {
throw new InternalServerErrorException('Invoice is missing Monero payment details');
}
return buildStorefrontInvoiceView({
invoice,
paymentLabel: paymentMethodLabel[PaymentMethod.Xmr],
convertAtomicToCrypto: convertXmrAtomicToXmr,
buildQrCodePayload: (paymentAddress, amountCrypto) =>
`monero:${paymentAddress}?tx_amount=${amountCrypto}`
});
case PaymentMethod.Btc:
if (!invoice.btcDetails) {
throw new InternalServerErrorException('Invoice is missing Bitcoin payment details');
}
return buildStorefrontInvoiceView({
invoice,
paymentLabel: paymentMethodLabel[PaymentMethod.Btc],
convertAtomicToCrypto: convertBtcAtomicToBtc,
buildQrCodePayload: (paymentAddress, amountCrypto) => `bitcoin:${paymentAddress}?amount=${amountCrypto}`
});
default: default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`); throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
} }
}; };
const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => { const buildStorefrontInvoiceView = async ({
if (!invoice.moneroDetails) { invoice,
throw new InternalServerErrorException('Invoice is missing Monero payment details'); paymentLabel,
} convertAtomicToCrypto,
buildQrCodePayload
}: {
invoice: Invoice;
paymentLabel: string;
convertAtomicToCrypto: CryptoAtomicConverter;
buildQrCodePayload: (paymentAddress: string, amountCrypto: string) => string;
}): Promise<StorefrontInvoiceView> => {
const invoiceState = deriveInvoiceState(invoice); const invoiceState = deriveInvoiceState(invoice);
const { const {
@@ -42,22 +72,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
hasPendingConfirmations hasPendingConfirmations
} = invoiceState; } = invoiceState;
const cryptoCurrency = 'XMR'; const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments); const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic); const receivedTotalCrypto = receivedAtomic === '0' ? null : convertAtomicToCrypto(receivedAtomic);
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
const payments = [...(invoice.payments ?? [])] const payments = [...(invoice.payments ?? [])]
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()) .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
.map(payment => toPaymentView(payment, requiredConfirmations)); .map(payment => toPaymentView(payment, requiredConfirmations, convertAtomicToCrypto));
const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired; const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired;
const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null; const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null;
let remainingTotalCrypto: string | null = null;
let instructionPrefix: string | null = null; let instructionPrefix: string | null = null;
let instructionAmountCrypto: string | null = null; let instructionAmountCrypto: string | null = null;
let instructionSuffix: string | null = null; let instructionSuffix: string | null = null;
@@ -65,22 +93,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
if (showPaymentCapture) { if (showPaymentCapture) {
if (isAwaitingPayment) { if (isAwaitingPayment) {
remainingTotalCrypto = expectedTotalCrypto;
instructionAmountCrypto = expectedTotalCrypto; instructionAmountCrypto = expectedTotalCrypto;
instructionPrefix = 'Send exactly'; instructionPrefix = 'Send exactly';
instructionSuffix = 'to the address below.'; instructionSuffix = 'to the address below.';
} else { } else {
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic); const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
remainingTotalCrypto = convertXmrAtomicToXmr(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL); instructionAmountCrypto = convertAtomicToCrypto(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
instructionAmountCrypto = remainingTotalCrypto;
instructionPrefix = 'Send'; instructionPrefix = 'Send';
instructionSuffix = 'more to the same address below.'; instructionSuffix = 'more to the same address below.';
} }
qrCodeUrl = await generateQrCodeDataUrl( const qrCodePayload = buildQrCodePayload(invoice.paymentAddress, instructionAmountCrypto);
`monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}`
); qrCodeUrl = await generateQrCodeDataUrl(qrCodePayload);
} }
const statusMessage = resolveInvoiceStatusMessage(invoiceState); const statusMessage = resolveInvoiceStatusMessage(invoiceState);
@@ -88,7 +114,7 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
const statusVariant = resolveInvoiceStatusVariant(invoiceState); const statusVariant = resolveInvoiceStatusVariant(invoiceState);
return { return {
cryptoCurrency, cryptoCurrency: paymentLabel,
expectedTotalCrypto, expectedTotalCrypto,
receivedTotalCrypto, receivedTotalCrypto,
paymentAddress: invoice.paymentAddress, paymentAddress: invoice.paymentAddress,
@@ -122,12 +148,16 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
}; };
}; };
const toPaymentView = (payment: InvoicePayment, requiredConfirmations: number): StorefrontInvoicePaymentView => { const toPaymentView = (
payment: InvoicePayment,
requiredConfirmations: number,
convertAtomicToCrypto: CryptoAtomicConverter
): StorefrontInvoicePaymentView => {
const isConfirmed = payment.confirmations >= requiredConfirmations; const isConfirmed = payment.confirmations >= requiredConfirmations;
return { return {
txHash: payment.txHash, txHash: payment.txHash,
amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic), amountCrypto: convertAtomicToCrypto(payment.amountAtomic),
confirmationStatus: formatInvoicePaymentConfirmationStatus({ confirmationStatus: formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations, confirmations: payment.confirmations,
requiredConfirmations, requiredConfirmations,
@@ -0,0 +1,8 @@
import type { InvoiceBtcDetails } from '../../../modules/payment/entities/InvoiceBtcDetails';
import type { InvoiceMoneroDetails } from '../../../modules/payment/entities/InvoiceMoneroDetails';
export type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
export type BtcDetailsOverrides = Partial<Pick<InvoiceBtcDetails, 'fiatPerBtcAtCreation' | 'requiredConfirmations'>>;