Files
waggle-os/docs/redesign-warm-hive/pr7-recon/04-screen-billing-design.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

19 KiB
Raw Blame History

PR7 Recon — Screen 14: Billing (Stripe) DESIGN spec

RECON ONLY. No product code touched. Topic owner: the Billing screen DESIGN spec (billing.html) + SCREENS.md §14, cross-checked against the REAL Stripe wiring in the monorepo so PR7 knows what is wired vs what must be gated/faked.

Sources read in full:

  • docs/design_handoff_waggle_app/design-files/screens/billing.html (247 lines)
  • docs/design_handoff_waggle_app/SCREENS.md §14 (lines 282295)
  • docs/redesign-warm-hive/BUILD-PLAN.md (§6 roadmap, §7.5 BYO-vs-metered flag)
  • Backend Stripe: packages/server/src/stripe/{index,checkout,portal,webhook,sync}.ts
  • Tier system: packages/shared/src/tiers.ts
  • Current billing UI: apps/web/src/hooks/useBilling.ts, apps/web/src/components/os/overlays/UpgradeModal.tsx, apps/web/src/components/os/apps/SettingsApp.tsx (Billing tab, ~L516650)
  • Tier route: packages/server/src/local/routes/settings.ts (GET /api/tier, L321341)
  • Identity name: packages/server/src/local/routes/home.ts (L258271, IdentityLayer)
  • Landing pricing: apps/www/app/_components/Pricing.tsx

0. TL;DR for the PR7 builder

The design is a 4-state segmented Stripe billing flow: Plans, Checkout, Success, Manage (billing.html:117122). The monorepo already has a fully wired, real Stripe backend for Plans → checkout-redirect → sync → tier and a hosted Customer Portal for Manage. But the design's Checkout, Success, and Manage states render a custom card form, a fabricated receipt, a fabricated payment method, and a fabricated 3-line invoice listnone of which have a data source in this codebase, and none of which we should hand-build (PCI + fabrication risk).

The governing instruction is already in the design itself (SCREENS.md:294):

"Use Stripe Checkout/Customer Portal where possible; theme to tokens."

So PR7's faithful-but-honest interpretation: build the Plans state for real (it maps 1:1 to the existing checkout route), and treat Checkout/Success/Manage's in-app chrome as Stripe-hosted redirects, not as locally-rendered card forms / invoice tables. The custom card form in the HTML is a mockup of what Stripe Checkout shows — we must not reimplement it.

Blocking dependency (already flagged): BUILD-PLAN §7 open-decision #5 — DESIGN_POV §4 "who pays for inference (BYO-key vs Waggle-metered)" — must be decided before PR7. The Plans copy ("you only pay for scale — no feature-count games") leans metered/scale framing; the product today is BYO-key (Settings model keys). This is a copy + product-positioning fork, not just a screen.


1. State 1 — PLANS (billing.html:127159) — REAL, ship it

Verbatim copy

  • Segmented control labels: Plans / Checkout / Success / Manage (:118121); top eyebrow Billing · Stripe · state (:116).
  • Header H1: "Upgrade your hive." (:130).
  • Subhead: "Memory is free forever. You only pay for scale — no feature-count games." (:131).
  • Cycle toggle: Monthly | Annual 20% (:132); the 20% is a .save span in --healthy green.

The 3 cards (verbatim)

Card Tag Price (mo) Price (yr) Unit suffix Tagline Feature list CTA
Solo Current (work-blue .curtag, :136) $0 $0 / forever "For individuals exploring an AI workspace." Personal memory graph · All major LLMs + local · Local-first by default "Your plan" (disabled, :141)
Pro "Most popular" (honey .pop, :144) .feat honey border+glow $19 $15 / month/ mo · billed yearly "For power users compounding across projects." Everything in Solo · Sync across devices · Marketplace skills & connectors · Self-evolving skills "Choose Pro" (honey, data-go="checkout")
Teams $49 $39 / seat / mo/ seat · yearly "Shared memory without losing privacy." Everything in Pro · Shared team memory · WaggleDance multi-agent · SSO & role-based access "Choose Teams" (ghost)
  • Annual prices come from data-yr attributes; the cycle toggle JS swaps pp innerHTML and special-cases Teams' suffix (:243). So $15 Pro / $39 Teams annual are the design's stated annual-equivalent monthly numbers (20% of $19→$15.20 rounded to $15; 20% of $49→$39.20 rounded to $39).
  • "Most popular" badge is honey (--honey bg, #1a1407 text, :38); "Current" badge is work-blue (--work, :39).

REAL backing

  • Prices match tiers.ts:712 exactly: FREE $0 / PRO $19/mo / TEAMS $49/seat. No drift.
  • "Choose Pro/Teams" → real route: adapter.createCheckoutSession('PRO'|'TEAMS') (adapter.ts:2667) → POST /api/stripe/create-checkout-session (checkout.ts:1356) → stripe.checkout.sessions.create({mode:'subscription', allow_promotion_codes:true, ...}) → returns hosted session.url. This already works when STRIPE_SECRET_KEY + a price ID env are set.
  • Monthly/Annual toggle is REAL-capable but currently NOT wired in-app. The backend priceIdForTier(tier, billingPeriod) (index.ts:91100) already resolves STRIPE_PRICE_PRO_MONTHLY/_ANNUAL + STRIPE_PRICE_TEAMS_MONTHLY/_ANNUAL. But adapter.createCheckoutSession(tier) (adapter.ts:2667) sends no billingPeriod → always monthly. MUST-BUILD (small): thread billingPeriod through the adapter + hook to honor the toggle. apps/www/app/_components/Pricing.tsx:9,95,165 already has the monthly/annual toggle pattern to copy.
  • "Solo = Current / Your plan (disabled)" — the current-plan marker is REAL: useBilling().tier
    • tierResolved (useBilling.ts:1337); SettingsApp already renders a tier badge from this (SettingsApp.tsx:537561).

Verdict: Plans is REAL/DERIVABLE — the highest-value, lowest-risk part of PR7.


2. State 2 — CHECKOUT (billing.html:161185) — DO NOT hand-build the card form

Verbatim copy (left "Payment details" panel, :163175)

  • H2 "Payment details".
  • Email field, value mara@egzakta.com (fabricated identity).
  • "Card information" → 1234 1234 1234 1234 placeholder, value 4242 4242 4242 4242 (fake Stripe test card), VISA brand chip.
  • Expiry MM / YY value 08 / 28; CVC value •••.
  • "Name on card" value Mara Kovač (fabricated).
  • "Country" value Germany (fabricated).
  • Secure line (:172): lock icon + "Encrypted & secure. We never store your card — Stripe does."
  • Pay button (:173): "Pay $19.00 / month"data-go="success".
  • Footer (:174): "Powered by Stripe · cancel anytime".

Verbatim copy (right "Order summary" panel, :176184)

  • H2 "Order summary".
  • Plan row: hex "W" mark + "Waggle Pro" / "Monthly · renews Jul 14" (:178).
  • Line: "Pro plan" — "$19.00" (:179).
  • Promo row: input placeholder "Promo code" + "Apply" button (:180).
  • Line: "Tax (est.)" — "$0.00" (:181).
  • Total line: "Due today" — "$19.00" (:182).
  • Guarantee (:183): "14-day free trial · you won't be charged until Jun 28".

Reality check — THIS IS THE CORE TENSION

  • There is NO custom-card-form backend, and there must not be one. The real flow is a redirect to Stripe-hosted Checkout: checkout.ts returns session.url, and useBilling.startCheckout() does window.open(url, '_blank') (useBilling.ts:6981). The card fields, brand detection, promo Apply, and live tax are all Stripe's hosted page, not ours.
  • SCREENS.md:294 explicitly says "Use Stripe Checkout/Customer Portal where possible." So the HTML's 2-col card form is a visual mock of Stripe Checkout — the honest PR7 build is: "Choose Pro" → spinner/redirect → Stripe-hosted Checkout (themeable via Stripe's Branding settings, NOT our DOM). We do not collect card/email/name/country in-app (PCI scope + the fields have no API to POST to).
  • Promo codes ARE real end-to-end: allow_promotion_codes:true (checkout.ts:44) — but they're entered on Stripe's page, not our Apply button.
  • Trial-aware "Due today / won't be charged until" is DERIVABLE from trialDaysRemaining + trialStartedAt (/api/tier, settings.ts:332333) for a plans-page hint, but the binding "Due today $19 / charged Jun 28" on the checkout page itself is Stripe-rendered (Stripe knows the actual trial config on the price). A hardcoded "Jun 28" / "Jul 14" in our UI would be fabrication.

Verdict: MUST-NOT-HAND-BUILD. Replace the custom card panel with the existing redirect-to-Stripe flow. The order-summary panel can be a real pre-checkout summary (plan + price from tiers.ts), but any date/tax/"due today" line must come from Stripe or be omitted — never invented.


3. State 3 — SUCCESS (billing.html:187199) — receipt = FABRICATED, gate it

Verbatim copy

  • Healthy-green check ring (.ring, --healthy-wash bg, :189).
  • H1 "You're Pro." (:190).
  • Body (:191): "Your hive just leveled up — sync, the marketplace, and self-evolving skills are live. Your trial runs 14 days; we'll remind you before the first charge."
  • Receipt card (:192197):
    • "Plan" — "Waggle Pro · Monthly"
    • "Trial ends" — "Jun 28, 2026" (fabricated date)
    • "Then" — "$19.00 / month"
    • "Receipt" — "Emailed →" (honey, clickable; no real email-receipt feature in-app)
  • CTA (:198): "Start using Pro →" (data-go="home") + "Manage billing" (data-go="manage").

Reality check

  • The success trigger is REAL: after Stripe redirect, the app detects ?session_id= and calls POST /api/stripe/sync (useBilling.ts:104115sync.ts:1890), which payment-gates (session.payment_status === 'paid' || 'no_payment_required', sync.ts:46) and updates the tier. There IS a /payment-success?session_id=… success_url already (checkout.ts:42). So a real "You're Pro" confirmation can render off the synced tier.
  • The receipt block is fabricated. /api/stripe/sync returns only { tier, customerId } (sync.ts:83). No "trial ends" date, no "$/mo then" line, no receipt number/email is returned. "Trial ends Jun 28" and the "Emailed →" receipt link have no data source — rendering them as shown would invent facts. ("Receipt emailed" is even arguably true only if Stripe email receipts are enabled on the account — out of our control.)

Verdict: Build the success state from the SYNCED TIER only ("You're Pro" + CTA buttons). GATE OFF the receipt rows (Plan/Trial-ends/Then/Receipt) unless sourced from Stripe — they are fabrication risks. Trial copy must be driven by real trialDaysRemaining, not a hardcoded "14 days / Jun 28."


4. State 4 — MANAGE (billing.html:201218) — invoice list + payment method = FABRICATED; use Customer Portal

Verbatim copy

  • H1 "Billing" (:203).
  • Current-plan card (:204209, honey border):
    • "Waggle Pro" / "$19.00 / month · renews Jul 14, 2026" + "● Active" badge (honey).
    • Row "Billing cycle" — "Monthly Switch to annual (20%)" (the .chg link is honey).
    • Row "Payment method" — "VISA ···· 4242" Update (:207).
    • Row "Next charge" — "$19.00 on Jul 14" (:208).
  • Invoices card (:210215):
    • Header "Invoices".
    • 3 rows, each: date · $19.00 (mono) · "Paid" (healthy pill) · "PDF ↓" download link:
      • Jun 14, 2026 · $19.00 · Paid · PDF
      • May 14, 2026 · $19.00 · Paid · PDF
      • Apr 14, 2026 · $19.00 · Paid · PDF
  • Footer actions (:216): "Change plan" (ghost, data-go="plans") + "Cancel subscription" (danger/red).
  • Footer note (:217): "Subscription managed securely via Stripe."

Reality check

  • renews Jul 14, VISA ···· 4242, Next charge $19.00 on Jul 14, and ALL 3 invoices are fabricated. Grep confirms no invoices route, no payment-method route, no invoices.list / customers.retrieve / paymentMethods call anywhere in packages/server/src. /api/stripe/sync
    • /api/tier return no renewal date, no card brand/last4, no next-charge, no invoice history.
  • What IS real for Manage: the Stripe Customer Portal. createPortalSession() (adapter.ts:2675) → POST /api/stripe/create-portal-session (portal.ts:1451, requireTier('PRO'), reads stripe_customer_id from config.json) → returns hosted billingPortal URL. The portal IS where "Update payment method, view invoices, cancel subscription" actually happens — and the current SettingsApp already says exactly that (SettingsApp.tsx:632: "Update payment method, view invoices, or cancel your subscription via the Stripe customer portal.").
  • "Switch to annual (20%)" / "Change plan" / "Cancel subscription" → all belong in the Customer Portal (or a fresh checkout for an upgrade). Building in-app cancel/swap buttons that hit Stripe write-APIs directly is out of scope and risky; the portal is the sanctioned surface.

Verdict: The Manage state's in-app "current plan / cycle / payment method / next charge / invoice table" must be REPLACED by (a) a real current-plan header off useBilling().tier and (b) a single "Manage subscription via Stripe" button that opens the Customer Portal. The fabricated invoice list, card number, renewal/next-charge dates, and PDF links MUST be gated off — there is no data for them.


5. The custom-card-form-vs-Stripe-Checkout tension (explicit, per task)

Design HTML shows Codebase reality PR7 resolution
In-app 2-col card form (email/card/expiry/CVC/name/country) No card-capture endpoint; PCI-out-of-scope by design (checkout.ts only mints a hosted session) Redirect to Stripe-hosted Checkout (existing startCheckoutwindow.open(session.url)). Theme via Stripe Branding, not our DOM.
In-app promo Apply, live Tax (est.), Due today allow_promotion_codes:true (real) but applied on Stripe's page; tax/proration is Stripe-computed Promo/tax/due-today live on the hosted page. An in-app pre-summary may show plan+list price from tiers.ts only.
In-app invoice table + PDF + payment method + cancel No invoices/payment-method/cancel route exists Stripe Customer Portal (existing openPortal).
Success receipt (#, trial-end, "emailed") /sync returns {tier, customerId} only Confirm off synced tier; gate the receipt block.

SCREENS.md:294 ("Use Stripe Checkout/Customer Portal where possible; theme to tokens") is the contract: the HTML card form / invoice table are fidelity mockups of Stripe's hosted surfaces, not a spec to reimplement. PR7 builds the Plans screen + the two redirect entry points (Checkout → hosted; Manage → portal) + an honest post-redirect success state, all themed.


6. Fabrication-risk register (what PR7 could silently invent — gate OFF)

  1. Logged-in identity — design hardcodes mara@egzakta.com / Mara Kovač. There is no real account email in any contract. The only display name available is IdentityLayer.get().name (memory-derived, often undefined — home.ts:261267), surfaced as HomeBriefing.userName. An email or "name on card" must NOT be invented. (Note: Auth/Clerk is screen 13's job; until Clerk lands there is no authenticated email at all.)
  2. Invoice list (3× $19 Paid + PDF) — no invoices route; pure fabrication. Gate off → Customer Portal.
  3. Payment method (VISA ···· 4242) — no payment-method route; fabrication. Gate off → Portal.
  4. Receipt number / "Emailed →" / "Trial ends Jun 28"/sync has none of these. Gate off.
  5. Renewal / next-charge dates (renews Jul 14, Next charge $19.00 on Jul 14) — no date in any contract. Do not hardcode dates. Trial dates only via real trialDaysRemaining/trialStartedAt.
  6. $15 / $39 annual prices — these are the design's annual numbers (20% rounded). The authoritative charge is whatever the STRIPE_PRICE_*_ANNUAL price says. Display the design's marketing number is fine; the actual charged amount must come from Stripe, never asserted by us.
  7. Test card 4242 4242 4242 4242 — fine as a placeholder in a mock, but must never appear in the real (hosted) flow; it's Stripe's own test PAN.

7. REAL vs MUST-BUILD vs EXTERNAL-DEP (summary)

Feature Status Evidence / note
Plans cards + prices ($0/$19/$49) REAL tiers.ts:712; rendered in SettingsApp.tsx:543558 + UpgradeModal.tsx:163165
"Choose Pro/Teams" → checkout session REAL checkout.ts:1356, adapter.ts:2667, useBilling.ts:6981
Monthly/Annual toggle honored at checkout MUST-BUILD (small) backend ready (index.ts:91100); adapter drops billingPeriod (adapter.ts:2667)
Hosted Stripe Checkout (card form) REAL (redirect) + EXTERNAL-DEP needs STRIPE_SECRET_KEY + price-id envs (index.ts:29, checkout.ts:30)
Promo code REAL (on hosted page) allow_promotion_codes:true (checkout.ts:44)
Post-checkout sync → tier flip REAL sync.ts:1890 (payment-gated), useBilling.ts:104115
Success "You're Pro" off synced tier DERIVABLE from useBilling().tier after sync
Success receipt rows (#/trial-end/then) MUST-GATE (fabrication) /sync returns only {tier, customerId} (sync.ts:83)
Manage: current plan header DERIVABLE useBilling().tier, tierResolved
Manage: payment method / invoices / cancel / next-charge MUST-GATE → Customer Portal no route exists; portal.ts:1451 is the sanctioned surface; SettingsApp already does this (SettingsApp.tsx:622634)
Stripe Customer Portal REAL + EXTERNAL-DEP portal.ts, needs stripe_customer_id in config.json + Stripe account
Authenticated user email/name EXTERNAL-DEP (Clerk, screen 13) no account email in any contract; userName is memory-derived only (home.ts:261)
BYO-key vs metered positioning ("pay for scale" copy) EXTERNAL-DEP (founder decision) BUILD-PLAN §7 #5 / DESIGN_POV §4 — decide before PR7

  1. Plans (real): port the 3-card grid + monthly/annual toggle from billing.html; bind prices to tiers.ts; wire the toggle through a new billingPeriod arg on createCheckoutSession.
  2. Checkout (redirect): "Choose Pro/Teams" → spinner → window.open(session.url). Optional in-app pre-summary with plan + list price ONLY. No card fields, no fake tax/dates.
  3. Success (synced): themed "You're Pro" off the post-?session_id= synced tier. Receipt block omitted (or shows only what /sync returns: tier). Trial line only from real trial fields.
  4. Manage (portal): themed current-plan header (real tier) + one "Manage subscription via Stripe" button → Customer Portal. No in-app invoice table / card / cancel.
  5. Graceful degradation: every state must handle Stripe-not-configured (503 STRIPE_NOT_CONFIGURED, index.ts:5) and unresolved tier (tierResolved=false, useBilling.ts:16) — already the SettingsApp pattern.