Skip to content

feat(billing): landing-style redesign, billing details, nav reorg#902

Open
eskp wants to merge 6 commits intostagingfrom
feature/billing-landing-redesign-nav-reorg
Open

feat(billing): landing-style redesign, billing details, nav reorg#902
eskp wants to merge 6 commits intostagingfrom
feature/billing-landing-redesign-nav-reorg

Conversation

@eskp
Copy link
Copy Markdown

@eskp eskp commented Apr 21, 2026

Summary

  • Redesign /billing Plans section to mirror the landing page /pricing aesthetic and add a new Billing Details card next to Billing History
  • Add an owner-auth GET /api/billing/billing-details endpoint and BillingProvider.getBillingDetails() that cascades customer default → subscription default → first customer payment method (Stripe Checkout attaches cards to subscription, not customer)
  • Fix /billing exposure to anonymous users (AuthGate mirroring analytics page pattern)
  • Reorganize left nav + user menu; consolidate Projects + Tags into a single tabbed overlay

Changes

Nav & user menu

  • Billing moved from left nav → user menu dropdown (gated on isOwner && isBillingEnabled(); navigates to /billing)
  • Address Book moved to left nav (Billing's former slot); opens overlay on click
  • Report an issue added to left nav bottom section under Documentation; opens FeedbackOverlay
  • User menu order: Wallet → Settings → Connections → API Keys → Billing → Projects and Tags → Logout
  • New ProjectsAndTagsOverlay replaces the two separate overlays (mirrors SettingsOverlay tabbed pattern)
  • NavItem gains an ACTION_ITEM_IDS allowlist so href: null items that open overlays are not marked "Coming Soon"

Billing page (/billing)

  • Pricing table uses landing aesthetic: pill Monthly/Annual toggle with inline "Save 20%" badge; centered 4-col grid at xl; HeroMetrics stat panel per card; custom TierSelect dropdown with green highlight for the selected tier
  • Outline the current plan with a thin keeperhub-green-dark border + CURRENT badge (removed the old POPULAR Pro highlight)
  • Enterprise card consistent with the others ("Custom" price, "Talk to us" CTA, mailto:human@keeperhub.com)
  • Shared "Compare all features" toggle below the grid reveals a 10-row striped ComparisonTable with the Enterprise column accented in green
  • CTA label normalized to "Change plan" for all paid plan changes
  • Removed subheadings under price and the redundant executions pill on the Current Plan card
  • Renewal message ("Your plan ends on …") rendered inline with the plan name + status pill
  • Usage + gas credit bars restyled on keeperhub-green tokens with a /15 track; overage tail in yellow
  • FAQ link footer routes to https://keeperhub.com/pricing

Billing Details card (new)

  • Rendered next to BillingHistory in a lg:grid-cols-[2fr_1fr] layout
  • Shows card brand + last4 + expiry and invoice email
  • Pencil inline next to the title opens the Stripe portal (/api/billing/portal); empty state when no card on file

Data plumbing

  • BillingDetails type + getBillingDetails(customerId) on BillingProvider interface
  • Stripe provider implementation cascades through three sources (customer default → subscription default → first customer payment method) because Stripe Checkout attaches the card to the subscription, not the customer, by default
  • BILLING_DETAILS added to BILLING_API constants
  • New GET /api/billing/billing-details route (owner-auth, same pattern as existing billing endpoints)
  • /billing bumps refreshKey 2s after ?checkout=success so BillingDetails remounts once Stripe has attached the payment method

Fixes & copy

  • Anonymous users visiting /billing now see a "Sign in to view billing" AuthGate (was rendering the pricing table to unauthenticated users)
  • Confirm plan change dialog: removed leading -- before the prorated billing note
  • Billing history View / PDF links recolored to keeperhub green

Tests

  • tests/unit/billing-handle-event.test.ts mock provider stubs getBillingDetails

UAT — Phase 31 Stripe Billing (11/11 passed, 0 issues)

Driven interactively over a live dev environment (localhost:3000, Docker Postgres, Stripe CLI listener, test-mode customer).

# Test Result
1 Cold Start Smoke + Billing Auth Gate pass — fixed local env DB port (5432 → 127.0.0.1:5433), added AuthGate
2 Billing Page Renders Pricing + Current Plan pass after redesign landed in this PR
3 New Subscription via Checkout (Pro 25k Monthly, 4242 …) pass — card attached to subscription, UI picks it up via cascade
4 Plan Upgrade with Proration (25k → 50k) pass
5 Cancel at Period End pass
6 Customer Portal Opens pass
7 Webhook Idempotency pass — two back-to-back stripe trigger invoice.paid, all 200s, zero duplicate billing_events rows
8 Overage Billing pass — drove 100,050 synthetic executions, /api/billing/overage returned {billed:true, overageCount:50, totalChargeCents:10} (Pro $2/1K), idempotent re-call returned already billed for this period
9 Execution Debt 15-day Grace pass — backdated overage, finalized unpaid Stripe invoice, /api/billing/debt-scan created debt, workflow execution returned 429 "Executions suspended due to unpaid overage invoice", stripe invoices pay fired invoice.paid webhook, clearDebtForInvoice flipped status to cleared, workflow ran again
10 Invoice Listing + Pagination pass
11 Usage Suggestion Recommends Upgrade pass — initial 100k@day21 correctly returned shouldUpgrade: false (Pro 100k + projected overage still cheaper than Business 250k); once pushed to 130k actual (projected 185k), blue UpgradeSuggestionBanner rendered at top of Current Plan card

Notes for reviewers

  • Initial testing guide (shared at session start) listed overage rates as Pro $0.20/1K and Business $0.15/1K. Code is authoritative at $2/1K (Pro) and $1.50/1K (Business) per lib/billing/plans.ts. UI already renders the correct rates. Verified with whoever owns pricing; guide was stale.
  • Test 8 used a temporary local HUB_SERVICE_API_KEY for /api/billing/overage and /api/billing/debt-scan endpoint auth. Don't forward that value — production/staging should have its own service key rotation.
  • Test 8/9 left synthetic rows in the dev DB (~130k workflow_executions, 1 overage_billing_records, 1 cleared execution_debt). Harmless but clean up if you reuse that local DB:
    DELETE FROM workflow_executions WHERE id LIKE 'exec_ovtest%';
    DELETE FROM execution_debt WHERE organization_id='<dev org id>';
    DELETE FROM overage_billing_records WHERE organization_id='<dev org id>';

Test plan

  • pnpm check and pnpm type-check pass locally
  • /billing renders correctly for Free org (pricing table, no Billing Details / no Overage section)
  • /billing renders correctly for Pro org with active subscription (Billing Details shows card, renewal message inline)
  • Anonymous /billing visit renders AuthGate, not pricing table
  • Avatar → Billing link routes to /billing only for owners
  • Left nav Address Book opens overlay; Report an issue opens feedback overlay
  • Projects and Tags user-menu item opens a tabbed overlay; both tabs CRUD correctly
  • Stripe Checkout → Billing Details card picks up the card within a few seconds of redirect
  • Plan upgrade triggers proration dialog; confirm updates subscription without new Checkout session
  • Existing billing unit + integration tests still pass (pnpm test billing)

Addendum: announcement banner (added 2026-04-21)

Third commit adds a skinny (36px) dismissible banner at the top of the app to introduce paid plans to existing users.

  • components/app-banner.tsx — fixed 36px strip, keeperhub-green tint with border, centered info icon + body + "See plans" link, close (X) at right edge
  • Copy: "New Pro and Business plans unlock higher execution limits and gas credits. Free stays free forever. See plans"
  • Dismissal is permanent-per-browser via localStorage key kh-billing-announce-v1 (version suffix lets us introduce a new banner later without wiping other prefs)
  • Banner height exposed via --app-banner-height CSS var on <html> so fixed overlays shift down and snap back cleanly. Touches: workflow-toolbar, navigation-sidebar, flyout-panel, workflow page side-panel, and the pt-20 of /billing, /analytics, /earnings
  • No hydration flash: component renders null until mounted to avoid SSR/client localStorage mismatch

To re-trigger the banner in dev (for QA): localStorage.removeItem("kh-billing-announce-v1") + refresh.

eskp added 2 commits April 21, 2026 11:35
- Move Billing from left nav to user menu dropdown (gated on isOwner + isBillingEnabled; routes to /billing)
- Move Address Book from user menu to left nav (replaces Billing slot); opens overlay on click
- Add Report an issue button to left nav bottom section (below Documentation); opens FeedbackOverlay
- New user menu order: Wallet, Settings, Connections, API Keys, Billing, Projects and Tags
- Combine separate Projects and Tags overlays into one tabbed ProjectsAndTagsOverlay
  (mirrors SettingsOverlay pattern; supports initialTab prop)
- NavItem ACTION_ITEM_IDS allowlist so href=null items that open overlays are not marked
  "Coming Soon"
…olish

UI redesign (/billing)
- PricingTable mirrors landing /pricing: pill Monthly/Annual toggle with inline
  "Save 20%" badge; centered 4-col grid at xl; HeroMetrics stat panel per card;
  custom TierSelect dropdown with green highlight for selected tier
- Outline current plan with a thin keeperhub-green-dark border + "CURRENT" badge
  instead of the old POPULAR highlight
- Enterprise card consistent with others ("Custom" price, "Talk to us" CTA,
  mailto:human@keeperhub.com); no gradient background or special border
- Shared ComparisonTable below the grid: "Compare all features" toggle reveals
  a 10-row striped matrix with Enterprise column accented in green
- CTA label normalized to "Change plan" for all paid plan changes
- Remove subheadings under price; remove redundant executions pill on Current
  Plan card; move renewal message (e.g. "Your plan ends on ...") inline with
  plan name + status pill
- Execution usage bar and gas credits bar restyled on keeperhub-green tokens
  with subtle /15 track; overage tail in yellow
- FAQ link footer at bottom of /billing routes to https://keeperhub.com/pricing

Billing Details card
- New BillingDetails card rendered next to BillingHistory (lg:grid-cols-[2fr_1fr])
- Fetches GET /api/billing/billing-details; shows card brand + last4 + expiry
  and invoice email; empty state when no card on file
- Edit pencil inline next to "Billing Details" title; opens Stripe portal

Auth gate
- billing-page.tsx mirrors analytics page pattern: useSession + local AuthGate,
  early-return when anonymous (no more pricing table exposure to logged-out users)

Data plumbing
- Add BillingDetails type and getBillingDetails(customerId) to BillingProvider
- Stripe implementation cascades: customer.invoice_settings.default_payment_method
  -> subscription.default_payment_method -> first customer payment method
  (Stripe Checkout attaches card to subscription, not customer, by default)
- Add BILLING_DETAILS to BILLING_API constants
- New GET /api/billing/billing-details route (owner-auth)
- /billing bumps refreshKey 2s after ?checkout=success so BillingDetails
  remounts once Stripe has attached the payment method
- BillingHistory view/PDF links recolored keeperhub green
- Confirm plan change dialog copy: remove leading "--" before the prorated
  billing note

Tests
- billing-handle-event.test.ts mock provider stubs getBillingDetails
@eskp eskp requested review from a team, OleksandrUA, joelorzet and suisuss and removed request for a team April 21, 2026 01:36
- New AppBanner client component mounted in app/layout.tsx: fixed 36px strip
  at the top of the app, keeperhub-green tint with border, centered info icon
  + body + "See plans" link, close (X) at right edge
- Dismissal is permanent-per-browser via localStorage key kh-billing-announce-v1
  so the banner never reappears for a user who closes it (version suffix lets
  us introduce a new banner later without wiping other prefs)
- Banner height is exposed via --app-banner-height CSS var on <html> so fixed
  overlays shift down cleanly when visible and snap back on dismiss. Updated:
  - components/workflow/workflow-toolbar.tsx (persistent toolbar top)
  - components/navigation-sidebar.tsx (sidebar top-[60px] now includes banner)
  - components/flyout-panel.tsx (two fixed surfaces)
  - app/workflows/[workflowId]/page.tsx (side panel lg breakpoint)
  - components/billing/billing-page.tsx (pt-20 -> calc)
  - components/analytics/analytics-page.tsx (pt-20 -> calc)
  - components/earnings/earnings-page.tsx (pt-20 -> calc)
- No hydration flash: component renders null until mounted to avoid SSR/client
  mismatch reading localStorage
@suisuss
Copy link
Copy Markdown

suisuss commented Apr 21, 2026

Solid

  • Auth on the new endpoint (app/api/billing/billing-details/route.ts) is correct. requireOrgOwner + isBillingEnabled() short-circuit + no-customer early return matches the existing billing endpoints.
  • AuthGate fix addresses a real bug. Anonymous users previously saw the pricing table in a broken shell.
  • ACTION_ITEM_IDS allowlist is a clean way to let href: null items skip the "Coming Soon" tooltip.
  • Banner coordination via a CSS custom property, with a 0px fallback in every consumer's calc(...), is cleaner than threading height through context.

Concerns

1. Stripe cascade can silently surface a card that is not being charged

lib/billing/providers/stripe.ts:267. Two issues with tiers 2 and 3:

  • subscriptions.list({ status: "all", limit: 1 }) picks newest, which includes canceled or incomplete. If a user has a since-canceled sub newer than their current active one, tier 2 returns the wrong card. Filter to status: "active" first, or (better) pass in the known providerSubscriptionId from getOrgSubscription and skip the list entirely.
  • paymentMethods.list({ type: "card", limit: 1 }) has no documented ordering guarantee. For a customer with multiple attached cards, you pick arbitrarily. The UI then says "ending in 4242" for a card that may not be the one on the invoice.

What would need to be true for this to be fine? Every customer has exactly one card on file. That is optimistic.

2. The 2 second setTimeout after ?checkout=success is a timing assumption

components/billing/billing-page.tsx:101. Works in UAT but will drift. Options: poll /api/billing/billing-details with backoff until paymentMethod !== null, or write the PM details into your own DB on the checkout.session.completed webhook and read from there.

3. Test coverage is thin for the new surface

The only addition to tests/unit/billing-handle-event.test.ts is stubbing getBillingDetails on the mock provider. Nothing exercises the cascade order, the route's owner guard, the feature flag short circuit, or the empty customer path. Three independent code paths in the cascade with zero unit coverage is exactly where rot starts when someone later bumps the Stripe SDK. At minimum: one unit test per cascade tier using a mocked Stripe client.

4. Banner causes a layout shift on first paint

components/app-banner.tsx:46. Renders null until mounted, then sets the banner height CSS variable, which reflows the sidebar. Two options: SSR the dismissal decision via a cookie (use the same key), or inject an inline script in app/layout.tsx that sets the banner height variable synchronously before React hydrates (same pattern ThemeProvider uses for theme flash). Given the v1 suffix acknowledges this banner is temporary, probably not worth deep engineering.

5. Cosmetic: the banner effect cleanup runs unconditionally

components/app-banner.tsx:23. In strict mode double invocation you get a one frame flicker because the effect always registers a removeProperty cleanup, even on the pass where it also set the value. Collapse the early return and the set+cleanup into a single guarded branch: bail when not mounted or dismissed, otherwise set the banner height property and return a cleanup that removes it. One code path per state instead of two overlapping ones.

Asks before merge

  • Add at least one unit test covering the Stripe cascade (happy path plus tier 2 fallback).
  • Either polling or a code comment on the 2 second timer, and filter the subscription cascade to active first.

Nothing here is a hard block. The billing surface is well UAT'd and the architecture is sound. The cascade is the one place where "works on my test card" could hide a production bug later.

@suisuss
Copy link
Copy Markdown

suisuss commented Apr 21, 2026

Looking into these now...

@suisuss
Copy link
Copy Markdown

suisuss commented Apr 21, 2026

Code looks good. Concerns 1 2 and 3 from #902 (comment) are addressed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants