From 8e66982ed5c7033b3ff1f86cd74e77a8086dca42 Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Thu, 30 Apr 2026 21:17:03 +0530 Subject: [PATCH] fix: manual Lightning payment reconciliation with Stripe invoice state --- .../0004_invoice_manual_lightning_payment.sql | 11 ++ backend/src/api.rs | 21 +++- backend/src/billing.rs | 113 +++++++++++++++++- backend/src/command.rs | 24 ++++ backend/src/query.rs | 13 ++ 5 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/0004_invoice_manual_lightning_payment.sql diff --git a/backend/migrations/0004_invoice_manual_lightning_payment.sql b/backend/migrations/0004_invoice_manual_lightning_payment.sql new file mode 100644 index 0000000..b590955 --- /dev/null +++ b/backend/migrations/0004_invoice_manual_lightning_payment.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS invoice_manual_lightning_payment ( + invoice_id TEXT PRIMARY KEY, + tenant_pubkey TEXT NOT NULL, + bolt11 TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) +); + +CREATE INDEX IF NOT EXISTS idx_invoice_manual_lightning_payment_tenant_pubkey +ON invoice_manual_lightning_payment (tenant_pubkey); diff --git a/backend/src/api.rs b/backend/src/api.rs index 4fe2f9b..f806ee8 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1009,6 +1009,13 @@ async fn get_invoice( .map_err(map_invoice_lookup_error)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; + let invoice = state + .api + .billing + .reconcile_manual_lightning_invoice(&id, &invoice) + .await + .map_err(map_invoice_lookup_error)?; + Ok(ok(StatusCode::OK, invoice)) } @@ -1026,6 +1033,13 @@ async fn get_invoice_bolt11( .map_err(map_invoice_lookup_error)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; + let invoice = state + .api + .billing + .reconcile_manual_lightning_invoice(&id, &invoice) + .await + .map_err(map_invoice_lookup_error)?; + let status = invoice["status"].as_str().unwrap_or_default(); if status != "open" { return Ok(err( @@ -1038,7 +1052,12 @@ async fn get_invoice_bolt11( let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); let currency = invoice["currency"].as_str().unwrap_or("usd"); - match state.api.billing.create_bolt11(amount_due, currency).await { + match state + .api + .billing + .get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency) + .await + { Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))), Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 336500d..320090d 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -1,7 +1,8 @@ use anyhow::{Result, anyhow}; use hmac::{Hmac, Mac}; use nwc::prelude::{ - MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest as NwcPayInvoiceRequest, + LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI, + PayInvoiceRequest as NwcPayInvoiceRequest, TransactionState, }; use sha2::Sha256; @@ -718,6 +719,50 @@ impl Billing { Ok((invoice, tenant)) } + pub async fn reconcile_manual_lightning_invoice( + &self, + invoice_id: &str, + invoice: &serde_json::Value, + ) -> std::result::Result { + self.reconcile_manual_lightning_invoice_if_settled(invoice_id, invoice) + .await + } + + pub async fn get_or_create_manual_lightning_bolt11( + &self, + invoice_id: &str, + tenant_pubkey: &str, + amount_due_minor: i64, + currency: &str, + ) -> Result { + if let Some(existing_bolt11) = self + .query + .get_invoice_manual_lightning_bolt11(invoice_id) + .await? + { + return Ok(existing_bolt11); + } + + let bolt11 = self.create_bolt11(amount_due_minor, currency).await?; + + if self + .command + .insert_manual_lightning_invoice_payment(invoice_id, tenant_pubkey, &bolt11) + .await? + { + return Ok(bolt11); + } + + self.query + .get_invoice_manual_lightning_bolt11(invoice_id) + .await? + .ok_or_else(|| { + anyhow!( + "manual lightning payment row missing after insert race for invoice {invoice_id}" + ) + }) + } + pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result { let short_pubkey: String = tenant_pubkey.chars().take(12).collect(); let display_name = format!("Caravel tenant {short_pubkey}"); @@ -1138,6 +1183,72 @@ impl Billing { Ok(()) } + async fn reconcile_manual_lightning_invoice_if_settled( + &self, + invoice_id: &str, + invoice: &serde_json::Value, + ) -> std::result::Result { + if invoice["status"].as_str().unwrap_or_default() != "open" { + return Ok(invoice.clone()); + } + + let Some(bolt11) = self + .query + .get_invoice_manual_lightning_bolt11(invoice_id) + .await? + else { + return Ok(invoice.clone()); + }; + + let settled = match self.is_manual_lightning_invoice_settled(&bolt11).await { + Ok(settled) => settled, + Err(error) => { + tracing::warn!( + error = %error, + invoice_id, + "failed to lookup manual lightning invoice settlement" + ); + return Ok(invoice.clone()); + } + }; + + if !settled { + return Ok(invoice.clone()); + } + + if let Err(error) = self.stripe_pay_invoice_out_of_band(invoice_id).await { + tracing::warn!( + error = %error, + invoice_id, + "failed to mark settled manual lightning invoice as paid_out_of_band" + ); + } + + self.stripe_get_invoice(invoice_id).await + } + + async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result { + let system_uri = Self::parse_nwc_uri(&self.nwc_url, "system")?; + let system_nwc = NWC::new(system_uri); + + let lookup_req = LookupInvoiceRequest { + payment_hash: None, + invoice: Some(bolt11.to_string()), + }; + + let lookup_result = system_nwc.lookup_invoice(lookup_req).await; + system_nwc.shutdown().await; + + let lookup_response = + lookup_result.map_err(|error| anyhow!("failed to lookup invoice: {error}"))?; + + Ok(Self::lookup_invoice_response_is_settled(&lookup_response)) + } + + fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool { + response.state == Some(TransactionState::Settled) || response.settled_at.is_some() + } + fn parse_nwc_uri(nwc_url: &str, role: &str) -> Result { nwc_url .parse::() diff --git a/backend/src/command.rs b/backend/src/command.rs index fb6c682..199462c 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -353,6 +353,30 @@ impl Command { Ok(()) } + pub async fn insert_manual_lightning_invoice_payment( + &self, + invoice_id: &str, + tenant_pubkey: &str, + bolt11: &str, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + let result = sqlx::query( + "INSERT INTO invoice_manual_lightning_payment + (invoice_id, tenant_pubkey, bolt11, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(invoice_id) DO NOTHING", + ) + .bind(invoice_id) + .bind(tenant_pubkey) + .bind(bolt11) + .bind(now) + .bind(now) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> { let now = chrono::Utc::now().timestamp(); sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?") diff --git a/backend/src/query.rs b/backend/src/query.rs index c1e3dd0..68715f1 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -171,6 +171,19 @@ impl Query { Ok(state) } + pub async fn get_invoice_manual_lightning_bolt11( + &self, + invoice_id: &str, + ) -> Result> { + let bolt11 = sqlx::query_scalar::<_, String>( + "SELECT bolt11 FROM invoice_manual_lightning_payment WHERE invoice_id = ?", + ) + .bind(invoice_id) + .fetch_optional(&self.pool) + .await?; + Ok(bolt11) + } + pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result { let plans = sqlx::query_scalar::<_, String>( "SELECT plan FROM relay WHERE tenant = ? AND status = 'active'", -- 2.52.0