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,206 @@
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 { 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 { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
import { subtractAtomic } from '../atomic/subtractAtomic';
import { deriveInvoiceState } from './deriveInvoiceState';
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
import { resolveInvoiceStatusVariant } from './resolveInvoiceStatusVariant';
import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations';
import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic';
export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
switch (invoice.paymentMethod) {
case PaymentMethod.Xmr:
return toXmrInvoiceView(invoice);
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 invoiceState = deriveInvoiceState(invoice);
const {
isAwaitingPayment,
isUnderpaid,
isPaidSufficient,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
isExpired,
hasPendingConfirmations
} = invoiceState;
const cryptoCurrency = 'XMR';
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic);
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
const payments = [...(invoice.payments ?? [])]
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
.map(payment => toPaymentView(payment, requiredConfirmations));
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;
let qrCodeUrl: string | null = null;
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;
instructionPrefix = 'Send';
instructionSuffix = 'more to the same address below.';
}
qrCodeUrl = await generateQrCodeDataUrl(
`monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}`
);
}
const statusMessage = resolveInvoiceStatusMessage(invoiceState);
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
return {
cryptoCurrency,
expectedTotalCrypto,
receivedTotalCrypto,
paymentAddress: invoice.paymentAddress,
qrCodeUrl,
instructionPrefix,
instructionAmountCrypto,
instructionSuffix,
expiresInDuration,
payments,
isPaidSufficient,
showStatusMessage: statusMessage !== null,
statusMessage,
statusVariant,
showProminentAmount: showPaymentCapture && isAwaitingPayment,
showExpectedTotal:
isPaidAwaitingConfirmations || isPaidAndConfirmed || (isExpired && (isAwaitingPayment || isUnderpaid)),
showReceivedTotal: isUnderpaid || isPaidAwaitingConfirmations || isPaidAndConfirmed,
showInstruction: showPaymentCapture,
showExpiry: showPaymentCapture,
showQr: showPaymentCapture,
showAddress: showPaymentCapture,
showPayments: (isUnderpaid || isPaidAwaitingConfirmations) && payments.length > 0,
showRefresh: resolveShowRefresh({
isExpired,
isAwaitingPayment,
isUnderpaid,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
hasPendingConfirmations
})
};
};
const toPaymentView = (payment: InvoicePayment, requiredConfirmations: number): StorefrontInvoicePaymentView => {
const isConfirmed = payment.confirmations >= requiredConfirmations;
return {
txHash: payment.txHash,
amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic),
confirmationStatus: formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations,
requiredConfirmations,
createdAt: payment.createdAt,
format: 'extended'
}),
confirmationStatusVariant: isConfirmed ? 'confirmed' : 'confirming'
};
};
const resolveShowRefresh = ({
isExpired,
isAwaitingPayment,
isUnderpaid,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
hasPendingConfirmations
}: {
isExpired: boolean;
isAwaitingPayment: boolean;
isUnderpaid: boolean;
isPaidAwaitingConfirmations: boolean;
isPaidAndConfirmed: boolean;
hasPendingConfirmations: boolean;
}): boolean => {
if (isPaidAndConfirmed) {
return false;
}
if (isPaidAwaitingConfirmations) {
return true;
}
if (isExpired && isAwaitingPayment) {
return false;
}
if (isExpired && isUnderpaid) {
return hasPendingConfirmations;
}
return isAwaitingPayment || isUnderpaid;
};
const formatInvoiceExpiresInDuration = (expiresAt: Date): string => {
const totalSeconds = Math.max(0, dayjs(expiresAt).diff(dayjs(), 'second'));
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
}
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
}
if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
}
if (seconds > 0) {
parts.push(`${seconds} ${seconds === 1 ? 'second' : 'seconds'}`);
}
if (parts.length === 0) {
return '0 seconds';
}
return parts.slice(0, 2).join(' and ');
};