moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
# 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 list** — **none 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:104115``sync.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 `startCheckout``window.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** |
---
## 8. Recommended PR7 shape for Billing (so the builder doesn't fabricate)
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.