From fd38f9cbc009a514d1833f0bf2d7b3306b524ff5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 1 Jun 2026 15:09:03 -0700 Subject: [PATCH] Fix bolt11 reconciliation --- backend/src/billing.rs | 37 +++++++++++++++++------------- backend/src/routes/invoices.rs | 15 ++++++++++++- backend/src/routes/tenants.rs | 5 ++++- frontend/src/pages/Account.tsx | 41 +++++++++++++++++----------------- 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 0f3d303..9ed1ff7 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -372,7 +372,7 @@ impl Billing { let result: Result<()> = async { let nwc_url = env::get().decrypt(&tenant.nwc_url)?; let tenant_wallet = Wallet::from_url(&nwc_url)?; - let bolt11 = self.ensure_bolt11(invoice).await?; + let bolt11 = self.ensure_bolt11_for_invoice(invoice).await?; tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?; @@ -433,7 +433,10 @@ impl Billing { } // The dunning poll runs hourly; avoid excessive reminder DMs. - if invoice.notified_at.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS) { + if invoice + .notified_at + .is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS) + { return Ok(()); } @@ -445,7 +448,10 @@ impl Billing { let dm_message = match error { Some(error) if !error.is_empty() => { let limit: usize = 240; - let summary = error.chars().take(limit.saturating_sub(3)).collect::(); + let summary = error + .chars() + .take(limit.saturating_sub(3)) + .collect::(); format!("{base}\n\nAuto-payment failed: {summary}") } _ => base, @@ -460,7 +466,7 @@ impl Billing { // --- Bolt11 utils --- - pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result { + pub async fn ensure_bolt11_for_invoice(&self, invoice: &Invoice) -> Result { let now = chrono::Utc::now().timestamp(); if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await? @@ -479,22 +485,23 @@ impl Billing { .ok_or_else(|| anyhow!("failed to insert bolt11")) } - /// Catch an out-of-band payment we never recorded — e.g. the user paid the - /// invoice but the frontend failed to notify us. If the invoice's bolt11 has - /// settled on the robot wallet, mark it paid and return the refreshed record; - /// otherwise return it unchanged. Meant to run before presenting a payable - /// invoice so we never hand back one that's already been paid. - pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result { - let bolt11 = self.ensure_bolt11(invoice).await?; + pub async fn reconcile_bolt11_for_invoice(&self, invoice: &Invoice) -> Result> { + if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await? { + return self.reconcile_bolt11(&bolt11).await; + }; + Ok(None) + } + + async fn reconcile_bolt11(&self, bolt11: &Bolt11) -> Result> { if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? { - command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?; + command::settle_invoice_out_of_band(&bolt11.id, &bolt11.invoice_id).await?; // Re-fetch so the caller sees that it's been settled. - Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11)) - } else { - Ok(bolt11) + return query::get_bolt11(&bolt11.id).await; } + + Ok(Some(bolt11.clone())) } // --- Stripe utils --- diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 4689cc0..5990225 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -18,6 +18,12 @@ pub async fn get_invoice( api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; + // Implicitly reconcile an outstanding lightning invoice if we have one + api.billing + .reconcile_bolt11_for_invoice(&invoice) + .await + .map_err(internal)?; + ok(invoice) } @@ -35,9 +41,16 @@ pub async fn get_invoice_bolt11( api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; + // Make sure we have a bolt11 for this invoice + api.billing + .ensure_bolt11_for_invoice(&invoice) + .await + .map_err(internal)?; + + // Check to see whether it was reconciled out of band let bolt11 = api .billing - .ensure_and_reconcile_bolt11(&invoice) + .reconcile_bolt11_for_invoice(&invoice) .await .map_err(internal)?; diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index 5943c24..7ef0ca9 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -66,7 +66,10 @@ pub async fn get_tenant( let mut tenant = api.get_tenant_or_404(&pubkey).await?; - api.billing.sync_stripe_customer(&mut tenant).await.map_err(internal)?; + api.billing + .sync_stripe_customer(&mut tenant) + .await + .map_err(internal)?; ok(TenantResponse::from(tenant)) } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index 82a0cf3..a13d6bf 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -200,36 +200,37 @@ export default function Account() { } return ( -
  • isOpen() && setSelectedInvoice(invoice)} - title={isOpen() ? "Click to pay this invoice" : undefined} - > +
  • -
    - - ${(invoice.amount / 100).toFixed(2)} - - - · paid via {methodLabels[invoice.method!] ?? invoice.method} - +
    +
    + + ${(invoice.amount / 100).toFixed(2)} + + + {status()} + + + · paid via {methodLabels[invoice.method!] ?? invoice.method} + +

    {periodLabel()}

    - Pay now + - - {status()} -