237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
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 { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter';
|
|
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 { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc';
|
|
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
|
|
import { subtractAtomic } from '../atomic/subtractAtomic';
|
|
import { paymentMethodLabel } from '../../consts/paymentMethodLabel';
|
|
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:
|
|
if (!invoice.moneroDetails) {
|
|
throw new InternalServerErrorException('Invoice is missing Monero payment details');
|
|
}
|
|
|
|
return buildStorefrontInvoiceView({
|
|
invoice,
|
|
paymentLabel: paymentMethodLabel[PaymentMethod.Xmr],
|
|
convertAtomicToCrypto: convertXmrAtomicToXmr,
|
|
buildQrCodePayload: (paymentAddress, amountCrypto) =>
|
|
`monero:${paymentAddress}?tx_amount=${amountCrypto}`
|
|
});
|
|
case PaymentMethod.Btc:
|
|
if (!invoice.btcDetails) {
|
|
throw new InternalServerErrorException('Invoice is missing Bitcoin payment details');
|
|
}
|
|
|
|
return buildStorefrontInvoiceView({
|
|
invoice,
|
|
paymentLabel: paymentMethodLabel[PaymentMethod.Btc],
|
|
convertAtomicToCrypto: convertBtcAtomicToBtc,
|
|
buildQrCodePayload: (paymentAddress, amountCrypto) => `bitcoin:${paymentAddress}?amount=${amountCrypto}`
|
|
});
|
|
default:
|
|
throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
|
|
}
|
|
};
|
|
|
|
const buildStorefrontInvoiceView = async ({
|
|
invoice,
|
|
paymentLabel,
|
|
convertAtomicToCrypto,
|
|
buildQrCodePayload
|
|
}: {
|
|
invoice: Invoice;
|
|
paymentLabel: string;
|
|
convertAtomicToCrypto: CryptoAtomicConverter;
|
|
buildQrCodePayload: (paymentAddress: string, amountCrypto: string) => string;
|
|
}): Promise<StorefrontInvoiceView> => {
|
|
const invoiceState = deriveInvoiceState(invoice);
|
|
|
|
const {
|
|
isAwaitingPayment,
|
|
isUnderpaid,
|
|
isPaidSufficient,
|
|
isPaidAwaitingConfirmations,
|
|
isPaidAndConfirmed,
|
|
isExpired,
|
|
hasPendingConfirmations
|
|
} = invoiceState;
|
|
|
|
const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
|
|
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
|
|
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertAtomicToCrypto(receivedAtomic);
|
|
|
|
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
|
|
|
|
const payments = [...(invoice.payments ?? [])]
|
|
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
|
|
.map(payment => toPaymentView(payment, requiredConfirmations, convertAtomicToCrypto));
|
|
|
|
const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired;
|
|
|
|
const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : 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) {
|
|
instructionAmountCrypto = expectedTotalCrypto;
|
|
instructionPrefix = 'Send exactly';
|
|
instructionSuffix = 'to the address below.';
|
|
} else {
|
|
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
|
|
|
|
instructionAmountCrypto = convertAtomicToCrypto(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
|
|
instructionPrefix = 'Send';
|
|
instructionSuffix = 'more to the same address below.';
|
|
}
|
|
|
|
const qrCodePayload = buildQrCodePayload(invoice.paymentAddress, instructionAmountCrypto);
|
|
|
|
qrCodeUrl = await generateQrCodeDataUrl(qrCodePayload);
|
|
}
|
|
|
|
const statusMessage = resolveInvoiceStatusMessage(invoiceState);
|
|
|
|
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
|
|
|
|
return {
|
|
paymentLabel,
|
|
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,
|
|
convertAtomicToCrypto: CryptoAtomicConverter
|
|
): StorefrontInvoicePaymentView => {
|
|
const isConfirmed = payment.confirmations >= requiredConfirmations;
|
|
|
|
return {
|
|
txHash: payment.txHash,
|
|
amountCrypto: convertAtomicToCrypto(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 ');
|
|
};
|