From b4dff9ac809b86d10df098ab9fa8db11c479154e Mon Sep 17 00:00:00 2001 From: vindard <17693119+vindard@users.noreply.github.com> Date: Fri, 16 Feb 2024 15:57:43 -0400 Subject: [PATCH] refactor(core): introduce 'ProcessPendingInvoiceResult' type This is to introduce the new type and use it to decide when to mark invoices as 'proceesing completed' and 'paid'. The existing logic is kept in place an no iteration has been done on that as yet here. That will be addressed in follow-up commits. --- .../wallets/decline-single-pending-invoice.ts | 112 ++++++---- core/api/src/app/wallets/index.types.d.ts | 24 ++ .../wallets/process-pending-invoice-result.ts | 34 +++ .../wallets/update-single-pending-invoice.ts | 208 +++++++++++------- 4 files changed, 252 insertions(+), 126 deletions(-) create mode 100644 core/api/src/app/wallets/process-pending-invoice-result.ts diff --git a/core/api/src/app/wallets/decline-single-pending-invoice.ts b/core/api/src/app/wallets/decline-single-pending-invoice.ts index 77981c5ed4..e6ec6d257f 100644 --- a/core/api/src/app/wallets/decline-single-pending-invoice.ts +++ b/core/api/src/app/wallets/decline-single-pending-invoice.ts @@ -1,3 +1,8 @@ +import { + ProcessPendingInvoiceResult, + ProcessedReason, +} from "./process-pending-invoice-result" + import { InvoiceNotFoundError } from "@/domain/bitcoin/lightning" import { InvalidNonHodlInvoiceError } from "@/domain/errors" @@ -18,66 +23,87 @@ export const declineHeldInvoice = wrapAsyncToRunInSpan({ walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger }): Promise => { - const { pubkey, paymentHash } = walletInvoice - addAttributesToCurrentSpan({ paymentHash, pubkey }) - - const lndService = LndService() - if (lndService instanceof Error) return lndService - - const walletInvoicesRepo = WalletInvoicesRepository() - - const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) - + const { paymentHash, pubkey } = walletInvoice const pendingInvoiceLogger = logger.child({ hash: paymentHash, pubkey, - lnInvoiceLookup, topic: "payment", protocol: "lightning", transactionType: "receipt", onUs: false, }) - if (lnInvoiceLookup instanceof InvoiceNotFoundError) { + const result = await processPendingInvoiceForDecline({ + walletInvoice, + logger: pendingInvoiceLogger, + }) + + if (result.isProcessed) { const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) + await WalletInvoicesRepository().markAsProcessingCompleted(paymentHash) if (processingCompletedInvoice instanceof Error) { pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - return processingCompletedInvoice } - return false } - if (lnInvoiceLookup instanceof Error) return lnInvoiceLookup - if (lnInvoiceLookup.isSettled) { - return new InvalidNonHodlInvoiceError( - JSON.stringify({ paymentHash: lnInvoiceLookup.paymentHash }), - ) - } + const error = "error" in result && result.error + return !(result.isPaid || result.isProcessed) + ? false + : result.isProcessed + ? false + : error + ? error + : result.isPaid + }, +}) - if (!lnInvoiceLookup.isHeld) { - pendingInvoiceLogger.info({ lnInvoiceLookup }, "invoice has not been paid yet") - return false - } +export const processPendingInvoiceForDecline = async ({ + walletInvoice, + logger: pendingInvoiceLogger, +}: { + walletInvoice: WalletInvoiceWithOptionalLnInvoice + logger: Logger +}): Promise => { + const { pubkey, paymentHash } = walletInvoice + addAttributesToCurrentSpan({ paymentHash, pubkey }) - let heldForMsg = "" - if (lnInvoiceLookup.heldAt) { - heldForMsg = `for ${elapsedSinceTimestamp(lnInvoiceLookup.heldAt)}s ` - } - pendingInvoiceLogger.error( - { lnInvoiceLookup }, - `invoice has been held ${heldForMsg}and is now been cancelled`, - ) + // Fetch invoice from lnd service + const lndService = LndService() + if (lndService instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(lndService) + } - const invoiceSettled = await lndService.cancelInvoice({ pubkey, paymentHash }) - if (invoiceSettled instanceof Error) return invoiceSettled + const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) + if (lnInvoiceLookup instanceof InvoiceNotFoundError) { + return ProcessPendingInvoiceResult.processedOnly(ProcessedReason.InvoiceNotFound) + } + if (lnInvoiceLookup instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(lnInvoiceLookup) + } - const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - } + // Check status on invoice fetched from lnd + const { isSettled, isHeld } = lnInvoiceLookup + if (isSettled) { + return ProcessPendingInvoiceResult.paidWithError( + new InvalidNonHodlInvoiceError(JSON.stringify({ paymentHash })), + ) + } + if (!isHeld) { + pendingInvoiceLogger.info({ lnInvoiceLookup }, "invoice has not been paid yet") + ProcessPendingInvoiceResult.notPaid() + } - return true - }, -}) + // Cancel held invoice + const { heldAt } = lnInvoiceLookup + const heldForMsg = heldAt ? `for ${elapsedSinceTimestamp(heldAt)}s ` : "" + pendingInvoiceLogger.error( + { lnInvoiceLookup }, + `invoice has been held ${heldForMsg}and is now been cancelled`, + ) + const invoiceSettled = await lndService.cancelInvoice({ pubkey, paymentHash }) + if (invoiceSettled instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(invoiceSettled) + } + + return ProcessPendingInvoiceResult.ok() +} diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index ea32ac09d6..280d14186e 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -131,3 +131,27 @@ type LnurlPaymentSendArgs = { lnurl: string amount: number } + +type ProcessedReason = + (typeof import("./process-pending-invoice-result").ProcessedReason)[keyof typeof import("./process-pending-invoice-result").ProcessedReason] + +type ProcessPendingInvoiceResult = + | { + isProcessed: true + isPaid: true + } + | { + isProcessed: false + isPaid: true + error?: ApplicationError + } + | { + isProcessed: true + isPaid: false + reason: ProcessedReason + } + | { + isProcessed: false + isPaid: false + error?: ApplicationError + } diff --git a/core/api/src/app/wallets/process-pending-invoice-result.ts b/core/api/src/app/wallets/process-pending-invoice-result.ts new file mode 100644 index 0000000000..47fc1258d3 --- /dev/null +++ b/core/api/src/app/wallets/process-pending-invoice-result.ts @@ -0,0 +1,34 @@ +export const ProcessedReason = { + InvoiceNotFound: "InvoiceNotFound", + InvoiceCanceled: "InvoiceCanceled", +} as const + +export const ProcessPendingInvoiceResult = { + ok: (): ProcessPendingInvoiceResult => ({ + isProcessed: true, + isPaid: true, + }), + paidOnly: (): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: true, + }), + paidWithError: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: true, + error, + }), + processedOnly: (reason: ProcessedReason): ProcessPendingInvoiceResult => ({ + isProcessed: true, + isPaid: false, + reason, + }), + notPaid: (): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: false, + }), + err: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: false, + error, + }), +} diff --git a/core/api/src/app/wallets/update-single-pending-invoice.ts b/core/api/src/app/wallets/update-single-pending-invoice.ts index c2b10777d3..860ff3cc00 100644 --- a/core/api/src/app/wallets/update-single-pending-invoice.ts +++ b/core/api/src/app/wallets/update-single-pending-invoice.ts @@ -1,6 +1,11 @@ import { getTransactionForWalletByJournalId } from "./get-transaction-by-journal-id" -import { declineHeldInvoice } from "./decline-single-pending-invoice" +import { processPendingInvoiceForDecline } from "./decline-single-pending-invoice" + +import { + ProcessPendingInvoiceResult, + ProcessedReason, +} from "./process-pending-invoice-result" import { removeDeviceTokens } from "@/app/users/remove-device-tokens" import { getCurrentPriceAsDisplayPriceRatio, usdFromBtcMidPriceFn } from "@/app/prices" @@ -31,6 +36,7 @@ import { DealerPriceService } from "@/services/dealer-price" import { NotificationsService } from "@/services/notifications" import { toDisplayBaseAmount } from "@/domain/payments" +import { LockServiceError } from "@/domain/lock" export const updatePendingInvoice = wrapAsyncToRunInSpan({ namespace: "app.invoices", @@ -42,133 +48,149 @@ export const updatePendingInvoice = wrapAsyncToRunInSpan({ walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger }): Promise => { - const result = await updatePendingInvoiceBeforeFinally({ + const walletInvoices = WalletInvoicesRepository() + const { paymentHash, recipientWalletDescriptor: recipientInvoiceWalletDescriptor } = + walletInvoice + + const pendingInvoiceLogger = logger.child({ + hash: paymentHash, + walletId: recipientInvoiceWalletDescriptor.id, + topic: "payment", + protocol: "lightning", + transactionType: "receipt", + onUs: false, + }) + + let result = await processPendingInvoice({ walletInvoice, logger, }) - if (result) { - if (!walletInvoice.paid) { - const walletInvoices = WalletInvoicesRepository() - const invoicePaid = await walletInvoices.markAsPaid(walletInvoice.paymentHash) - if ( - invoicePaid instanceof Error && - !(invoicePaid instanceof CouldNotFindWalletInvoiceError) - ) { - return invoicePaid - } + + if (result.isProcessed) { + const processingCompletedInvoice = + await walletInvoices.markAsProcessingCompleted(paymentHash) + if (processingCompletedInvoice instanceof Error) { + pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") + recordExceptionInCurrentSpan({ + error: processingCompletedInvoice, + level: processingCompletedInvoice.level, + }) + + result = ProcessPendingInvoiceResult.paidWithError(processingCompletedInvoice) // Marking this here temporarily to enforce status quo + } + } + + if (result.isPaid && !walletInvoice.paid) { + const invoicePaid = await walletInvoices.markAsPaid(walletInvoice.paymentHash) + if ( + invoicePaid instanceof Error && + !(invoicePaid instanceof CouldNotFindWalletInvoiceError) + ) { + return invoicePaid } } - return result + + const error = "error" in result && result.error + return !(result.isPaid || result.isProcessed) + ? false + : result.isProcessed + ? false + : error + ? error + : result.isPaid }, }) -const updatePendingInvoiceBeforeFinally = async ({ +const processPendingInvoice = async ({ walletInvoice, - logger, + logger: pendingInvoiceLogger, }: { walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger -}): Promise => { - addAttributesToCurrentSpan({ - paymentHash: walletInvoice.paymentHash, - pubkey: walletInvoice.pubkey, - }) - - const walletInvoicesRepo = WalletInvoicesRepository() - +}): Promise => { const { pubkey, paymentHash, recipientWalletDescriptor: recipientInvoiceWalletDescriptor, } = walletInvoice - addAttributesToCurrentSpan({ + paymentHash, + pubkey, "invoices.originalRecipient": JSON.stringify(recipientInvoiceWalletDescriptor), }) - const pendingInvoiceLogger = logger.child({ - hash: paymentHash, - walletId: recipientInvoiceWalletDescriptor.id, - topic: "payment", - protocol: "lightning", - transactionType: "receipt", - onUs: false, - }) - + // Fetch invoice from lnd service const lndService = LndService() if (lndService instanceof Error) { pendingInvoiceLogger.error("Unable to initialize LndService") recordExceptionInCurrentSpan({ error: lndService }) - return false + return ProcessPendingInvoiceResult.err(lndService) } + const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) if (lnInvoiceLookup instanceof InvoiceNotFoundError) { - const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - return processingCompletedInvoice - } - return false + return ProcessPendingInvoiceResult.processedOnly(ProcessedReason.InvoiceNotFound) + } + if (lnInvoiceLookup instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(lnInvoiceLookup) } - if (lnInvoiceLookup instanceof Error) return lnInvoiceLookup + // Check paid after invoice has been successfully fetched if (walletInvoice.paid) { pendingInvoiceLogger.info("invoice has already been processed") - return true + return ProcessPendingInvoiceResult.paidOnly() } - const { - lnInvoice: { description }, - roundedDownReceived: uncheckedRoundedDownReceived, - } = lnInvoiceLookup - - if (lnInvoiceLookup.isCanceled) { + // Check status of invoice fetched from lnd service + const { isCanceled, isHeld, isSettled } = lnInvoiceLookup + if (isCanceled) { pendingInvoiceLogger.info("invoice has been canceled") - const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - return processingCompletedInvoice - } - return false + return ProcessPendingInvoiceResult.processedOnly(ProcessedReason.InvoiceCanceled) } - - if (!lnInvoiceLookup.isHeld && !lnInvoiceLookup.isSettled) { + if (!isHeld && !isSettled) { pendingInvoiceLogger.info("invoice has not been paid yet") - return false + return ProcessPendingInvoiceResult.notPaid() } - // TODO: validate roundedDownReceived as user input + // Check amount from invoice fetched from lnd service + const { + lnInvoice: { description }, + roundedDownReceived: uncheckedRoundedDownReceived, + } = lnInvoiceLookup const roundedDownReceived = checkedToSats(uncheckedRoundedDownReceived) if (roundedDownReceived instanceof Error) { recordExceptionInCurrentSpan({ error: roundedDownReceived, level: roundedDownReceived.level, }) - return declineHeldInvoice({ + return processPendingInvoiceForDecline({ walletInvoice, - logger, + logger: pendingInvoiceLogger, }) } - const receivedBtc = paymentAmountFromNumber({ amount: roundedDownReceived, currency: WalletCurrency.Btc, }) - if (receivedBtc instanceof Error) return receivedBtc + if (receivedBtc instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(receivedBtc) + } - const lockService = LockService() - return lockService.lockPaymentHash(paymentHash, async () => + // Continue in lock + const result = await LockService().lockPaymentHash(paymentHash, async () => lockedUpdatePendingInvoiceSteps({ recipientWalletId: recipientInvoiceWalletDescriptor.id, paymentHash, receivedBtc, description, isSettledInLnd: lnInvoiceLookup.isSettled, - logger, + logger: pendingInvoiceLogger, }), ) + if (result instanceof LockServiceError) { + return ProcessPendingInvoiceResult.err(result) + } + return result } const lockedUpdatePendingInvoiceSteps = async ({ @@ -185,28 +207,34 @@ const lockedUpdatePendingInvoiceSteps = async ({ description: string isSettledInLnd: boolean logger: Logger -}) => { +}): Promise => { const walletInvoices = WalletInvoicesRepository() const walletInvoiceInsideLock = await walletInvoices.findByPaymentHash(paymentHash) if (walletInvoiceInsideLock instanceof CouldNotFindError) { logger.error({ paymentHash }, "WalletInvoice doesn't exist") - return false + return ProcessPendingInvoiceResult.err(walletInvoiceInsideLock) + } + if (walletInvoiceInsideLock instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(walletInvoiceInsideLock) } - if (walletInvoiceInsideLock instanceof Error) return walletInvoiceInsideLock if (walletInvoiceInsideLock.paid) { logger.info("invoice has already been processed") - return true + return ProcessPendingInvoiceResult.paidOnly() } // Prepare metadata and record transaction const recipientInvoiceWallet = await WalletsRepository().findById(recipientWalletId) - if (recipientInvoiceWallet instanceof Error) return recipientInvoiceWallet + if (recipientInvoiceWallet instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(recipientInvoiceWallet) + } const { accountId: recipientAccountId } = recipientInvoiceWallet const accountWallets = await WalletsRepository().findAccountWalletsByAccountId(recipientAccountId) - if (accountWallets instanceof Error) return accountWallets + if (accountWallets instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(accountWallets) + } const receivedWalletInvoice = await WalletInvoiceReceiver({ walletInvoice: walletInvoiceInsideLock, @@ -216,7 +244,9 @@ const lockedUpdatePendingInvoiceSteps = async ({ mid: { usdFromBtc: usdFromBtcMidPriceFn }, hedgeBuyUsd: { usdFromBtc: DealerPriceService().getCentsFromSatsForImmediateBuy }, }) - if (receivedWalletInvoice instanceof Error) return receivedWalletInvoice + if (receivedWalletInvoice instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(receivedWalletInvoice) + } const { recipientWalletDescriptor, @@ -231,12 +261,16 @@ const lockedUpdatePendingInvoiceSteps = async ({ }) const recipientAccount = await AccountsRepository().findById(recipientAccountId) - if (recipientAccount instanceof Error) return recipientAccount + if (recipientAccount instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(recipientAccount) + } const { displayCurrency: recipientDisplayCurrency } = recipientAccount const displayPriceRatio = await getCurrentPriceAsDisplayPriceRatio({ currency: recipientDisplayCurrency, }) - if (displayPriceRatio instanceof Error) return displayPriceRatio + if (displayPriceRatio instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(displayPriceRatio) + } const { displayAmount: displayPaymentAmount, displayFee } = DisplayAmountsConverter( displayPriceRatio, @@ -276,7 +310,7 @@ const lockedUpdatePendingInvoiceSteps = async ({ if (lndService instanceof Error) { logger.error("Unable to initialize LndService") recordExceptionInCurrentSpan({ error: lndService }) - return false + return ProcessPendingInvoiceResult.err(lndService) } const invoiceSettled = await lndService.settleInvoice({ pubkey: walletInvoiceInsideLock.pubkey, @@ -285,12 +319,14 @@ const lockedUpdatePendingInvoiceSteps = async ({ if (invoiceSettled instanceof Error) { logger.error({ paymentHash }, "Unable to settleInvoice") recordExceptionInCurrentSpan({ error: invoiceSettled }) - return false + return ProcessPendingInvoiceResult.err(invoiceSettled) } } const invoicePaid = await walletInvoices.markAsPaid(paymentHash) - if (invoicePaid instanceof Error) return invoicePaid + if (invoicePaid instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(invoicePaid) + } //TODO: add displayCurrency: displayPaymentAmount.currency, const journal = await LedgerFacade.recordReceiveOffChain({ @@ -311,17 +347,23 @@ const lockedUpdatePendingInvoiceSteps = async ({ additionalCreditMetadata: creditAccountAdditionalMetadata, additionalInternalMetadata: internalAccountsAdditionalMetadata, }) - if (journal instanceof Error) return journal + if (journal instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(journal) + } // Prepare and send notification const recipientUser = await UsersRepository().findById(recipientAccount.kratosUserId) - if (recipientUser instanceof Error) return recipientUser + if (recipientUser instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(recipientUser) + } const walletTransaction = await getTransactionForWalletByJournalId({ walletId: recipientWalletDescriptor.id, journalId: journal.journalId, }) - if (walletTransaction instanceof Error) return walletTransaction + if (walletTransaction instanceof Error) { + return ProcessPendingInvoiceResult.paidWithError(walletTransaction) + } const result = await NotificationsService().sendTransaction({ recipient: { @@ -340,5 +382,5 @@ const lockedUpdatePendingInvoiceSteps = async ({ }) } - return true + return ProcessPendingInvoiceResult.paidOnly() }