Skip to content

feat(website): Stripe Checkout for paid pricing tiers (PR B-Stripe)#508

Merged
blove merged 15 commits into
mainfrom
claude/stripe-checkout
May 21, 2026
Merged

feat(website): Stripe Checkout for paid pricing tiers (PR B-Stripe)#508
blove merged 15 commits into
mainfrom
claude/stripe-checkout

Conversation

@blove
Copy link
Copy Markdown
Contributor

@blove blove commented May 21, 2026

Summary

Replaces the /contact?source=pricing_tier_<slug> placeholder CTAs on /pricing with real Stripe-hosted Checkout. Three buyable tiers (Indie, Developer Seat, App Deployment) now POST to /api/checkout/session, which 303-redirects to checkout.stripe.com. Community keeps the npm link; Enterprise keeps /contact.

One-time 12-month payments (no subscriptions per locked brainstorm). License token minting is handled by the existing apps/minting-service/ webhook handler — out of scope here; PR C deploys it.

What's in the PR

  • pricing/tiers.config.ts — typed source of truth for all 5 tiers (3 stripeBuyable). Consumed by PricingGrid and the sync script.
  • pricing/tiers.generated.ts — empty Stripe-IDs map; populated by the sync script. Committed empty so the route 503s gracefully until products are created.
  • scripts/stripe/sync-products.ts — idempotent products/prices sync. Identifies products by metadata.ngaf_tier_slug (never by name). Re-runnable; stale prices are archived when unit_amount changes. 3 unit tests against a Stripe stub.
  • apps/website/src/lib/stripe.ts — Stripe client wrapper that refuses to load if STRIPE_SECRET_KEY is missing or doesn't begin with sk_. 3 unit tests.
  • apps/website/src/app/api/checkout/session/route.ts — Next.js Route Handler. Accepts both JSON and form POST. 7 unit tests covering invalid tiers (400), community/enterprise (400), happy path (303), adjustable_quantity on Developer Seat, quantity clamping [1, 100], Stripe-returned-no-URL (502).
  • apps/website/src/app/thanks/page.tsx — server component shown after Checkout success. Tells buyer their license token is on its way. 3 unit tests.
  • apps/website/src/components/pricing/PricingGrid.tsx — refactored to read from pricing/tiers.config.ts; paid tiers submit a POST form to /api/checkout/session. Community + Enterprise hrefs unchanged.
  • apps/website/src/lib/analytics/events.ts — added marketing:checkout_started and marketing:checkout_succeeded events.
  • eslint.config.mjs — added one allow pattern for pricing/tiers.{config,generated} since they're shared between apps/website and scripts/stripe and live outside any Nx project on purpose.

Test plan

  • cd apps/website && npx vitest run17 files, 72 tests passing (was 14/59 before this PR; +3 stripe.spec + 7 route.spec + 3 thanks.spec).
  • npx vitest run scripts/stripe/sync-products.spec.ts → 3 passed.
  • npx nx run website:lint → success.
  • npx nx build website → success (build does not require Stripe env vars or populated STRIPE_PRICE_IDS).
  • Scope check: only apps/website/, pricing/, scripts/stripe/, docs/superpowers/, eslint.config.mjs, and two .env.example files touched.

Operational steps for go-live (after merge)

  1. Set STRIPE_SECRET_KEY (test mode) in the Vercel threadplane website project.
  2. STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe/sync-products.ts — populates pricing/tiers.generated.ts with real price IDs; commit the result.
  3. In Stripe Dashboard → Webhooks, point a webhook endpoint at the minting service URL (PR C activates this end).
  4. Smoke test: open /pricing, click "Buy indie license", complete with 4242 4242 4242 4242. Confirm redirect to /thanks and Stripe Dashboard shows the session with metadata.ngaf_tier_slug=indie.

Out of scope (deliberately)

  • License token minting / email delivery. The existing minting service handles checkout.session.completed; PR C deploys it to production.
  • Subscriptions / renewals. All paid tiers are one-time 12-month payments. Renewal flow is a future PR.
  • Origin allowlist field at checkout. Deferred to a future PR alongside the claims-schema extension.
  • Live mode. Ship in test mode; flip to live by changing the Vercel STRIPE_SECRET_KEY value and re-running the sync script with sk_live_….

Risks

  • Token won't arrive in this PR — the minting service isn't deployed yet (PR C). The /thanks copy says "within a few minutes" to leave room for manual fulfillment if needed.
  • The 7-level relative import (../../../../../../../pricing/...) from the route handler is ugly but correct; the alternative (a paths alias) didn't prove necessary. ESLint's nx-module-boundaries rule now explicitly allows it via a single regex entry.

🤖 Generated with Claude Code

blove and others added 11 commits May 21, 2026 08:54
Two parallel sub-PRs that close the @ngaf/chat relicense loop after PRs
A + B landed.

- PR B-Stripe: Stripe-hosted Checkout for Indie / Developer Seat /
  App Deployment tiers; idempotent products/prices sync script;
  /thanks page. One-time 12-month payments. Token minting handled by
  the existing minting service webhook.

- PR C: deploy the existing apps/minting-service to Vercel
  (threadplane-minting-service project already exists with the prod
  private key); wire CACHEPLANE_LICENSE_PUBLIC_KEY into publish.yml so
  published @ngaf/chat ships with the prod public key; fix the
  runLicenseCheck idempotency bug; document provideChat({license}) and
  exercise the verify path via one example. Enforcement stays
  advisory-only; origin allowlist deferred.

Both specs include an end-to-end smoke-test runbook that uses
examples/chat/angular/ as the verification surface and is explicit
about reverting all temporary injections before commit, so the demo
stays clean in main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 tasks: tiers.config source of truth, generated price-IDs stub,
Stripe client wrapper with sk_ guard, /api/checkout/session route,
/thanks page, PricingGrid form swap for paid tiers, idempotent
products/prices sync script, analytics events + env example, final
verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-tier definition consumed by both the website's PricingGrid and the
Stripe products/prices sync script. Stripe products are matched by
metadata.ngaf_tier_slug, never by display name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Populated by scripts/stripe/sync-products.ts. The /api/checkout/session
route detects an empty map and 503s with "checkout not yet configured."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getStripe() refuses to load if STRIPE_SECRET_KEY is missing or doesn't
begin with sk_. The key encodes test vs live mode, so we don't need
explicit mode detection elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST with { tier } returns a 303 redirect to Stripe-hosted Checkout for
the three buyable tiers (indie / developer_seat / app_deployment).
Community and Enterprise return 400. Quantity is clamped to [1, 100]
and adjustable_quantity is enabled only for Developer Seat. Returns
503 when STRIPE_PRICE_IDS is empty (sync-products hasn't run yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tells buyers their @ngaf/chat license token will be emailed within a
few minutes, links to installation docs and support. Static server
component; reads no query params at runtime — Stripe handles the
session_id reconciliation via webhook in the minting service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Tier data now sourced from pricing/tiers.config.ts (single source of
  truth shared with the Stripe sync script).
- Paid tiers (Indie / Developer Seat / App Deployment) submit a POST
  form to /api/checkout/session which 303-redirects to Stripe-hosted
  Checkout.
- Community keeps the npm link; Enterprise keeps /contact.
- Tracking events unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mports

apps/website (PricingGrid + /api/checkout/session route) and
scripts/stripe (sync-products) both import the repo-root pricing/
config files. They live outside any Nx project on purpose — they're
the source of truth shared between display and Stripe sync. The
@nx/enforce-module-boundaries rule needs an allow entry to permit
this; the alternative is making pricing/ an Nx library, which is
overkill for two config files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads pricing/tiers.config.ts and ensures each stripeBuyable tier has a
Stripe product (matched by metadata.ngaf_tier_slug) and exactly one
active one-time USD price. Writes pricing/tiers.generated.ts with the
resulting price IDs. Re-runnable; stale prices are archived when
unit_amount changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
threadplane Ready Ready Preview, Comment May 21, 2026 6:23pm

Request Review

blove and others added 2 commits May 21, 2026 10:50
6 tasks: runLicenseCheck idempotency bug fix + regression test;
inject CACHEPLANE_LICENSE_PUBLIC_KEY into publish.yml; add
minting-deploy job to ci.yml mirroring existing Vercel deploy
patterns; document provideChat({license}) in libs/chat/README;
wire examples/chat/angular to read optional license token; final
verification + operational checklist for PR description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding stripe to apps/website/package.json without regenerating
package-lock.json broke npm ci in CI. The root workspace already
ships stripe ^22.0.2, so the import resolves fine without the
explicit declaration. (Memory: regenerating package-lock.json on
macOS drops Linux @next/swc-* bindings and breaks CI.)

Local build + tests still pass; this is a pure lockfile-hygiene fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI installed stripe@22.0.x (LatestApiVersion = '2026-03-25.dahlia')
while local pnpm had 22.1.x ('2026-04-22.dahlia'). The hardcoded
literal failed the website build's TypeScript narrowing.

Omitting apiVersion lets the Stripe lib use whichever default ships
with the installed version. Account-level API version pinning still
applies; we just don't override it from code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant