From 8ae009fa40bf0e5b8a5a8c2e06415a60e50eb73d Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 27 Apr 2026 14:52:13 -0400 Subject: [PATCH] feat(admin): add Billing tab and dashboard page (TASK-828) (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin): add Billing tab and dashboard page (TASK-828) Surfaces the Pad Cloud billing metrics in the admin console as a new tab between "Audit Log" and "Settings". Final piece of PLAN-825. The page calls GET /api/v1/admin/billing-stats (TASK-827) and renders six metric cards in a responsive auto-fit grid: 1. MRR (Stripe-derived; greyed when unavailable) 2. ARR (Stripe-derived; greyed when unavailable) 3. Active Subs (Stripe-derived; greyed when unavailable) 4. Customers/Plan (LOCAL — always real; e.g. "Free: 42 · Pro: 7") 5. New Signups 30d (LOCAL — always real) 6. Churn 30d (Stripe-derived; greyed when unavailable; subtitle shows cancelled count) Two banners drive the degraded-state UX: - cloud_unreachable=true → amber warning ("sidecar unreachable, showing local data only") - stripe_configured=false → blue info banner explaining that Stripe metrics will be zero until STRIPE_SECRET_KEY is set on pad-cloud (the expected pre-launch steady state) Header carries a Refresh button (re-fetches without unmounting the page) and an "Open in Stripe Dashboard ↗" external anchor (rel=noopener). A subtle footer renders "Updated just now" or "Updated N min ago" from the cache_age_seconds field. The Billing tab is hidden from the layout's tab list when adminStore.stats.cloud_mode is false — self-host operators won't see a tab that always 404s on click. Used $derived(...) for the tabs array so the tab list reacts to the cloud_mode flag flipping after stats load. Svelte 5: runes throughout ($state, $derived, $props), single onMount for the initial fetch, no combined effect-on-effect chains (CONVE-606). Visual idiom mirrors the existing /console/admin stats-bar (.stat cards, --bg-secondary background, --radius-lg, value/label sizing). Validated with the svelte MCP autofixer (clean) and `npm run build` (clean, page emitted to entries/pages/console/admin/billing). Closes PLAN-825's UI work. * fix(admin): add role=status / aria-live=polite to Stripe info banner Codex round 1 LOW: the warning banner already carries role=alert because its message is urgent (sidecar unreachable), but the "Stripe not configured" info banner appears asynchronously after load with no live- region semantics, so screen readers never announce that the page is in a degraded state. Add role=status + aria-live=polite so the announcement is non-interrupting but happens. --- web/src/routes/console/admin/+layout.svelte | 10 +- .../routes/console/admin/billing/+page.svelte | 359 ++++++++++++++++++ 2 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/console/admin/billing/+page.svelte diff --git a/web/src/routes/console/admin/+layout.svelte b/web/src/routes/console/admin/+layout.svelte index ad7152c6..4b7214ed 100644 --- a/web/src/routes/console/admin/+layout.svelte +++ b/web/src/routes/console/admin/+layout.svelte @@ -14,12 +14,18 @@ } }); - const tabs = [ + // Billing is cloud-only — the underlying endpoint returns 404 in + // self-host. Hide the tab when adminStore.stats reports cloud_mode=false + // so a self-host operator doesn't see a tab that always 404s on click. + let cloudMode = $derived(adminStore.stats?.cloud_mode ?? false); + + let tabs = $derived([ { label: 'Users', href: '/console/admin' }, { label: 'Invitations', href: '/console/admin/invitations' }, { label: 'Audit Log', href: '/console/admin/audit-log' }, + ...(cloudMode ? [{ label: 'Billing', href: '/console/admin/billing' }] : []), { label: 'Settings', href: '/console/admin/settings' } - ]; + ]); function isActive(href: string): boolean { const path = page.url.pathname; diff --git a/web/src/routes/console/admin/billing/+page.svelte b/web/src/routes/console/admin/billing/+page.svelte new file mode 100644 index 00000000..ed0f9e4c --- /dev/null +++ b/web/src/routes/console/admin/billing/+page.svelte @@ -0,0 +1,359 @@ + + +
+ {#if loading} +
Loading billing stats...
+ {:else if error} +
+

{error}

+ +
+ {:else if stats} + + + {#if stats.cloud_unreachable} + + {:else if !stats.stripe_configured} + + {/if} + +
+ +
+ MRR + {#if stripeUnavailable} + N/A + {:else} + {mrrFormatted} + {/if} + Monthly recurring revenue +
+ + +
+ ARR + {#if stripeUnavailable} + N/A + {:else} + {arrFormatted} + {/if} + Annual run rate +
+ + +
+ Active Subscriptions + {#if stripeUnavailable} + N/A + {:else} + {stats.active_subscriptions} + {/if} + Currently paying customers +
+ + +
+ Customers by Plan + {#if Object.keys(stats.customers_by_plan).length === 0} + + {:else} + {plansBreakdown} + {/if} + All registered users +
+ + +
+ New Signups (30d) + {stats.new_signups_30d} + Pro signups in last 30 days +
+ + +
+ Churn (30d) + {#if stripeUnavailable} + N/A + {:else} + {churnFormatted} + {/if} + {stats.cancelled_30d} cancelled +
+
+ +
+ Updated {cacheAgeText} +
+ {/if} +
+ +