Disallow minConfirmations: 0 for BTC and XMR tiers at startup validation, update env examples and deploy docs, and drop tx-detected UI copy and legacy tests.
691 lines
26 KiB
TypeScript
691 lines
26 KiB
TypeScript
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 =>
|
|
({
|
|
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> = {}): 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 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();
|
|
});
|
|
});
|
|
});
|