From 0e18d4020a5882a76cc0e0bc9d187aeb83041542 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 3 Jun 2026 10:19:52 -0700 Subject: [PATCH] Restructure reconciliation to always reconcile oob payments --- backend/src/api.rs | 2 +- backend/src/billing.rs | 103 ++++++++++++++++++++------------- backend/src/command.rs | 7 +-- backend/src/routes/invoices.rs | 4 +- frontend/src/pages/Account.tsx | 34 ++--------- 5 files changed, 76 insertions(+), 74 deletions(-) diff --git a/backend/src/api.rs b/backend/src/api.rs index 9f6e64f..448f822 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -33,7 +33,7 @@ use crate::query; use crate::robot::Robot; use crate::routes::identity::get_identity; use crate::routes::invoices::{ - create_invoice_checkout, ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices, + ensure_invoice_bolt11, ensure_invoice_checkout, get_invoice, list_invoice_items, list_invoices, reconcile_invoice, }; use crate::routes::plans::{get_plan, list_plans}; diff --git a/backend/src/billing.rs b/backend/src/billing.rs index e16821a..791d44b 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -120,10 +120,25 @@ impl Billing { command::create_invoice(&tenant, &period).await?; } - // Attempt payment on every open invoice after syncing with stripe. + // Fetch the tenant's open invoices once + let invoices = query::list_open_invoices(&tenant.pubkey).await?; + + // If the tenant is past due, churn them + if self.maybe_churn_tenant(&tenant, &invoices).await? { + return Ok(()); + } + + // If we're going to try to collect, make sure we have an updated payment method if attempt_payment { tenant.stripe_payment_method_id = self.sync_stripe_customer(&tenant).await?; - self.collect_open_invoices(&tenant).await?; + } + + // Reconcile out-of-band payments (and, when collecting, charge) on every + // open invoice. The out-of-band checks run even when attempt_payment is + // false, so a checkout or bolt11 paid out of band settles on any reconcile. + for invoice in &invoices { + self.reconcile_payments(&tenant, invoice, attempt_payment, true) + .await?; } Ok(()) @@ -284,39 +299,38 @@ impl Billing { command::insert_invoice_items_for_renewal(&line_items, period).await } - // --- Payments --- + // --- Auto-churn --- - /// Dunning pass over a tenant's open invoices: if the oldest has been unpaid - /// past the grace period, churn the tenant; otherwise retry payment on each. - async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> { - let open = query::list_open_invoices(&tenant.pubkey).await?; - let Some(oldest) = open.first() else { - return Ok(()); + /// Churn a tenant whose oldest open invoice has blown past the grace period: + /// pause their relays and DM them once, on the transition into churn. Returns + /// whether the tenant is past due, so the caller can skip collecting this pass. + async fn maybe_churn_tenant(&self, tenant: &Tenant, invoices: &[Invoice]) -> Result { + let Some(oldest) = invoices.first() else { + return Ok(false); }; let now = chrono::Utc::now().timestamp(); - if now - oldest.created_at >= GRACE_PERIOD_SECS { - if tenant.churned_at.is_none() { - let relays = query::list_relays_for_tenant(&tenant.pubkey).await?; - command::churn_tenant(&tenant.pubkey, now, &relays).await?; + if now - oldest.created_at < GRACE_PERIOD_SECS { + return Ok(false); + } - // Notify the tenant once, on the transition into churn (the guard - // above fires this a single time). Log-and-continue on failure. - let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url); - if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await { - tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM"); - } + // Past due. Churn once (the guard fires a single time on the transition) + // and notify the tenant, logging and continuing on DM failure. + if tenant.churned_at.is_none() { + let relays = query::list_relays_for_tenant(&tenant.pubkey).await?; + command::churn_tenant(&tenant.pubkey, now, &relays).await?; + + let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url); + if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await { + tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM"); } - return Ok(()); } - for invoice in &open { - self.attempt_payment(tenant, invoice, true).await?; - } - - Ok(()) + Ok(true) } + // --- Payments --- + /// Collect an invoice. We check the out-of-band rails first — a Lightning /// invoice or Checkout session the tenant may have already paid — and only /// then initiate a fresh charge (NWC, then a saved card), so a payment that's @@ -326,10 +340,11 @@ impl Billing { /// retries; it's cleared when a method next succeeds. Caller-initiated /// payments pass `notify = false` to skip the dunning DM, since the failure /// is already surfaced on screen. - pub async fn attempt_payment( + pub async fn reconcile_payments( &self, tenant: &Tenant, invoice: &Invoice, + autopay: bool, notify: bool, ) -> Result<()> { let mut error_message: Option = None; @@ -360,6 +375,10 @@ impl Billing { return self.cleanup_pending_payments(invoice).await; } + if !autopay { + return Ok(()); + } + // 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it. if !tenant.nwc_url.is_empty() { match self.attempt_payment_using_nwc(tenant, invoice).await { @@ -380,11 +399,14 @@ impl Billing { } } + if !notify { + return Ok(()); + } + // 5. Manual payment: DM a link to the in-app payment page for this invoice. - if notify - && let Err(e) = self - .attempt_payment_using_dm(tenant, invoice, error_message) - .await + if let Err(e) = self + .attempt_payment_using_dm(tenant, invoice, error_message) + .await { tracing::error!( tenant = %tenant.pubkey, @@ -490,7 +512,6 @@ impl Billing { // Record the send to avoid spammy notifications. command::mark_invoice_notified(invoice_id).await - } /// Run after an invoice is settled to invalidate out-of-band payment methods @@ -567,16 +588,18 @@ impl Billing { && now < existing.expires_at { if existing.settled_at.is_some() { - return Err(anyhow!("a checkout has already been settled for this invoice")); + return Err(anyhow!( + "a checkout has already been settled for this invoice" + )); } return Ok(existing); } - // Stripe returns the tenant to their account page; tag the invoice so the - // landing page reconciles it promptly instead of waiting for the poll. - let base = format!("{}/account", env::get().app_url); - let success_url = format!("{base}?paid_invoice={}", invoice.id); + // Stripe returns the tenant to their account page on success or cancel. + // The landing page reconciles the tenant, which now settles a paid + // Checkout out of band, so the URL needs no per-invoice marker. + let return_url = format!("{}/account", env::get().app_url); let (session_id, url, expires_at) = self .stripe @@ -585,12 +608,14 @@ impl Billing { &invoice.id, invoice.amount, "usd", - &success_url, - &base, + &return_url, + &return_url, ) .await?; - command::insert_checkout(&invoice.id, &session_id, &url, expires_at).await + command::insert_checkout(&invoice.id, &session_id, &url, expires_at) + .await? + .ok_or_else(|| anyhow!("failed to insert checkout")) } // --- Stripe utils --- diff --git a/backend/src/command.rs b/backend/src/command.rs index 6e04cae..6502a4a 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -684,9 +684,7 @@ async fn insert_invoice_item_tx( /// Mark an invoice paid, but only while it is still open — a late Lightning /// payment never flips a voided/forgiven invoice to paid, and a Stripe-paid -/// invoice never has its provenance overwritten by a later bolt11. When this -/// call is the one that settles it, close out the invoice's other outstanding -/// payment instruments so a late completion on another rail can't double-charge. +/// invoice never has its provenance overwritten by a later bolt11. async fn mark_invoice_paid_tx( tx: &mut Transaction<'_, Sqlite>, invoice_id: &str, @@ -702,7 +700,8 @@ async fn mark_invoice_paid_tx( .bind(paid_at) .bind(invoice_id) .execute(&mut **tx) - .await; + .await?; + Ok(()) } /// Void all of a tenant's open invoices, forgiving the balance — used when a diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index ad36bae..3c8706e 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -4,7 +4,7 @@ use axum::extract::{Path, State}; use crate::api::{Api, AuthedPubkey}; use crate::query; -use crate::web::{ApiResult, bad_request, internal, not_found, ok}; +use crate::web::{ApiResult, internal, not_found, ok}; pub async fn list_invoices( State(api): State>, @@ -57,7 +57,7 @@ pub async fn reconcile_invoice( .map_err(internal)?; api.billing - .attempt_payment(&tenant, &invoice, false) + .reconcile_payments(&tenant, &invoice, true, false) .await .map_err(internal)?; diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index b8395d9..b17c09f 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -7,9 +7,9 @@ import { useInvoicePdf } from "@/lib/useInvoicePdf" import PaymentSetupNWC from "@/components/PaymentSetupNWC" import PaymentSetupCard from "@/components/PaymentSetupCard" import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing" -import { invoiceStatus, listDraftInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api" +import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api" import { cardState, methodLabel, nwcState, type PaymentMethodState } from "@/lib/paymentMethod" -import { account, setToastMessage } from "@/lib/state" +import { account } from "@/lib/state" import { formatPeriod } from "@/lib/format" import PaymentMethodRow from "@/components/account/PaymentMethodRow" import InvoiceListItem from "@/components/account/InvoiceListItem" @@ -46,37 +46,15 @@ export default function Account() { // composite: reconcile the subscription, sync a card just added in the portal, // and collect the open invoice if a method is now on file — then refresh. This // is what pays the outstanding invoice after the user adds a card and returns. + // Reconciles on landing (including after returning from a Stripe Checkout or + // the billing portal): reconcile_tenant settles any out-of-band payment — a + // completed Checkout or a bolt11 paid elsewhere — and collects when a method + // is on file, then refreshes. No per-invoice return marker needed. createEffect(() => { const pubkey = account()?.pubkey if (pubkey) void billing.autopay(pubkey) }) - // Returning from a per-invoice Stripe Checkout (the success_url carries - // ?paid_invoice=ID): reconcile that invoice so it flips to paid promptly — - // autopay above only collects when a recurring method is on file, and a - // one-off Checkout payment doesn't leave one — then strip the marker. - createEffect(() => { - const pubkey = account()?.pubkey - const paidInvoice = new URLSearchParams(window.location.search).get("paid_invoice") - if (!pubkey || !paidInvoice) return - void (async () => { - try { - const invoice = await reconcileInvoice(paidInvoice) - setToastMessage( - invoice.paid_at != null ? "Payment received. Thank you!" : "Your payment is still processing.", - ) - } catch (e) { - setToastMessage(e instanceof Error ? e.message : "Failed to confirm payment") - } finally { - const params = new URLSearchParams(window.location.search) - params.delete("paid_invoice") - const qs = params.toString() - window.history.replaceState({}, "", `${window.location.pathname}${qs ? `?${qs}` : ""}`) - billing.refetch() - } - })() - }) - // Coarse account-health summary for the badge. Same snapshot the inline prompt // consumes, so the two can never disagree. const status = createMemo(() =>