From e6b5d821b03d96cd30176a9c8d55a24b0ef02aeb Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Tue, 21 Apr 2026 02:12:19 +0545 Subject: [PATCH] feat: invoice payment flow for paid relays --- backend/spec/billing.md | 15 +++- backend/src/api.rs | 19 +++- backend/src/billing.rs | 56 ++++++++++++ frontend/src/components/PaymentDialog.tsx | 19 +++- frontend/src/components/PaymentSetup.tsx | 6 +- frontend/src/lib/hooks.ts | 12 ++- frontend/src/lib/useRelayToggles.ts | 12 +-- frontend/src/pages/relays/RelayDetail.tsx | 103 ++++++++++++++++++++-- frontend/src/pages/relays/RelayNew.tsx | 30 ++++--- 9 files changed, 243 insertions(+), 29 deletions(-) diff --git a/backend/spec/billing.md b/backend/spec/billing.md index f95a62c..2876ed0 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -26,6 +26,8 @@ Members: Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent. +Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment. + - Fetch the relay and tenant associated with the `activity` - **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early. - **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early. @@ -66,9 +68,20 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl - Creates a Stripe Customer Portal session for the given customer - Returns the portal session URL +## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>` + +Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid. + +- If `tenant.nwc_url` is empty, return early. +- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`. +- For each invoice with `status == "open"` and `amount_due > 0`: + - Attempt NWC payment via `nwc_pay_invoice`. + - On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`. + - On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice. + ## `fn handle_invoice_created(&self, invoice: &Invoice)` -Attempts to pay a new subscription invoice. Payment priority: +Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority: 1. **NWC auto-pay**: If the tenant has a `nwc_url`: - Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet) diff --git a/backend/src/api.rs b/backend/src/api.rs index 7a9ed63..8895648 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -945,12 +945,29 @@ async fn update_tenant( state.api.require_admin_or_tenant(&auth, &pubkey)?; let mut tenant = state.api.get_tenant_or_404(&pubkey).await?; + let nwc_previously_empty = tenant.nwc_url.is_empty(); if let Some(nwc_url) = payload.nwc_url { tenant.nwc_url = nwc_url; } match state.api.command.update_tenant(&tenant).await { - Ok(()) => Ok(ok(StatusCode::OK, tenant)), + Ok(()) => { + // When NWC is first connected, attempt to pay any outstanding open invoices. + if nwc_previously_empty && !tenant.nwc_url.is_empty() { + let billing = state.api.billing.clone(); + let tenant_clone = tenant.clone(); + tokio::spawn(async move { + if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await { + tracing::error!( + error = %e, + pubkey = %tenant_clone.pubkey, + "pay_outstanding_nwc_invoices failed after NWC setup" + ); + } + }); + } + Ok(ok(StatusCode::OK, tenant)) + } Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 75be841..209aa46 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -599,6 +599,62 @@ impl Billing { Ok(invoice_response.invoice) } + pub async fn pay_outstanding_nwc_invoices( + &self, + tenant: &crate::models::Tenant, + ) -> Result<()> { + if tenant.nwc_url.is_empty() { + return Ok(()); + } + + let invoices = self + .stripe_list_invoices(&tenant.stripe_customer_id) + .await?; + let invoices_arr = invoices.as_array().cloned().unwrap_or_default(); + + for invoice in &invoices_arr { + let status = invoice["status"].as_str().unwrap_or_default(); + let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); + let invoice_id = invoice["id"].as_str().unwrap_or_default(); + let currency = invoice["currency"].as_str().unwrap_or("usd"); + + if status != "open" || amount_due == 0 || invoice_id.is_empty() { + continue; + } + + match self + .nwc_pay_invoice(amount_due, currency, &tenant.nwc_url) + .await + { + Ok(()) => { + if let Err(e) = self.stripe_pay_invoice_out_of_band(invoice_id).await { + tracing::error!( + error = %e, + invoice_id, + "failed to mark invoice paid out of band" + ); + } else { + let _ = self.command.clear_tenant_nwc_error(&tenant.pubkey).await; + } + } + Err(e) => { + let error_msg = format!("{e}"); + tracing::error!( + error = %e, + invoice_id, + "nwc payment failed for outstanding invoice" + ); + let _ = self + .command + .set_tenant_nwc_error(&tenant.pubkey, &error_msg) + .await; + } + } + } + + Ok(()) + } + pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result { let resp = self .http diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index 9e3315a..50f3538 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -28,6 +28,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { const [payError, setPayError] = createSignal("") const [showSetup, setShowSetup] = createSignal(false) const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) + const [setupSaved, setSetupSaved] = createSignal(false) async function loadBolt11() { if (!props.invoice.id) return @@ -160,6 +161,15 @@ export default function PaymentDialog(props: PaymentDialogProps) { +
+ +
} @@ -228,7 +238,14 @@ export default function PaymentDialog(props: PaymentDialogProps) { setShowPaymentSetup(false)} + onClose={() => { + setShowPaymentSetup(false) + if (setupSaved()) { + setSetupSaved(false) + props.onClose() + } + }} + onSaved={() => setSetupSaved(true)} /> ) diff --git a/frontend/src/components/PaymentSetup.tsx b/frontend/src/components/PaymentSetup.tsx index 2a54f24..251a0fe 100644 --- a/frontend/src/components/PaymentSetup.tsx +++ b/frontend/src/components/PaymentSetup.tsx @@ -9,6 +9,7 @@ type Tab = "nwc" | "card" type PaymentSetupProps = { open: boolean onClose: () => void + onSaved?: () => void } export default function PaymentSetup(props: PaymentSetupProps) { @@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) { try { await updateActiveTenant({ nwc_url: url }) setSaved(true) + props.onSaved?.() } catch (e) { setError(e instanceof Error ? e.message : "Failed to save wallet connection") } finally { @@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {

Set Up Payments

-

Choose how you'd like to pay for your relay.

+

Choose how you'd like to pay once invoices are issued for your relay.

-

Add a payment card via Stripe to enable automatic billing.

+

Add a payment card via Stripe to enable automatic billing once invoices are issued.

+ + + + + + )} + + {(inv) => ( + { + clearPendingInvoice() + void refetchTenant() + void refetchOpenInvoice() + }} + /> + )} + + + {(inv) => ( + { + setInvoiceDialogOpen(false) + void refetchOpenInvoice() + }} + /> + )} + { + setPaymentSetupOpen(false) + void refetchTenant() + }} /> ) diff --git a/frontend/src/pages/relays/RelayNew.tsx b/frontend/src/pages/relays/RelayNew.tsx index 6e1bef3..aa48944 100644 --- a/frontend/src/pages/relays/RelayNew.tsx +++ b/frontend/src/pages/relays/RelayNew.tsx @@ -1,14 +1,15 @@ -import { createSignal } from "solid-js" +import { createSignal, Show } from "solid-js" import { useNavigate } from "@solidjs/router" import BackLink from "@/components/BackLink" import PageContainer from "@/components/PageContainer" -import PaymentSetup from "@/components/PaymentSetup" +import PaymentDialog from "@/components/PaymentDialog" import RelayForm, { type RelayFormValues } from "@/components/RelayForm" -import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks" +import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks" +import type { Invoice } from "@/lib/api" export default function RelayNew() { const navigate = useNavigate() - const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) + const [pendingInvoice, setPendingInvoice] = createSignal() let createdRelayId = "" async function handleSubmit(values: RelayFormValues) { @@ -16,9 +17,9 @@ export default function RelayNew() { createdRelayId = relay.id if (values.plan !== "free") { - const needs = await tenantNeedsPaymentSetup() - if (needs) { - setShowPaymentSetup(true) + const invoice = await getLatestOpenInvoice() + if (invoice) { + setPendingInvoice(invoice) return } } @@ -27,7 +28,7 @@ export default function RelayNew() { } function handleDialogClose() { - setShowPaymentSetup(false) + setPendingInvoice(undefined) navigate(`/relays/${createdRelayId}`) } @@ -41,10 +42,15 @@ export default function RelayNew() { submitLabel="Create Relay" submittingLabel="Creating..." /> - + + {(inv) => ( + + )} + ) }