feat(hub): Billing in the account menu + in-app /billing view (managed hubs) (#55)

* feat(hub): Billing entry with current plan in the account menu (managed hubs)

webapp.Server gains an optional Billing hook — the display mirror of the
Quota seam: managed deployments return (plan, url) per signed-in user and
/api/config exposes it as the 'billing' block; OSS hubs leave it nil and
nothing changes. The frontend renders a Billing item with a plan chip under
the Organization section of the account menu when the block is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* architecture: Server gains the Billing display seam

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(hub): Billing is an in-app view at /billing (managed hubs)

The account-menu Billing entry now routes to a real SPA view instead of a
standalone server page: /billing is a top-level route like /orgs, rendered
in the app shell from BillingView, which fetches config.billing.url with
Accept: application/json (plan, usage, seats, plan cards, checkout/portal
form URLs). OSS hubs without a billing block get an honest 'no billing on
this hub' page at that path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-27 17:55:42 +09:00
committed by GitHub
co-authored by Claude Fable 5
parent 2c85747464
commit 46db6507c4
16 changed files with 364 additions and 127 deletions
+1
View File
@@ -28,6 +28,7 @@ classDiagram
class router {
+VIEW_ROUTES insights history install settings
+top-level routes orgs billing
+parseRoute(pathname, mode) Route
+urlForPath / urlForView
+encodePath / decodePath
+1
View File
@@ -24,6 +24,7 @@ classDiagram
+Reads *ReadLedger
+Dir Directory
+Quota QuotaProvider
+Billing func(email) (plan, url, ok)
+ShareRPM int
-vols per-project volume cache
+Handler() http.Handler
+46
View File
@@ -464,3 +464,49 @@ func readFileString(path string) (string, error) {
data, err := os.ReadFile(path)
return string(data), err
}
// TestConfigBillingSeam: the billing block appears in /api/config only when
// the hook is set, the caller is signed in, and the hook says ok.
func TestConfigBillingSeam(t *testing.T) {
srv, _, _ := authHub(t, true)
srv.Billing = func(email string) (string, string, bool) {
if email == "a@x.io" {
return "Team", "/billing", true
}
return "", "", false
}
h := srv.Handler()
get := func(cookie *http.Cookie) map[string]json.RawMessage {
t.Helper()
req := httptest.NewRequest("GET", "/api/config", nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("config: %d %s", rec.Code, rec.Body)
}
var out map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
// Signed out: no billing key, even with the hook set.
if _, ok := get(nil)["billing"]; ok {
t.Fatal("billing leaked to a signed-out config")
}
// Signed in and the hook says ok: plan + url.
cookie := signupAndSession(t, h, "a@x.io", "Alice", "password1")
if got := string(get(cookie)["billing"]); got != `{"plan":"Team","url":"/billing"}` {
t.Fatalf("billing block = %s", got)
}
// A user the hook declines (no org yet): key absent.
other := signupAndSession(t, h, "b@x.io", "Bob", "password1")
if _, ok := get(other)["billing"]; ok {
t.Fatal("billing shown to a user the hook declined")
}
}
+3 -1
View File
@@ -40,7 +40,9 @@ async function fail(r: Response): Promise<never> {
}
export async function getJSON<T>(url: string): Promise<T> {
const r = await fetch(url);
// Explicit Accept so endpoints that content-negotiate (e.g. /billing:
// JSON data vs the app shell) know this is a data fetch.
const r = await fetch(url, { headers: { Accept: "application/json" } });
if (r.status === 401) toLogin();
if (!r.ok) await fail(r);
return r.json();
+18
View File
@@ -17,6 +17,24 @@ export interface ServerConfig {
};
reads: { enabled: boolean };
me?: { email: string; name: string };
// Managed deployments only: where billing lives + the user's current plan.
billing?: { plan: string; url: string };
}
// GET <config.billing.url> with Accept: application/json (managed hubs).
// The SPA's /billing view renders this; the money actions stay server-side
// (checkout_url/portal_url are plain form-POST targets that leave the SPA).
export interface BillingInfo {
org: string;
role: string;
owner: boolean;
plan: { id: string; name: string; status?: string };
usage: { used: string; cap: string; pct: number };
seats: { used: number; cap: number };
plans: { id: string; name: string; price: string; blurb: string; current: boolean }[];
has_customer: boolean;
checkout_url: string;
portal_url: string;
}
// Per-project permission levels (perms.go). Ordered: each includes the ones
+22 -2
View File
@@ -9,6 +9,7 @@ import { OrgAdmin } from "../components/OrgAdmin";
import { HubSettings } from "../components/HubSettings";
import { ProjectNav } from "../components/ProjectNav";
import { AccountBar } from "../components/AccountBar";
import { BillingView } from "../components/BillingView";
import { ProjectSettings } from "../components/ProjectSettings";
import { ConnectGuide } from "../components/ConnectGuide";
import { EmptyState } from "../components/EmptyState";
@@ -83,6 +84,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
me={config.me}
org={org}
orgActive={!!route.org}
billing={config.billing}
admin={
isAdmin
? {
@@ -170,6 +172,24 @@ export default function HubApp({ config }: { config: ServerConfig }) {
}
: null;
// Billing is hub-level (the managed deployment's surface), not
// project-scoped — like the org route it borrows whichever project the
// sidebar is showing. An OSS hub has no billing block; a hand-typed
// /billing there says so instead of silently showing files.
const billingPage = route.billing
? {
crumb: "Billing",
body: config.billing ? (
<BillingView url={config.billing.url} />
) : (
<div className="empty">
<h3>No billing on this hub</h3>
<p>This BearDrive hub doesn't have a billing surface.</p>
</div>
),
}
: null;
const routePage =
route.view === "settings"
? {
@@ -203,7 +223,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
// URL; replace so back/forward never bounces through the redirect. The
// org route is not project-scoped, so it is exempt — it borrows whichever
// project the sidebar is showing.
if (!route.org && route.project !== current.id) {
if (!route.org && !route.billing && route.project !== current.id) {
return <Redirect to={"/" + current.id} />;
}
@@ -265,7 +285,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
),
orgBar: accountBar,
}}
panel={activePanel || orgPage || routePage}
panel={activePanel || orgPage || billingPage || routePage}
onClosePanel={() => setPanel(null)}
/>
);
@@ -24,11 +24,13 @@ export function AccountBar({
org,
admin,
orgActive,
billing,
}: {
me: { email: string; name: string };
org: Org | null;
admin?: { pending: number; onClick: () => void }; // hub admins only
orgActive?: boolean; // the org page is the open surface
billing?: { plan: string; url: string }; // managed deployments only
}) {
const display = me.name || me.email;
// The menu's open state is ours because the org entry is a link: linkProps
@@ -39,6 +41,7 @@ export function AccountBar({
// (middle-click, copy link address).
const [menuOpen, setMenuOpen] = useState(false);
const orgLink = org ? linkProps(org.manage_url) : null;
const billingLink = billing ? linkProps(billing.url) : null;
return (
<footer id="accountbar">
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
@@ -80,6 +83,24 @@ export function AccountBar({
)}
</a>
</DropdownMenuItem>
{billing && (
<DropdownMenuItem asChild>
{/* An in-app view route (/billing); the chip shows the
org's current plan. */}
<a
id="menu-billing"
{...billingLink}
onClick={(e) => {
billingLink?.onClick?.(e);
setMenuOpen(false);
}}
>
<Icon name="card" />
<span>Billing</span>
<span className="ps-chip plan-chip">{billing.plan}</span>
</a>
</DropdownMenuItem>
)}
</>
)}
{admin && (
@@ -0,0 +1,96 @@
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { BillingInfo } from "../api/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
// The Billing view (managed hubs) renders inside the app shell; the data and
// the money actions live on the hub's billing endpoints — the SPA only knows
// the URL /api/config handed it. Checkout and the portal are plain form
// POSTs: both leave the app for Stripe anyway, so a full-page navigation is
// the honest shape.
export function BillingView({ url }: { url: string }) {
const q = useQuery({ queryKey: ["billing"], queryFn: () => getJSON<BillingInfo>(url) });
if (q.isLoading) return <div className="empty">Loading</div>;
if (q.error || !q.data) {
return (
<div className="empty">
<h3>Billing is unavailable</h3>
<p>{(q.error as Error | null)?.message || "Try again shortly."}</p>
</div>
);
}
const b = q.data;
return (
<div className="project-settings" id="billing-view">
<h2>
Billing
<span className="ps-chip plan-chip">{b.plan.name}</span>
</h2>
<Card>
<CardHeader>
<CardTitle>
{b.plan.name} plan{b.plan.status ? ` (${b.plan.status})` : ""}
</CardTitle>
<CardDescription>
Organization {b.org} · {b.usage.used} of {b.usage.cap} used · {b.seats.used} of {b.seats.cap}{" "}
{b.seats.cap === 1 ? "seat" : "seats"}
</CardDescription>
</CardHeader>
<Separator />
<CardContent>
<div className="usage-bar">
<div style={{ width: `${b.usage.pct}%` }} />
</div>
</CardContent>
</Card>
{b.owner ? (
<div className="plan-grid">
{b.plans.map((p) => (
<Card key={p.id}>
<CardHeader>
<CardTitle>{p.name}</CardTitle>
<CardDescription>{p.blurb}</CardDescription>
</CardHeader>
<Separator />
<CardContent>
<p className="plan-price">
{p.price}
<small> / user / month</small>
</p>
<form method="post" action={b.checkout_url}>
<input type="hidden" name="plan" value={p.id} />
<Button type="submit" disabled={p.current} variant={p.current ? "subtle" : "default"}>
{p.current ? "Current plan" : `Upgrade to ${p.name}`}
</Button>
</form>
</CardContent>
</Card>
))}
</div>
) : (
<p className="muted-note">Only an organization owner can change the plan.</p>
)}
{b.owner && b.has_customer && (
<Card>
<CardHeader>
<CardTitle>Manage subscription</CardTitle>
<CardDescription>Change seats, update the card, download invoices, or cancel.</CardDescription>
</CardHeader>
<Separator />
<CardContent>
<form method="post" action={b.portal_url}>
<Button type="submit" variant="subtle">
Open the billing portal
</Button>
</form>
</CardContent>
</Card>
)}
</div>
);
}
@@ -14,6 +14,7 @@ import {
Code,
Compass,
Copy,
CreditCard,
Database,
Download,
Ellipsis,
@@ -125,6 +126,7 @@ if (typeof window !== "undefined") {
// `.ico` sizing/stroke rules apply unchanged.
const ICONS: Record<string, LucideIcon> = {
alert: TriangleAlert,
card: CreditCard,
check: Check,
chev: ChevronRight,
chevd: ChevronDown,
+7
View File
@@ -33,6 +33,10 @@ export interface Route {
// rather than a view under a project. The server hands out this URL (see
// manage_url on /api/orgs), which is why it is reserved here.
org?: string;
// Billing (managed hubs) is hub-level like the org route; the URL comes
// from /api/config's billing block. Reserved only in hub mode — project
// ids are p-… so the segment can't collide with a project.
billing?: boolean;
project?: string;
path: string;
view?: ViewName;
@@ -45,6 +49,9 @@ export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
if (raw === "orgs" || raw.startsWith("orgs/")) {
return { org: raw.slice(5).replace(/\/+$/, ""), path: "" };
}
if (raw === "billing" || raw.startsWith("billing/")) {
return { billing: true, path: "" };
}
const slash = raw.indexOf("/");
if (slash === -1) return { project: raw, path: "" };
const r: Route = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
+11
View File
@@ -281,9 +281,20 @@ button, input, a.btn { font-family: inherit; }
#account-menu [role="menuitem"]:hover { background: var(--hover); color: var(--text); }
#account-menu [role="menuitem"] b { font-weight: 600; }
#account-menu [role="menuitem"] .ico { width: 15px; height: 15px; }
#account-menu .plan-chip { margin-left: auto; color: var(--accent); border-color: var(--border-2); }
#account-menu #signout { color: var(--del); }
#account-menu #signout:hover { color: var(--del); background: var(--hover); }
/* ---- billing view (managed hubs) ---- */
#billing-view .plan-chip { color: var(--accent); }
.plan-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 700px) { .plan-grid { grid-template-columns: 1fr; } }
.usage-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; height: 6px; overflow: hidden; }
.usage-bar > div { background: var(--accent); height: 100%; }
.plan-price { font-size: 20px; font-weight: 700; margin: 0 0 10px; }
.plan-price small { font-size: 12px; color: var(--text-dim); font-weight: 500; }
.muted-note { color: var(--text-dim); font-size: 13px; }
/* ---- main pane ---- */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#topbar {
+12
View File
@@ -84,6 +84,13 @@ type Server struct {
// Quota, when set, enforces plan limits (managed deployments). Nil
// means UnlimitedQuota: the open-source server never says no.
Quota QuotaProvider
// Billing, when set, surfaces a billing entry in the frontend's account
// menu: the billing page URL plus the signed-in user's current plan name
// (/api/config `billing`). The OSS hub has no billing; managed
// deployments plug this in. Nil — or ok=false for a user with no org —
// hides the entry. The mirror of the Quota seam: Quota enforces the
// plan, Billing displays it.
Billing func(email string) (plan, url string, ok bool)
// ShareRPM is the per-IP request rate on public share links (/s/*);
// 0 means DefaultShareRPM.
ShareRPM int
@@ -477,6 +484,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
}
if me.Email != "" {
out["me"] = map[string]string{"email": me.Email, "name": me.Name}
if s.Billing != nil {
if plan, url, ok := s.Billing(me.Email); ok {
out["billing"] = map[string]string{"plan": plan, "url": url}
}
}
}
writeJSON(w, out)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-DNEdP71j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C9MMaLMG.css">
<script type="module" crossorigin src="/assets/index-89AlIPy9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_qEQ9NO.css">
</head>
<body>
<div id="root"></div>