From 46db6507c4eb9db05926473fd59c84b9774a4412 Mon Sep 17 00:00:00 2001 From: "Snow W. Lee (Sungwon)" Date: Mon, 27 Jul 2026 17:55:42 +0900 Subject: [PATCH] feat(hub): Billing in the account menu + in-app /billing view (managed hubs) (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * architecture: Server gains the Billing display seam Co-Authored-By: Claude Fable 5 * 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 --------- Co-authored-by: Claude Fable 5 --- architecture/webapp-frontend.md | 1 + architecture/webapp-server.md | 1 + internal/webapp/auth_test.go | 46 +++++++ internal/webapp/frontend/src/api/http.ts | 4 +- internal/webapp/frontend/src/api/types.ts | 18 +++ internal/webapp/frontend/src/apps/HubApp.tsx | 24 +++- .../frontend/src/components/AccountBar.tsx | 21 +++ .../frontend/src/components/BillingView.tsx | 96 ++++++++++++++ .../webapp/frontend/src/components/shell.tsx | 2 + internal/webapp/frontend/src/router.ts | 7 + internal/webapp/frontend/src/style.css | 11 ++ internal/webapp/server.go | 12 ++ .../webapp/static/assets/index-89AlIPy9.js | 121 ++++++++++++++++++ ...{index-C9MMaLMG.css => index-C_qEQ9NO.css} | 2 +- .../webapp/static/assets/index-DNEdP71j.js | 121 ------------------ internal/webapp/static/index.html | 4 +- 16 files changed, 364 insertions(+), 127 deletions(-) create mode 100644 internal/webapp/frontend/src/components/BillingView.tsx create mode 100644 internal/webapp/static/assets/index-89AlIPy9.js rename internal/webapp/static/assets/{index-C9MMaLMG.css => index-C_qEQ9NO.css} (57%) delete mode 100644 internal/webapp/static/assets/index-DNEdP71j.js diff --git a/architecture/webapp-frontend.md b/architecture/webapp-frontend.md index d2ea43e..23149c9 100644 --- a/architecture/webapp-frontend.md +++ b/architecture/webapp-frontend.md @@ -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 diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index b0e74e8..2ad8807 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -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 diff --git a/internal/webapp/auth_test.go b/internal/webapp/auth_test.go index 66afbf8..bdb0b42 100644 --- a/internal/webapp/auth_test.go +++ b/internal/webapp/auth_test.go @@ -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") + } +} diff --git a/internal/webapp/frontend/src/api/http.ts b/internal/webapp/frontend/src/api/http.ts index c57c633..78d8da5 100644 --- a/internal/webapp/frontend/src/api/http.ts +++ b/internal/webapp/frontend/src/api/http.ts @@ -40,7 +40,9 @@ async function fail(r: Response): Promise { } export async function getJSON(url: string): Promise { - 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(); diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 1607097..925812f 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -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 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 diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index 9332ce9..be9a24c 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -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 ? ( + + ) : ( +
+

No billing on this hub

+

This BearDrive hub doesn't have a billing surface.

+
+ ), + } + : 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 ; } @@ -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)} /> ); diff --git a/internal/webapp/frontend/src/components/AccountBar.tsx b/internal/webapp/frontend/src/components/AccountBar.tsx index 189fcb4..89bf914 100644 --- a/internal/webapp/frontend/src/components/AccountBar.tsx +++ b/internal/webapp/frontend/src/components/AccountBar.tsx @@ -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 (