This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,664 @@
import { InternalServerErrorException } from '@nestjs/common';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
import type { Invoice } from '../../modules/payment/entities/Invoice';
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 * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const paymentAddress = '4StorefrontInvoiceViewTestAddress';
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
({
txHash: 'default-tx-hash',
amountAtomic: oneXmrAtomic,
confirmations: 0,
createdAt: new Date('2026-01-01T12:00:00.000Z'),
...overrides
}) as InvoicePayment;
type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
({
paymentAddressIndex: 1,
fiatPerXmrAtCreation: 150,
requiredConfirmations: 1,
...overrides
}) as InvoiceMoneroDetails;
const buildInvoice = (overrides: Partial<Invoice> = {}): 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<typeof generateQrCodeDataUrlModule.generateQrCodeDataUrl>;
let formatRelativeTimeAgoSpy: jest.SpiedFunction<typeof formatRelativeTimeAgoModule.formatRelativeTimeAgo>;
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 for unsupported payment methods', async () => {
const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod });
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: btc');
});
});
describe('awaiting payment', () => {
it('maps core invoice fields and awaiting-payment visibility', async () => {
const invoice = buildInvoice();
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
cryptoCurrency: '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('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'
});
});
it('treats tx-detected invoices as confirmed for payment status display', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.showRefresh).toBe(false);
expect(view.payments[0].confirmationStatus).toBe('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();
});
});
});