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 => ({ txHash: 'default-tx-hash', amountAtomic: oneXmrAtomic, confirmations: 0, createdAt: new Date('2026-01-01T12:00:00.000Z'), ...overrides }) as InvoicePayment; const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails => ({ paymentAddressIndex: 1, fiatPerXmrAtCreation: 150, requiredConfirmations: 1, ...overrides }) as InvoiceMoneroDetails; const buildBtcDetails = (overrides: BtcDetailsOverrides = {}): InvoiceBtcDetails => ({ fiatPerBtcAtCreation: 60_000, requiredConfirmations: 1, ...overrides }) as InvoiceBtcDetails; const buildInvoice = (overrides: Partial = {}): Invoice => ({ reason: InvoiceReason.Checkout, paymentMethod: PaymentMethod.Xmr, amountFiat: 42, fiatCurrency: 'USD', expiresAt: new Date('2099-06-15T12:05:30.000Z'), paymentAddress, expectedTotalAtomic: oneXmrAtomic, payments: [], moneroDetails: buildMoneroDetails(), ...overrides }) as Invoice; describe('toStorefrontInvoiceView', () => { let generateQrCodeDataUrlSpy: jest.SpiedFunction; let formatRelativeTimeAgoSpy: jest.SpiedFunction; beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); generateQrCodeDataUrlSpy = jest .spyOn(generateQrCodeDataUrlModule, 'generateQrCodeDataUrl') .mockResolvedValue('data:image/png;base64,qr'); formatRelativeTimeAgoSpy = jest .spyOn(formatRelativeTimeAgoModule, 'formatRelativeTimeAgo') .mockReturnValue('2 hours ago'); }); afterEach(() => { generateQrCodeDataUrlSpy.mockRestore(); formatRelativeTimeAgoSpy.mockRestore(); jest.useRealTimers(); }); describe('errors', () => { it('throws when monero details are missing', async () => { const invoice = buildInvoice({ moneroDetails: null }); await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException); }); 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('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'); }); }); describe('awaiting payment', () => { it('maps core invoice fields and awaiting-payment visibility', async () => { const invoice = buildInvoice(); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ paymentLabel: 'XMR', expectedTotalCrypto: '1.00000000', receivedTotalCrypto: null, paymentAddress, qrCodeUrl: 'data:image/png;base64,qr', instructionPrefix: 'Send exactly', instructionAmountCrypto: '1.00000000', instructionSuffix: 'to the address below.', payments: [], isPaidSufficient: false, showStatusMessage: true, statusMessage: 'Awaiting payment', statusVariant: 'awaiting-payment', showProminentAmount: true, showExpectedTotal: false, showReceivedTotal: false, showInstruction: true, showExpiry: true, showQr: true, showAddress: true, showPayments: false, showRefresh: true }); }); it('builds a qr code for the full expected amount', async () => { const invoice = buildInvoice(); await toStorefrontInvoiceView(invoice); 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({ paymentLabel: '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') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBe('45 seconds'); }); it('formats expiry as minutes only when seconds are zero', async () => { const invoice = buildInvoice({ expiresAt: new Date('2026-06-01T12:05:00.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBe('5 minutes'); }); it('formats expiry with minutes and seconds', async () => { const invoice = buildInvoice({ expiresAt: new Date('2026-06-01T12:05:30.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBe('5 minutes and 30 seconds'); }); it('uses singular minute and second labels', async () => { const invoice = buildInvoice({ expiresAt: new Date('2026-06-01T12:01:01.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBe('1 minute and 1 second'); }); it('clamps expired invoices to zero remaining seconds in the expiry label', async () => { const invoice = buildInvoice({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBeNull(); expect(view.showRefresh).toBe(false); }); it('formats long expiry durations using days and hours instead of thousands of minutes', async () => { const invoice = buildInvoice({ expiresAt: new Date('2026-06-03T11:52:12.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBe('1 day and 23 hours'); }); it('formats sub-day expiry as hours and minutes', async () => { const invoice = buildInvoice({ expiresAt: new Date('2026-06-02T11:52:00.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view.expiresInDuration).toBe('23 hours and 52 minutes'); }); }); describe('expired', () => { it('maps expired awaiting-payment invoices without misleading payment capture UI', async () => { const invoice = buildInvoice({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ qrCodeUrl: null, instructionPrefix: null, instructionAmountCrypto: null, instructionSuffix: null, expiresInDuration: null, statusMessage: 'Payment expired', statusVariant: 'expired', showProminentAmount: false, showExpectedTotal: true, showReceivedTotal: false, showInstruction: false, showExpiry: false, showQr: false, showAddress: false, showPayments: false, showRefresh: false }); expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); }); it('still refreshes expired underpaid invoices while confirmations are pending', async () => { const payment = buildPayment({ txHash: 'partial-expired', amountAtomic: '100000000000', confirmations: 0 }); const invoice = buildInvoice({ expiresAt: new Date('2020-01-01T00:00:00.000Z'), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ statusMessage: 'Payment expired', statusVariant: 'expired', receivedTotalCrypto: '0.10000000', qrCodeUrl: null, instructionPrefix: null, showExpectedTotal: true, showReceivedTotal: true, showInstruction: false, showQr: false, showAddress: false, showPayments: true, showRefresh: true }); expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); }); it('stops refresh on expired underpaid invoices once partial txs are confirmed', async () => { const payment = buildPayment({ txHash: 'partial-expired-confirmed', amountAtomic: '100000000000', confirmations: 10 }); const invoice = buildInvoice({ expiresAt: new Date('2020-01-01T00:00:00.000Z'), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ statusMessage: 'Payment expired', showPayments: true, showRefresh: false }); }); it('keeps awaiting-confirmations status when an expired invoice is already paid sufficient', async () => { const payment = buildPayment({ txHash: 'paid-expired-unconfirmed', confirmations: 0 }); const invoice = buildInvoice({ expiresAt: new Date('2020-01-01T00:00:00.000Z'), moneroDetails: buildMoneroDetails({ requiredConfirmations: 3 }), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ statusMessage: 'Awaiting confirmations', showRefresh: true, showInstruction: false, showQr: false }); }); }); describe('underpaid', () => { it('maps partial payment totals, instruction, and visibility', async () => { const payment = buildPayment({ txHash: 'partial-tx', amountAtomic: '100000000000', confirmations: 10 }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ expectedTotalCrypto: '1.00000000', receivedTotalCrypto: '0.10000000', instructionPrefix: 'Send', instructionAmountCrypto: '0.90000000', instructionSuffix: 'more to the same address below.', isPaidSufficient: false, showProminentAmount: false, showReceivedTotal: true, showInstruction: true, showQr: true, showAddress: true, showPayments: true, showRefresh: true, statusMessage: 'Partial payment received', statusVariant: 'underpaid' }); }); it('rounds the remaining amount up when converting atomic to display units', async () => { const payment = buildPayment({ amountAtomic: '999999999999' }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.instructionAmountCrypto).toBe('0.00000001'); expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=0.00000001`); }); it('builds a qr code for the remaining amount', async () => { const payment = buildPayment({ amountAtomic: '250000000000' }); const invoice = buildInvoice({ payments: [payment] }); await toStorefrontInvoiceView(invoice); expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=0.75000000`); }); it('hides the payments list when there are no payments yet', async () => { const invoice = buildInvoice(); const view = await toStorefrontInvoiceView(invoice); expect(view.showPayments).toBe(false); expect(view.payments).toEqual([]); }); }); describe('paid awaiting confirmations', () => { it('maps paid-awaiting state and hides payment capture UI', async () => { const payment = buildPayment({ txHash: 'paid-unconfirmed', confirmations: 0 }); const invoice = buildInvoice({ moneroDetails: buildMoneroDetails({ requiredConfirmations: 3 }), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ receivedTotalCrypto: '1.00000000', qrCodeUrl: null, expiresInDuration: null, instructionPrefix: null, instructionAmountCrypto: null, instructionSuffix: null, isPaidSufficient: true, showProminentAmount: false, showExpectedTotal: true, showReceivedTotal: true, showInstruction: false, showExpiry: false, showQr: false, showAddress: false, showPayments: true, showRefresh: true, statusMessage: 'Awaiting confirmations', statusVariant: 'awaiting-confirmations' }); expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); }); }); describe('paid and confirmed', () => { it('maps confirmed state with collapsed payment capture UI', async () => { const payment = buildPayment({ txHash: 'confirmed-tx', confirmations: 5 }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view).toMatchObject({ receivedTotalCrypto: '1.00000000', qrCodeUrl: null, expiresInDuration: null, instructionPrefix: null, instructionAmountCrypto: null, instructionSuffix: null, isPaidSufficient: true, showProminentAmount: false, showExpectedTotal: true, showReceivedTotal: true, showInstruction: false, showExpiry: false, showQr: false, showAddress: false, showPayments: false, showRefresh: false, statusMessage: 'Payment confirmed', statusVariant: 'confirmed' }); }); }); describe('overpayment', () => { it('treats overpayment as paid sufficient without a qr code', async () => { const payment = buildPayment({ amountAtomic: '2000000000000', confirmations: 10 }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.isPaidSufficient).toBe(true); expect(view.receivedTotalCrypto).toBe('2.00000000'); expect(view.qrCodeUrl).toBeNull(); expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); }); }); describe('payment mapping', () => { it('maps tx hash and converts atomic amount to display crypto', async () => { const payment = buildPayment({ txHash: 'abc123hash', amountAtomic: '1500000000000', confirmations: 0 }); const invoice = buildInvoice({ moneroDetails: buildMoneroDetails({ requiredConfirmations: 2 }), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments).toEqual([ { txHash: 'abc123hash', amountCrypto: '1.50000000', confirmationStatus: '0 / 2 confirmations · detected 2 hours ago', confirmationStatusVariant: 'confirming' } ]); expect(formatRelativeTimeAgoSpy).toHaveBeenCalledWith(payment.createdAt); }); it('sorts payments by detection time ascending', async () => { const laterPayment = buildPayment({ txHash: 'later', createdAt: new Date('2026-01-02T12:00:00.000Z') }); const earlierPayment = buildPayment({ txHash: 'earlier', createdAt: new Date('2026-01-01T12:00:00.000Z') }); const invoice = buildInvoice({ payments: [laterPayment, earlierPayment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments.map(payment => payment.txHash)).toEqual(['earlier', 'later']); }); it('marks a payment confirmed when confirmations meet the requirement', async () => { const payment = buildPayment({ confirmations: 1 }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments[0].confirmationStatus).toBe('Confirmed'); expect(view.payments[0].confirmationStatusVariant).toBe('confirmed'); expect(formatRelativeTimeAgoSpy).not.toHaveBeenCalled(); }); it('marks a payment confirmed when confirmations exceed the requirement', async () => { const payment = buildPayment({ confirmations: 99 }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments[0].confirmationStatus).toBe('Confirmed'); expect(view.payments[0].confirmationStatusVariant).toBe('confirmed'); }); it('shows partial confirmation progress when below the requirement', async () => { const payment = buildPayment({ confirmations: 2 }); const invoice = buildInvoice({ moneroDetails: buildMoneroDetails({ requiredConfirmations: 10 }), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments[0].confirmationStatus).toBe('2 / 10 confirmations · detected 2 hours ago'); expect(view.payments[0].confirmationStatusVariant).toBe('confirming'); }); it('shows zero confirmations against a non-zero requirement', async () => { const payment = buildPayment({ confirmations: 0 }); const invoice = buildInvoice({ moneroDetails: buildMoneroDetails({ requiredConfirmations: 6 }), payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments[0].confirmationStatus).toBe('0 / 6 confirmations · detected 2 hours ago'); expect(view.payments[0].confirmationStatusVariant).toBe('confirming'); }); it('maps each payment independently in a multi-payment invoice', async () => { const firstPayment = buildPayment({ txHash: 'first', amountAtomic: '400000000000', confirmations: 0, createdAt: new Date('2026-01-01T10:00:00.000Z') }); const secondPayment = buildPayment({ txHash: 'second', amountAtomic: '600000000000', confirmations: 1, createdAt: new Date('2026-01-01T11:00:00.000Z') }); const invoice = buildInvoice({ payments: [secondPayment, firstPayment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments).toEqual([ { txHash: 'first', amountCrypto: '0.40000000', confirmationStatus: '0 / 1 confirmations · detected 2 hours ago', confirmationStatusVariant: 'confirming' }, { txHash: 'second', amountCrypto: '0.60000000', confirmationStatus: 'Confirmed', confirmationStatusVariant: 'confirmed' } ]); expect(view.isPaidSufficient).toBe(true); expect(view.showRefresh).toBe(true); expect(view.showPayments).toBe(true); }); it('marks the invoice paid and confirmed only when every payment meets confirmations', async () => { const firstPayment = buildPayment({ txHash: 'first', amountAtomic: '400000000000', confirmations: 1, createdAt: new Date('2026-01-01T10:00:00.000Z') }); const secondPayment = buildPayment({ txHash: 'second', amountAtomic: '600000000000', confirmations: 3, createdAt: new Date('2026-01-01T11:00:00.000Z') }); const invoice = buildInvoice({ payments: [firstPayment, secondPayment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments.every(payment => payment.confirmationStatus === 'Confirmed')).toBe(true); expect(view.showRefresh).toBe(false); expect(view.showPayments).toBe(false); }); it('still shows confirmed status on underpaid partial txs', async () => { const payment = buildPayment({ amountAtomic: '100000000000', confirmations: 10 }); const invoice = buildInvoice({ payments: [payment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.statusMessage).toBe('Partial payment received'); expect(view.payments[0].confirmationStatus).toBe('Confirmed'); expect(view.showPayments).toBe(true); }); it('sums multiple underpaid payments before computing remaining amount', async () => { const firstPayment = buildPayment({ txHash: 'first', amountAtomic: '100000000000' }); const secondPayment = buildPayment({ txHash: 'second', amountAtomic: '200000000000', createdAt: new Date('2026-01-02T12:00:00.000Z') }); const invoice = buildInvoice({ payments: [firstPayment, secondPayment] }); const view = await toStorefrontInvoiceView(invoice); expect(view.receivedTotalCrypto).toBe('0.30000000'); expect(view.instructionAmountCrypto).toBe('0.70000000'); expect(view.payments).toHaveLength(2); }); it('handles an empty payments array', async () => { const invoice = buildInvoice({ payments: [] }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments).toEqual([]); expect(view.receivedTotalCrypto).toBeNull(); }); it('handles a missing payments relation as empty', async () => { const invoice = buildInvoice({ payments: undefined }); const view = await toStorefrontInvoiceView(invoice); expect(view.payments).toEqual([]); expect(view.receivedTotalCrypto).toBeNull(); }); }); });