diff --git a/backend/spec/billing.md b/backend/spec/billing.md index f95a62c..4edc4fa 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -26,12 +26,15 @@ 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 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 run downgrade proration validation by previewing the upcoming invoice and logging proration lines/amounts. 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. - **If relay is `active` and on a paid plan**: - **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early. - **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`. + - **Downgrade validation**: when changing an existing subscription item, detect if the new plan amount is lower than the current one. If yes, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior. - **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`. ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` @@ -44,6 +47,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl - `invoice.overdue` -> `self.handle_invoice_overdue` - `customer.subscription.updated` -> `self.handle_subscription_updated` - `customer.subscription.deleted` -> `self.handle_subscription_deleted` + - `payment_method.attached` -> `self.handle_payment_method_attached` - Unknown event types are ignored (return Ok) ## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result` @@ -66,16 +70,37 @@ 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. + +## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>` + +Attempts Stripe-side collection for open invoices when the tenant has a card on file. + +- If tenant has no card payment method, return early. +- List all Stripe invoices for `tenant.stripe_customer_id`. +- For each invoice with `status == "open"` and `amount_due > 0`: + - Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file. + - Log and continue on failures. + ## `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) - Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet) - If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`. - If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option. -2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically. +2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt. 3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment. Skip invoices with `amount_due` of 0. @@ -112,3 +137,8 @@ Skip invoices with `amount_due` of 0. - Look up tenant by `stripe_customer_id` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription` + +## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)` + +- Look up tenant by `stripe_customer_id` +- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately 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..ebc9d16 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -167,6 +167,8 @@ impl Billing { self.command .delete_relay_subscription_item(&relay.id) .await?; + self.validate_downgrade_proration(&tenant, "free-plan-downgrade") + .await; } self.cleanup_empty_subscription(&tenant.pubkey).await?; return Ok(()); @@ -206,8 +208,28 @@ impl Billing { // Sync the subscription item: create or update let subscription_id = tenant.stripe_subscription_id.as_ref().unwrap(); let item_id = if let Some(ref existing_item_id) = relay.stripe_subscription_item_id { - self.stripe_update_subscription_item(existing_item_id, stripe_price_id) - .await? + let is_downgrade = self + .is_subscription_item_downgrade(existing_item_id, plan.amount) + .await + .unwrap_or_else(|error| { + tracing::warn!( + error = %error, + relay_id = %relay.id, + "failed to determine relay plan downgrade direction" + ); + false + }); + + let updated_item_id = self + .stripe_update_subscription_item(existing_item_id, stripe_price_id) + .await?; + + if is_downgrade { + self.validate_downgrade_proration(&tenant, "paid-plan-downgrade") + .await; + } + + updated_item_id } else { self.stripe_create_subscription_item(subscription_id, stripe_price_id) .await? @@ -275,6 +297,10 @@ impl Billing { let customer = obj["customer"].as_str().unwrap_or_default(); self.handle_subscription_deleted(customer).await?; } + "payment_method.attached" => { + let customer = obj["customer"].as_str().unwrap_or_default(); + self.handle_payment_method_attached(customer).await?; + } _ => {} } @@ -492,6 +518,103 @@ impl Billing { Ok(()) } + async fn handle_payment_method_attached(&self, stripe_customer_id: &str) -> Result<()> { + if stripe_customer_id.is_empty() { + return Ok(()); + } + + let Some(tenant) = self + .query + .get_tenant_by_stripe_customer_id(stripe_customer_id) + .await? + else { + return Ok(()); + }; + + self.pay_outstanding_card_invoices(&tenant).await?; + Ok(()) + } + + async fn is_subscription_item_downgrade( + &self, + item_id: &str, + next_plan_amount: i64, + ) -> Result { + let Some(current_price_id) = self.stripe_get_subscription_item_price_id(item_id).await? + else { + return Ok(false); + }; + + let Some(current_plan_amount) = Self::plan_amount_from_price_id(¤t_price_id) else { + return Ok(false); + }; + + Ok(next_plan_amount < current_plan_amount) + } + + fn plan_amount_from_price_id(price_id: &str) -> Option { + Query::list_plans().into_iter().find_map(|plan| { + if plan.stripe_price_id.as_deref() == Some(price_id) { + Some(plan.amount) + } else { + None + } + }) + } + + async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) { + match self + .stripe_preview_upcoming_invoice( + &tenant.stripe_customer_id, + tenant.stripe_subscription_id.as_deref(), + ) + .await + { + Ok(upcoming) => { + let lines = upcoming["lines"]["data"] + .as_array() + .cloned() + .unwrap_or_default(); + let proration_lines = lines + .iter() + .filter(|line| line["proration"].as_bool().unwrap_or(false)) + .count(); + let amount_due = upcoming["amount_due"] + .as_i64() + .unwrap_or_else(|| upcoming["total"].as_i64().unwrap_or(0)); + let currency = upcoming["currency"].as_str().unwrap_or("usd"); + let preview_id = upcoming["id"].as_str().unwrap_or_default(); + + tracing::info!( + tenant_pubkey = %tenant.pubkey, + stripe_customer_id = %tenant.stripe_customer_id, + context, + preview_id, + proration_lines, + amount_due, + currency, + "validated Stripe proration preview for downgrade" + ); + + if proration_lines == 0 { + tracing::warn!( + tenant_pubkey = %tenant.pubkey, + context, + "downgrade proration preview has no proration lines; verify in Stripe dashboard" + ); + } + } + Err(error) => { + tracing::warn!( + error = %error, + tenant_pubkey = %tenant.pubkey, + context, + "failed to fetch downgrade proration preview" + ); + } + } + } + // --- Public API helpers --- pub async fn get_invoice_with_tenant( @@ -599,6 +722,93 @@ 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(()) + } + + async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> { + if !self + .stripe_has_payment_method(&tenant.stripe_customer_id) + .await? + { + 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(); + + if status != "open" || amount_due == 0 || invoice_id.is_empty() { + continue; + } + + if let Err(error) = self.stripe_pay_invoice(invoice_id).await { + tracing::error!( + error = %error, + invoice_id, + "failed to retry card payment for outstanding invoice" + ); + } + } + + Ok(()) + } + pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result { let resp = self .http @@ -715,6 +925,48 @@ impl Billing { Ok(()) } + async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> { + self.http + .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) + .bearer_auth(&self.stripe_secret_key) + .send() + .await? + .error_for_status()?; + + Ok(()) + } + + async fn stripe_get_subscription_item_price_id(&self, item_id: &str) -> Result> { + let resp = self + .http + .get(format!("{STRIPE_API}/subscription_items/{item_id}")) + .bearer_auth(&self.stripe_secret_key) + .send() + .await?; + + let body: serde_json::Value = resp.error_for_status()?.json().await?; + Ok(body["price"]["id"].as_str().map(ToString::to_string)) + } + + async fn stripe_preview_upcoming_invoice( + &self, + customer_id: &str, + subscription_id: Option<&str>, + ) -> Result { + let mut req = self + .http + .get(format!("{STRIPE_API}/invoices/upcoming")) + .bearer_auth(&self.stripe_secret_key) + .query(&[("customer", customer_id)]); + + if let Some(subscription_id) = subscription_id { + req = req.query(&[("subscription", subscription_id)]); + } + + let body: serde_json::Value = req.send().await?.error_for_status()?.json().await?; + Ok(body) + } + async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> { self.http .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index 9e3315a..5b2381e 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -3,7 +3,6 @@ import QRCode from "qrcode" import Modal from "@/components/Modal" import PaymentSetup from "@/components/PaymentSetup" import { getInvoice, getInvoiceBolt11 } from "@/lib/api" -import { tenantNeedsPaymentSetup } from "@/lib/hooks" type PayStatus = "idle" | "loading" | "success" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error" @@ -26,8 +25,8 @@ export default function PaymentDialog(props: PaymentDialogProps) { const [bolt11Error, setBolt11Error] = createSignal("") const [payStatus, setPayStatus] = createSignal("idle") 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 @@ -63,7 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) { const invoice = await getInvoice(props.invoice.id) if (invoice.status === "paid") { setPayStatus("success") - tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {}) } else { setPayStatus("error") setPayError("Payment not yet confirmed. Please try again after sending.") @@ -81,7 +79,6 @@ export default function PaymentDialog(props: PaymentDialogProps) { setBolt11Error("") setBolt11("") setQrDataUrl("") - setShowSetup(false) props.onClose() } @@ -160,6 +157,15 @@ export default function PaymentDialog(props: PaymentDialogProps) { +
+ +
} @@ -172,15 +178,13 @@ export default function PaymentDialog(props: PaymentDialogProps) {

Payment confirmed!

Thank you. Your account is up to date.

- - - + @@ -228,7 +232,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..81d3c50 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. If an invoice is currently due, we will retry collection after card setup.

+ + + + + + )} + + {(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) => ( + + )} + ) }