diff --git a/backend/.env.template b/backend/.env.template index 3c15293..0a2442b 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -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 diff --git a/frontend/src/components/PaymentSetupCard.tsx b/frontend/src/components/PaymentSetupCard.tsx new file mode 100644 index 0000000..3149c15 --- /dev/null +++ b/frontend/src/components/PaymentSetupCard.tsx @@ -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 ( + +
+
+
+

+ {props.isUpdate ? "Manage Card" : "Add a Card"} +

+

+ {props.isUpdate + ? "Manage your saved card in the Stripe billing portal." + : "Add a card via the Stripe billing portal to pay invoices automatically."} +

+
+ +
+
+ +
+
+
+ + + + +
+

+ {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."} +

+ +
+
+ + +
+

{error()}

+
+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/PaymentSetupNWC.tsx b/frontend/src/components/PaymentSetupNWC.tsx new file mode 100644 index 0000000..bef7913 --- /dev/null +++ b/frontend/src/components/PaymentSetupNWC.tsx @@ -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 ( + +
+
+
+

+ {props.isUpdate ? "Update Lightning Wallet" : "Connect Lightning Wallet"} +

+

+ {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."} +

+
+ +
+
+ +
+ +
+ + + +
+

Wallet connected!

+

Automatic payments are now enabled.

+
+ } + > +
+ + setNwcUrl(e.currentTarget.value)} + placeholder="nostr+walletconnect://..." + class="w-full border border-gray-300 rounded-lg px-3 py-2" + /> + +
+ + + + +
+

{error()}

+
+
+ +
+ + Cancel + + } + > +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts index 42a3128..83c8981 100644 --- a/frontend/src/lib/billing.ts +++ b/frontend/src/lib/billing.ts @@ -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" +} diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index a6446c4..82a0cf3 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -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 = { @@ -22,13 +23,25 @@ const invoiceStatusStyles: Record = { void: "bg-gray-100 text-gray-500 border-gray-200", } +const accountStatusStyles: Record = { + 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 = { + "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() - 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(() => { + 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(() => { + 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() {
-
-

Account Status

+
+

Account & Billing

- - tenant + + {status()}
-
-
-
-

Recurring Billing

- -
- - {/* Current balance */} -
+ {/* Current balance (relocated from the old Recurring Billing section, logic unchanged) */} +

Current balance

-

- Enable automatic payments by providing your Nostr Wallet Connect URL, or add a card via Manage Billing. -

- -

A wallet is connected. Enter a new URL to replace it.

-
-
- setNwcUrl(e.currentTarget.value)} - placeholder="nostr+walletconnect://..." - class="flex-1 border border-gray-300 rounded-lg px-3 py-2" - /> - -
- -

{error()}

-
+ {/* Billing methods */} +

Billing methods

+
    + {/* Lightning / NWC row — CTA opens the NWC modal */} +
  • +
    +
    +

    Lightning (NWC)

    + +

    {nwcState().error}

    +
    +
    +
    + + {nwcState().status} + + +
    +
    +
  • + + {/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */} +
  • +
    +
    +

    Card

    + +

    {cardState().error}

    +
    +
    +
    + + {cardState().status} + + +
    +
    +
  • +
@@ -239,6 +259,24 @@ export default function Account() { /> )} + + { + setNwcModalOpen(false) + billing.refetch() + }} + /> + + { + setCardModalOpen(false) + billing.refetch() + }} + /> ) } diff --git a/frontend/src/pages/relays/RelayList.tsx b/frontend/src/pages/relays/RelayList.tsx index bb9299a..d0d5488 100644 --- a/frontend/src/pages/relays/RelayList.tsx +++ b/frontend/src/pages/relays/RelayList.tsx @@ -32,7 +32,7 @@ export default function RelayList() {