From e7c0e6fdbe3aa00f6bc9a4100030e275169776a4 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 21 May 2026 17:19:35 -0700 Subject: [PATCH] Some lightning invoice refactoring --- backend/src/billing.rs | 146 ++++++++------------------------- backend/src/command.rs | 53 ++++-------- backend/src/routes/invoices.rs | 4 +- backend/src/routes/stripe.rs | 29 +++++-- 4 files changed, 75 insertions(+), 157 deletions(-) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 9b84b9b..7b0b9ea 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -4,24 +4,18 @@ use std::collections::BTreeMap; use crate::bitcoin; use crate::command::Command; use crate::env::Env; -use crate::models::{Activity, Tenant, RELAY_STATUS_ACTIVE}; +use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE}; use crate::query::Query; use crate::stripe::{Stripe, StripeInvoice, StripeSubscription}; use crate::wallet::Wallet; -const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment"; - -/// How long a freshly minted bolt11 stays valid. Once it lapses, an unpaid -/// invoice's bolt11 is regenerated on next access, so the tenant is never shown -/// a dead invoice and the sat amount stays pegged to the current BTC price. -const BOLT11_EXPIRY_SECS: i64 = 3600; - #[derive(Clone)] pub struct Billing { stripe: Stripe, wallet: Wallet, query: Query, command: Command, + env: Env, } impl Billing { @@ -31,9 +25,12 @@ impl Billing { wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"), query, command, + env: env.clone(), } } + // --- lifecycle methods --- + pub async fn start(self) { let mut rx = self.command.notify.subscribe(); @@ -131,7 +128,7 @@ impl Billing { self.ensure_subscription_items(subscription, quantity_by_price_id).await } - // --Stripe helpers-- + // --- Stripe helpers --- /// Gets a map of stripe_price_id -> quantity based on the tenant's current relays async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result> { @@ -228,131 +225,58 @@ impl Billing { Ok(()) } - // --- Public API helpers --- + // --- lightning helpers --- - pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result { - let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?; - self.wallet - .make_invoice( - amount_msats, - LIGHTNING_INVOICE_DESCRIPTION, - BOLT11_EXPIRY_SECS as u64, - ) - .await - } - - /// Return the current valid bolt11 for an open invoice, minting one if none - /// exists and regenerating it if the stored one has expired. There is - /// exactly one bolt11 per invoice: whoever pays it — the tenant's NWC wallet - /// or a human — settles the same Lightning invoice, so the bolt11 itself is - /// the double-charge guard. + /// return or generate a lightning invoice for an open stripe invoice pub async fn ensure_lightning_invoice( &self, stripe_invoice_id: &str, tenant_pubkey: &str, - amount_due_minor: i64, + amount_due: i64, currency: &str, - ) -> Result { + ) -> Result { let now = chrono::Utc::now().timestamp(); - if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await? { - // Keep a still-valid invoice, or any bolt11 we've already settled. - if existing.status != "pending" || now < existing.expires_at { - return Ok(existing.bolt11); - } - // The stored invoice expired unpaid, so mint a fresh one. The old - // invoice can no longer be paid, so no settlement can be missed. - let bolt11 = self.create_bolt11(amount_due_minor, currency).await?; - self.command - .regenerate_lightning_invoice(stripe_invoice_id, &bolt11, now + BOLT11_EXPIRY_SECS) - .await?; - return Ok(bolt11); + if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await? + && (existing.status != "pending" || now < existing.expires_at) + { + return Ok(existing); } - let bolt11 = self.create_bolt11(amount_due_minor, currency).await?; - if self + let expiry: i64 = 3600; + let info = "Relay subscription payment"; + let msats = bitcoin::fiat_to_msats(amount_due, currency).await?; + let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?; + + let invoice = match self .command - .insert_pending_lightning_invoice( - stripe_invoice_id, - tenant_pubkey, - &bolt11, - now + BOLT11_EXPIRY_SECS, - ) + .insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry) .await? { - Ok(bolt11) - } else { - // Lost the insert race; use whatever the winner stored. - Ok(self + Some(invoice) => invoice, + None => self .query .get_lightning_invoice(stripe_invoice_id) .await? - .ok_or_else(|| { - anyhow!("lightning_invoice row missing after insert race for invoice {stripe_invoice_id}") - })? - .bolt11) - } + .ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?, + }; + + Ok(invoice) } - pub async fn pay_outstanding_card_invoices( - &self, - tenant: &crate::models::Tenant, - ) -> Result<()> { - if !self - .stripe - .has_payment_method(&tenant.stripe_customer_id) - .await? - { - return Ok(()); - } + /// Attempt to pay and settle an invoice via nwc + pub async fn nwc_pay_invoice(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> { + let nwc_url = self.env.decrypt(&tenant.nwc_url)?; + let tenant_wallet = Wallet::from_url(&nwc_url)?; - let invoices = self - .stripe - .list_invoices(&tenant.stripe_customer_id) - .await?; - - for invoice in &invoices { - if invoice.status != "open" || invoice.amount_due == 0 { - continue; - } - if let Err(error) = self.stripe.pay_invoice(&invoice.id).await { - tracing::error!( - error = %error, - stripe_invoice_id = %invoice.id, - "failed to retry card payment for outstanding invoice" - ); - } - } - - Ok(()) - } - - // --- Lightning / NWC orchestration --- - - /// Push a payment for an invoice's persisted bolt11 from the tenant's NWC - /// wallet, then confirm settlement against the system wallet. On success the - /// Stripe invoice is marked paid out of band and `Ok(())` is returned; on - /// failure the returned error is the reason to surface to the tenant (the - /// caller falls through to other payment methods rather than propagating - /// it). Reusing the same bolt11 means a retry — or a concurrent manual - /// payment — can never double-charge. - pub async fn nwc_pay_invoice( - &self, - stripe_invoice_id: &str, - tenant_pubkey: &str, - bolt11: &str, - tenant_nwc_url: &str, - ) -> Result<()> { - let tenant_wallet = Wallet::from_url(tenant_nwc_url)?; - - match tenant_wallet.pay_invoice(bolt11.to_string()).await { - Ok(()) => self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await, + match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await { + Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await, Err(pay_error) => { // The pay request errored, but the payment may have landed // before the response was lost. Confirm against the system // wallet before reporting failure. - if self.wallet.is_settled(bolt11).await.unwrap_or(false) { - self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await + if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) { + self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await } else { Err(pay_error) } diff --git a/backend/src/command.rs b/backend/src/command.rs index 5564559..f457ba8 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -3,7 +3,8 @@ use sqlx::{Sqlite, SqlitePool, Transaction}; use tokio::sync::broadcast; use crate::models::{ - Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, + Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, + RELAY_STATUS_INACTIVE, Relay, Tenant, }; #[derive(Clone)] @@ -298,22 +299,29 @@ impl Command { // Invoices - /// Insert a freshly minted pending bolt11 for an invoice. Returns `false` if - /// a row already exists (lost an insert race), in which case the caller - /// should read and use the existing row's bolt11. - pub async fn insert_pending_lightning_invoice( + /// Upsert the pending bolt11 for an invoice, returning the resulting row. On + /// conflict the stored bolt11/expiry are replaced — this is how an expired + /// invoice is regenerated — except once the invoice is paid, when the + /// `status = 'pending'` guard makes the update a no-op and `None` is + /// returned so the caller can fall back to reading the settled row. + pub async fn insert_lightning_invoice( &self, stripe_invoice_id: &str, tenant_pubkey: &str, bolt11: &str, expires_at: i64, - ) -> Result { + ) -> Result> { let now = chrono::Utc::now().timestamp(); - let result = sqlx::query( + let row = sqlx::query_as::<_, LightningInvoice>( "INSERT INTO lightning_invoice (stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at) VALUES (?, ?, ?, 'pending', ?, ?, ?) - ON CONFLICT(stripe_invoice_id) DO NOTHING", + ON CONFLICT(stripe_invoice_id) DO UPDATE SET + bolt11 = excluded.bolt11, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + WHERE status = 'pending' + RETURNING *", ) .bind(stripe_invoice_id) .bind(tenant_pubkey) @@ -321,35 +329,10 @@ impl Command { .bind(expires_at) .bind(now) .bind(now) - .execute(&self.pool) + .fetch_optional(&self.pool) .await?; - Ok(result.rows_affected() > 0) - } - - /// Replace the stored bolt11 for a still-pending invoice whose previous - /// invoice expired. No-op once the invoice is paid, so this can never - /// overwrite a settled invoice. - pub async fn regenerate_lightning_invoice( - &self, - stripe_invoice_id: &str, - bolt11: &str, - expires_at: i64, - ) -> Result<()> { - let now = chrono::Utc::now().timestamp(); - sqlx::query( - "UPDATE lightning_invoice - SET bolt11 = ?, expires_at = ?, updated_at = ? - WHERE stripe_invoice_id = ? AND status = 'pending'", - ) - .bind(bolt11) - .bind(expires_at) - .bind(now) - .bind(stripe_invoice_id) - .execute(&self.pool) - .await?; - - Ok(()) + Ok(row) } /// Mark a pending invoice paid, recording which method settled it. The diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index d1025b6..affe496 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -57,13 +57,13 @@ pub async fn get_lightning_invoice( return Err(bad_request("invoice-not-open", "invoice is not open")); } - let bolt11 = api + let invoice = api .billing .ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency) .await .map_err(internal)?; - ok(serde_json::json!({ "bolt11": bolt11 })) + ok(serde_json::json!(invoice)) } /// Fetch a Stripe invoice and the tenant that owns it, enforcing that the diff --git a/backend/src/routes/stripe.rs b/backend/src/routes/stripe.rs index b95dfde..90b4717 100644 --- a/backend/src/routes/stripe.rs +++ b/backend/src/routes/stripe.rs @@ -127,8 +127,7 @@ async fn handle_invoice_created( return Ok(()); }; - // Mint (or reuse) the single bolt11 that both NWC and manual payment settle. - let bolt11 = api + let invoice = api .billing .ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency) .await?; @@ -137,12 +136,7 @@ async fn handle_invoice_created( // 1. NWC auto-pay: if the tenant has a nwc_url if !tenant.nwc_url.is_empty() { - let plain_nwc_url = api.env.decrypt(&tenant.nwc_url)?; - match api - .billing - .nwc_pay_invoice(stripe_invoice_id, &tenant.pubkey, &bolt11, &plain_nwc_url) - .await - { + match api.billing.nwc_pay_invoice(&tenant, &invoice).await { Ok(()) => return Ok(()), Err(e) => { let error_msg = format!("{e}"); @@ -309,7 +303,24 @@ async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> return Ok(()); }; - api.billing.pay_outstanding_card_invoices(&tenant).await?; + let invoices = api + .stripe + .list_invoices(&tenant.stripe_customer_id) + .await?; + + for invoice in &invoices { + if invoice.status != "open" || invoice.amount_due == 0 { + continue; + } + if let Err(error) = api.stripe.pay_invoice(&invoice.id).await { + tracing::error!( + error = %error, + stripe_invoice_id = %invoice.id, + "failed to retry card payment for outstanding invoice" + ); + } + } + Ok(()) }