Split payment setup into separate components

This commit is contained in:
Jon Staab
2026-06-01 14:39:58 -07:00
parent fed9387617
commit 572f772ed1
6 changed files with 411 additions and 83 deletions
+3
View File
@@ -4,6 +4,9 @@ SERVER_PORT=2892
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
SERVER_ADMIN_PUBKEYS=
# Frontend
APP_URL=http://127.0.0.1:5173
# Database
DATABASE_URL=sqlite://data/caravel.db
@@ -0,0 +1,112 @@
import { createSignal, Show } from "solid-js"
import Modal from "@/components/Modal"
import { createPortalSession } from "@/lib/api"
import { account } from "@/lib/state"
type PaymentSetupCardProps = {
open: boolean
onClose: () => void
// The tenant already has a card on file, so the copy frames this as managing
// the existing one rather than adding a first card.
isUpdate?: boolean
}
// Focused card dialog. PaymentSetup offers both methods behind tabs for the
// general setup flow; here the entry point is explicitly "manage your card", so
// there's no method switcher — adding/updating a card is a redirect to the
// Stripe billing portal, which returns to wherever it was opened from.
export default function PaymentSetupCard(props: PaymentSetupCardProps) {
const [redirecting, setRedirecting] = createSignal(false)
const [error, setError] = createSignal("")
async function openPortal() {
setRedirecting(true)
setError("")
try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
setRedirecting(false)
}
}
function handleClose() {
setError("")
props.onClose()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">
{props.isUpdate ? "Manage Card" : "Add a Card"}
</h2>
<p class="text-sm text-gray-500 mt-1">
{props.isUpdate
? "Manage your saved card in the Stripe billing portal."
: "Add a card via the Stripe billing portal to pay invoices automatically."}
</p>
</div>
<button
type="button"
onClick={handleClose}
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">
{props.isUpdate
? "Update or remove your card in the Stripe billing portal. We'll retry any due invoice after you're done."
: "Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup."}
</p>
<button
type="button"
onClick={openPortal}
disabled={redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{redirecting() ? "Redirecting..." : props.isUpdate ? "Manage card" : "Add a payment card"}
</button>
</div>
</div>
<Show when={error()}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{error()}</p>
</div>
</Show>
<div class="px-6 py-4 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</Modal>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { createSignal, Show } from "solid-js"
import Modal from "@/components/Modal"
import { updateActiveTenant } from "@/lib/hooks"
type PaymentSetupNWCProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// The tenant already has a wallet connected, so the copy frames this as
// replacing it (the stored URL is write-only and never sent back).
isUpdate?: boolean
}
// Focused Lightning/NWC connect dialog. PaymentSetup offers both methods behind
// tabs for the general setup flow; here the entry point is explicitly "connect a
// Lightning wallet", so there's no method switcher — the card path lives on its
// own row that redirects to Stripe.
export default function PaymentSetupNWC(props: PaymentSetupNWCProps) {
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [saved, setSaved] = createSignal(false)
const [error, setError] = createSignal("")
async function save() {
const url = nwcUrl().trim()
if (!url) return
setSaving(true)
setError("")
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
props.onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
setSaving(false)
}
}
function handleClose() {
setNwcUrl("")
setSaved(false)
setError("")
props.onClose()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">
{props.isUpdate ? "Update Lightning Wallet" : "Connect Lightning Wallet"}
</h2>
<p class="text-sm text-gray-500 mt-1">
{props.isUpdate
? "Paste a new Nostr Wallet Connect URL to replace your connected wallet."
: "Paste your Nostr Wallet Connect URL to pay invoices automatically over Lightning."}
</p>
</div>
<button
type="button"
onClick={handleClose}
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">
<Show
when={!saved()}
fallback={
<div class="text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p class="text-sm font-medium text-gray-900">Wallet connected!</p>
<p class="text-xs text-gray-500 mt-1">Automatic payments are now enabled.</p>
</div>
}
>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Nostr Wallet Connect URL</label>
<input
type="text"
value={nwcUrl()}
onInput={(e) => setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={save}
disabled={saving() || !nwcUrl().trim()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving() ? "Saving..." : "Save"}
</button>
</div>
</Show>
</div>
<Show when={error()}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{error()}</p>
</div>
</Show>
<div class="px-6 py-4 border-t border-gray-100">
<Show
when={saved()}
fallback={
<button
type="button"
onClick={handleClose}
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
}
>
<div class="flex justify-end">
<button
type="button"
onClick={handleClose}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
Done
</button>
</div>
</Show>
</div>
</Modal>
)
}
+28
View File
@@ -104,3 +104,31 @@ export function activeBillingPrompt(
return null
}
export type AccountStatus = "active" | "inactive" | "delinquent"
// Coarse account-health summary for the status badge. Pure function of the same
// snapshot `activeBillingPrompt` consumes, so the badge can never disagree with
// the prompt. Mutually exclusive and total:
// - delinquent: churned_at is set — the ONLY frontend-visible signal of a real
// suspension (churn_tenant is the single place relays are paused). An open
// invoice alone is NOT delinquency: the tenant has a 7-day grace window and
// autopay may simply not have fired yet. Matches the sole severity:"error"
// branch in activeBillingPrompt.
// - active: not churned AND there is paid business to keep running — an active
// paid relay, an open balance, or a configured payment method. A failed method
// (nwc_error/stripe_error) or an unpaid invoice within grace stays "active";
// the per-method rows and the inline prompt carry that detail.
// - inactive: not churned and nothing billable — no paid relay, no balance, no
// method. The brand-new or free-only tenant (typically billing_anchor == null).
export function accountStatus(s: BillingStatusSnapshot): AccountStatus {
const tenant = s.tenant
if (!tenant) return "inactive"
if (tenant.churned_at) return "delinquent"
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
const hasOpenInvoice = Boolean(s.openInvoice)
if (s.hasPaidSubscription || hasOpenInvoice || autopayConfigured) return "active"
return "inactive"
}
+120 -82
View File
@@ -5,9 +5,10 @@ import PaymentDialog from "@/components/PaymentDialog"
import BillingPrompts from "@/components/BillingPrompts"
import useMinLoading from "@/components/useMinLoading"
import { useInvoicePdf } from "@/lib/useInvoicePdf"
import { updateActiveTenant } from "@/lib/hooks"
import { useBillingStatus } from "@/lib/billing"
import { createPortalSession, invoiceStatus, type Invoice } from "@/lib/api"
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
import PaymentSetupCard from "@/components/PaymentSetupCard"
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
import { invoiceStatus, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
const methodLabels: Record<string, string> = {
@@ -22,13 +23,25 @@ const invoiceStatusStyles: Record<string, string> = {
void: "bg-gray-100 text-gray-500 border-gray-200",
}
const accountStatusStyles: Record<AccountStatus, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
delinquent: "bg-red-50 text-red-700 border-red-200",
}
type MethodState = { status: "not set up" | "ok" | "error"; error?: string }
const methodStatusStyles: Record<MethodState["status"], string> = {
"not set up": "bg-gray-100 text-gray-500 border-gray-200",
ok: "bg-green-50 text-green-700 border-green-200",
error: "bg-red-50 text-red-700 border-red-200",
}
export default function Account() {
const billing = useBillingStatus()
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [error, setError] = createSignal("")
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
const [portalLoading, setPortalLoading] = createSignal(false)
const [nwcModalOpen, setNwcModalOpen] = createSignal(false)
const [cardModalOpen, setCardModalOpen] = createSignal(false)
const invoicesLoading = useMinLoading(() => billing.loading())
const { printInvoice, printing } = useInvoicePdf()
@@ -39,9 +52,31 @@ export default function Account() {
if (account()?.pubkey) billing.refetch()
})
// 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)
// Coarse account-health summary for the badge. Same snapshot the inline prompt
// consumes, so the two can never disagree.
const status = createMemo(() =>
accountStatus({
tenant: billing.tenant(),
openInvoice: billing.openInvoice(),
hasPaidSubscription: billing.hasPaidSubscription(),
}),
)
// Per-method state, reported independently so a concurrent error on one method
// isn't masked by the other.
const nwcState = createMemo<MethodState>(() => {
const t = billing.tenant()
if (!t?.nwc_is_set) return { status: "not set up" }
if (t.nwc_error) return { status: "error", error: t.nwc_error }
return { status: "ok" }
})
const cardState = createMemo<MethodState>(() => {
const t = billing.tenant()
if (!t?.stripe_payment_method_id) return { status: "not set up" }
if (t.stripe_error) return { status: "error", error: t.stripe_error }
return { status: "ok" }
})
// The amount to surface: the total of any open invoices, else nothing owed.
const balance = createMemo(() => {
@@ -49,32 +84,6 @@ export default function Account() {
return due > 0 ? { kind: "due" as const, amount: due } : { kind: "clear" as const, amount: 0 }
})
async function saveBilling() {
setError("")
setSaving(true)
try {
await updateActiveTenant({ nwc_url: nwcUrl().trim() })
setNwcUrl("")
billing.refetch()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
} finally {
setSaving(false)
}
}
async function openPortal() {
setPortalLoading(true)
try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
} finally {
setPortalLoading(false)
}
}
function logout() {
localStorage.clear()
window.location.href = "/"
@@ -98,31 +107,17 @@ export default function Account() {
<BillingPrompts variant="inline" />
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
<div class="flex items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-900">Account &amp; Billing</h2>
<Show when={billing.tenant()}>
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
tenant
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${accountStatusStyles[status()]}`}>
{status()}
</span>
</Show>
</div>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recurring Billing</h2>
<button
type="button"
onClick={openPortal}
disabled={portalLoading()}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{portalLoading() ? "Loading..." : "Manage Billing"}
</button>
</div>
{/* Current balance */}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
{/* Current balance (relocated from the old Recurring Billing section, logic unchanged) */}
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Current balance</p>
<Show
when={balance().kind === "due"}
@@ -132,32 +127,57 @@ export default function Account() {
</Show>
</div>
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL, or add a card via Manage Billing.
</p>
<Show when={billing.tenant()?.nwc_is_set}>
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
</Show>
<div class="flex gap-2">
<input
type="text"
value={nwcUrl()}
onInput={(e) => setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="flex-1 border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={saveBilling}
disabled={saving() || !hasBillingChanges()}
class="py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{saving() ? "Saving..." : "Save"}
</button>
</div>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
{/* Billing methods */}
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Billing methods</p>
<ul class="space-y-3">
{/* Lightning / NWC row — CTA opens the NWC modal */}
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Lightning (NWC)</p>
<Show when={nwcState().status === "error" && nwcState().error}>
<p class="text-xs text-red-600 mt-0.5 break-words">{nwcState().error}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[nwcState().status]}`}>
{nwcState().status}
</span>
<button
type="button"
onClick={() => setNwcModalOpen(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{nwcState().status === "not set up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
{/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */}
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Card</p>
<Show when={cardState().status === "error" && cardState().error}>
<p class="text-xs text-red-600 mt-0.5 break-words">{cardState().error}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[cardState().status]}`}>
{cardState().status}
</span>
<button
type="button"
onClick={() => setCardModalOpen(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{cardState().status === "not set up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
</ul>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
@@ -239,6 +259,24 @@ export default function Account() {
/>
)}
</Show>
<PaymentSetupNWC
open={nwcModalOpen()}
isUpdate={nwcState().status !== "not set up"}
onClose={() => {
setNwcModalOpen(false)
billing.refetch()
}}
/>
<PaymentSetupCard
open={cardModalOpen()}
isUpdate={cardState().status !== "not set up"}
onClose={() => {
setCardModalOpen(false)
billing.refetch()
}}
/>
</PageContainer>
)
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function RelayList() {
<div class="mb-6 grid gap-3 sm:grid-cols-[1fr_auto]">
<SearchInput value={query()} onInput={setQuery} placeholder="Search by name or subdomain" />
<select value={status()} onChange={e => setStatus(e.currentTarget.value)} class="border border-gray-300 rounded-lg px-3 py-2 bg-white">
<option value="all">All statuses</option>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>