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:
@@ -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 { 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')
|
||||
|
||||
@@ -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<StorefrontInvoiceView> => {
|
||||
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<StorefrontInvoiceView> => {
|
||||
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<StorefrontInvoiceView> => {
|
||||
const invoiceState = deriveInvoiceState(invoice);
|
||||
|
||||
const {
|
||||
@@ -42,22 +72,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
|
||||
hasPendingConfirmations
|
||||
} = invoiceState;
|
||||
|
||||
const cryptoCurrency = 'XMR';
|
||||
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
|
||||
const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
|
||||
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
|
||||
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic);
|
||||
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertAtomicToCrypto(receivedAtomic);
|
||||
|
||||
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
|
||||
|
||||
const payments = [...(invoice.payments ?? [])]
|
||||
.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 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<StorefrontInvoiceView
|
||||
|
||||
if (showPaymentCapture) {
|
||||
if (isAwaitingPayment) {
|
||||
remainingTotalCrypto = expectedTotalCrypto;
|
||||
instructionAmountCrypto = expectedTotalCrypto;
|
||||
instructionPrefix = 'Send exactly';
|
||||
instructionSuffix = 'to the address below.';
|
||||
} else {
|
||||
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
|
||||
|
||||
remainingTotalCrypto = convertXmrAtomicToXmr(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
|
||||
instructionAmountCrypto = remainingTotalCrypto;
|
||||
instructionAmountCrypto = convertAtomicToCrypto(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
|
||||
instructionPrefix = 'Send';
|
||||
instructionSuffix = 'more to the same address below.';
|
||||
}
|
||||
|
||||
qrCodeUrl = await generateQrCodeDataUrl(
|
||||
`monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}`
|
||||
);
|
||||
const qrCodePayload = buildQrCodePayload(invoice.paymentAddress, instructionAmountCrypto);
|
||||
|
||||
qrCodeUrl = await generateQrCodeDataUrl(qrCodePayload);
|
||||
}
|
||||
|
||||
const statusMessage = resolveInvoiceStatusMessage(invoiceState);
|
||||
@@ -88,7 +114,7 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
|
||||
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
|
||||
|
||||
return {
|
||||
cryptoCurrency,
|
||||
cryptoCurrency: paymentLabel,
|
||||
expectedTotalCrypto,
|
||||
receivedTotalCrypto,
|
||||
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;
|
||||
|
||||
return {
|
||||
txHash: payment.txHash,
|
||||
amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic),
|
||||
amountCrypto: convertAtomicToCrypto(payment.amountAtomic),
|
||||
confirmationStatus: formatInvoicePaymentConfirmationStatus({
|
||||
confirmations: payment.confirmations,
|
||||
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'>>;
|
||||
Reference in New Issue
Block a user