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 => { 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 => { 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 '); };