Compare commits

..

1 Commits

Author SHA1 Message Date
userAdityaa 41602e21c2 feat: display relay provisioning errors in UI 2026-04-20 18:08:38 +00:00
14 changed files with 43 additions and 480 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ Notes:
- Serves `POST /relays/:id/deactivate`
- Authorizes admin or relay owner
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
- If relay is already inactive, return a `400` with `code=relay-is-inactive`
- Call `command.deactivate_relay`
- Return `data` is empty
+5 -18
View File
@@ -26,11 +26,9 @@ 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.
- **If relay is `inactive`**: 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`.
@@ -68,20 +66,9 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
- 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. 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:
Attempts to pay a new subscription invoice. 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)
@@ -98,7 +85,7 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id`
- If tenant has `past_due_at` set:
- Clear `past_due_at` via `command.clear_tenant_past_due`
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
- Reactivate each one via `command.activate_relay`
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
@@ -111,7 +98,7 @@ Skip invoices with `amount_due` of 0.
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
- Deactivate all active relays on paid plans via `command.deactivate_relay`
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
@@ -119,7 +106,7 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id`
- If subscription status is `canceled` or `unpaid`:
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
-8
View File
@@ -44,14 +44,6 @@ Notes:
- Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)`
- Used for user/admin-initiated deactivation only
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent`
- Logs activity as `(deactivate_relay, relay_id)`
- Used exclusively by the billing system when a relay's subscription becomes past due
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
+1 -1
View File
@@ -69,7 +69,7 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
- `subdomain` - the relay's subdomain
- `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
- `status` - `active|inactive`. Only `active` relays count toward billing.
- `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name
+1 -18
View File
@@ -945,29 +945,12 @@ 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(()) => {
// 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))
}
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
-66
View File
@@ -599,62 +599,6 @@ 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<String> {
let resp = self
.http
@@ -861,16 +805,6 @@ impl Billing {
}
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
// BTC_PRICE_USD_OVERRIDE bypasses the external price API entirely.
// Set this to a fixed USD/BTC rate (e.g. "97000") for local testing
// when Coinbase is unreachable. Remove or leave unset in production.
if let Ok(val) = std::env::var("BTC_PRICE_USD_OVERRIDE") {
let price = val
.trim()
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC_PRICE_USD_OVERRIDE: {e}"))?;
return Ok(price);
}
fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
}
-151
View File
@@ -1,151 +0,0 @@
import { For, Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
type ConfirmDialogProps = {
open: boolean
title: string
description: string
/** Optional bullet points shown in a warning box below the description */
details?: string[]
confirmLabel: string
busyLabel?: string
busy?: boolean
tone?: "danger" | "primary"
onConfirm: () => void | Promise<void>
onClose: () => void
}
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "bg-red-600 text-white hover:bg-red-700",
primary: "bg-blue-600 text-white hover:bg-blue-700",
}
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "border-amber-200 bg-amber-50 text-amber-800",
primary: "border-blue-200 bg-blue-50 text-blue-800",
}
type ConfirmDialogSnapshot = {
title: string
description: string
details?: string[]
confirmLabel: string
busyLabel?: string
busy: boolean
tone: NonNullable<ConfirmDialogProps["tone"]>
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
createEffect(() => {
if (!props.open) return
setSnapshot({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
})
const content = () => props.open
? {
title: props.title,
description: props.description,
details: props.details,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
}
: snapshot()
const tone = () => content().tone
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
function handleClose() {
if (props.busy) return
props.onClose()
}
function handleConfirm() {
if (props.busy) return
void props.onConfirm()
}
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">{content().title}</h2>
</div>
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
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">
<div class="space-y-3 text-left">
<p class="text-sm text-gray-600">{content().description}</p>
<Show when={content().details && content().details!.length > 0}>
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
<For each={content().details}>
{(item) => (
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 shrink-0 select-none"></span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={content().busy}
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
>
{confirmText()}
</button>
</div>
</Modal>
)
}
+1 -18
View File
@@ -28,7 +28,6 @@ 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
@@ -161,15 +160,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</button>
</div>
</Show>
<div class="text-center pt-1">
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm text-blue-600 hover:text-blue-700"
>
Set up payment method instead
</button>
</div>
</Show>
</div>
}
@@ -238,14 +228,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal>
<PaymentSetup
open={showPaymentSetup()}
onClose={() => {
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
onClose={() => setShowPaymentSetup(false)}
/>
</>
)
+2 -4
View File
@@ -9,7 +9,6 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
}
export default function PaymentSetup(props: PaymentSetupProps) {
@@ -28,7 +27,6 @@ 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 {
@@ -66,7 +64,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
</div>
<button
type="button"
@@ -146,7 +144,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing once invoices are issued.</p>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<button
type="button"
onClick={openPortal}
+8 -62
View File
@@ -2,7 +2,6 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
@@ -52,8 +51,8 @@ type RelayDetailCardProps = {
currentMembers?: number
showTenant?: boolean
editHref?: string
onDeactivate?: () => void | Promise<void>
onReactivate?: () => void | Promise<void>
onDeactivate?: () => void
onReactivate?: () => void
deactivating?: boolean
reactivating?: boolean
onTogglePublicJoin?: () => void
@@ -77,7 +76,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined
@@ -88,24 +86,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
const confirmDescription = () => pendingAction() === "deactivate"
? `${relayLabel()} will be taken offline immediately.`
: `${relayLabel()} will come back online and start accepting connections.`
const confirmDetails = () => pendingAction() === "deactivate"
? [
"All client connections will be dropped immediately.",
"Members will be unable to read from or publish to the relay.",
"Scheduled and automated tasks (billing, syncing) will be paused.",
"All relay data, settings, and members are preserved, nothing is deleted.",
"You can reactivate at any time from this page.",
]
: undefined
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlan(plan: PlanId) {
setPlan(plan)
@@ -117,29 +97,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
}
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
setPendingAction(null)
}
createEffect(() => {
if (!menuOpen()) return
@@ -171,7 +128,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
@@ -191,7 +148,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}>
<div class="relative flex-shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
@@ -220,7 +177,8 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
openActionDialog("deactivate")
setMenuOpen(false)
props.onDeactivate?.()
}}
disabled={props.deactivating}
>
@@ -232,7 +190,8 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
openActionDialog("reactivate")
setMenuOpen(false)
props.onReactivate?.()
}}
disabled={props.reactivating}
>
@@ -387,19 +346,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show>
</DetailSection>
</Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div>
)
}
+1 -11
View File
@@ -12,14 +12,12 @@ import {
getTenant,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
updateRelay,
updateTenant,
type Activity,
type CreateRelayInput,
type Invoice,
type Relay,
type Tenant,
type UpdateRelayInput,
@@ -139,14 +137,6 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
return !tenant.nwc_url && !tenant.stripe_subscription_id
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0)
.sort((a, b) => b.period_start - a.period_start)
return open[0] ?? null
}
export async function getRelayMembers(url: string) {
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
@@ -157,4 +147,4 @@ export async function getRelayMembers(url: string) {
}
}
export type { Activity, Invoice, Relay, Tenant }
export type { Activity, Relay, Tenant }
+6 -6
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api"
import type { PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
@@ -30,7 +30,7 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions,
) {
const [busy, setBusy] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) {
mutate(next)
@@ -101,8 +101,8 @@ export default function useRelayToggles(
}
if (plan !== "free") {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
const needs = await tenantNeedsPaymentSetup()
if (needs) setNeedsPaymentSetup(true)
}
}
@@ -116,5 +116,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
}
+5 -98
View File
@@ -1,16 +1,14 @@
import { useParams } from "@solidjs/router"
import { createMemo, createResource, createSignal, Show } from "solid-js"
import { createMemo, createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { getLatestOpenInvoice, getRelayMembers, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
@@ -23,32 +21,7 @@ export default function RelayDetail() {
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_url
})
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
return (
<PageContainer>
@@ -57,44 +30,6 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" 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>
</Show>
<RelayDetailCard
relay={r()}
currentMembers={members.length}
@@ -110,37 +45,9 @@ export default function RelayDetail() {
</div>
)}
</Show>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
}}
/>
)}
</Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
}}
open={needsPaymentSetup()}
onClose={clearNeedsPaymentSetup}
/>
</PageContainer>
)
+12 -18
View File
@@ -1,15 +1,14 @@
import { createSignal, Show } from "solid-js"
import { createSignal } from "solid-js"
import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
export default function RelayNew() {
const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) {
@@ -17,9 +16,9 @@ export default function RelayNew() {
createdRelayId = relay.id
if (values.plan !== "free") {
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
const needs = await tenantNeedsPaymentSetup()
if (needs) {
setShowPaymentSetup(true)
return
}
}
@@ -28,7 +27,7 @@ export default function RelayNew() {
}
function handleDialogClose() {
setPendingInvoice(undefined)
setShowPaymentSetup(false)
navigate(`/relays/${createdRelayId}`)
}
@@ -42,15 +41,10 @@ export default function RelayNew() {
submitLabel="Create Relay"
submittingLabel="Creating..."
/>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleDialogClose}
/>
)}
</Show>
<PaymentSetup
open={showPaymentSetup()}
onClose={handleDialogClose}
/>
</PageContainer>
)
}