From 1ea087643b61ad0888db325eb483b468f85918fc Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 9 Mar 2026 10:04:13 -0700 Subject: [PATCH] Work on billing --- backend/src/api.rs | 111 ++++++++++++ frontend/src/components/PricingTable.tsx | 108 ++++++++++++ frontend/src/components/RelayDetailCard.tsx | 163 +++++++++++++++--- frontend/src/lib/api.ts | 7 + frontend/src/lib/relayPlans.ts | 42 +++++ frontend/src/pages/Home.tsx | 60 +------ frontend/src/pages/admin/AdminRelayDetail.tsx | 2 + frontend/src/pages/relays/RelayDetail.tsx | 29 +++- 8 files changed, 441 insertions(+), 81 deletions(-) create mode 100644 frontend/src/components/PricingTable.tsx diff --git a/backend/src/api.rs b/backend/src/api.rs index 3203f9c..4f6fecd 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -9,6 +9,7 @@ use axum::{ routing::{get, post, put}, }; use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::auth::verify_nip98; use crate::models::{NewTenant, Relay, RelayConfig}; @@ -33,6 +34,7 @@ pub fn router(state: AppState) -> Router { "/tenant/relays/:id", get(get_tenant_relay).put(update_tenant_relay), ) + .route("/tenant/relays/:id/plan", put(update_tenant_relay_plan)) .route( "/tenant/relays/:id/deactivate", post(deactivate_tenant_relay), @@ -401,6 +403,115 @@ async fn update_tenant_relay( (StatusCode::OK, Json(relay)).into_response() } +#[derive(Debug, Deserialize)] +struct UpdateRelayPlanRequest { + plan: String, +} + +async fn update_tenant_relay_plan( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, + Json(payload): Json, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + let plan = payload.plan.trim().to_lowercase(); + if !matches!(plan.as_str(), "free" | "basic" | "growth") { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "invalid plan".into(), + }), + ) + .into_response(); + } + + let existing = match state.repo.get_relay(&id).await { + Ok(Some(relay)) => relay, + Ok(None) => return not_found(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: "failed to load relay".into(), + }), + ) + .into_response(); + } + }; + + if existing.tenant != pubkey { + return forbidden(); + } + + let mut relay = Relay { + plan, + ..existing + }; + + if relay.plan == "free" { + relay.config = Some(disable_paid_features(relay.config)); + } + + if let Err(_) = state.repo.upsert_relay(&relay).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: "failed to update relay plan".into(), + }), + ) + .into_response(); + } + + if let Err(err) = state.provisioner.update_relay(&relay).await { + tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: format!("failed to provision relay: {err}"), + }), + ) + .into_response(); + } + + let _ = state.repo.update_relay_status(&relay.id, "active").await; + + (StatusCode::OK, Json(relay)).into_response() +} + +fn disable_paid_features(config: Option) -> RelayConfig { + let mut cfg = config.unwrap_or_else(empty_relay_config); + set_config_bool(&mut cfg.blossom, "enabled", false); + set_config_bool(&mut cfg.livekit, "enabled", false); + cfg +} + +fn empty_relay_config() -> RelayConfig { + RelayConfig { + policy: None, + groups: None, + management: None, + blossom: None, + livekit: None, + push: None, + } +} + +fn set_config_bool(section: &mut Option, key: &str, enabled: bool) { + let mut object = section + .take() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + object.insert(key.to_string(), Value::Bool(enabled)); + *section = Some(Value::Object(object)); +} + async fn deactivate_tenant_relay( State(state): State, headers: HeaderMap, diff --git a/frontend/src/components/PricingTable.tsx b/frontend/src/components/PricingTable.tsx new file mode 100644 index 0000000..9e81716 --- /dev/null +++ b/frontend/src/components/PricingTable.tsx @@ -0,0 +1,108 @@ +import { A } from "@solidjs/router" +import { For } from "solid-js" +import { RELAY_PLANS, type RelayPlanId } from "../lib/relayPlans" + +function CheckIcon() { + return ( + + + + ) +} + +function XIcon() { + return ( + + + + + + ) +} + +type PricingTableProps = { + selectable?: boolean + selectedPlan?: RelayPlanId + onSelect?: (plan: RelayPlanId) => void + ctaHref?: string + compactOnMobile?: boolean +} + +export default function PricingTable(props: PricingTableProps) { + return ( +
+ + {(plan) => { + const isPopular = plan.id === "basic" + const isSelected = () => props.selectable && props.selectedPlan === plan.id + + const card = ( + <> + {isPopular && !props.selectable && ( + + POPULAR + + )} +

{plan.label}

+

{plan.subtitle}

+
+ {plan.priceLabel} + sats / mo +
+
    +
  • {plan.memberLabel}
  • +
  • + {plan.blossom ? : } + Blossom storage +
  • +
  • + {plan.livekit ? : } + LiveKit video +
  • +
+ {props.compactOnMobile && props.selectable && ( +
+
{plan.memberLabel.replace(" members", "")}
+
+ {plan.blossom ? "✓" : "✕"} + Blossom +
+
+ {plan.livekit ? "✓" : "✕"} + LiveKit +
+
+ )} + {!props.selectable && ( + + Get started + + )} + + ) + + if (props.selectable) { + return ( + + ) + } + + return ( +
+ {card} +
+ ) + }} +
+
+ ) +} diff --git a/frontend/src/components/RelayDetailCard.tsx b/frontend/src/components/RelayDetailCard.tsx index ff20859..79e2ec5 100644 --- a/frontend/src/components/RelayDetailCard.tsx +++ b/frontend/src/components/RelayDetailCard.tsx @@ -2,6 +2,9 @@ import { A } from "@solidjs/router" import { Show, createEffect, createSignal, onCleanup } from "solid-js" import type { Relay } from "../lib/api" import menuDotsIcon from "../assets/menu-dots-2.svg" +import Modal from "./Modal" +import PricingTable from "./PricingTable" +import { RELAY_PLAN_IDS, type RelayPlanId } from "../lib/relayPlans" function Field(props: { label: string; children: any }) { return ( @@ -53,7 +56,7 @@ function ToggleButton(props: { function DetailSection(props: { title: string; children: any }) { return (
-

{props.title}

+

{props.title}

{props.children}
@@ -64,7 +67,7 @@ function DetailSection(props: { title: string; children: any }) { function MembershipSection(props: { title: string; children: any }) { return (
-

{props.title}

+

{props.title}

{props.children}
@@ -86,12 +89,20 @@ type RelayDetailCardProps = { onToggleMediaStorage?: () => void onToggleLivekitSupport?: () => void onTogglePushNotifications?: () => void + onUpdatePlan?: (plan: RelayPlanId) => Promise + updatingPlan?: boolean + enforcePlanLimits?: boolean + showPlanActions?: boolean } export default function RelayDetailCard(props: RelayDetailCardProps) { const r = () => props.relay const cfg = () => r().config const [menuOpen, setMenuOpen] = createSignal(false) + const [planModalOpen, setPlanModalOpen] = createSignal(false) + const [selectedPlan, setSelectedPlan] = createSignal("free") + const [planError, setPlanError] = createSignal("") + const [submittingPlan, setSubmittingPlan] = createSignal(false) let menuContainerRef: HTMLDivElement | undefined const memberLimitByPlan: Record = { @@ -102,6 +113,32 @@ export default function RelayDetailCard(props: RelayDetailCardProps) { const memberLimitLabel = () => memberLimitByPlan[r().plan] ?? "?" const isTopTier = () => r().plan === "growth" + const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free" + const showPlanActions = () => props.showPlanActions ?? true + + createEffect(() => { + if (!planModalOpen()) return + const current = RELAY_PLAN_IDS.find((id) => id === r().plan) ?? "free" + setSelectedPlan(current) + setPlanError("") + }) + + const canSubmitPlan = () => selectedPlan() !== r().plan + + async function handlePlanContinue() { + if (!props.onUpdatePlan || submittingPlan() || !canSubmitPlan()) return + setPlanError("") + setSubmittingPlan(true) + + try { + await props.onUpdatePlan(selectedPlan()) + setPlanModalOpen(false) + } catch (e) { + setPlanError(e instanceof Error ? e.message : "Failed to update plan") + } finally { + setSubmittingPlan(false) + } + } createEffect(() => { if (!menuOpen()) return @@ -231,14 +268,29 @@ export default function RelayDetailCard(props: RelayDetailCardProps) { - Upgrade Plan - + }> + + Upgrade Plan + + } + > + + + } > - Upgrade Plan - + }> + + Upgrade Plan + + } + > + + + } > {r().tenant} - + - } + > + + Upgrade Plan + + + } > - Upgrade Plan - + + + + setPlanModalOpen(false)} + wrapperClass="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4" + panelClass="w-full max-w-5xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white" + > +
+

Update plan

+

Choose the plan you want for this relay.

+ + + + +

{planError()}

+
+ +
+ +
+
+
) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4a05984..a812e5a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -192,6 +192,13 @@ export function updateTenantRelay(id: string, input: UpdateRelayInput) { }) } +export function updateTenantRelayPlan(id: string, plan: string) { + return request(`/tenant/relays/${id}/plan`, { + method: "PUT", + body: JSON.stringify({ plan }), + }) +} + export function deactivateTenantRelay(id: string) { return request(`/tenant/relays/${id}/deactivate`, { method: "POST", diff --git a/frontend/src/lib/relayPlans.ts b/frontend/src/lib/relayPlans.ts index a175e89..6acfed6 100644 --- a/frontend/src/lib/relayPlans.ts +++ b/frontend/src/lib/relayPlans.ts @@ -1,3 +1,45 @@ export const RELAY_PLAN_IDS = ["free", "basic", "growth"] as const export type RelayPlanId = (typeof RELAY_PLAN_IDS)[number] + +export const RELAY_PLANS = [ + { + id: "free", + label: "Free", + subtitle: "Get started, no commitment.", + price: 0, + priceLabel: "0", + memberLabel: "Up to 10 members", + blossom: false, + livekit: false, + }, + { + id: "basic", + label: "Basic", + subtitle: "For growing communities.", + price: 10_000, + priceLabel: "10K", + memberLabel: "Up to 100 members", + blossom: true, + livekit: true, + }, + { + id: "growth", + label: "Growth", + subtitle: "For large-scale communities.", + price: 50_000, + priceLabel: "50K", + memberLabel: "Unlimited members", + blossom: true, + livekit: true, + }, +] as const satisfies readonly { + id: RelayPlanId + label: string + subtitle: string + price: number + priceLabel: string + memberLabel: string + blossom: boolean + livekit: boolean +}[] diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 17b0464..8b81707 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,4 +1,5 @@ import { A } from "@solidjs/router" +import PricingTable from "../components/PricingTable" function CheckIcon() { return ( @@ -260,64 +261,7 @@ export default function Home() { Pay in sats. Upgrade or cancel any time.

-
- {/* Free */} -
-

Free

-

Get started, no commitment.

-
- 0 - sats / mo -
-
    -
  • Up to 10 members
  • -
  • Blossom storage
  • -
  • LiveKit video
  • -
- - Get started - -
- - {/* Basic */} -
- - POPULAR - -

Basic

-

For growing communities.

-
- 10K - sats / mo -
-
    -
  • Up to 100 members
  • -
  • Blossom storage
  • -
  • LiveKit video
  • -
- - Get started - -
- - {/* Growth */} -
-

Growth

-

For large-scale communities.

-
- 50K - sats / mo -
-
    -
  • Unlimited members
  • -
  • Blossom storage
  • -
  • LiveKit video
  • -
- - Get started - -
-
+
diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx index b0bdb22..d32776a 100644 --- a/frontend/src/pages/admin/AdminRelayDetail.tsx +++ b/frontend/src/pages/admin/AdminRelayDetail.tsx @@ -171,6 +171,8 @@ export default function AdminRelayDetail() { onToggleMediaStorage={toggleMediaStorage} onToggleLivekitSupport={toggleLivekitSupport} onTogglePushNotifications={togglePushNotifications} + enforcePlanLimits={false} + showPlanActions={false} /> )} diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index 4c78913..1ab7b33 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -1,6 +1,7 @@ import { useParams } from "@solidjs/router" import { createMemo, createResource, createSignal, Show } from "solid-js" -import { deactivateTenantRelay, getRelayMemberCount, getTenantRelay, updateTenantRelay, type RelayConfig } from "../../lib/api" +import { deactivateTenantRelay, getRelayMemberCount, getTenantRelay, updateTenantRelay, updateTenantRelayPlan, type RelayConfig } from "../../lib/api" +import type { RelayPlanId } from "../../lib/relayPlans" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import RelayDetailCard from "../../components/RelayDetailCard" @@ -144,6 +145,31 @@ export default function RelayDetail() { void updateFlags(nextConfig, config) } + async function handleUpdatePlan(plan: RelayPlanId) { + const current = relay() + if (!current) return + + const previous = current + setError("") + + const nextConfig = withDefaults(current.config) + if (plan === "free") { + nextConfig.blossom = { enabled: false } + nextConfig.livekit = { enabled: false } + } + + mutate({ ...current, plan, config: nextConfig }) + + try { + await updateTenantRelayPlan(relayId(), plan) + await refetch() + } catch (e) { + mutate(previous) + setError(e instanceof Error ? e.message : "Failed to update relay plan") + throw e + } + } + return ( @@ -172,6 +198,7 @@ export default function RelayDetail() { onToggleMediaStorage={toggleMediaStorage} onToggleLivekitSupport={toggleLivekitSupport} onTogglePushNotifications={togglePushNotifications} + onUpdatePlan={handleUpdatePlan} /> )}