Skip to content

WP-08: Billing + quota - #7

Merged
gitAbid merged 3 commits into
mainfrom
wp-08-billing-quota
Oct 27, 2026
Merged

gitAbid merged 3 commits into
mainfrom
wp-08-billing-quota

Conversation

@gitAbid

@gitAbid gitAbid commented Oct 27, 2026

Copy link
Copy Markdown
Owner

OWNER BLOCKERS (top) — Lemon Squeezy does not exist yet

No LS_API_KEY / LS_STORE_ID / LS_WEBHOOK_SECRET / variant ids / Founding payment link exist anywhere (docs/FOR-ABID.md §1). Until the owner runs the activation steps (now written up in docs/FOR-ABID.md §7, new):

  • POST /api/billing/checkout → 503 "Checkout isn't set up in this preview."
  • POST /api/webhooks/lemonsqueezy → 503 (fails closed; never accepts unsigned traffic)
  • Every workspace stays Free; quota wall still fires at 2 episodes/month on Free
  • Founding manual fallback (LS_FOUNDING_PAYMENT_URL + owner flag in /admin) works with zero LS config apart from that one env var

U14's "plan becomes Pro within 10 s of webhook" could not be verified against a live LS store. Everything else is verified below.

What shipped

  • web/lib/billing/entitlements.ts — plan limits (Free 2/mo ≤60 min + footer; Pro 10/mo ≤2 h, no footer; Founding = Pro yearly), quotaMonth (UTC, char(7) per §7.1), reset date, effectivePlan (cancel keeps Pro until current_period_end, then derives Free — no cron), verbatim quota-wall + cancel-dialog copy, lsStatusToWorkspaceStatus, founding counter math (first 100, derived only)
  • Quota semantics — assertAndConsumeQuota (counted when generation starts) + consumeEpisodeQuota / refundEpisode (refunded on pipeline failure) for WP-05/06 to call around POST /api/episodes
  • LS integration (web/lib/billing/ls.ts single fetch boundary; shapes verified against docs.lemonsqueezy.com today) — POST /api/billing/checkout (JSON:API checkout, variant from LS_VARIANT_PRO_MONTHLY/LS_VARIANT_FOUNDING, checkout_data.custom.workspace_id → meta.custom_data), POST /api/billing/cancel (DELETE /v1/subscriptions/:id), POST /api/webhooks/lemonsqueezy (HMAC-SHA256 X-Signature via crypto.timingSafeEqual, ported from api/ls-webhook.js incl. serialization fallbacks; idempotent via webhook_events keyed sha256(raw body))
  • Webhook mapping — order/subscription create+update+payment events → plan/status/current_period_end(renews_at)/founding; subscription_cancelled → status=cancelled, period end = ends_at (U15 access date); subscription_expired → downgrade; payment_failed/paused → past_due; unknown workspace → 200 unmatched (logged, still deduped); unhandled events ignored
  • Manual plan flag (owner-only) — POST /api/admin/billing, OWNER_EMAILS check inline in lib/billing/service.ts (fail-closed 403 when unset) because WP-04's requireOwner is concurrent — tie-break logged, integrator may unify
  • Founding counter — derived COUNT(*) of founding workspaces, "N of 100 left" on the button, hidden (not faked) without a DB
  • MK-9 billing page /app/settings/billing — plan card (name/price/renewal date), QuotaMeter ("3 of 10 episodes · resets Nov 14"), Upgrade/$19 buttons, Founding button, [Manage payment] via the signed LS urls.customer_portal (fetched at render, hidden when unavailable), Cancel → dialog "Cancel Pro? You'll keep Pro until {date}. Your episodes stay." [Keep Pro] [Cancel plan]
  • Quota wall component (components/app/billing/quota-wall.tsx) with the exact §6 copy, ready for WP-06 intake mounting
  • LS_* + OWNER_EMAILS added to web/.env.example (empty); 13 decisions appended to docs/relaunch/DECISIONS.md

Acceptance (U14/U15 in LS TEST MODE semantics)

Verified (automated):

  • Webhook HMAC: real test vectors signed locally with node crypto (valid/wrong-secret/tampered/malformed-header/uppercase-hex) — 9 tests
  • U14 logic: signed subscription_created fixture → workspace update plan=pro, plan_source=ls, current_period_end=renews_at (+founding variant) — 17 webhook tests incl. idempotent redelivery (duplicate no-op, side effect once) and full event mapping
  • Entitlements: limits, quota month/reset, refund/count semantics, founding counter, effectivePlan expiry, exact quota-wall copy ("You've used 2 of 2 episodes this month. Upgrade for more, or wait until Nov 1.") and exact cancel dialog copy — 20 tests
  • Service: checkout variants/409 already-Pro/503s/payment-link fallback/cancel paths/owner-only admin flag — 29 tests; LS HTTP client shapes — 4 tests
  • E2e (keyless preview): billing page renders MK-9 free plan card + quota meter + upgrade buttons; upgrade surfaces preview copy; webhook rejects unsigned — 20/20 Playwright passed
  • Gates: pnpm lint ✅ · pnpm typecheck ✅ · pnpm test 232 passed (19 files) ✅ · pnpm build ✅ · pnpm e2e 20 passed ✅

Not verifiable without owner LS setup (stated explicitly):

  • A real LS checkout redirect, a live test-mode purchase, or a real webhook delivery (LS dashboard "Send test") — code paths are covered by the signed-fixture tests above; the boundary is the single httpLsClient fetch module
  • U15 cancel against a real LS subscription (same boundary)

Preview: https://episodekit-1yc5wczlg-pera-byte.vercel.app/app/settings/billing (keyless shell: Free card, meter, upgrade buttons; server probed: webhook 503, checkout 503 preview copy)

Notes for integrator: WP-02's settings.spec.ts billing-stub assertion updated (stub replaced by the real page — the stub's own comment said it ships in WP-08). db/queries.ts gains billing functions (documented boundary). Billing page is partial-prerender + Suspense (Next 16 cacheComponents forbids dynamic = "force-dynamic"). Local e2e teardown of the detached next start webServer hangs on this machine (WP-00 decision note) — tests were run against a reused server; CI starts its own.

Entitlements (free 2/mo <=60min footer, pro 10/mo <=2h, founding = pro yearly),
quota month + wall copy (verbatim §6), LS checkout/cancel/webhook behind the
documented interface (HMAC port of api/ls-webhook.js, webhook_events
idempotency, event mapping per §7.1), owner-only manual plan flag, derived
founding counter (first 100), MK-9 billing settings page. Tests-first:
entitlements, webhook HMAC/idempotency/mapping, service, LS client, e2e.
LS keys do not exist yet (owner blocker, docs/FOR-ABID.md §7) — everything
verified against signed fixtures + the mocked single LS fetch boundary.
@gitAbid
gitAbid merged commit e92b4f8 into main Oct 27, 2026
2 checks passed
@gitAbid
gitAbid deleted the wp-08-billing-quota branch October 27, 2026 13:47
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