From 8bdeef2b1426125306c7ed637eef8be91ed0f5ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:42:43 +0000 Subject: [PATCH 1/2] Initial plan From 344d1a810d08aaf92b93e0e8f103b20889c3e844 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:04:52 +0000 Subject: [PATCH 2/2] Add background polling for unpaid LN invoices to prevent stuck checkouts Co-authored-by: danieldaquino <24692108+danieldaquino@users.noreply.github.com> --- src/invoicing.js | 32 ++++++++++++++++++- test/controllers/purple_test_controller.js | 3 ++ test/ln_flow.test.js | 36 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/invoicing.js b/src/invoicing.js index ef4ddc3..4359ab9 100644 --- a/src/invoicing.js +++ b/src/invoicing.js @@ -3,6 +3,7 @@ const { add_successful_transactions_to_account } = require('./user_management') const { nip19 } = require('nostr-tools') const { v4: uuidv4 } = require('uuid') const { current_time } = require('./utils') +const error = require("debug")("api:error") const PURPLE_ONE_MONTH = "purple_one_month" const PURPLE_ONE_YEAR = "purple_one_year" @@ -68,6 +69,9 @@ class PurpleInvoiceManager { if (PURGE_OLD_INVOICES) { this.purging_interval_timer = setInterval(() => this.purge_old_invoices(), 10 * 60 * 1000) } + // Poll for unpaid invoices periodically to handle cases where the client failed to complete the checkout flow + const polling_interval_ms = parseInt(process.env.LN_INVOICE_POLLING_INTERVAL_MS) || 60 * 1000 + this.polling_interval_timer = setInterval(() => this.poll_unpaid_invoices(), polling_interval_ms) } // Purge old invoices from the database @@ -139,10 +143,16 @@ class PurpleInvoiceManager { // Checks the status of the invoice associated with the given checkout object directly with the LN node, and handles successful payments. async check_checkout_object_invoice(checkout_id) { const checkout_object = await this.get_checkout_object(checkout_id) + if (!checkout_object) { + return null + } + if (checkout_object.completed) { + return checkout_object // Already completed, nothing to do + } if (checkout_object?.invoice) { checkout_object.invoice.paid = await this.check_invoice_is_paid(checkout_object.invoice.label) if (checkout_object.invoice.paid) { - this.handle_successful_payment(checkout_object) + await this.handle_successful_payment(checkout_object) checkout_object.completed = true await this.checkout_sessions_db.put(checkout_id, checkout_object) // Update the checkout object since the state has changed } @@ -150,6 +160,23 @@ class PurpleInvoiceManager { return checkout_object } + // Polls all incomplete checkout sessions to check for successful payments. + // Returns a promise that resolves when all checks are done. + async poll_unpaid_invoices() { + const checks = [] + for (const checkout_id of this.checkout_sessions_db.getKeys()) { + const checkout_object = this.checkout_sessions_db.get(checkout_id) + if (!checkout_object.completed && checkout_object.invoice) { + checks.push( + this.check_checkout_object_invoice(checkout_id).catch(e => { + error("Error polling invoice for checkout %s: %s", checkout_id, e.toString()) + }) + ) + } + } + return Promise.all(checks) + } + // Call this when the user wants to checkout a purple subscription and needs an invoice to pay async request_invoice(npub, template_name) { if (!this.invoice_templates[template_name]) { @@ -240,6 +267,9 @@ class PurpleInvoiceManager { if (this.purging_interval_timer) { clearInterval(this.purging_interval_timer) } + if (this.polling_interval_timer) { + clearInterval(this.polling_interval_timer) + } } } diff --git a/test/controllers/purple_test_controller.js b/test/controllers/purple_test_controller.js index aff3a3f..a678451 100644 --- a/test/controllers/purple_test_controller.js +++ b/test/controllers/purple_test_controller.js @@ -105,6 +105,9 @@ class PurpleTestController { async connect_and_init() { this.test_request = await supertest_client(this.purple_api.router, this.t); + this.t.teardown(async () => { + await this.purple_api.invoice_manager.disconnect() + }) } static async new(t) { diff --git a/test/ln_flow.test.js b/test/ln_flow.test.js index ffec683..4247b7a 100644 --- a/test/ln_flow.test.js +++ b/test/ln_flow.test.js @@ -204,3 +204,39 @@ test('LN Flow — Renewals, expiration and expiry bumping', async (t) => { t.end(); }); + +test('LN Flow — Background polling processes paid invoices without client check', async (t) => { + // Initialize the PurpleTestController + const purple_api_controller = await PurpleTestController.new(t); + + // Instantiate a new client + const user_pubkey_1 = purple_api_controller.new_client(); + + // Get the account info (should not exist yet) + const response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(response.statusCode, 404); + + // Start a new checkout + const new_checkout_response = await purple_api_controller.clients[user_pubkey_1].new_checkout(PURPLE_ONE_MONTH); + t.same(new_checkout_response.statusCode, 200); + + // Verify the checkout (generates invoice) + const verify_checkout_response = await purple_api_controller.clients[user_pubkey_1].verify_checkout(new_checkout_response.body.id); + t.same(verify_checkout_response.statusCode, 200); + t.ok(verify_checkout_response.body.invoice?.bolt11); + + // Pay the invoice without notifying the server (simulates client failure to call check-invoice) + purple_api_controller.mock_ln_node_controller.simulate_pay_for_invoice(verify_checkout_response.body.invoice?.bolt11); + + // Trigger background polling directly (simulates the server's periodic poll) + await purple_api_controller.purple_api.invoice_manager.poll_unpaid_invoices(); + + // The account should now be active — processed by the background poll + const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(account_info_response.statusCode, 200); + t.same(account_info_response.body.pubkey, user_pubkey_1); + t.same(account_info_response.body.active, true); + t.same(account_info_response.body.expiry, purple_api_controller.current_time() + 30 * 24 * 60 * 60); + + t.end(); +});