From 34d5e732f4b447c97a4a70a6e0cdd70654a54df5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 25 May 2026 10:51:19 -0700 Subject: [PATCH] Add endpoint for paying an invoice so that users don't get expired qr codes --- backend/.env.template | 3 +- backend/spec/api.md | 2 +- backend/spec/env.md | 1 + backend/src/env.rs | 2 + backend/src/routes/stripe.rs | 23 ++++--- frontend/src/components/PaymentDialog.tsx | 75 ++++++++++++++++++----- frontend/src/pages/Account.tsx | 22 ++++++- 7 files changed, 95 insertions(+), 33 deletions(-) diff --git a/backend/.env.template b/backend/.env.template index ed19339..60360dd 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -3,6 +3,7 @@ SERVER_HOST=127.0.0.1 SERVER_PORT=2892 SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access +APP_URL=http://127.0.0.1:5173 # Public base URL of the frontend, used for links in billing DMs # Database DATABASE_URL=sqlite://data/caravel.db @@ -24,7 +25,7 @@ LIVEKIT_URL= LIVEKIT_API_KEY= LIVEKIT_API_SECRET= -# Blossom S3 (optional; when region, bucket, access key, and secret are all set, relays with blossom enabled sync with adapter s3 and key_prefix = relay schema) +# Blossom S3 BLOSSOM_S3_ENDPOINT= BLOSSOM_S3_REGION= BLOSSOM_S3_BUCKET= diff --git a/backend/spec/api.md b/backend/spec/api.md index f18ffd9..475b052 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -219,7 +219,7 @@ Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fi 1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through. 2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt. -3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any), with a link to the app for manual Lightning payment. +3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any). The DM includes a link to the dashboard payment view for this invoice — `{env.app_url}/account?invoice={stripe_invoice_id}` — where the tenant can review the invoice and pay by Lightning or card. ## `invoice.paid` diff --git a/backend/spec/env.md b/backend/spec/env.md index d0372cb..19060de 100644 --- a/backend/spec/env.md +++ b/backend/spec/env.md @@ -8,6 +8,7 @@ Members (all populated from environment variables): - `server_port: u16` - from `SERVER_PORT` - `server_admin_pubkeys: Vec` - admin pubkeys from `SERVER_ADMIN_PUBKEYS` - `server_allow_origins: Vec` - CORS origins from `SERVER_ALLOW_ORIGINS` +- `app_url: String` - public base URL of the frontend app from `APP_URL`, with any trailing slash stripped; used to build tenant-facing links such as the invoice payment link in billing DMs - `database_url: String` - from `DATABASE_URL` - `robot_name: String` - from `ROBOT_NAME` - `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices diff --git a/backend/src/env.rs b/backend/src/env.rs index 614a332..e6fc74d 100644 --- a/backend/src/env.rs +++ b/backend/src/env.rs @@ -7,6 +7,7 @@ pub struct Env { pub server_port: u16, pub server_admin_pubkeys: Vec, pub server_allow_origins: Vec, + pub app_url: String, pub database_url: String, pub robot_name: String, pub robot_wallet: String, @@ -43,6 +44,7 @@ impl Env { server_port: require_u16("SERVER_PORT"), server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"), server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"), + app_url: require_str("APP_URL").trim_end_matches('/').to_string(), database_url: require_str("DATABASE_URL"), robot_name: require_str("ROBOT_NAME"), robot_wallet: require_str("ROBOT_WALLET"), diff --git a/backend/src/routes/stripe.rs b/backend/src/routes/stripe.rs index ef4d1a6..e388e18 100644 --- a/backend/src/routes/stripe.rs +++ b/backend/src/routes/stripe.rs @@ -12,7 +12,7 @@ use crate::api::{Api, AuthedPubkey}; use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT}; use crate::web::{ApiResult, bad_request, internal, ok}; -const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment."; +const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:"; const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:"; const NWC_ERROR_DM_MAX_CHARS: usize = 240; @@ -165,8 +165,16 @@ async fn handle_invoice_created( return Ok(()); } - // 3. Manual payment: send a DM - let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref()); + // 3. Manual payment: DM a link to the in-app payment page for this invoice + let url_base = &api.env.app_url; + let payment_url = format!("{url_base}/account?invoice={stripe_invoice_id}"); + let base = format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{payment_url}"); + let dm_message = match nwc_error_for_dm { + Some(error) if !error.is_empty() => { + format!("{base}\n\n{NWC_ERROR_DM_PREFIX} {error}") + } + _ => base, + }; api.robot.send_dm(&tenant.pubkey, &dm_message).await?; Ok(()) @@ -339,12 +347,3 @@ fn summarize_nwc_error_for_dm(error: &str) -> Option { truncated.push_str("..."); Some(truncated) } - -fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String { - match nwc_error { - Some(error) if !error.is_empty() => { - format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}") - } - _ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(), - } -} diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index 5b2381e..e15e973 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -1,16 +1,16 @@ -import { createEffect, createSignal, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import QRCode from "qrcode" import Modal from "@/components/Modal" import PaymentSetup from "@/components/PaymentSetup" -import { getInvoice, getInvoiceBolt11 } from "@/lib/api" +import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api" +import { useTenantRelays } from "@/lib/hooks" +import { plans } from "@/lib/state" type PayStatus = "idle" | "loading" | "success" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error" -type PaymentInvoice = { - id: string - amount_due: number -} +type PaymentInvoice = Pick & + Partial> type PaymentDialogProps = { invoice: PaymentInvoice @@ -27,6 +27,14 @@ export default function PaymentDialog(props: PaymentDialogProps) { const [payError, setPayError] = createSignal("") const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [setupSaved, setSetupSaved] = createSignal(false) + const [relays] = useTenantRelays() + + const billedRelays = createMemo(() => { + const planById = new Map(plans().map((p) => [p.id, p])) + return (relays() ?? []) + .map((relay) => ({ relay, plan: planById.get(relay.plan) })) + .filter((entry) => entry.plan?.stripe_price_id) + }) async function loadBolt11() { if (!props.invoice.id) return @@ -84,6 +92,14 @@ export default function PaymentDialog(props: PaymentDialogProps) { const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}` + const periodLabel = () => { + const { period_start, period_end } = props.invoice + if (!period_start || !period_end) return "" + const start = new Date(period_start * 1000).toLocaleDateString() + const end = new Date(period_end * 1000).toLocaleDateString() + return `${start} – ${end}` + } + return ( <>

Pay Invoice

{amountLabel()}

+ +

Billing period {periodLabel()}

+
-
- -
+ + {/* Card / automatic payment alternative */} +
+

Prefer to pay with a card?

+ +
} > diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index 6815a26..d8011de 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -1,10 +1,11 @@ -import { createMemo, createResource, createSignal, For, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js" +import { useSearchParams } from "@solidjs/router" import PageContainer from "@/components/PageContainer" import LoadingState from "@/components/LoadingState" import PaymentDialog from "@/components/PaymentDialog" import useMinLoading from "@/components/useMinLoading" import { updateActiveTenant, useTenant } from "@/lib/hooks" -import { createPortalSession, listTenantInvoices, type Invoice } from "@/lib/api" +import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api" import { account } from "@/lib/state" export default function Account() { @@ -17,6 +18,19 @@ export default function Account() { const [portalLoading, setPortalLoading] = createSignal(false) const invoicesLoading = useMinLoading(() => invoices.loading) + // Deep link: /account?invoice= (e.g. from the billing DM) fetches that + // invoice and opens the payment dialog. The fetched invoice takes precedence + // over a row the user clicked in the list. + const [searchParams, setSearchParams] = useSearchParams() + const [deepLinkedInvoice] = createResource( + () => searchParams.invoice as string | undefined, + (id) => getInvoice(id), + ) + createEffect(() => { + if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.") + }) + const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice() + // The backend never returns the stored nwc_url (it's private), so the input is // write-only: we can only act on a newly entered URL, not prefill the saved one. const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0) @@ -38,6 +52,8 @@ export default function Account() { function handleInvoiceDialogClose() { setSelectedInvoice(undefined) + // Clearing the query param drops the deep-linked invoice and closes the dialog. + if (searchParams.invoice) setSearchParams({ invoice: undefined }) void refetchInvoices() } @@ -186,7 +202,7 @@ export default function Account() { - + {(invoice) => (