Skip to content

Commit 0f039b9

Browse files
Add background polling for unpaid LN invoices to prevent stuck checkouts
Co-authored-by: danieldaquino <24692108+danieldaquino@users.noreply.github.com>
1 parent 9ec225e commit 0f039b9

File tree

3 files changed

+70
-1
lines changed

3 files changed

+70
-1
lines changed

src/invoicing.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const { add_successful_transactions_to_account } = require('./user_management')
33
const { nip19 } = require('nostr-tools')
44
const { v4: uuidv4 } = require('uuid')
55
const { current_time } = require('./utils')
6+
const error = require("debug")("api:error")
67

78
const PURPLE_ONE_MONTH = "purple_one_month"
89
const PURPLE_ONE_YEAR = "purple_one_year"
@@ -68,6 +69,9 @@ class PurpleInvoiceManager {
6869
if (PURGE_OLD_INVOICES) {
6970
this.purging_interval_timer = setInterval(() => this.purge_old_invoices(), 10 * 60 * 1000)
7071
}
72+
// Poll for unpaid invoices periodically to handle cases where the client failed to complete the checkout flow
73+
const polling_interval_ms = parseInt(process.env.LN_INVOICE_POLLING_INTERVAL_MS) || 60 * 1000
74+
this.polling_interval_timer = setInterval(() => this.poll_unpaid_invoices(), polling_interval_ms)
7175
}
7276

7377
// Purge old invoices from the database
@@ -139,17 +143,40 @@ class PurpleInvoiceManager {
139143
// Checks the status of the invoice associated with the given checkout object directly with the LN node, and handles successful payments.
140144
async check_checkout_object_invoice(checkout_id) {
141145
const checkout_object = await this.get_checkout_object(checkout_id)
146+
if (!checkout_object) {
147+
return null
148+
}
149+
if (checkout_object.completed) {
150+
return checkout_object // Already completed, nothing to do
151+
}
142152
if (checkout_object?.invoice) {
143153
checkout_object.invoice.paid = await this.check_invoice_is_paid(checkout_object.invoice.label)
144154
if (checkout_object.invoice.paid) {
145-
this.handle_successful_payment(checkout_object)
155+
await this.handle_successful_payment(checkout_object)
146156
checkout_object.completed = true
147157
await this.checkout_sessions_db.put(checkout_id, checkout_object) // Update the checkout object since the state has changed
148158
}
149159
}
150160
return checkout_object
151161
}
152162

163+
// Polls all incomplete checkout sessions to check for successful payments.
164+
// Returns a promise that resolves when all checks are done.
165+
async poll_unpaid_invoices() {
166+
const checks = []
167+
for (const checkout_id of this.checkout_sessions_db.getKeys()) {
168+
const checkout_object = this.checkout_sessions_db.get(checkout_id)
169+
if (!checkout_object.completed && checkout_object.invoice) {
170+
checks.push(
171+
this.check_checkout_object_invoice(checkout_id).catch(e => {
172+
error("Error polling invoice for checkout %s: %s", checkout_id, e.toString())
173+
})
174+
)
175+
}
176+
}
177+
return Promise.all(checks)
178+
}
179+
153180
// Call this when the user wants to checkout a purple subscription and needs an invoice to pay
154181
async request_invoice(npub, template_name) {
155182
if (!this.invoice_templates[template_name]) {
@@ -240,6 +267,9 @@ class PurpleInvoiceManager {
240267
if (this.purging_interval_timer) {
241268
clearInterval(this.purging_interval_timer)
242269
}
270+
if (this.polling_interval_timer) {
271+
clearInterval(this.polling_interval_timer)
272+
}
243273
}
244274
}
245275

test/controllers/purple_test_controller.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ class PurpleTestController {
105105

106106
async connect_and_init() {
107107
this.test_request = await supertest_client(this.purple_api.router, this.t);
108+
this.t.teardown(async () => {
109+
await this.purple_api.invoice_manager.disconnect()
110+
})
108111
}
109112

110113
static async new(t) {

test/ln_flow.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,39 @@ test('LN Flow — Renewals, expiration and expiry bumping', async (t) => {
204204

205205
t.end();
206206
});
207+
208+
test('LN Flow — Background polling processes paid invoices without client check', async (t) => {
209+
// Initialize the PurpleTestController
210+
const purple_api_controller = await PurpleTestController.new(t);
211+
212+
// Instantiate a new client
213+
const user_pubkey_1 = purple_api_controller.new_client();
214+
215+
// Get the account info (should not exist yet)
216+
const response = await purple_api_controller.clients[user_pubkey_1].get_account();
217+
t.same(response.statusCode, 404);
218+
219+
// Start a new checkout
220+
const new_checkout_response = await purple_api_controller.clients[user_pubkey_1].new_checkout(PURPLE_ONE_MONTH);
221+
t.same(new_checkout_response.statusCode, 200);
222+
223+
// Verify the checkout (generates invoice)
224+
const verify_checkout_response = await purple_api_controller.clients[user_pubkey_1].verify_checkout(new_checkout_response.body.id);
225+
t.same(verify_checkout_response.statusCode, 200);
226+
t.ok(verify_checkout_response.body.invoice?.bolt11);
227+
228+
// Pay the invoice without notifying the server (simulates client failure to call check-invoice)
229+
purple_api_controller.mock_ln_node_controller.simulate_pay_for_invoice(verify_checkout_response.body.invoice?.bolt11);
230+
231+
// Trigger background polling directly (simulates the server's periodic poll)
232+
await purple_api_controller.purple_api.invoice_manager.poll_unpaid_invoices();
233+
234+
// The account should now be active — processed by the background poll
235+
const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account();
236+
t.same(account_info_response.statusCode, 200);
237+
t.same(account_info_response.body.pubkey, user_pubkey_1);
238+
t.same(account_info_response.body.active, true);
239+
t.same(account_info_response.body.expiry, purple_api_controller.current_time() + 30 * 24 * 60 * 60);
240+
241+
t.end();
242+
});

0 commit comments

Comments
 (0)