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

117 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR7 Recon · 01 — Stripe / Billing BACKEND state
**Scope:** What is REAL vs MUST-BUILD for the 4 billing screen states (Plans / Checkout / Success / Manage) of **screen 14 (Billing)** — plus the auth-identity context that screen 13 (Auth) and "Manage" lean on.
**Method:** read + grep only, no product code changed. Every claim cites `file:line`.
**Target surface:** PR7 builds in **`apps/web`** (the React desktop cockpit) → talks to the **local Fastify sidecar** (`packages/server/src/local/index.ts`). This is a DIFFERENT server entry from the cloud/`apps/www` stack, and that distinction is the single most important fact below.
---
## 0. The two-stack split (read this first)
There are **two** server entry points and **two** Stripe/auth integrations. They must not be conflated:
| | **Local sidecar** (PR7 target) | **Cloud server / `apps/www`** (PR8 / not PR7) |
|---|---|---|
| Entry | `packages/server/src/local/index.ts` | cloud: `packages/server/src/index.ts`; landing: `apps/www/app/**` |
| Tier source | `config.json` on disk (`assert-tier.ts:21-32`) | Clerk `publicMetadata` + cloud DB |
| Auth | **none** — no Clerk plugin registered in `local/` (grep: 0 hits for `plugins/auth` in `src/local`) | Clerk: `packages/server/src/plugins/auth.ts` (only imported by `src/index.ts`); `apps/www` uses `@clerk/nextjs` |
| Stripe routes | `packages/server/src/stripe/**` (checkout/portal/sync/webhook) | `apps/www/app/api/stripe/checkout/route.ts` + `app/api/webhooks/stripe/route.ts` |
| Stripe ↔ identity link | customer id stored in **`config.json` `stripe_customer_id`** (`webhook.ts:60`, `portal.ts:28`) | customer id stored in **Clerk `publicMetadata.stripeCustomerId`** (`apps/www/.../checkout/route.ts:94-97`) |
`apps/web` consumes the **local sidecar** (adapter base = local URL; `apps/web/src/lib/adapter.ts` calls `/api/stripe/*`). So **PR7's backend is the `packages/server/src/stripe/**` set, NOT the richer `apps/www` Clerk-linked flow.** The `apps/www` flow (full Clerk identity + lazy Stripe Customer + lookup_key price resolution) is PR8 territory and should not be assumed available in the desktop cockpit.
---
## 1. Stripe backend inventory (local sidecar — the PR7 surface)
All routes registered via `stripeRoutes` at `packages/server/src/local/index.ts:128,2161`. All gate on `STRIPE_SECRET_KEY`; absent → **503 `STRIPE_NOT_CONFIGURED`** (`index.ts:25-43`, each route).
| Route | File:line | What it does | REAL? |
|---|---|---|---|
| `POST /api/stripe/create-checkout-session` | `checkout.ts:16` | Hosted Stripe Checkout session (`mode:'subscription'`), PRO/TEAMS only, period-aware price, returns `{url}` | **REAL** |
| `POST /api/stripe/create-portal-session` | `portal.ts:15` | Stripe **Customer Portal** session, `requireTier('PRO')` gated, needs `config.json.stripe_customer_id`, returns `{url}` | **REAL** |
| `POST /api/stripe/sync` | `sync.ts:21` | Poll-confirm after redirect (desktop behind NAT), retrieves session, **payment-gated** (`status==='complete' && paid`, `sync.ts:46-49`), writes tier to config | **REAL** |
| `POST /api/stripe/webhook` | `webhook.ts:74` | Signature-verified lifecycle handler — **flips tier in config.json** | **REAL** |
| `GET /api/tier` | `settings.ts:322` | Authoritative tier read (+ `trialDaysRemaining`, `trialExpired`) | **REAL** |
| `PATCH /api/tier` | `settings.ts:363` | Dev/testing tier override ("will be replaced by Stripe webhook") | REAL (dev) |
| `POST /api/tier/start-trial` | `settings.ts:401` | Atomic 15-day trial start | REAL |
| **invoice list** | — | **does not exist** (grep `invoice`/`invoices.list` in `packages/server` → 0 product hits; only a chat-keyword at `chat-helpers.ts:15`) | **MUST-BUILD** |
| **payment-method read/update** | — | **does not exist** (grep `paymentMethod`/`payment_method` → 0 hits) | **MUST-BUILD (or defer to Portal)** |
**Adapter methods already on the FE** (`apps/web/src/lib/adapter.ts`): `createCheckoutSession` (2667), `createPortalSession` (2675), `syncStripeCheckout` (2659), `getTier` (2682). The **`useBilling` hook already orchestrates the whole happy path** — `apps/web/src/hooks/useBilling.ts`: `startCheckout` opens the URL (69-81), `openPortal` opens the portal URL (84-96), `syncAfterCheckout` confirms (49-66), and it **auto-detects `?session_id=` on mount** to run sync (104-115). It also carries the honesty primitive `tierResolved` (16-21): until a real `getTier()` round-trip succeeds, the default `'FREE'` is a **placeholder, not a fact** — billing surfaces must render an unresolved state, not the FREE card.
### Webhook DOES flip tiers (REAL)
`webhook.ts:114-158` handles three events, all writing tier to `config.json` via `updateUserTier` (52-62):
- `checkout.session.completed` → grants tier **only if `payment_status` is `paid`/`no_payment_required`** (122) — unpaid sessions never grant.
- `customer.subscription.updated` → re-resolves tier from price id via `tierFromPriceId` (139).
- `customer.subscription.deleted` → **downgrades to FREE** (150).
Plus real hardening: raw-body signature verification (88-95), idempotency via `.stripe-processed-events.json` + a serialized critical section against TOCTOU double-processing (40-45, 102-165), atomic temp-file writes (25-29). CLAUDE.md §10 (E-10) confirms 17/17 webhook tests green.
---
## 2. Price wiring / env contract (REAL, documented)
`tierFromPriceId` (`index.ts:71-87`) and `priceIdForTier` (`index.ts:91-100`) resolve a **dual env contract**:
- **4-var** (matches `apps/www`): `STRIPE_PRICE_PRO_MONTHLY` / `_PRO_ANNUAL` / `_TEAMS_MONTHLY` / `_TEAMS_ANNUAL`.
- **legacy single-var** fallback: `STRIPE_PRICE_PRO` / `_TEAMS` / `_BASIC`(→PRO).
- final fallback: `TIER_CAPABILITIES[tier].stripePriceId` (`tiers.ts:122,143`, read from `STRIPE_PRICE_PRO`/`_TEAMS` env).
CLAUDE.md §10 (M7) records both **test (`acct_1SzHlbC0mmjh4oEM`) and live (`CNCrMQy1f7`)** Stripe accounts hold 2 products × 2 prices with lookup_keys `pro_monthly`/`pro_annual`/`teams_monthly`/`teams_annual`; live price IDs in `docs/launch/drafts/2026-05-12-apps-www-deployment-readiness.md`. **The price IDs/secret are EXTERNAL-DEP** (must be present in the sidecar's env at runtime). Pricing on the screen (Solo $0 / Pro $19 / Teams $49-seat, `SCREENS.md:294-295`) is **DERIVABLE** from `tiers.ts` doc-comment (`tiers.ts:7-12`) — but the literal dollar amounts are NOT machine-readable fields in `TIER_CAPABILITIES`; only `stripePriceId` is. So plan-card prices are static copy unless cross-checked against the Stripe dashboard.
---
## 3. Per-state verdict (screen 14 · Billing)
Screen spec: `SCREENS.md:282-295`. Note the explicit instruction: **"Use Stripe Checkout/Customer Portal where possible; theme to tokens."**
| State | Backend verdict | Evidence / what's needed |
|---|---|---|
| **Plans** (monthly/annual toggle, 3 cards) | **REAL (read) + DERIVABLE (copy)** | tier from `GET /api/tier` (`settings.ts:322`); current-plan highlight from `useBilling.tier` + `tierResolved`; prices are static copy derivable from `tiers.ts:7-12`. Upgrade buttons call existing `createCheckoutSession`. No new backend. |
| **Checkout** (custom card form: email, card 4242, expiry/CVC, country) | **MUST-NOT-BUILD as custom; redirect to HOSTED Checkout (REAL)** | The screen mock shows a **custom card form** (`SCREENS.md:287-289`), but the backend only produces a **hosted Stripe Checkout URL** (`checkout.ts:39-51`). There is **no card-tokenization / PaymentElement / Stripe.js** anywhere in `apps/web` (grep `CardElement`/`PaymentElement`/`4242` → 0 hits). **PCI/scope flag below.** Recommended: the "Checkout" segment is a themed **summary/preview that hands off to hosted Checkout**, not a real PAN field. |
| **Success** ("You're Pro", receipt, Manage) | **REAL** | Already wired: `useBilling` auto-runs `syncStripeCheckout` on `?session_id=` (`useBilling.ts:104-115`); sync is payment-gated (`sync.ts:46-49`). The post-redirect `/payment-success` URL is set at `checkout.ts:42`. "Receipt" line-items are **NOT returned by sync** (`sync.ts:83` returns only `{tier, customerId}`) → a real receipt would need a new fetch or Portal link → **MUST-BUILD or show generic confirmation**. |
| **Manage** (current plan, switch annual, payment method ···4242, next charge, **invoices PDF**, change/cancel) | **PARTLY REAL via Portal; the in-app detail is MUST-BUILD** | `createPortalSession` (`portal.ts:15`) gives a one-click jump to Stripe's **hosted Customer Portal**, which natively does payment-method update, invoice PDFs, plan change, cancel. **BUT** rendering those *inside* the cockpit (the "VISA ···4242", "next charge", invoice list with Paid+PDF in the mock, `SCREENS.md:291-292`) requires routes that **do not exist**: no invoice-list, no payment-method read, no subscription-detail route on the local sidecar. Also Portal requires `config.json.stripe_customer_id` to already be set (only written after a real paid checkout/webhook, `webhook.ts:60`) else **400 `NO_STRIPE_CUSTOMER`** (`portal.ts:32-34`). |
---
## 4. Auth context for screen 13 (and what "Manage" identity rests on)
Screen 13 spec (`SCREENS.md:269-278`): "Build with **Clerk** components themed to the tokens," with honest "account is optional / local-first" framing.
- **In `apps/web` (PR7 target): Clerk is NOT present.** grep `@clerk`/`ClerkProvider`/`useUser`/`signIn` in `apps/web` → **0 files**. The local sidecar registers **no auth plugin** (grep `plugins/auth` in `src/local` → 0). So **the desktop has no real logged-in identity** — tier lives in `config.json`, not behind a session.
- **Clerk IS wired, but only in the OTHER stack:** root dep `@clerk/fastify` (`package.json:45`) is consumed by `packages/server/src/plugins/auth.ts` (verifyToken + auto-provision) and `ws/gateway.ts:3` — both reachable **only from the cloud entry `src/index.ts`**, not the desktop sidecar. `apps/www` has the full Next.js Clerk surface (`sign-in`, `sign-up`, `account/page.tsx` using `<UserProfile>`, `@clerk/nextjs ^7.3.0` + `@clerk/themes ^2.4.57` in `apps/www/package.json:15-16`).
- **Therefore screen 13 in the desktop is EXTERNAL-DEP + design decision, not a wiring task.** Either (a) embed Clerk in `apps/web` for the first time (new provider, new keys, new session model — large, and contradicts "local-first / account optional"), or (b) make screen 13 a **themed informational/SSO-handoff** screen that links to `apps/www` Clerk and keeps the desktop accountless. The honesty contract leans hard toward (b): **do not render a logged-in identity (name/email/avatar) the desktop does not actually have.** (PR1 already flagged the `userName={null}` sidebar row, BUILD-PLAN.md:189.)
---
## 5. FABRICATION RISKS — must be gated off, never invented
1. **Invoices list (Manage).** No invoice route exists. A static "Invoice #1234 · Paid · PDF" list would be **fabricated billing history**. Gate: only show invoices if a real route is built against `stripe.invoices.list(customer)`; otherwise **link out to the hosted Portal** for invoices. (`SCREENS.md:292`)
2. **Payment method "VISA ···4242".** No payment-method route. The mock's "···4242" is literally Stripe's test PAN. Hardcoding it = **fake payment method**. Gate: render only from a real `paymentMethods.list`, else Portal-only. (`SCREENS.md:291`)
3. **"Next charge" / billing-cycle date.** Not returned by any local route (`sync.ts:83`, `getTier`). Inventing a date = fabrication. Gate: derive from a real subscription fetch or omit.
4. **Custom card form (4242 PAN field).** A real-looking PAN/CVC field that doesn't tokenize would be both fake AND a PCI-scope trap (see §6). Gate: never collect raw PAN in-app; hand off to hosted Checkout.
5. **Logged-in identity on Auth / sidebar (screen 13).** Desktop has no Clerk session. Showing a real name/email/avatar = fabricated identity. Gate: keep accountless or SSO-handoff; the `tierResolved`-style "unresolved" pattern (`useBilling.ts:16-21`) is the precedent.
6. **Tier shown as FREE before resolution.** `useBilling` already guards this with `tierResolved` (`useBilling.ts:36-46`) — the Plans "current plan" badge must honor it, not assume FREE.
7. **Receipt on Success.** `syncStripeCheckout` returns no receipt/amount (`sync.ts:83`). A "$19 charged" receipt line would be invented. Gate: generic "You're Pro" confirmation, or Portal link, until a real receipt fetch exists.
---
## 6. Custom card form vs hosted — PCI / scope implication (FLAG)
The screen mock shows a **custom card form** (email, card 4242, expiry/CVC, name, country — `SCREENS.md:287-289`), but the **backend only emits a hosted Stripe Checkout URL** (`checkout.ts:39-51`) and the screen note itself says **"Use Stripe Checkout/Customer Portal where possible"** (`SCREENS.md:294`).
**Implication:** A real custom PAN form means the card number touches the app's DOM → **PCI-DSS scope jumps from the trivial SAQ-A (hosted/redirect) to SAQ-A-EP or higher**, and would require Stripe.js Elements / PaymentElement client-side tokenization (none exists in `apps/web` today — grep confirms 0). For a Tauri desktop binary this is a material compliance + security burden for zero functional gain over the already-built hosted flow.
**Recommendation (for the build-plan decision):** treat the Checkout-segment card UI as a **themed visual preview/order-summary that redirects to hosted Stripe Checkout** (reuse `createCheckoutSession`), and treat "Manage" detail (payment method, invoices, cancel) as a **themed launchpad to the hosted Customer Portal** (reuse `createPortalSession`). Build new local routes (invoice-list / subscription-detail) ONLY if founder wants those rendered in-app — and even then, render strictly from live Stripe data, never placeholders.
---
## 7. One-line summary per question asked
- **create-checkout-session route?** YES — REAL (`checkout.ts:16`).
- **customer-portal route?** YES — REAL (`portal.ts:15`), but needs `stripe_customer_id` in config first.
- **invoice-list route?** NO — MUST-BUILD (or defer to hosted Portal).
- **Does the webhook flip tiers?** YES — REAL, payment-gated, idempotent (`webhook.ts:114-158`).
- **What does "Manage" need?** Either reuse the hosted Portal (REAL today) OR build 3 new local routes: invoice-list, payment-method, subscription-detail (none exist).
- **Hosted vs custom?** Hosted is the intended + already-built path; the mock's custom 4242 form is a PCI-scope trap — flag and prefer hosted.
- **Auth screen 13 backend?** Desktop has NO Clerk/identity (EXTERNAL-DEP + design call); Clerk only lives in the cloud server + `apps/www`.