From cf92212226cf7b8ffea130fbb7d442b7939639d9 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 6 Sep 2026 00:41:57 +0200 Subject: [PATCH] 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 --- backend/src/types/CryptoAtomicConverter.ts | 3 + .../invoice/toStorefrontInvoiceView.spec.ts | 53 ++++++++++++-- .../utils/invoice/toStorefrontInvoiceView.ts | 70 +++++++++++++------ .../types/ToStorefrontInvoiceViewTestTypes.ts | 8 +++ 4 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 backend/src/types/CryptoAtomicConverter.ts create mode 100644 backend/src/utils/invoice/types/ToStorefrontInvoiceViewTestTypes.ts diff --git a/backend/src/types/CryptoAtomicConverter.ts b/backend/src/types/CryptoAtomicConverter.ts new file mode 100644 index 0000000..ac639a3 --- /dev/null +++ b/backend/src/types/CryptoAtomicConverter.ts @@ -0,0 +1,3 @@ +import type Decimal from 'decimal.js'; + +export type CryptoAtomicConverter = (amountAtomic: string, rounding?: Decimal.Rounding) => string; diff --git a/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts b/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts index 5690112..134ef39 100644 --- a/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts +++ b/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts @@ -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 => @@ -21,10 +25,6 @@ const buildPayment = (overrides: Partial = {}): InvoicePayment = ...overrides }) as InvoicePayment; -type MoneroDetailsOverrides = Partial< - Pick ->; - 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 => ({ 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') diff --git a/backend/src/utils/invoice/toStorefrontInvoiceView.ts b/backend/src/utils/invoice/toStorefrontInvoiceView.ts index 4a8c3df..b0ed335 100644 --- a/backend/src/utils/invoice/toStorefrontInvoiceView.ts +++ b/backend/src/utils/invoice/toStorefrontInvoiceView.ts @@ -2,13 +2,16 @@ import Decimal from 'decimal.js'; import { InternalServerErrorException } from '@nestjs/common'; import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView'; import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView'; +import type { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter'; import type { Invoice } from '../../modules/payment/entities/Invoice'; import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; import dayjs from '../../plugins/dayjs'; import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl'; +import { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc'; import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr'; import { subtractAtomic } from '../atomic/subtractAtomic'; +import { paymentMethodLabel } from '../../consts/paymentMethodLabel'; import { deriveInvoiceState } from './deriveInvoiceState'; import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus'; import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage'; @@ -19,17 +22,44 @@ import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic export const toStorefrontInvoiceView = async (invoice: Invoice): Promise => { switch (invoice.paymentMethod) { 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: throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`); } }; -const toXmrInvoiceView = async (invoice: Invoice): Promise => { - if (!invoice.moneroDetails) { - throw new InternalServerErrorException('Invoice is missing Monero payment details'); - } - +const buildStorefrontInvoiceView = async ({ + invoice, + paymentLabel, + convertAtomicToCrypto, + buildQrCodePayload +}: { + invoice: Invoice; + paymentLabel: string; + convertAtomicToCrypto: CryptoAtomicConverter; + buildQrCodePayload: (paymentAddress: string, amountCrypto: string) => string; +}): Promise => { const invoiceState = deriveInvoiceState(invoice); const { @@ -42,22 +72,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise left.createdAt.getTime() - right.createdAt.getTime()) - .map(payment => toPaymentView(payment, requiredConfirmations)); + .map(payment => toPaymentView(payment, requiredConfirmations, convertAtomicToCrypto)); const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired; const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null; - let remainingTotalCrypto: string | null = null; let instructionPrefix: string | null = null; let instructionAmountCrypto: string | null = null; let instructionSuffix: string | null = null; @@ -65,22 +93,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise { +const toPaymentView = ( + payment: InvoicePayment, + requiredConfirmations: number, + convertAtomicToCrypto: CryptoAtomicConverter +): StorefrontInvoicePaymentView => { const isConfirmed = payment.confirmations >= requiredConfirmations; return { txHash: payment.txHash, - amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic), + amountCrypto: convertAtomicToCrypto(payment.amountAtomic), confirmationStatus: formatInvoicePaymentConfirmationStatus({ confirmations: payment.confirmations, requiredConfirmations, diff --git a/backend/src/utils/invoice/types/ToStorefrontInvoiceViewTestTypes.ts b/backend/src/utils/invoice/types/ToStorefrontInvoiceViewTestTypes.ts new file mode 100644 index 0000000..b034e8e --- /dev/null +++ b/backend/src/utils/invoice/types/ToStorefrontInvoiceViewTestTypes.ts @@ -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 +>; + +export type BtcDetailsOverrides = Partial>;