init
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user