From 6b693e11d3989536da3b9fa55c2b8e8a4e343ea5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 2 Jun 2026 14:30:50 -0700 Subject: [PATCH] refactor billing endpoints to separate reads from reconciliation requests --- backend/src/api.rs | 10 +++- backend/src/billing.rs | 66 ++++++++-------------- backend/src/query.rs | 9 --- backend/src/routes/invoices.rs | 59 +++++++++++++------ backend/src/routes/tenants.rs | 51 +++++++++-------- backend/src/stripe.rs | 8 ++- frontend/src/components/BillingPrompts.tsx | 13 ++++- frontend/src/components/PaymentDialog.tsx | 6 +- frontend/src/lib/api.ts | 33 ++++++++++- frontend/src/lib/billing.ts | 13 ++--- frontend/src/lib/hooks.ts | 6 ++ frontend/src/lib/state.ts | 47 ++++++++++++++- frontend/src/lib/useInvoicePdf.ts | 4 +- frontend/src/pages/Account.tsx | 16 +++--- 14 files changed, 217 insertions(+), 124 deletions(-) diff --git a/backend/src/api.rs b/backend/src/api.rs index 0fa916e..e06c2a0 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -32,7 +32,9 @@ use crate::models::{Relay, Tenant}; use crate::query; use crate::robot::Robot; use crate::routes::identity::get_identity; -use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items, list_invoices}; +use crate::routes::invoices::{ + ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices, reconcile_invoice, +}; use crate::routes::plans::{get_plan, list_plans}; use crate::routes::relays::{ create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members, @@ -40,7 +42,7 @@ use crate::routes::relays::{ }; use crate::routes::tenants::{ create_stripe_session, create_tenant, get_draft_invoice, get_tenant, list_draft_invoice_items, - list_tenant_invoices, list_tenant_relays, list_tenants, update_tenant, + list_tenant_invoices, list_tenant_relays, list_tenants, reconcile_tenant, update_tenant, }; use crate::stripe::Stripe; use crate::web::{ApiError, forbidden, internal, not_found, unauthorized}; @@ -77,6 +79,7 @@ impl Api { "/tenants/:pubkey/invoices/draft/items", get(list_draft_invoice_items), ) + .route("/tenants/:pubkey/reconcile", post(reconcile_tenant)) .route( "/tenants/:pubkey/stripe/session", get(create_stripe_session), @@ -89,7 +92,8 @@ impl Api { .route("/relays/:id/reactivate", post(reactivate_relay)) .route("/invoices", get(list_invoices)) .route("/invoices/:id", get(get_invoice)) - .route("/invoices/:id/bolt11", get(get_invoice_bolt11)) + .route("/invoices/:id/reconcile", post(reconcile_invoice)) + .route("/invoices/:id/bolt11", post(ensure_invoice_bolt11)) .route("/invoices/:id/items", get(list_invoice_items)) .with_state(api) } diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 5c71f63..86fc7a3 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -122,7 +122,7 @@ impl Billing { // Attempt payment on every open invoice after syncing with stripe. if attempt_payment { - self.sync_stripe_customer(&mut tenant).await?; + tenant.stripe_payment_method_id = self.sync_stripe_customer(&tenant).await?; self.collect_open_invoices(&tenant).await?; } @@ -311,17 +311,24 @@ impl Billing { } for invoice in &open { - self.attempt_payment(tenant, invoice).await?; + self.attempt_payment(tenant, invoice, true).await?; } Ok(()) } - /// Collect an invoice via NWC, then a saved card, then a manual DM. A failing - /// method's error is stored on the tenant (to warn them in the UI) but never - /// aborts the cascade or future retries; a method's error is cleared when it - /// next succeeds. - pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> { + /// Collect an invoice via NWC, then a saved card, then (when `notify`) a + /// manual DM. A failing method's error is stored on the tenant (to warn them + /// in the UI) but never aborts the cascade or future retries; a method's + /// error is cleared when it 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( + &self, + tenant: &Tenant, + invoice: &Invoice, + notify: bool, + ) -> Result<()> { let mut error_message: Option = None; // 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first. @@ -354,9 +361,10 @@ impl Billing { } // 4. Manual payment: DM a link to the in-app payment page for this invoice. - if let Err(e) = self - .attempt_payment_using_dm(tenant, invoice, error_message) - .await + if notify + && let Err(e) = self + .attempt_payment_using_dm(tenant, invoice, error_message) + .await { tracing::error!( tenant = %tenant.pubkey, @@ -493,44 +501,18 @@ impl Billing { .ok_or_else(|| anyhow!("failed to insert bolt11")) } - pub async fn reconcile_bolt11_for_invoice(&self, invoice: &Invoice) -> Result> { - if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await? { - // Don't settle an invoice that is already resolved - if invoice.paid_at.is_some() || invoice.voided_at.is_some() { - return Ok(Some(bolt11)); - } - - 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, &bolt11.invoice_id).await?; - - // Re-fetch so the caller sees that it's been settled. - return query::get_bolt11(&bolt11.id).await; - } - - Ok(Some(bolt11.clone())) - } - // --- Stripe utils --- - /// Copy down any stripe-related stuff to our local tenant model. Fail gracefully. - pub async fn sync_stripe_customer(&self, tenant: &mut Tenant) -> Result<()> { + /// Refresh stripe-related state for a tenant, returning the synced payment + /// method id (the tenant's existing one on failure). Fails gracefully. + pub async fn sync_stripe_customer(&self, tenant: &Tenant) -> Result> { match self.sync_stripe_payment_method(tenant).await { - Ok(payment_method_id) => { - tenant.stripe_payment_method_id = payment_method_id; - } + Ok(payment_method_id) => Ok(payment_method_id), Err(error) => { tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method"); + Ok(tenant.stripe_payment_method_id.clone()) } - }; - - Ok(()) + } } /// Refresh the cached Stripe payment method from Stripe so collection can charge diff --git a/backend/src/query.rs b/backend/src/query.rs index ec0841e..6a4b4cb 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -188,15 +188,6 @@ pub async fn list_open_invoices(tenant_pubkey: &str) -> Result> { // --- Bolt11 --- -pub async fn get_bolt11(bolt11_id: &str) -> Result> { - Ok( - sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?") - .bind(bolt11_id) - .fetch_optional(pool()) - .await?, - ) -} - pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result> { Ok(sqlx::query_as::<_, Bolt11>( "SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1", diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 8290517..803d073 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -15,6 +15,7 @@ pub async fn list_invoices( ok(query::list_invoices().await.map_err(internal)?) } +/// Read a single invoice pub async fn get_invoice( State(api): State>, AuthedPubkey(auth): AuthedPubkey, @@ -27,18 +28,50 @@ pub async fn get_invoice( api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; - // Implicitly reconcile an outstanding lightning invoice if we have one + ok(invoice) +} + +/// Reconcile and collect an open invoice +pub async fn reconcile_invoice( + State(api): State>, + AuthedPubkey(auth): AuthedPubkey, + Path(id): Path, +) -> ApiResult { + let invoice = query::get_invoice(&id) + .await + .map_err(internal)? + .ok_or_else(|| not_found("invoice not found"))?; + + api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; + + // Nothing to collect on an already-resolved invoice. + if invoice.paid_at.is_some() || invoice.voided_at.is_some() { + return ok(invoice); + } + + let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?; + api.billing - .reconcile_bolt11_for_invoice(&invoice) + .ensure_bolt11_for_invoice(&invoice) .await .map_err(internal)?; + api.billing + .attempt_payment(&tenant, &invoice, false) + .await + .map_err(internal)?; + + // Re-read so the caller sees the possibly now-paid invoice. + let invoice = query::get_invoice(&id) + .await + .map_err(internal)? + .ok_or_else(|| not_found("invoice not found"))?; + ok(invoice) } -/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if -/// needed and first settling it if it was already paid out of band. -pub async fn get_invoice_bolt11( +/// Idempotently create a payable Lightning invoice (bolt11) +pub async fn ensure_invoice_bolt11( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(invoice_id): Path, @@ -50,24 +83,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 + let bolt11 = 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 - .reconcile_bolt11_for_invoice(&invoice) - .await - .map_err(internal)?; - - ok(serde_json::json!(bolt11)) + ok(bolt11) } -/// The line items billed on an invoice, for rendering its contents and a -/// downloadable copy. +/// The line items billed on an invoice pub async fn list_invoice_items( State(api): State>, AuthedPubkey(auth): AuthedPubkey, diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index e00da8f..079e0c7 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -57,7 +57,7 @@ pub async fn list_tenants( .collect::>()) } -/// Fetch a tenant by pubkey. Automatically refreshes the tenant's stripe payment data. +/// Fetch a tenant by pubkey. pub async fn get_tenant( State(api): State>, AuthedPubkey(auth): AuthedPubkey, @@ -65,12 +65,7 @@ pub async fn get_tenant( ) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; - let mut tenant = api.get_tenant_or_404(&pubkey).await?; - - api.billing - .sync_stripe_customer(&mut tenant) - .await - .map_err(internal)?; + let tenant = api.get_tenant_or_404(&pubkey).await?; ok(TenantResponse::from(tenant)) } @@ -159,7 +154,7 @@ pub async fn list_tenant_relays( ok(relays) } -/// List a tenant's invoices after reconciling the tenant's billing state. +/// List a tenant's invoices. pub async fn list_tenant_invoices( State(api): State>, AuthedPubkey(auth): AuthedPubkey, @@ -167,13 +162,6 @@ pub async fn list_tenant_invoices( ) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; - let tenant = api.get_tenant_or_404(&pubkey).await?; - - api.billing - .reconcile_subscription(&tenant, false) - .await - .map_err(internal)?; - let invoices = query::list_invoices_for_tenant(&pubkey) .await .map_err(internal)?; @@ -181,14 +169,8 @@ pub async fn list_tenant_invoices( ok(invoices) } -/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the -/// outstanding line items for the current period (reconciled first so it reflects -/// the latest activity). It mirrors what `create_invoice` would bill once the -/// balance turns positive. `null` when the tenant has no billing anchor yet or -/// nothing is outstanding. The id is a fixed `draft` sentinel and the lifecycle -/// fields are empty — it isn't persisted or payable, so the UI renders it -/// read-only with a `draft` status. -pub async fn get_draft_invoice( +/// Reconcile a tenant's subscription +pub async fn reconcile_tenant( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(pubkey): Path, @@ -197,13 +179,32 @@ pub async fn get_draft_invoice( let tenant = api.get_tenant_or_404(&pubkey).await?; + api.billing + .sync_stripe_customer(&tenant) + .await + .map_err(internal)?; + api.billing .reconcile_subscription(&tenant, false) .await .map_err(internal)?; - // Re-read so the draft sees a billing anchor that reconcile may have just set - // (it persists the anchor but mutates only its own clone). + // Re-read so the response reflects the synced method and any billing anchor. + let tenant = api.get_tenant_or_404(&pubkey).await?; + + ok(TenantResponse::from(tenant)) +} + +/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the +/// outstanding line items for the current period. It mirrors what `create_invoice` +/// would bill once the balance turns positive. +pub async fn get_draft_invoice( + State(api): State>, + AuthedPubkey(auth): AuthedPubkey, + Path(pubkey): Path, +) -> ApiResult { + api.require_admin_or_tenant(&auth, &pubkey)?; + let tenant = api.get_tenant_or_404(&pubkey).await?; let draft = match BillingPeriod::current(&tenant) { diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index 7ac8f2f..97bad34 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -104,8 +104,10 @@ impl Stripe { /// A decline or an issuer authentication demand (`authentication_required`, /// which we can't satisfy off-session) comes back from Stripe as an HTTP /// error, so the caller naturally falls through to another payment method. - /// The charge is made idempotent on `invoice_id`, so a retried collection - /// reuses the same charge instead of billing the payment method twice. + /// The charge is made idempotent on `invoice_id` and `payment_method_id`, + /// so a retried collection against the same method reuses the same charge + /// instead of billing twice, while a fall-back to a different method issues + /// a distinct charge instead of colliding on the original key. pub async fn create_payment_intent( &self, customer_id: &str, @@ -119,7 +121,7 @@ impl Stripe { .post("/payment_intents") .header( "Idempotency-Key", - self.idempotency_key(&["payment_intent", invoice_id]), + self.idempotency_key(&["payment_intent", invoice_id, payment_method_id]), ) .form(&[ ("amount", amount.as_str()), diff --git a/frontend/src/components/BillingPrompts.tsx b/frontend/src/components/BillingPrompts.tsx index 95d7a3c..c5607f9 100644 --- a/frontend/src/components/BillingPrompts.tsx +++ b/frontend/src/components/BillingPrompts.tsx @@ -5,7 +5,7 @@ import PaymentDialog from "@/components/PaymentDialog" import PaymentSetup from "@/components/PaymentSetup" import { getInvoice, type Invoice } from "@/lib/api" import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing" -import { billingFlowActive } from "@/lib/state" +import { account, billingFlowActive } from "@/lib/state" type BillingPromptsProps = { // "banner" sits in the dashboard shell (mounted on every page except the @@ -84,6 +84,13 @@ export default function BillingPrompts(props: BillingPromptsProps) { if (searchParams.invoice) setSearchParams({ invoice: undefined }) } + // After paying or saving a method, reconcile + sync + (auto)collect, then + // refresh — so a card added inside the dialog actually settles the invoice. + function refreshBilling() { + const pubkey = account()?.pubkey + if (pubkey) void status.autopay(pubkey) + } + const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6") return ( @@ -110,7 +117,7 @@ export default function BillingPrompts(props: BillingPromptsProps) { const wasDeepLink = !payInvoice() setPayInvoice(undefined) if (wasDeepLink) clearDeepLink() - status.refetch() + refreshBilling() }} /> )} @@ -121,7 +128,7 @@ export default function BillingPrompts(props: BillingPromptsProps) { initialTab={setupTab()} onClose={() => { setSetupOpen(false) - status.refetch() + refreshBilling() }} /> diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index 4de9446..76570a7 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -8,7 +8,7 @@ import LightningPayBody from "@/components/payment/LightningPayBody" import { setToastMessage } from "@/lib/state" import { copyToClipboard } from "@/lib/clipboard" import { useCardPortal } from "@/lib/usePaymentSetup" -import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api" +import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api" import { autopayConfigured } from "@/lib/paymentMethod" import { billingTenant } from "@/lib/state" import { formatUsd, formatPeriod } from "@/lib/format" @@ -57,7 +57,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { setQrDataUrl("") try { - const { lnbc } = await getInvoiceBolt11(props.invoice.id) + const { lnbc } = await ensureInvoiceBolt11(props.invoice.id) setBolt11(lnbc) setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 })) setBolt11Status("ready") @@ -86,7 +86,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { async function checkPayment() { setPayStatus("loading") try { - const invoice = await getInvoice(props.invoice.id) + const invoice = await reconcileInvoice(props.invoice.id) if (invoice.paid_at != null) { setPayStatus("success") } else { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a69c85..3a08168 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -154,6 +154,16 @@ export function invoiceStatus(invoice: Pick): return "open" } +// The single invoice autopay collects and the dashboard surfaces as "Pay now": +// the OLDEST open invoice with a positive balance, matching the backend's +// dunning order so the UI pays the same one collection targets. undefined when +// nothing is due. Canonical pick shared by useBillingStatus and autopayBilling. +export function selectPayableInvoice(invoices: Invoice[]): Invoice | undefined { + return invoices + .filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0) + .sort((a, b) => a.created_at - b.created_at)[0] +} + export type Activity = { id: string tenant_pubkey: string @@ -265,6 +275,14 @@ export function listTenantInvoices(pubkey: string) { return callApi("GET", `/tenants/${pubkey}/invoices`) } +// Reconcile a tenant's billing: sync the Stripe payment method (picking up a +// card added via the portal), fold billable activity into invoice items, renew +// the current period, and cut an invoice for any outstanding balance. Does not +// attempt payment. Returns the reconciled tenant. +export function reconcileTenant(pubkey: string) { + return callApi("POST", `/tenants/${pubkey}/reconcile`) +} + // The draft is a synthetic Invoice (id "draft", empty lifecycle fields) summing // the current period's not-yet-billed items, or null when there's nothing due. export function getDraftInvoice(pubkey: string) { @@ -310,8 +328,19 @@ export function createPortalSession(pubkey: string, returnUrl?: string) { return callApi("GET", `/tenants/${pubkey}/stripe/session${query}`) } -export function getInvoiceBolt11(invoiceId: string) { - return callApi("GET", `/invoices/${invoiceId}/bolt11`) +// Idempotently create a payable bolt11 for an invoice (reusing a valid existing +// one) and return it. No reconciliation — settlement is detected by +// reconcileInvoice. The lnbc string is the data the QR needs. +export function ensureInvoiceBolt11(invoiceId: string) { + return callApi("POST", `/invoices/${invoiceId}/bolt11`) +} + +// Reconcile and collect an open invoice: ensure a payable bolt11 exists, then +// run the payment cascade (NWC, then an out-of-band Lightning settle, then a +// saved card). Caller-initiated, so no dunning DM and no churn. Returns the +// refreshed invoice (paid_at set once collected). +export function reconcileInvoice(invoiceId: string) { + return callApi("POST", `/invoices/${invoiceId}/reconcile`) } export function listInvoiceItems(invoiceId: string) { diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts index 73ae531..b8e540b 100644 --- a/frontend/src/lib/billing.ts +++ b/frontend/src/lib/billing.ts @@ -1,8 +1,8 @@ import { createMemo } from "solid-js" import { indexBy } from "@welshman/lib" -import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api" +import { invoiceStatus, selectPayableInvoice, type Invoice, type Tenant } from "@/lib/api" import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod" -import { billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state" +import { autopayBilling, billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state" export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay" @@ -30,11 +30,10 @@ export function useBillingStatus() { const draftInvoice = () => billingDraftInvoice() ?? undefined const openInvoices = createMemo(() => - invoices() - .filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0) - .sort((a, b) => a.created_at - b.created_at), + invoices().filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0), ) - const openInvoice = () => openInvoices()[0] + // The autopay/dunning target — the same pick autopayBilling collects. + const openInvoice = () => selectPayableInvoice(invoices()) // Amount due: the total of all open invoices. const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0) @@ -49,7 +48,7 @@ export function useBillingStatus() { const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading - return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling } + return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling, autopay: autopayBilling } } // Pure priority selector: returns the single highest-priority billing prompt to diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 30e6f12..c1b2aea 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -20,6 +20,7 @@ import { listTenantInvoices, listTenantRelays, listTenants, + reconcileTenant, updateRelay, updateTenant, type Activity, @@ -208,6 +209,11 @@ export async function getLatestOpenInvoice(): Promise { // none is available). Shared by RelayNew, Home's signup-and-create path, and the // plan-upgrade toggle so the post-paid ladder stays identical across all three. export async function resolvePostPaidFlow(): Promise { + const pubkey = account()!.pubkey + // The reads below are pure GETs now, so explicitly materialize the just-created + // invoice and pick up any portal-added card before deciding the post-paid ladder. + await reconcileTenant(pubkey) + const needsSetup = await tenantNeedsPaymentSetup() const invoice = needsSetup ? await getLatestOpenInvoice() : null return decidePostPaidFlow({ needsSetup, invoice }) diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts index 018d58f..255a274 100644 --- a/frontend/src/lib/state.ts +++ b/frontend/src/lib/state.ts @@ -6,7 +6,8 @@ import { EventStore } from "applesauce-core" import { createEventLoaderForStore } from "applesauce-loaders/loaders" import { RelayPool } from "applesauce-relay" import { NostrConnectSigner } from "applesauce-signers" -import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api" +import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, reconcileInvoice, reconcileTenant, registerAccountGetter, selectPayableInvoice, type Plan } from "@/lib/api" +import { autopayConfigured } from "@/lib/paymentMethod" export type UnsignedEvent = { kind: number @@ -101,6 +102,50 @@ export function refetchBilling() { }) } +// In-flight autopay run, keyed by pubkey, so concurrent triggers (a mount +// double-fire, two dialog onClose handlers) collapse into one run. +let autopayInFlight: { pubkey: string; promise: Promise } | undefined + +// The side-effecting billing refresh, layered above the pure refetchBilling: on +// load of the billing surface it reconciles the subscription (materializing the +// current invoice), syncs the Stripe payment method (picking up a portal-added +// card), and — when a method is on file and an invoice is due — collects it, +// then refreshes all billing reads. This is what makes "add a card, return to +// the app" actually pay the open invoice. Payment is skipped while the +// create/upgrade flow owns the invoice (billingFlowActive). +export function autopayBilling(pubkey: string): Promise { + if (autopayInFlight?.pubkey === pubkey) return autopayInFlight.promise + + const promise = (async () => { + try { + // The tenant row is provisioned lazily on first login; make sure it exists + // before the tenant-scoped POSTs, since autopay can fire (on the Account + // landing effect) before provisioning has completed. + if (billingPubkey() !== pubkey) await ensureSessionTenant() + + // Sync the payment method + reconcile the subscription, then collect the + // oldest open invoice when a method is on file (and the create/upgrade + // flow isn't already driving its own invoice). + const tenant = await reconcileTenant(pubkey) + + const invoice = selectPayableInvoice(await listTenantInvoices(pubkey)) + if (invoice && autopayConfigured(tenant) && !billingFlowActive()) { + await reconcileInvoice(invoice.id) + } + } catch (e) { + console.error("Autopay billing failed", e) + setToastMessage(e instanceof Error ? e.message : "Failed to update billing") + } finally { + // Reflect the final state (paid invoice, cleared errors, relay changes). + refetchBilling() + if (autopayInFlight?.pubkey === pubkey) autopayInFlight = undefined + } + })() + + autopayInFlight = { pubkey, promise } + return promise +} + // Ensure the active pubkey's tenant row exists, then unlock billing reads. The // tenant is created lazily on first login, so this must run before any // tenant-scoped read. The in-flight promise is shared so the login flow (which diff --git a/frontend/src/lib/useInvoicePdf.ts b/frontend/src/lib/useInvoicePdf.ts index eaa74b3..b2b1242 100644 --- a/frontend/src/lib/useInvoicePdf.ts +++ b/frontend/src/lib/useInvoicePdf.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" import QRCode from "qrcode" -import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api" +import { ensureInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api" import { methodLabel } from "@/lib/paymentMethod" import { formatUsd } from "@/lib/format" import { PLATFORM_NAME } from "@/lib/state" @@ -36,7 +36,7 @@ export function useInvoicePdf() { let qrDataUrl: string | undefined if (invoice.method !== "stripe" && invoice.voided_at == null) { try { - const bolt11 = await getInvoiceBolt11(invoice.id) + const bolt11 = await ensureInvoiceBolt11(invoice.id) sats = Math.round(bolt11.msats / 1000) if (invoice.paid_at == null) { qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 }) diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index 1875bc7..bc1ea33 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -42,11 +42,13 @@ export default function Account() { const invoicesLoading = useMinLoading(() => billing.loading()) const { printInvoice, printing } = useInvoicePdf() - // On landing here (the billing portal returns to this page), refresh billing so - // a card just added in the portal — which get_tenant syncs onto the tenant — and - // any cleared error show immediately rather than only on the next reconcile. + // On landing here (the billing portal returns to this page), run the autopay + // 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. createEffect(() => { - if (account()?.pubkey) billing.refetch() + const pubkey = account()?.pubkey + if (pubkey) void billing.autopay(pubkey) }) // Coarse account-health summary for the badge. Same snapshot the inline prompt @@ -176,7 +178,7 @@ export default function Account() { open={true} onClose={() => { setSelectedInvoice(undefined) - billing.refetch() + void billing.autopay(account()!.pubkey) }} /> )} @@ -187,7 +189,7 @@ export default function Account() { isUpdate={nwc().kind !== "not_set_up"} onClose={() => { setNwcModalOpen(false) - billing.refetch() + void billing.autopay(account()!.pubkey) }} /> @@ -196,7 +198,7 @@ export default function Account() { isUpdate={card().kind !== "not_set_up"} onClose={() => { setCardModalOpen(false) - billing.refetch() + void billing.autopay(account()!.pubkey) }} />