Restructure reconciliation to always reconcile oob payments

This commit is contained in:
Jon Staab
2026-06-03 10:19:52 -07:00
parent b702733559
commit 0e18d4020a
5 changed files with 76 additions and 74 deletions
+1 -1
View File
@@ -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};
+64 -39
View File
@@ -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<bool> {
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<String> = 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 ---
+3 -4
View File
@@ -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
+2 -2
View File
@@ -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<Arc<Api>>,
@@ -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)?;
+6 -28
View File
@@ -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(() =>