Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/invoicing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -139,17 +143,40 @@ 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
}
}
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]) {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions test/controllers/purple_test_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions test/ln_flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});