diff --git a/docs/FOR-ABID.md b/docs/FOR-ABID.md index 7bc79f1..e31b525 100644 --- a/docs/FOR-ABID.md +++ b/docs/FOR-ABID.md @@ -66,3 +66,29 @@ webhook, and RSS prefill works against real feeds. Env var reference: `web/.env. That's the whole list. Every morning at 9 the agent session checks uptime, executes the day's roadmap items (A1–A7), activates whatever you've unlocked, and logs everything to the [live map](https://episodekit.vercel.app/map). + +--- + +## 7. Lemon Squeezy (WP-08 blocker): subscriptions + quota walls activate + +The billing code (checkout, webhook, cancel, quota) is built and tested with +signed fixtures, but nothing charges until you create the LS store. Until then: +upgrade buttons answer "Checkout isn't set up in this preview." and every +workspace stays on Free. Three steps: + +1. **LS store (test mode first)** — lemonsqueezy.com → create store + **EpisodeKit**, then two products: + - **EpisodeKit Pro** — $19/month subscription → note the **variant id** + - **EpisodeKit Founding Pro** — $99/year subscription (first 100) → note the + **variant id** +2. **Webhook** — Settings → Webhooks → new endpoint + `https:///api/webhooks/lemonsqueezy`, events: order + + subscription lifecycle; copy the **signing secret**. +3. **Env vars** (Vercel → episodekit-web → Settings → Environment Variables): + `LS_API_KEY`, `LS_STORE_ID`, `LS_WEBHOOK_SECRET`, + `LS_VARIANT_PRO_MONTHLY`, `LS_VARIANT_FOUNDING` (numeric ids as strings) → + redeploy. Verify with LS's "Send test" webhook + a test purchase. + +Founding without a store (launch fallback, §0.2): create the $99/yr product's +**payment link**, set `LS_FOUNDING_PAYMENT_URL`, and flip buyers to Pro in +`/admin` (manual flag) — see the PR for WP-08 for the exact flow. diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md index 91fd4a5..0083c98 100644 --- a/docs/relaunch/DECISIONS.md +++ b/docs/relaunch/DECISIONS.md @@ -3,6 +3,43 @@ Agents append one entry per tie-break decision (01-product.md §0.4). Newest first. Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`. +- 2026-10-27 · WP-08 · Billing DB access extends the shared `db/queries.ts` boundary (`updateWorkspacePlan`, `getWorkspaceById`, `getWorkspaceByOwnerEmail`, `countFoundingWorkspaces`, `countQuotaEpisodes`, `markEpisodeQuotaCounted`, `refundEpisodeQuota`, `getUserEmail`) instead of a private `lib/billing/store` · `@/db/queries` is the documented boundary every service mocks/tests against; a parallel store module would fork DB conventions · separate `lib/billing/store.ts` (self-contained but two DB paths) +- 2026-10-27 · WP-08 · Webhook idempotency key = sha256 of the raw body in `webhook_events` · Lemon Squeezy delivers no unique event id (only `meta.event_name`), and LS redeliveries are byte-identical, so the hash is a stable dedupe key; verification candidates are normalized before hashing · subscription-id+updated_at composite (collides across event types); ignoring idempotency (duplicate plan writes are harmless but webhook_events exists for this) +- 2026-10-27 · WP-08 · Cancel keeps Pro: `subscription_cancelled` sets `status=cancelled` + `current_period_end=ends_at` with plan unchanged; `effectivePlan()` derives the downgrade at read time; `subscription_expired` sets plan=free · matches LS's grace-period model and U15 "see access end date" with no cron (§0.2 has no queue infra); the DB row keeps the truth the UI shows · a scheduled downgrade job (no scheduler in the MVP) +- 2026-10-27 · WP-08 · LS `paused`/`unpaid` map to workspace `past_due`; unknown future LS statuses also fail closed to `past_due` · §7.1 has only active|past_due|cancelled; a hold or payment hiccup should keep access while flagging, and an unmapped new status must never cancel a paying user · mapping paused→cancelled (cuts off a paying user mid-hold) +- 2026-10-27 · WP-08 · Quota month = UTC calendar month (`quota_month` char(7) per §7.1); reset = 1st of next month, independent of the billing renewal date shown above it ("Renews Nov 30" vs "resets Nov 1" in MK-9) · the schema stores a calendar month, so calendar semantics are the documented unit · anchoring quota to `renews_at` (would need a different column shape) +- 2026-10-27 · WP-08 · New optional env `LS_FOUNDING_PAYMENT_URL` for the keyless Founding flow: when the LS API is not configured, `POST /api/billing/checkout` (plan=founding) returns that payment link (`mode:"payment_link"`) and activation stays a manual owner flag · §0.2 "LS payment link for Founding Pro + manual plan flag" needs a URL env; §9 names only the variant ids · reusing legacy `LS_FOUNDER_URL` (different deal: $49 lifetime, wrong product) +- 2026-10-27 · WP-08 · Manual plan flag lives at `POST /api/admin/billing` with the owner check inline in `lib/billing/service.ts` (`OWNER_EMAILS`, case-insensitive, 403 + "Owner access isn't configured." when unset — fail closed) · WP-04's admin boundary was concurrent, so WP-08 stays self-contained; the integrator can unify under one `requireOwner` later · importing WP-04's not-yet-merged requireOwner (merge risk both ways) +- 2026-10-27 · WP-08 · Manual plan periods: Founding flag = +12 calendar months (UTC), plain manual Pro = +1 month · the owner sets the flag once per payment-link sale; calendar months read naturally on the plan card · fixed 365-day year (drifts), no expiry at all (owner must remember) +- 2026-10-27 · WP-08 · [Manage payment] resolves the signed LS `urls.customer_portal` link at page render via `GET /v1/subscriptions/:id` and the button hides when the fetch fails · LS portal links expire (~24 h), so storing them at webhook time would ship dead links · persisting `urls` in a new column (schema churn + expiry still applies) +- 2026-10-27 · WP-08 · Signature verification keeps the legacy multi-candidate fallback (raw bytes → `JSON.stringify` → `stableStringify`) on top of the raw-body check · App Router `req.text()` gives the exact signed bytes, but dashboard "Send test" payloads through proxies have historically been re-serialized (legacy api/ls-webhook.js comment); extra candidates are still HMAC'd with the same secret, so this stays fail-closed · raw-body only (stricter, risks bouncing re-serialized deliveries) +- 2026-10-27 · WP-08 · Checkout answers 409 "You're already on Pro." when the effective plan is paid; cancel on Free answers 400 "You're on the Free plan." · plain §0.4 copy for states the §6 deck doesn't cover; effective plan (not raw plan) is checked so a cancelled-but-expired workspace can re-upgrade · 200-with-url always (LS would reject a duplicate subscription) +- 2026-10-27 · WP-08 · `order_created` sets plan/status/ids but leaves `current_period_end` untouched · the order payload carries no renewal date; the `subscription_created` event that always accompanies it (LS docs) sets `renews_at` · deriving a period end from the order (would invent a date) +- 2026-10-27 · WP-08 · Quota consume = check-then-mark (not atomic) in `assertAndConsumeQuota` + `consumeEpisodeQuota` · a workspace has a single owner (§3: no teams in the MVP), so a race can only double-count the owner against themselves; the pipeline routes (WP-05/06) call these around POST /api/episodes · a transactional `INSERT … WHERE count < limit` (needs a CTE on every create for no real contention) +- 2026-10-27 · WP-08 · Billing page renders as a partial-prerender shell with the plan card streamed in ``; the quota reset date is computed at request time · Next 16 `cacheComponents` forbids route segment `dynamic` and blocks `new Date()` during prerender; the streaming shell keeps the instant preview · `export const instant = false` (newer, less documented escape hatch) + +- 2026-10-27 · WP-01 · `/dev/components` ships in every build; "dev only" is enforced by noindex metadata + zero inbound links (no nav/sitemap entry) · WP-00 fixed `pnpm e2e` to test the production build via `next start`, so a NODE_ENV or env-var gate would break the axe e2e locally and in CI · gating the route on `NODE_ENV=development`; a `NEXT_PUBLIC_*` flag set in the CI e2e step +- 2026-10-27 · WP-01 · Toast = shadcn `sonner` with the Toaster pinned bottom-right + 4 s (§5.3); dropped the generated `next-themes` wiring, sonner `theme="system"` follows prefers-color-scheme like every other component · the registry has no radix `toast` for the radix style ("only available for Base UI projects" per CLI) and sonner is shadcn's current default · hand-rolled wrapper on radix Toast primitives +- 2026-10-27 · WP-01 · shadcn `--muted-foreground` maps to §5.2 `--body`, not `--faint`: faint on surface-2/3 sits at/below the 4.5:1 AA floor (4.51:1 / ~4.2:1) and muted-foreground renders on muted surfaces (dialog footers, segmented tabs); faint remains in use for text on bg/surface (4.83:1) via custom components · acceptance says fix the component mapping, not the spec tokens · mapping muted-foreground to faint and flagging the AA miss in the PR +- 2026-10-27 · WP-01 · §5.2 focus ring (2px accent + 2px offset) is one global unlayered `:focus-visible` outline; shadcn's per-component `focus-visible:ring-3 ring-ring/50` classes were removed so indicators don't double up · the written spec wins over shadcn defaults · keeping shadcn rings (3px, 50% alpha, no offset) +- 2026-10-27 · WP-01 · axe scans of the open dropdown menu and tooltip are scoped to the open portal (`data-radix-popper-content-wrapper`); dialog/sheet scans stay full-page · Radix renders menus modal: the whole background is aria-hidden while focus is trapped, so full-page scans report hidden-but-focusable background nodes plus page-level heading/region rules — artifacts of the correct modal pattern, not real defects · disabling those rules for the whole suite +- 2026-10-27 · WP-01 · mock.css component styles are realized as React components (shadcn adaptations + `components/app/`) using the tokens; globals.css carries only the §5.2 tokens, shadcn mapping, type scale, radii, shadow, focus and motion · one source of truth per component instead of duplicated utility classes · literal port of `.btn/.pill/.quote/...` classes into globals.css +- 2026-10-27 · WP-01 · Button sizes remapped to §5.3 32/40/48 px (sm/default/lg); dense xs/icon-xs stay 28 px for dialog/sheet close affordances · honors §5.3 while keeping shadcn's dense variants usable · deleting the dense sizes +- 2026-10-27 · WP-02 · Keyless preview mode: when Clerk keys AND DATABASE_URL are both absent, `getAuthUserId()` returns a fixed `user_preview` id and DB-backed routes answer 503; if either is configured the app fails closed (DB-without-Clerk → no user) · previews/CI/e2e must render shells with zero secrets (handoff rule) without faking a provider; the half-configured window (DB, no auth) must never expose data · env flag like ALLOW_KEYLESS_AUTH; dev-only gating (Vercel preview is NODE_ENV=production) +- 2026-10-27 · WP-02 · ClerkProvider, clerkMiddleware (in `proxy.ts`, Next 16 convention) and `/` mount only when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`+`CLERK_SECRET_KEY` are set; /sign-in shows a plain "Sign in isn't set up in this preview." card otherwise · app must build/preview keyless; rendering Clerk components keyless throws · always mounting Clerk with dummy keys (fake provider) +- 2026-10-27 · WP-02 · `user.deleted` deletes the users row; workspaces/shows/episodes cascade via FK, feedback + usage_events keep with SET NULL · account deletion should wipe personal data (GDPR-friendly) while the owner keeps the learning signal from feedback notes · soft-delete flag (no column in §7.1) +- 2026-10-27 · WP-02 · POST /api/show is idempotent: if the caller already has a show it returns 200 with the existing row instead of a duplicate · refresh during onboarding must not create two shows (shows.workspace_id is unique) · 409 error the client must handle +- 2026-10-27 · WP-02 · RSS prefill = hand-rolled extraction of channel `` + `itunes:image`/`<image><url>` (with CDATA/entity decode, relative-URL resolution); no XML parser dependency · MVP only needs two fields and RSS import is explicitly out of scope; §0.4 fewer dependencies · fast-xml-parser +- 2026-10-27 · WP-02 · SSRF guard: literal IPs checked without DNS, hostnames resolved with `dns.lookup(all)` and every address must be `unicast` after unwrapping IPv4-mapped IPv6, redirects followed manually (≤3) and re-validated per hop, 5 MB counted-body cap, one shared 5 s deadline · §7.5 requires private-IP blocking; a hostile feed must not bounce us to an internal service or an infinite redirect · allowlist-only domains (breaks real feeds) +- 2026-10-27 · WP-02 · RSS prefill fills only EMPTY fields (name when blank; artwork always) · typed values must never be silently overwritten · always overwrite +- 2026-10-27 · WP-02 · A readable feed with neither title nor artwork is treated as unreadable → the exact U2 error copy · otherwise the user sees success and no prefill happens, which feels broken · 200 with nulls +- 2026-10-27 · WP-02 · Columns without a stated default use boring conventions: timestamps `defaultNow()`, arrays `default []`, counters `default 0`, boolean flags `default false`; status enums have NO default (inserts state one explicitly); `plan_source`, `current_period_end`, ls ids, feed/voice fields stay nullable · §0.4 rule 1 while keeping §7.1's stated defaults byte-exact · inventing defaults for status enums (could mask pipeline bugs) +- 2026-10-27 · WP-02 · /app/episodes (MK-3 empty state) + /app/episodes/new (MK-4 intake shell) shipped as static shells · §5.4 says `/app (→ episodes)`, so U1's post-signup flow needs a real target; WP-06 replaces the list · leaving /app dead-ending after onboarding skip +- 2026-10-27 · WP-02 · Settings Voice/Billing/Account and onboarding step 2 are stubs with copy "This page is coming soon." + the stepper stays navigable (step 2 has "Skip for now") · those WPs (07/08) build the real pages; routes must exist per §5.4 · omitting routes until their WP lands (dead tabs) +- 2026-10-27 · WP-02 · Clerk webhook verifies via the `svix` package directly (payload + svix-* headers) instead of `@clerk/nextjs/webhooks` verifyWebhook · same Svix signatures, but plain `Request` typing and testable without Next-specific request shapes · verifyWebhook (needs RequestLike cast) +- 2026-10-27 · WP-02 · Playwright `E2E_PORT` env override (default 3000, command unchanged) · sibling agents run e2e concurrently on this machine and race for :3000; CI path stays byte-identical · hard-coding a different port (would break the documented CI flow) +- 2026-10-27 · WP-02 · `cost_usd` is `numeric(12,6)` and `units` int · money estimates should not accumulate float error; drizzle maps numeric to string · doublePrecision (simpler, lossy) +Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`. - 2026-10-27 · WP-04 · WP-02 integration landed in this branch via merge: `resolveAppSettingsStore()` now returns a real Drizzle store over `app_settings` (WP-02 schema), null only when `DATABASE_URL` is unset (keyless preview); `requireOwner` uses `lib/env.isClerkConfigured()` for clean 503-vs-401 classification and still denies keyless previews (admin AI switching must never work where auth is fake) · completes the documented WP-02 seams instead of leaving them stubbed; U18 now exercises the real store path in tests via the same interface · keeping the null stub until WP-09 - 2026-10-27 · WP-04 · Groq fallback models: `openai/gpt-oss-120b` (long) / `openai/gpt-oss-20b` (fast) · §8.1 requires the choice to be made from Groq's current model list at build time; console.groq.com/docs/models (2026-10-27) lists the llama-3.3/3.1 models as Enterprise ("contact sales") while gpt-oss-120b/20b are self-serve with public prices ($0.15/$0.60 and $0.075/$0.30 per M) and 131k context · llama-3.3-70b-versatile long + llama-3.1-8b-instant fast diff --git a/web/.env.example b/web/.env.example index 75152a0..e595ddb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -18,3 +18,24 @@ CLERK_WEBHOOK_SIGNING_SECRET= # Clerk routing (defaults shown; used by ClerkProvider and the sign-in/up pages) # NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in # NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up + +# ── Lemon Squeezy (WP-08 billing; §9). Owner blocker until the store is live +# (docs/FOR-ABID.md §1) — everything degrades to preview copy when unset. +# API key (dashboard.lemonsqueezy.com → Settings → API keys). Server only. +LS_API_KEY= +# Store id (number, from the LS dashboard URL or API). +LS_STORE_ID= +# Webhook signing secret (Settings → Webhooks; 6–40 chars). Verifies the +# X-Signature HMAC of POST /api/webhooks/lemonsqueezy. +LS_WEBHOOK_SECRET= +# Variant id of the "Pro $19/month" subscription. +LS_VARIANT_PRO_MONTHLY= +# Variant id of the "Founding Pro $99/yr" product (first 100). +LS_VARIANT_FOUNDING= +# Optional fallback when the LS store is NOT live (§0.2 manual flow): the +# Founding Pro payment link (https://<store>.lemonsqueezy.com/buy/<uuid>). +# The purchase only becomes Pro after the owner flags it in /admin. +LS_FOUNDING_PAYMENT_URL= + +# Owner emails for /admin (comma-separated; M13). Gates the manual plan flag. +OWNER_EMAILS= diff --git a/web/app/(app)/app/settings/billing/page.tsx b/web/app/(app)/app/settings/billing/page.tsx index c090420..7465456 100644 --- a/web/app/(app)/app/settings/billing/page.tsx +++ b/web/app/(app)/app/settings/billing/page.tsx @@ -1,11 +1,55 @@ +import { connection } from "next/server"; +import { Suspense } from "react"; +import { BillingPanel } from "@/components/app/billing/billing-panel"; +import { requireUser } from "@/lib/auth"; +import { quotaResetDate } from "@/lib/billing/entitlements"; +import { defaultBillingDeps, getBillingView, type BillingView } from "@/lib/billing/service"; +import { isDbConfigured } from "@/lib/env"; + export const metadata = { title: "Settings · EpisodeKit" }; -/** Billing settings ship in WP-08 (M10, MK-9). */ +/** + * Keyless preview default (WP-02 pattern): a free plan with honest zeros. + * Nothing is faked — the founding counter stays hidden without a DB and the + * checkout/cancel buttons surface the preview copy from the API. + */ +function keylessView(): BillingView { + return { + plan: "free", + founding: false, + planSource: null, + status: "active", + used: 0, + limit: 2, + resetsAt: quotaResetDate(new Date()).toISOString(), + accessEndsAt: null, + foundingLeft: null, + checkoutConfigured: false, + foundingPaymentUrl: process.env.LS_FOUNDING_PAYMENT_URL ?? null, + managePaymentUrl: null, + }; +} + +async function BillingContent() { + // Per-user state (plan, quota, reset date) — request-time rendering. + await connection(); + const userId = await requireUser(); + let view = keylessView(); + if (isDbConfigured()) { + try { + view = (await getBillingView(userId, defaultBillingDeps())) ?? view; + } catch (error) { + console.error("[billing] view unavailable", error); + } + } + return <BillingPanel view={view} />; +} + +/** Settings → Billing (M10, MK-9). Shell prerenders; the panel streams per user. */ export default function SettingsBillingPage() { return ( - <div className="rounded-lg border p-6" data-testid="billing-stub"> - <h2 className="text-base font-semibold">Billing</h2> - <p className="mt-1 text-sm text-neutral-500">This page is coming soon.</p> - </div> + <Suspense fallback={<div data-testid="billing-loading" className="h-48 animate-pulse rounded-lg bg-neutral-100 dark:bg-[#1b212a]" />}> + <BillingContent /> + </Suspense> ); } diff --git a/web/app/api/admin/billing/route.ts b/web/app/api/admin/billing/route.ts new file mode 100644 index 0000000..a44b73f --- /dev/null +++ b/web/app/api/admin/billing/route.ts @@ -0,0 +1,18 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { defaultBillingDeps, handleAdminSetPlan } from "@/lib/billing/service"; + +/** + * POST /api/admin/billing — the owner's manual plan flag (§0.2): flag a + * workspace Founding Pro after a payment-link purchase, or downgrade it. + * Owner authorization lives in handleAdminSetPlan (OWNER_EMAILS). + */ +export async function POST(req: Request) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const body: unknown = await req.json().catch(() => null); + const result = await handleAdminSetPlan(userId, body, defaultBillingDeps()); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/billing/cancel/route.ts b/web/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..a0ca5a2 --- /dev/null +++ b/web/app/api/billing/cancel/route.ts @@ -0,0 +1,13 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { defaultBillingDeps, handleBillingCancel } from "@/lib/billing/service"; + +/** POST /api/billing/cancel — one-click cancel, keeps Pro until period end (§7.5, U15). */ +export async function POST() { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const result = await handleBillingCancel(userId, defaultBillingDeps()); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..ce9b702 --- /dev/null +++ b/web/app/api/billing/checkout/route.ts @@ -0,0 +1,14 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { defaultBillingDeps, handleBillingCheckout } from "@/lib/billing/service"; + +/** POST /api/billing/checkout — LS checkout (or Founding payment link), §7.5. */ +export async function POST(req: Request) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const body: unknown = await req.json().catch(() => null); + const result = await handleBillingCheckout(userId, body, defaultBillingDeps()); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/webhooks/lemonsqueezy/route.ts b/web/app/api/webhooks/lemonsqueezy/route.ts new file mode 100644 index 0000000..0ba0b23 --- /dev/null +++ b/web/app/api/webhooks/lemonsqueezy/route.ts @@ -0,0 +1,6 @@ +import { handleLsWebhook } from "@/lib/billing/webhook"; + +/** POST /api/webhooks/lemonsqueezy (public) — HMAC-verified, idempotent, §7.5. */ +export async function POST(req: Request) { + return handleLsWebhook(req); +} diff --git a/web/components/app/billing/billing-panel.tsx b/web/components/app/billing/billing-panel.tsx new file mode 100644 index 0000000..b9e19d1 --- /dev/null +++ b/web/components/app/billing/billing-panel.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { + cancelDialogCopy, + formatResetDate, + FOUNDING_PRICE_COPY, + PRO_PRICE_COPY, +} from "@/lib/billing/entitlements"; +import type { BillingView } from "@/lib/billing/service"; +import { UpgradeButton } from "./upgrade-button"; +import { QuotaMeter } from "./quota-meter"; + +/** + * MK-9 Billing settings: plan card (plan name, price, renewal/access date, + * QuotaMeter), Upgrade / Founding buttons on Free, Manage payment + Cancel on + * Pro, and the cancel dialog with the verbatim MK-9 copy. + */ +export function BillingPanel({ view }: { view: BillingView }) { + const router = useRouter(); + const [cancelOpen, setCancelOpen] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [error, setError] = useState<string | null>(null); + + const paid = view.plan === "pro"; + const dialog = + view.accessEndsAt !== null + ? cancelDialogCopy({ periodEnd: new Date(view.accessEndsAt) }) + : cancelDialogCopy({ periodEnd: new Date(view.resetsAt) }); + const foundingLabel = + view.foundingLeft === null + ? `Founding Pro — ${FOUNDING_PRICE_COPY}` + : `Founding Pro — ${FOUNDING_PRICE_COPY} · ${view.foundingLeft} left`; + + async function confirmCancel() { + setCancelling(true); + setError(null); + try { + const res = await fetch("/api/billing/cancel", { method: "POST" }); + const data = (await res.json().catch(() => null)) as { error?: string } | null; + if (res.ok) { + setCancelOpen(false); + router.refresh(); + return; + } + setError(data?.error ?? "Something went wrong. Try again."); + } catch { + setError("Something went wrong. Try again."); + } finally { + setCancelling(false); + } + } + + return ( + <div className="flex flex-col gap-4"> + <div data-testid="billing-plan-card" className="max-w-xl rounded-lg border border-[#e3e6eb] p-6 dark:border-[#2a313c]"> + <div className="flex items-start justify-between gap-4"> + <div> + <p className="text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400"> + Current plan + </p> + <h2 className="mt-1 text-lg font-semibold" data-testid="billing-plan-name"> + {paid ? (view.founding ? "Founding Pro" : "Pro") : "Free"} + </h2> + {paid && view.accessEndsAt ? ( + <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400" data-testid="billing-renewal"> + {view.status === "cancelled" ? "Cancels" : "Renews"} {formatResetDate(new Date(view.accessEndsAt))} + </p> + ) : null} + </div> + <span className="text-lg font-semibold" data-testid="billing-price"> + {paid ? PRO_PRICE_COPY : "$0"} + </span> + </div> + + <div className="mt-4"> + <QuotaMeter used={view.used} limit={view.limit} resetsAt={view.resetsAt} /> + </div> + + <div className="mt-5 flex flex-wrap items-start gap-2"> + {paid ? ( + <> + {view.managePaymentUrl ? ( + <a + href={view.managePaymentUrl} + data-testid="manage-payment" + className="rounded-md border border-[#cdd2da] px-4 py-2 text-sm font-medium hover:bg-neutral-50 dark:border-[#3a4350] dark:hover:bg-[#1b212a]" + > + Manage payment + </a> + ) : null} + <button + type="button" + data-testid="cancel-plan" + onClick={() => setCancelOpen(true)} + className="rounded-md px-4 py-2 text-sm font-medium text-[#b42318] hover:bg-[#fdecea] dark:text-[#ff6b5e] dark:hover:bg-[#2f1614]" + > + Cancel plan + </button> + </> + ) : ( + <> + <UpgradeButton plan="pro" label={`Upgrade to Pro — ${PRO_PRICE_COPY}`} testId="upgrade-pro" /> + <UpgradeButton + plan="founding" + label={foundingLabel} + testId="upgrade-founding" + disabled={view.foundingLeft === 0} + /> + </> + )} + </div> + </div> + + {cancelOpen ? ( + <div className="fixed inset-0 z-50 grid place-items-center bg-[rgb(17_24_39/0.4)] p-4"> + <div + role="dialog" + aria-modal="true" + aria-labelledby="cancel-dialog-title" + data-testid="cancel-dialog" + className="w-full max-w-sm rounded-xl border border-[#e3e6eb] bg-white p-6 dark:border-[#2a313c] dark:bg-[#151a21]" + > + <h2 id="cancel-dialog-title" className="text-base font-semibold"> + {dialog.title} + </h2> + <p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300" data-testid="cancel-dialog-copy"> + {dialog.body} + </p> + {error ? ( + <p role="alert" data-testid="cancel-error" className="mt-2 text-xs text-[#b42318] dark:text-[#ff6b5e]"> + {error} + </p> + ) : null} + <div className="mt-4 flex justify-end gap-2"> + <button + type="button" + data-testid="keep-pro" + onClick={() => setCancelOpen(false)} + className="rounded-md border border-[#cdd2da] px-4 py-2 text-sm font-medium hover:bg-neutral-50 dark:border-[#3a4350] dark:hover:bg-[#1b212a]" + > + {dialog.keepLabel} + </button> + <button + type="button" + data-testid="confirm-cancel-plan" + onClick={confirmCancel} + disabled={cancelling} + className="rounded-md bg-[#b42318] px-4 py-2 text-sm font-medium text-white hover:bg-[#9a1c12] disabled:opacity-50 dark:bg-[#ff6b5e] dark:text-[#0e1116]" + > + {cancelling ? "Cancelling…" : dialog.cancelLabel} + </button> + </div> + </div> + </div> + ) : null} + </div> + ); +} diff --git a/web/components/app/billing/quota-meter.tsx b/web/components/app/billing/quota-meter.tsx new file mode 100644 index 0000000..96330a5 --- /dev/null +++ b/web/components/app/billing/quota-meter.tsx @@ -0,0 +1,30 @@ +import { formatResetDate } from "@/lib/billing/entitlements"; + +/** + * QuotaMeter (02-design.md §5.3) — bar + "3 of 10 episodes · resets Nov 14" + * counter line from MK-3/MK-9. Plain Tailwind; §5.2 tokens inline (WP-01 not merged). + */ +export function QuotaMeter({ used, limit, resetsAt }: { used: number; limit: number; resetsAt: string }) { + const pct = Math.min(100, Math.round((used / Math.max(1, limit)) * 100)); + const atLimit = used >= limit; + return ( + <div data-testid="quota-meter"> + <div + className="h-2 w-full overflow-hidden rounded-full bg-[#eceef2] dark:bg-[#232a35]" + role="progressbar" + aria-valuemin={0} + aria-valuemax={limit} + aria-valuenow={used} + aria-label={`${used} of ${limit} episodes used this month`} + > + <div + className={`h-full rounded-full ${atLimit ? "bg-[#9a5b00] dark:bg-[#e0a548]" : "bg-[#2457d6] dark:bg-[#5b8cff]"}`} + style={{ width: `${pct}%` }} + /> + </div> + <p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400"> + {used} of {limit} episodes · resets {formatResetDate(new Date(resetsAt))} + </p> + </div> + ); +} diff --git a/web/components/app/billing/quota-wall.tsx b/web/components/app/billing/quota-wall.tsx new file mode 100644 index 0000000..442b183 --- /dev/null +++ b/web/components/app/billing/quota-wall.tsx @@ -0,0 +1,21 @@ +import { quotaWallCopy } from "@/lib/billing/entitlements"; +import { UpgradeButton } from "./upgrade-button"; + +/** + * Plan wall card (02-design.md §5.7) with the §6 verbatim quota copy. + * Mounted by the intake flow when the workspace is over quota (U14); the + * billing page itself always shows the plan card instead. + */ +export function QuotaWall({ used, limit, resetsAt }: { used: number; limit: number; resetsAt: string }) { + return ( + <div data-testid="quota-wall" className="rounded-lg border border-[#e3e6eb] p-6 dark:border-[#2a313c]"> + <p className="text-sm font-semibold">Monthly episode limit reached</p> + <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-300"> + {quotaWallCopy({ used, limit, resetDate: new Date(resetsAt) })} + </p> + <div className="mt-4"> + <UpgradeButton plan="pro" label="Upgrade to Pro — $19/month" testId="quota-wall-upgrade" /> + </div> + </div> + ); +} diff --git a/web/components/app/billing/upgrade-button.tsx b/web/components/app/billing/upgrade-button.tsx new file mode 100644 index 0000000..b149f5f --- /dev/null +++ b/web/components/app/billing/upgrade-button.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; + +/** + * Upgrade button: POSTs /api/billing/checkout and redirects to the returned + * Lemon Squeezy checkout (or Founding payment link) URL. Route errors carry + * their own copy (preview unavailable, already Pro, LS unreachable). + */ +export function UpgradeButton({ + plan, + label, + testId, + disabled = false, +}: { + plan: "pro" | "founding"; + label: string; + testId?: string; + disabled?: boolean; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + async function startCheckout() { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/billing/checkout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ plan }), + }); + const data = (await res.json().catch(() => null)) as { url?: string; error?: string } | null; + if (res.ok && typeof data?.url === "string") { + window.location.href = data.url; + return; + } + setError(data?.error ?? "Something went wrong. Try again."); + } catch { + setError("Something went wrong. Try again."); + } finally { + setBusy(false); + } + } + + return ( + <span className="inline-flex flex-col gap-1"> + <button + type="button" + data-testid={testId} + onClick={startCheckout} + disabled={disabled || busy} + className="rounded-md bg-[#2457d6] px-4 py-2 text-sm font-medium text-white hover:bg-[#1d48b5] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#5b8cff] dark:text-[#0e1116] dark:hover:bg-[#7aa2ff]" + > + {busy ? "Opening checkout…" : label} + </button> + {error ? ( + <span role="alert" data-testid="checkout-error" className="text-xs text-[#b42318] dark:text-[#ff6b5e]"> + {error} + </span> + ) : null} + </span> + ); +} diff --git a/web/db/queries.ts b/web/db/queries.ts index b50f242..8afa943 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -1,6 +1,6 @@ -import { eq } from "drizzle-orm"; +import { and, count, eq } from "drizzle-orm"; import { getDb } from "./index"; -import { shows, users, webhookEvents, workspaces } from "./schema"; +import { episodes, shows, users, webhookEvents, workspaces } from "./schema"; export type ShowRow = typeof shows.$inferSelect; export type WorkspaceRow = typeof workspaces.$inferSelect; @@ -95,3 +95,103 @@ export async function markWebhookEventProcessed(id: string, provider: string): P const db = getDb(); await db.insert(webhookEvents).values({ id, provider }).onConflictDoNothing(); } + +/** + * Billing (WP-08) workspace updates. `undefined` values leave the column + * unchanged; an explicit `null` clears it. Never fakes values: the caller + * decides every field (webhook mapping, manual owner flag). + */ +export type WorkspacePlanUpdate = Partial<{ + plan: "free" | "pro"; + planSource: "ls" | "manual" | null; + founding: boolean; + status: "active" | "past_due" | "cancelled"; + currentPeriodEnd: Date | null; + lsCustomerId: string | null; + lsSubscriptionId: string | null; +}>; + +export async function updateWorkspacePlan( + workspaceId: string, + update: WorkspacePlanUpdate, +): Promise<WorkspaceRow> { + const db = getDb(); + const set = Object.fromEntries( + Object.entries(update).filter(([, v]) => v !== undefined), + ); + const updated = await db + .update(workspaces) + .set(set) + .where(eq(workspaces.id, workspaceId)) + .returning(); + return updated[0]; +} + +export async function getWorkspaceById(id: string): Promise<WorkspaceRow | null> { + const db = getDb(); + const rows = await db.select().from(workspaces).where(eq(workspaces.id, id)).limit(1); + return rows[0] ?? null; +} + +/** Webhook fallback attribution when the checkout carried no workspace_id. */ +export async function getWorkspaceByOwnerEmail(email: string): Promise<WorkspaceRow | null> { + const db = getDb(); + const rows = await db + .select({ workspace: workspaces }) + .from(users) + .innerJoin(workspaces, eq(workspaces.ownerId, users.id)) + .where(eq(users.email, email)) + .limit(1); + return rows[0]?.workspace ?? null; +} + +/** Founding counter — always derived (first 100, M10); never stored/faked. */ +export async function countFoundingWorkspaces(): Promise<number> { + const db = getDb(); + const rows = await db + .select({ value: count() }) + .from(workspaces) + .where(eq(workspaces.founding, true)); + return rows[0]?.value ?? 0; +} + +/** Episodes that count against the workspace quota in the given month (M10). */ +export async function countQuotaEpisodes(workspaceId: string, month: string): Promise<number> { + const db = getDb(); + const rows = await db + .select({ value: count() }) + .from(episodes) + .innerJoin(shows, eq(episodes.showId, shows.id)) + .where( + and( + eq(shows.workspaceId, workspaceId), + eq(episodes.quotaMonth, month), + eq(episodes.countsQuota, true), + ), + ); + return rows[0]?.value ?? 0; +} + +/** Counted when generation starts (M10): stamp the month and flip the flag. */ +export async function markEpisodeQuotaCounted(episodeId: string, month: string): Promise<void> { + const db = getDb(); + await db + .update(episodes) + .set({ quotaMonth: month, countsQuota: true, updatedAt: new Date() }) + .where(eq(episodes.id, episodeId)); +} + +/** Refunded on pipeline failure (M10): keep quota_month for audit, stop counting. */ +export async function refundEpisodeQuota(episodeId: string): Promise<void> { + const db = getDb(); + await db + .update(episodes) + .set({ countsQuota: false, updatedAt: new Date() }) + .where(eq(episodes.id, episodeId)); +} + +export async function getUserEmail(userId: string): Promise<string | null> { + const db = getDb(); + const rows = await db.select({ email: users.email }).from(users).where(eq(users.id, userId)).limit(1); + return rows[0]?.email ?? null; +} diff --git a/web/lib/billing/entitlements.ts b/web/lib/billing/entitlements.ts new file mode 100644 index 0000000..a34fe07 --- /dev/null +++ b/web/lib/billing/entitlements.ts @@ -0,0 +1,151 @@ +/** + * Plan entitlements and quota semantics (01-product.md M10, 02-design.md §5.5 MK-9, §6 copy deck). + * + * Pure module — no DB, no env. Semantics: + * - Quota is counted when generation starts (episodes.counts_quota=true with + * episodes.quota_month = the UTC calendar month) and refunded on pipeline + * failure (counts_quota=false). §7.5 POST /api/episodes "counts quota". + * - quota_month is a UTC calendar month (char(7) per §7.1), so the quota + * resets on the first day of the next UTC month — independent of the + * billing renewal date shown on the plan card. + * - Founding Pro = Pro yearly (M10): a workspace with founding=true always has + * plan=pro (invariant maintained by every writer); the flag only marks the + * $99/yr founding deal and counts against the first-100 counter. + * - Cancelling keeps Pro until current_period_end ("You'll keep Pro until …"). + * effectivePlan() derives the downgrade — no cron needed. + */ + +export type PlanId = "free" | "pro"; + +/** The active|past_due|cancelled enum from §7.1 workspaces.status. */ +export type WorkspaceStatus = "active" | "past_due" | "cancelled"; + +/** Structural subset of the workspaces row the billing layer needs. */ +export type WorkspacePlanState = { + plan: PlanId; + status: WorkspaceStatus; + founding: boolean; + currentPeriodEnd: Date | null; +}; + +export const FREE_EPISODE_LIMIT = 2; +export const PRO_EPISODE_LIMIT = 10; +export const FREE_MAX_DURATION_MS = 60 * 60_000; +export const PRO_MAX_DURATION_MS = 2 * 60 * 60_000; + +/** M10: "Founding Pro $99/yr, first 100" — the cap is fixed; the sold count is always derived from the DB. */ +export const FOUNDING_TOTAL = 100; +export const FOUNDING_PRICE_COPY = "$99/year"; +export const PRO_PRICE_COPY = "$19/month"; + +export const PLAN_LIMITS: Record< + PlanId, + { episodesPerMonth: number; maxEpisodeDurationMs: number; footer: boolean } +> = { + free: { episodesPerMonth: FREE_EPISODE_LIMIT, maxEpisodeDurationMs: FREE_MAX_DURATION_MS, footer: true }, + pro: { episodesPerMonth: PRO_EPISODE_LIMIT, maxEpisodeDurationMs: PRO_MAX_DURATION_MS, footer: false }, +}; + +/** "2026-10" — the UTC calendar month an episode counts against (episodes.quota_month). */ +export function quotaMonth(date: Date): string { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; +} + +/** Quota resets on the first day of the next UTC month. */ +export function quotaResetDate(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1)); +} + +/** Mockup counter format ("resets Nov 14"). UTC for determinism. */ +export function formatResetDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date); +} + +// Alias kept short for local copy assembly below. +const FORMAT_RESET_DATE = formatResetDate; +export { FORMAT_RESET_DATE }; + +/** + * The plan the workspace may use right now. Cancelled workspaces keep Pro + * until current_period_end, then read as free (U15 "see access end date"). + */ +export function effectivePlan(wsState: WorkspacePlanState, now: Date): PlanId { + if (wsState.plan === "free") return "free"; + if ( + wsState.status === "cancelled" && + wsState.currentPeriodEnd && + wsState.currentPeriodEnd.getTime() <= now.getTime() + ) { + return "free"; + } + return "pro"; +} + +export type Entitlements = { + plan: PlanId; + episodesPerMonth: number; + maxEpisodeDurationMs: number; + /** Free plan appends "Show notes made with EpisodeKit" (M7/M10). */ + footer: boolean; +}; + +export function entitlementsFor(wsState: WorkspacePlanState, now: Date): Entitlements { + const plan = effectivePlan(wsState, now); + return { plan, ...PLAN_LIMITS[plan] }; +} + +export function getEntitlements(wsState: WorkspacePlanState, now: Date): Entitlements { + return entitlementsFor(wsState, now); +} + +/** {limit} for the §6 too_long copy "Episodes can be up to {limit} on your plan." */ +export function durationLimitCopy(plan: PlanId): string { + return plan === "pro" ? "2 hours" : "60 minutes"; +} + +/** Quota wall card copy — verbatim from the §6 copy deck. */ +export function quotaWallCopy(input: { used: number; limit: number; resetDate: Date }): string { + return ( + `You've used ${input.used} of ${input.limit} episodes this month. ` + + `Upgrade for more, or wait until ${FORMAT_RESET_DATE(input.resetDate)}.` + ); +} + +/** Cancel dialog copy — verbatim from MK-9. */ +export function cancelDialogCopy(input: { periodEnd: Date }): { + title: string; + body: string; + keepLabel: string; + cancelLabel: string; +} { + return { + title: "Cancel Pro?", + body: `You'll keep Pro until ${FORMAT_RESET_DATE(input.periodEnd)}. Your episodes stay.`, + keepLabel: "Keep Pro", + cancelLabel: "Cancel plan", + }; +} + +/** "N of 100 left" — derived from a COUNT(*) of founding workspaces; never stored. */ +export function foundingLeft(foundingCount: number): number { + return Math.max(0, FOUNDING_TOTAL - foundingCount); +} + +/** + * LS subscription status → §7.1 workspace status. LS states: on_trial, active, + * paused, past_due, unpaid, cancelled, expired. Paused/unpaid are holds, not + * cancellations, so they map to past_due; unknown future states fail closed to + * past_due (access kept, flagged) rather than cancelling a paying user. + */ +export function lsStatusToWorkspaceStatus(lsStatus: string): WorkspaceStatus { + switch (lsStatus) { + case "active": + case "on_trial": + return "active"; + case "cancelled": + case "expired": + return "cancelled"; + default: + return "past_due"; + } +} diff --git a/web/lib/billing/ls.ts b/web/lib/billing/ls.ts new file mode 100644 index 0000000..e6249a3 --- /dev/null +++ b/web/lib/billing/ls.ts @@ -0,0 +1,136 @@ +/** + * Lemon Squeezy API client — the single fetch boundary for billing (WP-08). + * Shapes verified against docs.lemonsqueezy.com/api (JSON:API, v1): + * - POST /v1/checkouts → data.attributes.url + * - DELETE /v1/subscriptions/:id → cancelled subscription object + * Keys come only from env (LS_API_KEY, LS_STORE_ID, §9). The whole client is + * injectable so tests and the keyless preview never touch the network. + */ + +const LS_API_BASE = "https://api.lemonsqueezy.com/v1"; + +export type LsCheckoutInput = { + storeId: string; + variantId: string; + /** Prefill + custom data; `custom` reappears as meta.custom_data in webhooks. */ + email?: string; + name?: string; + custom: Record<string, string>; +}; + +export type LsCancelResult = { + status: string; + endsAt: Date | null; + /** Signed, ~24 h links for [Manage payment] (urls.customer_portal). */ + customerPortalUrl: string | null; + updatePaymentMethodUrl: string | null; +}; + +export type LsSubscriptionInfo = { + status: string; + renewsAt: Date | null; + endsAt: Date | null; + customerPortalUrl: string | null; + updatePaymentMethodUrl: string | null; +}; + +export type LsClient = { + createCheckout(input: LsCheckoutInput): Promise<{ url: string }>; + cancelSubscription(subscriptionId: string): Promise<LsCancelResult>; + /** Read for the MK-9 [Manage payment] button (signed ~24 h portal links). */ + getSubscription(subscriptionId: string): Promise<LsSubscriptionInfo>; +}; + +function jsonApiHeaders(apiKey: string): HeadersInit { + return { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${apiKey}`, + }; +} + +function parseSubscription(body: { data?: { attributes?: Record<string, unknown> } }): LsSubscriptionInfo { + const attrs = (body.data?.attributes ?? {}) as Record<string, unknown>; + const urls = (attrs.urls ?? {}) as Record<string, string | null>; + return { + status: typeof attrs.status === "string" ? attrs.status : "unknown", + renewsAt: typeof attrs.renews_at === "string" ? new Date(attrs.renews_at) : null, + endsAt: typeof attrs.ends_at === "string" ? new Date(attrs.ends_at) : null, + customerPortalUrl: urls.customer_portal ?? null, + updatePaymentMethodUrl: urls.update_payment_method ?? null, + }; +} + +export const httpLsClient: LsClient = { + async createCheckout(input) { + const res = await fetch(`${LS_API_BASE}/checkouts`, { + method: "POST", + headers: jsonApiHeaders(requireApiKey()), + body: JSON.stringify({ + data: { + type: "checkouts", + attributes: { + checkout_options: { embed: false, dark: false, locale: "en" }, + checkout_data: { + ...(input.email ? { email: input.email } : {}), + ...(input.name ? { name: input.name } : {}), + custom: input.custom, + }, + }, + relationships: { + store: { data: { type: "stores", id: input.storeId } }, + variant: { data: { type: "variants", id: input.variantId } }, + }, + }, + }), + }); + if (!res.ok) { + throw new Error(`LS checkout failed: ${res.status}`); + } + const body = (await res.json()) as { data?: { attributes?: { url?: string } } }; + const url = body.data?.attributes?.url; + if (!url) throw new Error("LS checkout response had no url"); + return { url }; + }, + + async cancelSubscription(subscriptionId) { + const res = await fetch(`${LS_API_BASE}/subscriptions/${encodeURIComponent(subscriptionId)}`, { + method: "DELETE", + headers: jsonApiHeaders(requireApiKey()), + }); + if (!res.ok) { + throw new Error(`LS cancel failed: ${res.status}`); + } + const body = (await res.json()) as { + data?: { attributes?: { status?: string; ends_at?: string | null; urls?: Record<string, string | null> } }; + }; + const attrs = body.data?.attributes ?? {}; + return { + status: attrs.status ?? "cancelled", + endsAt: attrs.ends_at ? new Date(attrs.ends_at) : null, + customerPortalUrl: attrs.urls?.customer_portal ?? null, + updatePaymentMethodUrl: attrs.urls?.update_payment_method ?? null, + }; + }, + + async getSubscription(subscriptionId) { + const res = await fetch(`${LS_API_BASE}/subscriptions/${encodeURIComponent(subscriptionId)}`, { + headers: jsonApiHeaders(requireApiKey()), + }); + if (!res.ok) { + throw new Error(`LS subscription fetch failed: ${res.status}`); + } + return parseSubscription((await res.json()) as { data?: { attributes?: Record<string, unknown> } }); + }, +}; + +function requireApiKey(): string { + const key = process.env.LS_API_KEY; + if (!key) throw new Error("LS_API_KEY is not set. See web/.env.example."); + return key; +} + +/** True when checkout/cancel API calls are possible (keys + store present). */ +export function isLsConfigured(): boolean { + return Boolean(process.env.LS_API_KEY && process.env.LS_STORE_ID); +} diff --git a/web/lib/billing/service.ts b/web/lib/billing/service.ts new file mode 100644 index 0000000..f7e95e8 --- /dev/null +++ b/web/lib/billing/service.ts @@ -0,0 +1,328 @@ +/** + * Billing service (WP-08, M10/MK-9): checkout, cancel, the owner-only manual + * plan flag, and quota consume/refund. All DB access goes through the + * documented @/db/queries boundary; the Lemon Squeezy API is reached only via + * the injectable client in ls.ts (single fetch boundary), so every path here + * is testable and the keyless preview never touches the network. + * + * Flow decisions logged in docs/relaunch/DECISIONS.md (founding payment-link + * fallback, cancel-keeps-Pro semantics, manual plan period lengths). + */ +import * as queries from "@/db/queries"; +import { + ALREADY_PRO_COPY, + CHECKOUT_UNAVAILABLE_COPY, + FREE_PLAN_CANCEL_COPY, + LS_UNREACHABLE_COPY, + NO_WORKSPACE_FOR_EMAIL_COPY, + OWNER_NOT_CONFIGURED_COPY, + OWNER_ONLY_COPY, + PLAN_REQUIRED_COPY, + SAVE_UNAVAILABLE_COPY, +} from "@/lib/copy"; +import { + effectivePlan, + foundingLeft, + PLAN_LIMITS, + quotaMonth, + quotaResetDate, + quotaWallCopy, + type WorkspaceStatus, +} from "./entitlements"; +import { httpLsClient, isLsConfigured, type LsClient } from "./ls"; + +export type BillingDeps = { + now: () => Date; + dbConfigured: () => boolean; + lsConfigured: () => boolean; + lsClient: LsClient; + ensureWorkspace: typeof queries.getOrCreateWorkspaceByOwner; + getWorkspaceById: typeof queries.getWorkspaceById; + getWorkspaceByOwnerEmail: typeof queries.getWorkspaceByOwnerEmail; + updateWorkspacePlan: typeof queries.updateWorkspacePlan; + countQuotaEpisodes: typeof queries.countQuotaEpisodes; + markEpisodeQuotaCounted: typeof queries.markEpisodeQuotaCounted; + refundEpisodeQuota: typeof queries.refundEpisodeQuota; + countFoundingWorkspaces: typeof queries.countFoundingWorkspaces; + getUserEmail: typeof queries.getUserEmail; +}; + +export function defaultBillingDeps(): BillingDeps { + return { + now: () => new Date(), + dbConfigured: () => Boolean(process.env.DATABASE_URL), + lsConfigured: () => isLsConfigured(), + lsClient: httpLsClient, + ensureWorkspace: queries.getOrCreateWorkspaceByOwner, + getWorkspaceById: queries.getWorkspaceById, + getWorkspaceByOwnerEmail: queries.getWorkspaceByOwnerEmail, + updateWorkspacePlan: queries.updateWorkspacePlan, + countQuotaEpisodes: queries.countQuotaEpisodes, + markEpisodeQuotaCounted: queries.markEpisodeQuotaCounted, + refundEpisodeQuota: queries.refundEpisodeQuota, + countFoundingWorkspaces: queries.countFoundingWorkspaces, + getUserEmail: queries.getUserEmail, + }; +} + +export type BillingPlanRequest = "pro" | "founding"; + +export type ServiceResult = { status: number; json: Record<string, unknown> }; + +/** Calendar-month arithmetic in UTC ("one month from Oct 27" = Nov 27). */ +function addUtcMonths(date: Date, months: number): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds())); +} + +function variantIdFor(plan: BillingPlanRequest): string | undefined { + return plan === "founding" ? process.env.LS_VARIANT_FOUNDING : process.env.LS_VARIANT_PRO_MONTHLY; +} + +/** POST /api/billing/checkout — LS checkout, or the Founding payment link without LS. */ +export async function handleBillingCheckout( + userId: string, + body: unknown, + deps: BillingDeps, +): Promise<ServiceResult> { + if (!deps.dbConfigured()) { + return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } }; + } + const plan = (body as { plan?: unknown } | null)?.plan; + if (plan !== "pro" && plan !== "founding") { + return { status: 400, json: { error: PLAN_REQUIRED_COPY } }; + } + + const workspace = await deps.ensureWorkspace(userId); + if (effectivePlan(workspace, deps.now()) === "pro") { + return { status: 409, json: { error: ALREADY_PRO_COPY } }; + } + + const email = await deps.getUserEmail(userId) ?? undefined; + + // Founding without an LS store: the owner's payment link (§0.2 manual flow). + // The plan only becomes active when the owner flags it manually in admin. + const paymentLinkUrl = process.env.LS_FOUNDING_PAYMENT_URL; + if (!deps.lsConfigured()) { + if (plan === "founding" && paymentLinkUrl) { + return { status: 200, json: { url: paymentLinkUrl, mode: "payment_link" } }; + } + return { status: 503, json: { error: CHECKOUT_UNAVAILABLE_COPY } }; + } + + const variantId = variantIdFor(plan); + const storeId = process.env.LS_STORE_ID; + if (!variantId || !storeId) { + return { status: 503, json: { error: CHECKOUT_UNAVAILABLE_COPY } }; + } + + try { + const { url } = await deps.lsClient.createCheckout({ + storeId, + variantId, + email, + custom: { workspace_id: workspace.id }, + }); + return { status: 200, json: { url, mode: "checkout" } }; + } catch (error) { + console.error("[billing] checkout failed", error); + return { status: 502, json: { error: LS_UNREACHABLE_COPY } }; + } +} + +/** POST /api/billing/cancel — one-click cancel; Pro stays until the access end date (U15). */ +export async function handleBillingCancel(userId: string, deps: BillingDeps): Promise<ServiceResult> { + if (!deps.dbConfigured()) { + return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } }; + } + const workspace = await deps.ensureWorkspace(userId); + if (effectivePlan(workspace, deps.now()) === "free") { + return { status: 400, json: { error: FREE_PLAN_CANCEL_COPY } }; + } + + let accessEnds = + workspace.currentPeriodEnd ?? + (workspace.planSource === "ls" ? null : quotaResetDate(deps.now())); + + // Cancel at LS when this subscription lives there; the webhook confirms with + // subscription_cancelled. Manual plans are cancelled locally only. + if (workspace.planSource === "ls" && workspace.lsSubscriptionId && deps.lsConfigured()) { + try { + const result = await deps.lsClient.cancelSubscription(workspace.lsSubscriptionId); + accessEnds = result.endsAt ?? accessEnds; + } catch (error) { + console.error("[billing] LS cancel failed", error); + return { status: 502, json: { error: LS_UNREACHABLE_COPY } }; + } + } + + await deps.updateWorkspacePlan(workspace.id, { + status: "cancelled", + currentPeriodEnd: accessEnds, + }); + return { + status: 200, + json: { cancelled: true, accessEnds: (accessEnds ?? quotaResetDate(deps.now())).toISOString() }, + }; +} + +/** OWNER_EMAILS — comma-separated; matched case-insensitively (M13). */ +function ownerEmails(): string[] { + return (process.env.OWNER_EMAILS ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); +} + +export type AdminPlanBody = { email: string; plan: "pro" | "free"; founding?: boolean }; + +/** + * Manual plan flag (§0.2: "LS payment link for Founding Pro + manual plan + * flag"). Owner-only; the buyer pays via the payment link and the owner flips + * the workspace to Pro by email. Founding runs a year, plain manual Pro a + * month — the owner re-flags or downgrades from /admin. + */ +export async function handleAdminSetPlan( + actorUserId: string, + body: unknown, + deps: BillingDeps, +): Promise<ServiceResult> { + const allowed = ownerEmails(); + if (allowed.length === 0) { + return { status: 403, json: { error: OWNER_NOT_CONFIGURED_COPY } }; + } + const actorEmail = (await deps.getUserEmail(actorUserId))?.toLowerCase(); + if (!actorEmail || !allowed.includes(actorEmail)) { + return { status: 403, json: { error: OWNER_ONLY_COPY } }; + } + + const parsed = body as AdminPlanBody | null; + if (!parsed || (parsed.plan !== "pro" && parsed.plan !== "free") || typeof parsed.email !== "string") { + return { status: 400, json: { error: PLAN_REQUIRED_COPY } }; + } + const workspace = await deps.getWorkspaceByOwnerEmail(parsed.email); + if (!workspace) { + return { status: 404, json: { error: NO_WORKSPACE_FOR_EMAIL_COPY } }; + } + + const now = deps.now(); + if (parsed.plan === "pro") { + const founding = parsed.founding === true; + const periodEnd = addUtcMonths(now, founding ? 12 : 1); + await deps.updateWorkspacePlan(workspace.id, { + plan: "pro", + planSource: "manual", + founding, + status: "active", + currentPeriodEnd: periodEnd, + }); + return { status: 200, json: { updated: true, plan: "pro", founding, accessEnds: periodEnd.toISOString() } }; + } + + await deps.updateWorkspacePlan(workspace.id, { + plan: "free", + planSource: null, + founding: false, + status: "active", + currentPeriodEnd: null, + }); + return { status: 200, json: { updated: true, plan: "free" } }; +} + +export type QuotaState = { + allowed: boolean; + used: number; + limit: number; + quotaMonth: string; + resetDate: Date; + wallCopy: string | null; +}; + +/** + * Quota check for the pipeline (§7.5 POST /api/episodes "counts quota"; M10: + * counted when generation starts, refunded on pipeline failure). + * Check-then-mark is not atomic, but a workspace has a single owner (§3: no + * teams in the MVP), so a double-submit races only against itself. + */ +export async function assertAndConsumeQuota(userId: string, deps: BillingDeps): Promise<QuotaState> { + const now = deps.now(); + const workspace = await deps.ensureWorkspace(userId); + const month = quotaMonth(now); + const used = await deps.countQuotaEpisodes(workspace.id, month); + const plan = effectivePlan(workspace, now); + const limit = PLAN_LIMITS[plan].episodesPerMonth; + const resetDate = quotaResetDate(now); + const allowed = used < limit; + return { + allowed, + used, + limit, + quotaMonth: month, + resetDate, + wallCopy: allowed ? null : quotaWallCopy({ used, limit, resetDate }), + }; +} + +/** Mark an episode as counted (generation started). */ +export async function consumeEpisodeQuota( + episodeId: string, + deps: BillingDeps, +): Promise<void> { + await deps.markEpisodeQuotaCounted(episodeId, quotaMonth(deps.now())); +} + +/** Refund on pipeline failure (M10) — keeps quota_month for the audit trail. */ +export async function refundEpisode(episodeId: string, deps: BillingDeps): Promise<void> { + await deps.refundEpisodeQuota(episodeId); +} + +export type BillingView = { + plan: "free" | "pro"; + founding: boolean; + planSource: "ls" | "manual" | null; + status: WorkspaceStatus; + used: number; + limit: number; + resetsAt: string; + accessEndsAt: string | null; + /** Derived from COUNT(*) of founding workspaces; null when the DB is unavailable. */ + foundingLeft: number | null; + checkoutConfigured: boolean; + /** Payment-link mode for Founding when the LS store is not live (§0.2). */ + foundingPaymentUrl: string | null; + /** Signed LS customer-portal link for [Manage payment]; null when unavailable. */ + managePaymentUrl: string | null; +}; + +/** Data for the MK-9 billing settings page. Null when the DB is unavailable. */ +export async function getBillingView(userId: string, deps: BillingDeps): Promise<BillingView | null> { + if (!deps.dbConfigured()) return null; + const now = deps.now(); + const workspace = await deps.ensureWorkspace(userId); + const plan = effectivePlan(workspace, now); + const month = quotaMonth(now); + const used = await deps.countQuotaEpisodes(workspace.id, month); + const foundingCount = await deps.countFoundingWorkspaces(); + let managePaymentUrl: string | null = null; + if (workspace.planSource === "ls" && workspace.lsSubscriptionId && deps.lsConfigured()) { + // [Manage payment] opens LS's signed customer-portal link (~24 h validity). + try { + managePaymentUrl = (await deps.lsClient.getSubscription(workspace.lsSubscriptionId)).customerPortalUrl; + } catch (error) { + console.warn("[billing] portal url unavailable", error); + } + } + return { + plan, + founding: workspace.founding, + planSource: workspace.planSource, + status: workspace.status, + used, + limit: PLAN_LIMITS[plan].episodesPerMonth, + resetsAt: quotaResetDate(now).toISOString(), + accessEndsAt: workspace.currentPeriodEnd ? workspace.currentPeriodEnd.toISOString() : null, + foundingLeft: foundingLeft(foundingCount), + checkoutConfigured: deps.lsConfigured(), + foundingPaymentUrl: process.env.LS_FOUNDING_PAYMENT_URL ?? null, + managePaymentUrl, + }; +} diff --git a/web/lib/billing/webhook-verify.ts b/web/lib/billing/webhook-verify.ts new file mode 100644 index 0000000..8a63b37 --- /dev/null +++ b/web/lib/billing/webhook-verify.ts @@ -0,0 +1,70 @@ +/** + * Lemon Squeezy webhook signature verification — ported from legacy + * api/ls-webhook.js, scheme confirmed against the official docs + * (docs.lemonsqueezy.com/help/webhooks/signing-requests): + * X-Signature = hex HMAC-SHA256 of the raw request body, compared with + * crypto.timingSafeEqual. + * + * Legacy also verified against plausible re-serializations because the old + * Vercel Pages runtime parsed application/json before the handler saw it. + * App Router route handlers read the exact bytes via req.text(), so the raw + * body is the primary candidate; the legacy candidates are kept as a + * fallback so in-flight dashboard "Send test" payloads never bounce. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** Deterministic serialization for key-sorted objects (ported from api/ls-webhook.js). */ +export function stableStringify(v: unknown): string { + if (v === null || typeof v !== "object") return JSON.stringify(v); + if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]"; + const obj = v as Record<string, unknown>; + return ( + "{" + + Object.keys(obj) + .sort() + .map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])) + .join(",") + + "}" + ); +} + +function hmacHex(raw: string, secret: string): string { + return createHmac("sha256", secret).update(raw, "utf8").digest("hex"); +} + +/** Test/fixture helper: the expected X-Signature for a raw body. */ +export function lsSignature(rawBody: string, secret: string): string { + return hmacHex(rawBody, secret); +} + +function hexEquals(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); + } catch { + return false; + } +} + +/** + * True when signatureHeader is a valid X-Signature for rawBody under secret. + * Fails closed on any malformed input (empty secret, non-64-char header). + */ +export function verifyLsSignature(rawBody: string, signatureHeader: string, secret: string): boolean { + if (!secret || !signatureHeader) return false; + // X-Signature is a 64-char lowercase hex digest; anything else cannot match. + if (!/^[0-9a-f]{64}$/.test(signatureHeader)) return false; + + const candidates = [rawBody]; + if (rawBody) { + try { + const parsed: unknown = JSON.parse(rawBody); + if (parsed && typeof parsed === "object") { + candidates.push(JSON.stringify(parsed), stableStringify(parsed)); + } + } catch { + // Non-JSON body: only the raw bytes can have been signed. + } + } + return candidates.some((candidate) => hexEquals(hmacHex(candidate, secret), signatureHeader)); +} diff --git a/web/lib/billing/webhook.ts b/web/lib/billing/webhook.ts new file mode 100644 index 0000000..983b409 --- /dev/null +++ b/web/lib/billing/webhook.ts @@ -0,0 +1,173 @@ +/** + * POST /api/webhooks/lemonsqueezy (public, 03-architecture.md §7.5). + * HMAC verification ported from legacy api/ls-webhook.js (see webhook-verify.ts); + * idempotency via the webhook_events table keyed on sha256(raw body) — Lemon + * Squeezy delivers no unique event id, but identical redeliveries are + * byte-identical, so the hash is a stable dedupe key. + * + * Event mapping (payload shapes per docs.lemonsqueezy.com/help/webhooks): + * - order_created / subscription_created|updated|resumed|unpaused / + * payment_success|recovered|failed / paused → plan=pro (+founding when the + * variant matches LS_VARIANT_FOUNDING), status mapped from + * attributes.status, current_period_end = renews_at. + * - subscription_cancelled → keep Pro, status=cancelled, + * current_period_end = ends_at (grace period; U15 access end date). + * - subscription_expired → downgrade to free (the grace period ended). + * - anything else → ignored with 200. + * Workspace attribution: meta.custom_data.workspace_id (passed as + * checkout_data.custom at checkout creation) → fallback attributes.user_email. + */ +import { createHash } from "node:crypto"; +import * as queries from "@/db/queries"; +import { lsStatusToWorkspaceStatus } from "./entitlements"; +import { verifyLsSignature } from "./webhook-verify"; + +export type WorkspacePlanUpdate = queries.WorkspacePlanUpdate; + +type LsWebhookPayload = { + meta?: { event_name?: string; custom_data?: Record<string, unknown> }; + data?: { + type?: string; + id?: string | number; + attributes?: Record<string, unknown>; + }; +}; + +/** The handler only needs the workspace id, so deps accept minimal rows. */ +export type MinimalWorkspace = { id: string }; + +export type LsWebhookDeps = { + secret?: string; + foundingVariantId?: string; + isEventProcessed?: typeof queries.isWebhookEventProcessed; + markEventProcessed?: typeof queries.markWebhookEventProcessed; + getWorkspaceById?: (id: string) => Promise<MinimalWorkspace | null>; + getWorkspaceByOwnerEmail?: (email: string) => Promise<MinimalWorkspace | null>; + updateWorkspacePlan?: (id: string, update: WorkspacePlanUpdate) => Promise<unknown>; +}; + +/** Events that set/extend a paid plan. */ +const PLAN_EVENTS = new Set([ + "order_created", + "subscription_created", + "subscription_updated", + "subscription_resumed", + "subscription_unpaused", + "subscription_payment_success", + "subscription_payment_recovered", + "subscription_payment_failed", + "subscription_paused", +]); + +function asString(value: unknown): string | null { + return value === null || value === undefined ? null : String(value); +} + +function asDate(value: unknown): Date | undefined { + return typeof value === "string" ? new Date(value) : undefined; +} + +function eventId(rawBody: string): string { + return createHash("sha256").update(rawBody, "utf8").digest("hex"); +} + +export async function handleLsWebhook(req: Request, deps: LsWebhookDeps = {}): Promise<Response> { + const secret = deps.secret ?? process.env.LS_WEBHOOK_SECRET; + if (!secret) { + return Response.json({ error: "Webhook not configured." }, { status: 503 }); + } + + const raw = await req.text(); + if (!verifyLsSignature(raw, req.headers.get("x-signature") ?? "", secret)) { + return Response.json({ error: "Invalid webhook signature." }, { status: 401 }); + } + + let event: LsWebhookPayload; + try { + event = JSON.parse(raw) as LsWebhookPayload; + } catch { + return Response.json({ error: "Invalid webhook body." }, { status: 400 }); + } + + const isProcessed = deps.isEventProcessed ?? queries.isWebhookEventProcessed; + const markEventProcessed = deps.markEventProcessed ?? queries.markWebhookEventProcessed; + const id = eventId(raw); + if (await isProcessed(id)) { + return Response.json({ received: true, duplicate: true }); + } + + const eventName = event.meta?.event_name ?? ""; + const attributes = event.data?.attributes ?? {}; + const handled = PLAN_EVENTS.has(eventName) || eventName === "subscription_cancelled" || eventName === "subscription_expired"; + if (!handled) { + await markEventProcessed(id, "lemonsqueezy"); + return Response.json({ ok: true, ignored: eventName }); + } + + const getWorkspaceById = deps.getWorkspaceById ?? queries.getWorkspaceById; + const getWorkspaceByOwnerEmail = deps.getWorkspaceByOwnerEmail ?? queries.getWorkspaceByOwnerEmail; + const updateWorkspacePlan = deps.updateWorkspacePlan ?? queries.updateWorkspacePlan; + + const customWorkspaceId = asString(event.meta?.custom_data?.workspace_id); + const email = asString(attributes.user_email); + const workspace = + (customWorkspaceId ? await getWorkspaceById(customWorkspaceId) : null) ?? + (email ? await getWorkspaceByOwnerEmail(email) : null); + if (!workspace) { + // Unknown workspace: 200 (not an error for LS) and record the id so + // redeliveries stay no-ops; surfaced in logs for the owner. + console.warn(JSON.stringify({ ls_webhook: { event: eventName, unmatched: true, email } })); + await markEventProcessed(id, "lemonsqueezy"); + return Response.json({ ok: true, unmatched: true }); + } + + const foundingVariantId = deps.foundingVariantId ?? process.env.LS_VARIANT_FOUNDING; + // Orders carry first_order_item.variant_id; subscription events carry a + // top-level attributes.variant_id (LS payload docs). + const firstOrder = attributes.first_order_item as { variant_id?: unknown } | undefined; + const variantId = asString(firstOrder?.variant_id ?? attributes.variant_id); + const founding = Boolean(foundingVariantId && variantId && variantId === foundingVariantId); + + let update: WorkspacePlanUpdate; + if (eventName === "subscription_expired") { + // Grace period over: access ends now (effectivePlan would also read this free). + update = { + plan: "free", + status: "cancelled", + currentPeriodEnd: asDate(attributes.ends_at) ?? null, + }; + } else if (eventName === "subscription_cancelled") { + // Keep Pro until the end of the paid period ("You'll keep Pro until …"). + update = { + plan: "pro", + status: "cancelled", + currentPeriodEnd: asDate(attributes.ends_at) ?? asDate(attributes.renews_at) ?? null, + }; + } else { + // Paid/extended: an order was paid or the subscription is in a healthy or + // held state; map the LS status (order status "paid" → active). + const lsStatus = asString(attributes.status) ?? ""; + update = { + plan: "pro", + planSource: "ls", + founding, + status: eventName === "order_created" ? "active" : lsStatusToWorkspaceStatus(lsStatus), + ...(asDate(attributes.renews_at) ? { currentPeriodEnd: asDate(attributes.renews_at) } : {}), + ...(asString(attributes.customer_id) ? { lsCustomerId: asString(attributes.customer_id) } : {}), + ...(event.data?.type === "subscriptions" && event.data.id + ? { lsSubscriptionId: String(event.data.id) } + : {}), + }; + } + + try { + await updateWorkspacePlan(workspace.id, update); + } catch (error) { + console.error("[ls-webhook] update failed", error); + // 500 so Lemon Squeezy retries; the id was not recorded, so a redelivery re-applies. + return Response.json({ error: "Webhook processing failed." }, { status: 500 }); + } + + await markEventProcessed(id, "lemonsqueezy"); + return Response.json({ ok: true, event: eventName }); +} diff --git a/web/lib/copy.ts b/web/lib/copy.ts index 5bafa61..0152caf 100644 --- a/web/lib/copy.ts +++ b/web/lib/copy.ts @@ -15,3 +15,21 @@ export const NO_SHOW_COPY = "Create your show first."; export const SHOW_NAME_REQUIRED_COPY = "Enter your show name."; export const URL_INVALID_COPY = "Enter a valid URL."; + +/** WP-08 billing route copy — plain, second person, <= 12 words (§0.4 rule 4). */ + +export const CHECKOUT_UNAVAILABLE_COPY = "Checkout isn't set up in this preview."; + +export const ALREADY_PRO_COPY = "You're already on Pro."; + +export const FREE_PLAN_CANCEL_COPY = "You're on the Free plan."; + +export const LS_UNREACHABLE_COPY = "Couldn't reach the payment provider. Try again."; + +export const OWNER_ONLY_COPY = "Only the owner can change plans."; + +export const OWNER_NOT_CONFIGURED_COPY = "Owner access isn't configured."; + +export const NO_WORKSPACE_FOR_EMAIL_COPY = "No workspace found for that email."; + +export const PLAN_REQUIRED_COPY = "Choose a plan to upgrade to."; diff --git a/web/tests/e2e/billing.spec.ts b/web/tests/e2e/billing.spec.ts new file mode 100644 index 0000000..389fbd0 --- /dev/null +++ b/web/tests/e2e/billing.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +/** + * WP-08 e2e — billing settings page (MK-9), keyless preview mode: free plan + * card, quota meter, upgrade buttons, checkout error copy. LS-backed states + * (Pro card, cancel dialog, webhook flow) are covered by unit tests with + * signed fixtures because they need LS keys + the DB (owner blocker). + */ +test.describe("WP-08 billing settings", () => { + test("renders the free plan card with the quota meter (MK-9)", async ({ page }) => { + await page.goto("/app/settings/billing"); + await expect(page.getByTestId("billing-plan-card")).toBeVisible(); + await expect(page.getByTestId("billing-plan-name")).toHaveText("Free"); + await expect(page.getByTestId("billing-price")).toHaveText("$0"); + await expect(page.getByTestId("quota-meter")).toContainText("0 of 2 episodes"); + await expect(page.getByTestId("quota-meter")).toContainText(/resets \w+ \d+/); + }); + + test("shows the MK-9 upgrade buttons with the founding counter hidden without a DB", async ({ page }) => { + await page.goto("/app/settings/billing"); + await expect(page.getByTestId("upgrade-pro")).toHaveText("Upgrade to Pro — $19/month"); + // Never fake the counter: without the DB there is no founding count. + await expect(page.getByTestId("upgrade-founding")).toHaveText("Founding Pro — $99/year"); + await expect(page.getByTestId("cancel-plan")).toHaveCount(0); + await expect(page.getByTestId("manage-payment")).toHaveCount(0); + }); + + test("upgrade keyless surfaces the preview copy instead of a checkout", async ({ page }) => { + await page.goto("/app/settings/billing"); + await page.getByTestId("upgrade-pro").click(); + // Keyless (no DB): the same 503 copy as every other DB-backed action. With + // a DB but no LS keys the API answers "Checkout isn't set up in this + // preview." (unit-tested in billing-service.test.ts). + await expect(page.getByTestId("checkout-error")).toHaveText( + "Saving is unavailable in this preview.", + ); + }); + + test("POST /api/billing/checkout requires a plan and auth holds keyless", async ({ request }) => { + const res = await request.post("/api/billing/checkout", { data: {} }); + // Keyless: auth passes (preview user), DB check answers 503; missing plan + // would answer 400 only with a DB. + expect([400, 503]).toContain(res.status()); + }); + + test("POST /api/webhooks/lemonsqueezy rejects unsigned deliveries", async ({ request }) => { + const res = await request.post("/api/webhooks/lemonsqueezy", { + data: { meta: { event_name: "order_created" }, data: { type: "orders", id: "1", attributes: {} } }, + }); + // 503 without LS_WEBHOOK_SECRET; an unsigned delivery must never be 2xx. + expect([401, 503]).toContain(res.status()); + }); +}); diff --git a/web/tests/e2e/settings.spec.ts b/web/tests/e2e/settings.spec.ts index 30febbf..265940d 100644 --- a/web/tests/e2e/settings.spec.ts +++ b/web/tests/e2e/settings.spec.ts @@ -35,11 +35,11 @@ test.describe("WP-02 settings and app shells", () => { ); }); - test("settings tabs exist; Voice/Billing/Account are stubs", async ({ page }) => { + test("settings tabs exist; Voice/Account are stubs and Billing is real (WP-08)", async ({ page }) => { await page.goto("/app/settings/voice"); await expect(page.getByTestId("voice-stub")).toBeVisible(); await page.goto("/app/settings/billing"); - await expect(page.getByTestId("billing-stub")).toBeVisible(); + await expect(page.getByTestId("billing-plan-card")).toBeVisible(); await page.goto("/app/settings/account"); await expect(page.getByTestId("account-stub")).toBeVisible(); }); diff --git a/web/tests/unit/billing-entitlements.test.ts b/web/tests/unit/billing-entitlements.test.ts new file mode 100644 index 0000000..d8f9a48 --- /dev/null +++ b/web/tests/unit/billing-entitlements.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + cancelDialogCopy, + durationLimitCopy, + effectivePlan, + entitlementsFor, + FORMAT_RESET_DATE, + foundingLeft, + getEntitlements, + PLAN_LIMITS, + quotaMonth, + quotaResetDate, + quotaWallCopy, + lsStatusToWorkspaceStatus, + type WorkspacePlanState, +} from "@/lib/billing/entitlements"; + +const OCT_27_2026 = new Date("2026-10-27T12:00:00.000Z"); + +function ws(overrides: Partial<WorkspacePlanState> = {}): WorkspacePlanState { + return { + plan: "free", + status: "active", + founding: false, + currentPeriodEnd: null, + ...overrides, + }; +} + +describe("plan limits (M10)", () => { + it("free: 2 episodes/month, <=60 min, footer on", () => { + expect(PLAN_LIMITS.free).toEqual({ + episodesPerMonth: 2, + maxEpisodeDurationMs: 60 * 60_000, + footer: true, + }); + }); + + it("pro: 10 episodes/month, <=2 h, no footer", () => { + expect(PLAN_LIMITS.pro).toEqual({ + episodesPerMonth: 10, + maxEpisodeDurationMs: 2 * 60 * 60_000, + footer: false, + }); + }); + + it("founding gets pro entitlements (founding = pro yearly)", () => { + const founding = getEntitlements(ws({ plan: "pro", founding: true }), OCT_27_2026); + const pro = getEntitlements(ws({ plan: "pro" }), OCT_27_2026); + expect(founding).toEqual(pro); + }); + + it("exposes the per-plan duration limit in words for the too_long error", () => { + expect(durationLimitCopy("free")).toBe("60 minutes"); + expect(durationLimitCopy("pro")).toBe("2 hours"); + }); +}); + +describe("quota month (episodes.quota_month char(7))", () => { + it("formats the UTC calendar month as YYYY-MM", () => { + expect(quotaMonth(OCT_27_2026)).toBe("2026-10"); + expect(quotaMonth(new Date("2026-01-01T00:30:00.000Z"))).toBe("2026-01"); + expect(quotaMonth(new Date("2026-12-31T23:59:59.000Z"))).toBe("2026-12"); + }); + + it("rolls over the year", () => { + expect(quotaMonth(new Date("2027-01-01T00:00:00.000Z"))).toBe("2027-01"); + }); + + it("reset date is the first day of the next UTC month", () => { + expect(quotaResetDate(OCT_27_2026).toISOString()).toBe("2026-11-01T00:00:00.000Z"); + expect(quotaResetDate(new Date("2026-12-14T10:00:00.000Z")).toISOString()).toBe( + "2027-01-01T00:00:00.000Z", + ); + }); + + it("formats the reset date like the mockup counter (Nov 14)", () => { + expect(FORMAT_RESET_DATE(new Date("2026-11-14T00:00:00.000Z"))).toBe("Nov 14"); + expect(FORMAT_RESET_DATE(new Date("2026-11-01T00:00:00.000Z"))).toBe("Nov 1"); + }); +}); + +describe("effective plan (cancel keeps Pro until period end)", () => { + it("free stays free", () => { + expect(effectivePlan(ws(), OCT_27_2026)).toBe("free"); + }); + + it("active pro stays pro", () => { + expect( + effectivePlan(ws({ plan: "pro", status: "active", currentPeriodEnd: new Date("2026-11-30") }), OCT_27_2026), + ).toBe("pro"); + }); + + it("cancelled pro keeps Pro until the access end date", () => { + const state = ws({ + plan: "pro", + status: "cancelled", + currentPeriodEnd: new Date("2026-11-30T00:00:00.000Z"), + }); + expect(effectivePlan(state, OCT_27_2026)).toBe("pro"); + expect(effectivePlan(state, new Date("2026-11-30T00:00:00.000Z"))).toBe("free"); + expect(effectivePlan(state, new Date("2026-12-01T00:00:00.000Z"))).toBe("free"); + }); + + it("cancelled without a period end stays pro (defensive; writers always set one)", () => { + expect(effectivePlan(ws({ plan: "pro", status: "cancelled" }), OCT_27_2026)).toBe("pro"); + }); +}); + +describe("entitlementsFor", () => { + it("derives limits and footer from the effective plan", () => { + expect(entitlementsFor(ws(), OCT_27_2026)).toEqual({ + plan: "free", + episodesPerMonth: 2, + maxEpisodeDurationMs: 60 * 60_000, + footer: true, + }); + expect(entitlementsFor(ws({ plan: "pro" }), OCT_27_2026)).toEqual({ + plan: "pro", + episodesPerMonth: 10, + maxEpisodeDurationMs: 2 * 60 * 60_000, + footer: false, + }); + }); +}); + +describe("quota wall copy (02-design.md §6, verbatim)", () => { + it("substitutes used, limit and the formatted reset date", () => { + expect( + quotaWallCopy({ used: 2, limit: 2, resetDate: new Date("2026-11-01T00:00:00.000Z") }), + ).toBe( + "You've used 2 of 2 episodes this month. Upgrade for more, or wait until Nov 1.", + ); + expect( + quotaWallCopy({ used: 10, limit: 10, resetDate: new Date("2026-12-01T00:00:00.000Z") }), + ).toBe( + "You've used 10 of 10 episodes this month. Upgrade for more, or wait until Dec 1.", + ); + }); +}); + +describe("cancel dialog copy (MK-9, verbatim)", () => { + it("matches the mockup with the access end date", () => { + expect( + cancelDialogCopy({ periodEnd: new Date("2026-11-30T00:00:00.000Z") }), + ).toEqual({ + title: "Cancel Pro?", + body: "You'll keep Pro until Nov 30. Your episodes stay.", + keepLabel: "Keep Pro", + cancelLabel: "Cancel plan", + }); + }); +}); + +describe("founding counter (first 100, derived from the DB)", () => { + it("subtracts founding workspaces from 100 and never goes negative", () => { + expect(foundingLeft(0)).toBe(100); + expect(foundingLeft(63)).toBe(37); + expect(foundingLeft(100)).toBe(0); + expect(foundingLeft(120)).toBe(0); + }); +}); + +describe("Lemon Squeezy status mapping (§7.1 workspace status)", () => { + it("maps healthy states to active", () => { + expect(lsStatusToWorkspaceStatus("active")).toBe("active"); + expect(lsStatusToWorkspaceStatus("on_trial")).toBe("active"); + }); + + it("maps payment problems and holds to past_due", () => { + expect(lsStatusToWorkspaceStatus("past_due")).toBe("past_due"); + expect(lsStatusToWorkspaceStatus("unpaid")).toBe("past_due"); + expect(lsStatusToWorkspaceStatus("paused")).toBe("past_due"); + }); + + it("maps ended states to cancelled", () => { + expect(lsStatusToWorkspaceStatus("cancelled")).toBe("cancelled"); + expect(lsStatusToWorkspaceStatus("expired")).toBe("cancelled"); + }); + + it("fails closed on unknown states", () => { + expect(lsStatusToWorkspaceStatus("something_new")).toBe("past_due"); + }); +}); diff --git a/web/tests/unit/billing-ls-client.test.ts b/web/tests/unit/billing-ls-client.test.ts new file mode 100644 index 0000000..d8dac5a --- /dev/null +++ b/web/tests/unit/billing-ls-client.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { httpLsClient } from "@/lib/billing/ls"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + fetchMock.mockReset(); + vi.unstubAllGlobals(); +}); + +describe("httpLsClient (single LS fetch boundary)", () => { + it("creates a JSON:API checkout with store + variant relationships and custom data", async () => { + process.env.LS_API_KEY = "sk_test"; + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { attributes: { url: "https://ls.test/checkout/x" } } }), { status: 201 }), + ); + const { url } = await httpLsClient.createCheckout({ + storeId: "1", + variantId: "111", + email: "maya@focus.fm", + custom: { workspace_id: "ws-1" }, + }); + expect(url).toBe("https://ls.test/checkout/x"); + const [input, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(input).toBe("https://api.lemonsqueezy.com/v1/checkouts"); + expect(init.method).toBe("POST"); + const headers = init.headers as Record<string, string>; + expect(headers.Authorization).toBe("Bearer sk_test"); + expect(headers["Content-Type"]).toBe("application/vnd.api+json"); + const body = JSON.parse(init.body as string) as { + data: { + type: string; + attributes: { checkout_data: { email: string; custom: Record<string, string> } }; + relationships: Record<string, { data: { type: string; id: string } }>; + }; + }; + expect(body.data.type).toBe("checkouts"); + expect(body.data.attributes.checkout_data.email).toBe("maya@focus.fm"); + expect(body.data.attributes.checkout_data.custom).toEqual({ workspace_id: "ws-1" }); + expect(body.data.relationships.store.data).toEqual({ type: "stores", id: "1" }); + expect(body.data.relationships.variant.data).toEqual({ type: "variants", id: "111" }); + delete process.env.LS_API_KEY; + }); + + it("cancels with DELETE /v1/subscriptions/:id and parses the cancelled state", async () => { + process.env.LS_API_KEY = "sk_test"; + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: { + attributes: { + status: "cancelled", + ends_at: "2026-11-30T00:00:00.000000Z", + urls: { customer_portal: "https://ls.test/portal", update_payment_method: null }, + }, + }, + }), + { status: 200 }, + ), + ); + const result = await httpLsClient.cancelSubscription("sub_1"); + const [input, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(input).toBe("https://api.lemonsqueezy.com/v1/subscriptions/sub_1"); + expect(init.method).toBe("DELETE"); + expect(result.status).toBe("cancelled"); + expect(result.endsAt?.toISOString()).toBe("2026-11-30T00:00:00.000Z"); + expect(result.customerPortalUrl).toBe("https://ls.test/portal"); + delete process.env.LS_API_KEY; + }); + + it("reads the signed customer-portal link for [Manage payment]", async () => { + process.env.LS_API_KEY = "sk_test"; + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: { attributes: { status: "active", renews_at: "2026-11-27T00:00:00Z", urls: { customer_portal: "https://ls.test/portal" } } }, + }), + { status: 200 }, + ), + ); + const info = await httpLsClient.getSubscription("sub_1"); + expect(info.customerPortalUrl).toBe("https://ls.test/portal"); + expect(info.renewsAt?.toISOString()).toBe("2026-11-27T00:00:00.000Z"); + delete process.env.LS_API_KEY; + }); + + it("throws on non-2xx responses", async () => { + process.env.LS_API_KEY = "sk_test"; + fetchMock.mockResolvedValue(new Response("nope", { status: 401 })); + await expect(httpLsClient.cancelSubscription("sub_1")).rejects.toThrow("LS cancel failed: 401"); + delete process.env.LS_API_KEY; + }); +}); diff --git a/web/tests/unit/billing-service.test.ts b/web/tests/unit/billing-service.test.ts new file mode 100644 index 0000000..4ce58a7 --- /dev/null +++ b/web/tests/unit/billing-service.test.ts @@ -0,0 +1,405 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ALREADY_PRO_COPY, + CHECKOUT_UNAVAILABLE_COPY, + FREE_PLAN_CANCEL_COPY, + LS_UNREACHABLE_COPY, + NO_WORKSPACE_FOR_EMAIL_COPY, + OWNER_NOT_CONFIGURED_COPY, + OWNER_ONLY_COPY, + PLAN_REQUIRED_COPY, + SAVE_UNAVAILABLE_COPY, +} from "@/lib/copy"; +import { quotaResetDate } from "@/lib/billing/entitlements"; +import { + assertAndConsumeQuota, + consumeEpisodeQuota, + getBillingView, + handleAdminSetPlan, + handleBillingCancel, + handleBillingCheckout, + refundEpisode, + type BillingDeps, +} from "@/lib/billing/service"; +import type { WorkspaceRow } from "@/db/queries"; + +const NOW = new Date("2026-10-27T12:00:00.000Z"); + +function workspaceRow(overrides: Partial<WorkspaceRow> = {}): WorkspaceRow { + return { + id: "ws-1", + ownerId: "user_1", + plan: "free", + planSource: null, + founding: false, + lsCustomerId: null, + lsSubscriptionId: null, + status: "active", + currentPeriodEnd: null, + createdAt: new Date(), + ...overrides, + }; +} + +function deps(overrides: Partial<BillingDeps> = {}): BillingDeps { + const rows: Record<string, WorkspaceRow> = { "ws-1": workspaceRow() }; + return { + now: () => NOW, + dbConfigured: () => true, + lsConfigured: () => true, + lsClient: { + createCheckout: vi.fn().mockResolvedValue({ url: "https://ls.test/checkout/abc" }), + cancelSubscription: vi + .fn() + .mockResolvedValue({ status: "cancelled", endsAt: new Date("2026-11-30T00:00:00Z"), customerPortalUrl: "https://portal", updatePaymentMethodUrl: null }), + getSubscription: vi.fn().mockResolvedValue({ + status: "active", + renewsAt: new Date("2026-11-27T00:00:00Z"), + endsAt: null, + customerPortalUrl: "https://ls.test/portal", + updatePaymentMethodUrl: null, + }), + }, + ensureWorkspace: vi.fn().mockResolvedValue(rows["ws-1"]), + getWorkspaceById: vi.fn().mockResolvedValue(rows["ws-1"]), + getWorkspaceByOwnerEmail: vi.fn().mockResolvedValue(rows["ws-1"]), + updateWorkspacePlan: vi.fn().mockImplementation(async (id, update) => ({ ...rows[id], ...update }) as WorkspaceRow), + countQuotaEpisodes: vi.fn().mockResolvedValue(0), + markEpisodeQuotaCounted: vi.fn().mockResolvedValue(undefined), + refundEpisodeQuota: vi.fn().mockResolvedValue(undefined), + countFoundingWorkspaces: vi.fn().mockResolvedValue(63), + getUserEmail: vi.fn().mockResolvedValue("maya@focus.fm"), + ...overrides, + }; +} + +beforeEach(() => { + process.env.LS_STORE_ID = "1"; + process.env.LS_VARIANT_PRO_MONTHLY = "111"; + process.env.LS_VARIANT_FOUNDING = "555"; + delete process.env.LS_FOUNDING_PAYMENT_URL; + delete process.env.OWNER_EMAILS; +}); + +afterEach(() => { + delete process.env.LS_STORE_ID; + delete process.env.LS_VARIANT_PRO_MONTHLY; + delete process.env.LS_VARIANT_FOUNDING; + delete process.env.LS_FOUNDING_PAYMENT_URL; + delete process.env.OWNER_EMAILS; +}); + +describe("handleBillingCheckout (POST /api/billing/checkout)", () => { + it("answers 503 with preview copy when the DB is not configured", async () => { + const result = await handleBillingCheckout("user_1", { plan: "pro" }, deps({ dbConfigured: () => false })); + expect(result.status).toBe(503); + expect(result.json).toEqual({ error: SAVE_UNAVAILABLE_COPY }); + }); + + it("validates the requested plan", async () => { + expect((await handleBillingCheckout("user_1", {}, deps())).json).toEqual({ error: PLAN_REQUIRED_COPY }); + expect((await handleBillingCheckout("user_1", { plan: "studio" }, deps())).json).toEqual({ + error: PLAN_REQUIRED_COPY, + }); + }); + + it("creates an LS checkout for Pro with the workspace id as custom_data", async () => { + const d = deps(); + const result = await handleBillingCheckout("user_1", { plan: "pro" }, d); + expect(result.status).toBe(200); + expect(result.json).toEqual({ url: "https://ls.test/checkout/abc", mode: "checkout" }); + expect(d.lsClient.createCheckout).toHaveBeenCalledWith({ + storeId: "1", + variantId: "111", + email: "maya@focus.fm", + custom: { workspace_id: "ws-1" }, + }); + }); + + it("uses the Founding variant for plan=founding", async () => { + const d = deps(); + await handleBillingCheckout("user_1", { plan: "founding" }, d); + expect(d.lsClient.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ variantId: "555" })); + }); + + it("refuses a second upgrade while Pro is active", async () => { + const d = deps({ ensureWorkspace: vi.fn().mockResolvedValue(workspaceRow({ plan: "pro" })) }); + const result = await handleBillingCheckout("user_1", { plan: "pro" }, d); + expect(result.status).toBe(409); + expect(result.json).toEqual({ error: ALREADY_PRO_COPY }); + }); + + it("answers 503 when LS is not configured (owner blocker)", async () => { + const result = await handleBillingCheckout("user_1", { plan: "pro" }, deps({ lsConfigured: () => false })); + expect(result.status).toBe(503); + expect(result.json).toEqual({ error: CHECKOUT_UNAVAILABLE_COPY }); + }); + + it("Founding falls back to the payment-link URL when LS API is not configured", async () => { + process.env.LS_FOUNDING_PAYMENT_URL = "https://ek.lemonsqueezy.com/buy/abc"; + const d = deps({ lsConfigured: () => false }); + const result = await handleBillingCheckout("user_1", { plan: "founding" }, d); + expect(result.status).toBe(200); + expect(result.json).toEqual({ url: "https://ek.lemonsqueezy.com/buy/abc", mode: "payment_link" }); + expect(d.lsClient.createCheckout).not.toHaveBeenCalled(); + }); + + it("surfaces LS failures as 502", async () => { + const d = deps({ + lsClient: { + createCheckout: vi.fn().mockRejectedValue(new Error("500")), + cancelSubscription: vi.fn(), + getSubscription: vi.fn(), + }, + }); + const result = await handleBillingCheckout("user_1", { plan: "pro" }, d); + expect(result.status).toBe(502); + expect(result.json).toEqual({ error: LS_UNREACHABLE_COPY }); + }); +}); + +describe("handleBillingCancel (POST /api/billing/cancel)", () => { + it("refuses to cancel the Free plan", async () => { + const result = await handleBillingCancel("user_1", deps()); + expect(result.status).toBe(400); + expect(result.json).toEqual({ error: FREE_PLAN_CANCEL_COPY }); + }); + + it("cancels via the LS API and keeps Pro until the LS end date", async () => { + const d = deps({ + ensureWorkspace: vi.fn().mockResolvedValue( + workspaceRow({ plan: "pro", planSource: "ls", lsSubscriptionId: "sub_1", currentPeriodEnd: new Date("2026-11-30T00:00:00Z") }), + ), + }); + const result = await handleBillingCancel("user_1", d); + expect(d.lsClient.cancelSubscription).toHaveBeenCalledWith("sub_1"); + expect(d.updateWorkspacePlan).toHaveBeenCalledWith("ws-1", { + status: "cancelled", + currentPeriodEnd: new Date("2026-11-30T00:00:00Z"), + }); + expect(result.status).toBe(200); + expect(result.json).toEqual({ cancelled: true, accessEnds: "2026-11-30T00:00:00.000Z" }); + }); + + it("cancels a manual plan without touching LS, keeping access until period end", async () => { + const d = deps({ + ensureWorkspace: vi.fn().mockResolvedValue( + workspaceRow({ plan: "pro", planSource: "manual", founding: true, currentPeriodEnd: new Date("2027-10-27T00:00:00Z") }), + ), + }); + const result = await handleBillingCancel("user_1", d); + expect(d.lsClient.cancelSubscription).not.toHaveBeenCalled(); + expect(d.updateWorkspacePlan).toHaveBeenCalledWith("ws-1", { + status: "cancelled", + currentPeriodEnd: new Date("2027-10-27T00:00:00Z"), + }); + expect(result.status).toBe(200); + }); + + it("manual plan without a period end keeps access until the quota reset", async () => { + const d = deps({ + ensureWorkspace: vi.fn().mockResolvedValue(workspaceRow({ plan: "pro", planSource: "manual" })), + }); + await handleBillingCancel("user_1", d); + expect(d.updateWorkspacePlan).toHaveBeenCalledWith("ws-1", { + status: "cancelled", + currentPeriodEnd: quotaResetDate(NOW), + }); + }); + + it("surfaces LS cancel failures as 502 without local changes", async () => { + const d = deps({ + ensureWorkspace: vi.fn().mockResolvedValue(workspaceRow({ plan: "pro", planSource: "ls", lsSubscriptionId: "sub_1" })), + lsClient: { + createCheckout: vi.fn(), + cancelSubscription: vi.fn().mockRejectedValue(new Error("500")), + getSubscription: vi.fn(), + }, + }); + const result = await handleBillingCancel("user_1", d); + expect(result.status).toBe(502); + expect(result.json).toEqual({ error: LS_UNREACHABLE_COPY }); + expect(d.updateWorkspacePlan).not.toHaveBeenCalled(); + }); +}); + +describe("handleAdminSetPlan (manual plan flag, owner only)", () => { + it("fails closed when OWNER_EMAILS is not configured", async () => { + const result = await handleAdminSetPlan("user_1", { email: "x@y.fm", plan: "pro" }, deps()); + expect(result.status).toBe(403); + expect(result.json).toEqual({ error: OWNER_NOT_CONFIGURED_COPY }); + }); + + it("rejects non-owner callers", async () => { + process.env.OWNER_EMAILS = "abid@episodekit.app"; + const result = await handleAdminSetPlan("user_1", { email: "x@y.fm", plan: "pro" }, deps()); + expect(result.status).toBe(403); + expect(result.json).toEqual({ error: OWNER_ONLY_COPY }); + }); + + it("flags a workspace Founding Pro manually (plan_source=manual, +1 year)", async () => { + process.env.OWNER_EMAILS = "maya@focus.fm, abid@episodekit.app"; + const d = deps(); + const result = await handleAdminSetPlan("user_1", { email: "maya@focus.fm", plan: "pro", founding: true }, d); + expect(result.status).toBe(200); + expect(d.updateWorkspacePlan).toHaveBeenCalledWith("ws-1", { + plan: "pro", + planSource: "manual", + founding: true, + status: "active", + currentPeriodEnd: new Date("2027-10-27T12:00:00.000Z"), + }); + }); + + it("manual Pro (non-founding) runs one month", async () => { + process.env.OWNER_EMAILS = "abid@episodekit.app, maya@focus.fm"; + const d = deps(); + await handleAdminSetPlan("user_1", { email: "maya@focus.fm", plan: "pro" }, d); + expect(d.updateWorkspacePlan).toHaveBeenCalledWith( + "ws-1", + expect.objectContaining({ plan: "pro", founding: false, currentPeriodEnd: new Date("2026-11-27T12:00:00.000Z") }), + ); + }); + + it("downgrades to Free and clears the founding flag", async () => { + process.env.OWNER_EMAILS = "abid@episodekit.app, maya@focus.fm"; + const d = deps(); + await handleAdminSetPlan("user_1", { email: "maya@focus.fm", plan: "free" }, d); + expect(d.updateWorkspacePlan).toHaveBeenCalledWith("ws-1", { + plan: "free", + planSource: null, + founding: false, + status: "active", + currentPeriodEnd: null, + }); + }); + + it("404s for an unknown workspace email", async () => { + process.env.OWNER_EMAILS = "abid@episodekit.app, maya@focus.fm"; + const result = await handleAdminSetPlan( + "user_1", + { email: "maya@focus.fm", plan: "pro" }, + deps({ getWorkspaceByOwnerEmail: vi.fn().mockResolvedValue(null) }), + ); + expect(result.status).toBe(404); + expect(result.json).toEqual({ error: NO_WORKSPACE_FOR_EMAIL_COPY }); + }); + + it("matches owner emails case-insensitively and trims the list", async () => { + process.env.OWNER_EMAILS = " Maya@Focus.FM "; + const result = await handleAdminSetPlan("user_1", { email: "maya@focus.fm", plan: "pro" }, deps()); + expect(result.status).toBe(200); + }); +}); + +describe("quota consume/refund semantics (M10)", () => { + it("counts against the current quota month and reports over-quota", async () => { + const d = deps({ countQuotaEpisodes: vi.fn().mockResolvedValue(2) }); + const state = await assertAndConsumeQuota("user_1", d); + expect(state).toEqual({ + allowed: false, + used: 2, + limit: 2, + quotaMonth: "2026-10", + resetDate: new Date("2026-11-01T00:00:00.000Z"), + wallCopy: "You've used 2 of 2 episodes this month. Upgrade for more, or wait until Nov 1.", + }); + expect(d.markEpisodeQuotaCounted).not.toHaveBeenCalled(); + }); + + it("marks the episode counted when generation starts under the limit", async () => { + const d = deps({ countQuotaEpisodes: vi.fn().mockResolvedValue(1) }); + const state = await assertAndConsumeQuota("user_1", d); + expect(state.allowed).toBe(true); + expect(state.wallCopy).toBeNull(); + await consumeEpisodeQuota("ep-9", d); + expect(d.markEpisodeQuotaCounted).toHaveBeenCalledWith("ep-9", "2026-10"); + }); + + it("pro users get the 10/month limit", async () => { + const d = deps({ + ensureWorkspace: vi.fn().mockResolvedValue(workspaceRow({ plan: "pro" })), + countQuotaEpisodes: vi.fn().mockResolvedValue(10), + }); + const state = await assertAndConsumeQuota("user_1", d); + expect(state.allowed).toBe(false); + expect(state.limit).toBe(10); + }); + + it("refunds on pipeline failure by unflagging counts_quota", async () => { + const d = deps(); + await refundEpisode("ep-1", d); + expect(d.refundEpisodeQuota).toHaveBeenCalledWith("ep-1"); + }); + + it("quota state reads pro until a cancelled period ends", async () => { + const d = deps({ + ensureWorkspace: vi.fn().mockResolvedValue( + workspaceRow({ plan: "pro", status: "cancelled", currentPeriodEnd: new Date("2026-10-01T00:00:00Z") }), + ), + }); + const state = await assertAndConsumeQuota("user_1", d); + expect(state.limit).toBe(2); + }); +}); + +describe("getBillingView (settings billing page)", () => { + it("returns null when the DB is unavailable", async () => { + expect(await getBillingView("user_1", deps({ dbConfigured: () => false }))).toBeNull(); + }); + + it("builds the free-plan view with derived quota and founding counter", async () => { + const d = deps({ countQuotaEpisodes: vi.fn().mockResolvedValue(1) }); + const view = await getBillingView("user_1", d); + expect(view).toEqual({ + status: "active", + plan: "free", + managePaymentUrl: null, + founding: false, + planSource: null, + used: 1, + limit: 2, + resetsAt: "2026-11-01T00:00:00.000Z", + accessEndsAt: null, + foundingLeft: 37, + checkoutConfigured: true, + foundingPaymentUrl: null, + }); + }); + + it("reports pro with the access end date", async () => { + const view = await getBillingView( + "user_1", + deps({ + ensureWorkspace: vi.fn().mockResolvedValue( + workspaceRow({ + plan: "pro", + planSource: "ls", + lsSubscriptionId: "sub_1", + currentPeriodEnd: new Date("2026-11-30T00:00:00Z"), + }), + ), + countQuotaEpisodes: vi.fn().mockResolvedValue(3), + }), + ); + expect(view).toMatchObject({ + plan: "pro", + used: 3, + limit: 10, + accessEndsAt: "2026-11-30T00:00:00.000Z", + managePaymentUrl: "https://ls.test/portal", + }); + }); + + it("downgrades the view after a cancelled period ended", async () => { + const view = await getBillingView( + "user_1", + deps({ + ensureWorkspace: vi.fn().mockResolvedValue( + workspaceRow({ plan: "pro", status: "cancelled", currentPeriodEnd: new Date("2026-10-01T00:00:00Z") }), + ), + }), + ); + expect(view).toMatchObject({ plan: "free", limit: 2 }); + }); +}); diff --git a/web/tests/unit/billing-webhook-verify.test.ts b/web/tests/unit/billing-webhook-verify.test.ts new file mode 100644 index 0000000..3285947 --- /dev/null +++ b/web/tests/unit/billing-webhook-verify.test.ts @@ -0,0 +1,73 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { lsSignature, stableStringify, verifyLsSignature } from "@/lib/billing/webhook-verify"; + +// Fixture secret (test only). Signatures are computed here with the same +// HMAC-SHA256 scheme the official LS docs specify, so the fixtures are real. +const SECRET = "whsec_test_fixture_secret"; + +const BODY = JSON.stringify({ + meta: { event_name: "subscription_created", custom_data: { workspace_id: "ws-1" } }, + data: { type: "subscriptions", id: "1", attributes: { status: "active", renews_at: "2026-11-27T00:00:00Z" } }, +}); + +function signed(raw: string, secret = SECRET): string { + return createHmac("sha256", secret).update(raw).digest("hex"); +} + +describe("verifyLsSignature (port of api/ls-webhook.js)", () => { + it("accepts a correctly signed raw body", () => { + expect(verifyLsSignature(BODY, signed(BODY), SECRET)).toBe(true); + }); + + it("rejects a signature made with the wrong secret", () => { + expect(verifyLsSignature(BODY, signed(BODY, "other-secret"), SECRET)).toBe(false); + }); + + it("rejects a tampered body", () => { + const tampered = BODY.replace("active", "expired"); + expect(verifyLsSignature(tampered, signed(BODY), SECRET)).toBe(false); + }); + + it("rejects malformed signature headers without throwing", () => { + expect(verifyLsSignature(BODY, "", SECRET)).toBe(false); + expect(verifyLsSignature(BODY, "not-hex", SECRET)).toBe(false); + expect(verifyLsSignature(BODY, "z".repeat(64), SECRET)).toBe(false); + expect(verifyLsSignature(BODY, signed(BODY).toUpperCase(), SECRET)).toBe(false); + }); + + it("rejects missing inputs", () => { + expect(verifyLsSignature("", signed(""), SECRET)).toBe(true); // empty body is still signable + expect(verifyLsSignature(BODY, signed(BODY), "")).toBe(false); + }); +}); + +describe("legacy candidate serializations (Vercel parses bodies before us)", () => { + it("stableStringify sorts keys deterministically", () => { + expect(stableStringify({ b: 1, a: { d: 2, c: [3, { z: 1, y: 2 }] } })).toBe( + '{"a":{"c":[3,{"y":2,"z":1}],"d":2},"b":1}', + ); + }); + + it("accepts a signature over the stable-stringified form of the same payload", () => { + const parsed = JSON.parse(BODY) as object; + expect(verifyLsSignature(BODY, signed(stableStringify(parsed)), SECRET)).toBe(true); + }); + + it("accepts a signature over JSON.stringify form too", () => { + const reordered = JSON.stringify({ + data: { id: "1", type: "subscriptions", attributes: { renews_at: "2026-11-27T00:00:00Z", status: "active" } }, + meta: { custom_data: { workspace_id: "ws-1" }, event_name: "subscription_created" }, + }); + // Raw-body bytes differ from JSON.stringify(parsed body) key order; both candidates verify. + expect(verifyLsSignature(reordered, signed(reordered), SECRET)).toBe(true); + }); +}); + +describe("lsSignature helper (used by tests and local fixtures)", () => { + it("produces lowercase hex of the HMAC-SHA256 digest", () => { + const sig = lsSignature(BODY, SECRET); + expect(sig).toMatch(/^[0-9a-f]{64}$/); + expect(sig).toBe(signed(BODY)); + }); +}); diff --git a/web/tests/unit/billing-webhook.test.ts b/web/tests/unit/billing-webhook.test.ts new file mode 100644 index 0000000..ec6ed00 --- /dev/null +++ b/web/tests/unit/billing-webhook.test.ts @@ -0,0 +1,275 @@ +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleLsWebhook, type WorkspacePlanUpdate } from "@/lib/billing/webhook"; + +vi.mock("@/db/queries", () => ({ + isWebhookEventProcessed: vi.fn().mockResolvedValue(false), + markWebhookEventProcessed: vi.fn(), + getWorkspaceById: vi.fn().mockResolvedValue({ id: "ws-1" }), + getWorkspaceByOwnerEmail: vi.fn().mockResolvedValue({ id: "ws-1" }), + updateWorkspacePlan: vi.fn().mockResolvedValue(undefined), +})); + +import * as queries from "@/db/queries"; +const mocked = vi.mocked(queries); + +// The webhook handler only reads workspace.id, so the stub carries just that. +const WS_STUB = { id: "ws-1" } as unknown as queries.WorkspaceRow; + +const SECRET = "ls_webhook_test_secret_123"; + +/** Sign exactly like Lemon Squeezy does (docs: HMAC-SHA256 hex of the raw body). */ +function sign(raw: string): string { + return createHmac("sha256", SECRET).update(raw).digest("hex"); +} + +function signedRequest(payload: unknown | string, opts: { signature?: string; secret?: string } = {}) { + const raw = typeof payload === "string" ? payload : JSON.stringify(payload); + return new Request("http://localhost/api/webhooks/lemonsqueezy", { + method: "POST", + headers: { + "content-type": "application/json", + "x-signature": opts.signature ?? sign(raw), + }, + body: raw, + }); +} + +function subscriptionEvent( + eventName: string, + attributes: Record<string, unknown>, + meta: Record<string, unknown> = {}, +) { + return { + meta: { event_name: eventName, ...meta }, + data: { + type: "subscriptions", + id: "sub_1", + attributes: { + status: "active", + renews_at: "2026-11-27T00:00:00.000000Z", + ends_at: null, + variant_id: 111, + customer_id: 42, + user_email: "maya@focus.fm", + ...attributes, + }, + }, + }; +} + +beforeEach(() => { + process.env.LS_WEBHOOK_SECRET = SECRET; + process.env.LS_VARIANT_FOUNDING = "555"; + mocked.isWebhookEventProcessed.mockResolvedValue(false); + mocked.getWorkspaceById.mockResolvedValue(WS_STUB); + mocked.getWorkspaceByOwnerEmail.mockResolvedValue(WS_STUB); +}); + +afterEach(() => { + delete process.env.LS_WEBHOOK_SECRET; + delete process.env.LS_VARIANT_FOUNDING; + vi.clearAllMocks(); +}); + +function expectUpdate(): WorkspacePlanUpdate[] { + return mocked.updateWorkspacePlan.mock.calls.map((call) => call[1] as WorkspacePlanUpdate); +} + +describe("POST /api/webhooks/lemonsqueezy", () => { + it("returns 503 while LS_WEBHOOK_SECRET is unset (owner blocker)", async () => { + delete process.env.LS_WEBHOOK_SECRET; + const res = await handleLsWebhook(signedRequest(subscriptionEvent("subscription_created", {}))); + expect(res.status).toBe(503); + expect(mocked.updateWorkspacePlan).not.toHaveBeenCalled(); + }); + + it("rejects a signature made with the wrong secret", async () => { + const res = await handleLsWebhook( + signedRequest(subscriptionEvent("subscription_created", {}), { signature: sign("x") }), + ); + expect(res.status).toBe(401); + expect(mocked.updateWorkspacePlan).not.toHaveBeenCalled(); + }); + + it("rejects a tampered body", async () => { + const event = subscriptionEvent("subscription_created", {}); + const raw = JSON.stringify(event); + const tampered = JSON.stringify(event).replace("active", "expired"); + expect(tampered).not.toBe(raw); + const res = await handleLsWebhook( + new Request("http://localhost/api/webhooks/lemonsqueezy", { + method: "POST", + headers: { "content-type": "application/json", "x-signature": sign(raw) }, + body: tampered, + }), + ); + expect(res.status).toBe(401); + }); + + it("rejects malformed JSON even with a valid signature over those bytes", async () => { + const res = await handleLsWebhook(signedRequest("not-json{")); + expect(res.status).toBe(400); + }); + + it("maps subscription_created to plan=pro via meta.custom_data.workspace_id (U14)", async () => { + const event = subscriptionEvent("subscription_created", {}, { custom_data: { workspace_id: "ws-1" } }); + const res = await handleLsWebhook(signedRequest(event)); + expect(res.status).toBe(200); + expect(mocked.getWorkspaceById).toHaveBeenCalledWith("ws-1"); + expect(expectUpdate()[0]).toEqual({ + plan: "pro", + planSource: "ls", + founding: false, + status: "active", + currentPeriodEnd: new Date("2026-11-27T00:00:00.000Z"), + lsCustomerId: "42", + lsSubscriptionId: "sub_1", + }); + expect(mocked.markWebhookEventProcessed).toHaveBeenCalledWith(expect.any(String), "lemonsqueezy"); + }); + + it("marks the founding deal when the variant matches LS_VARIANT_FOUNDING", async () => { + const event = subscriptionEvent( + "subscription_created", + { variant_id: 555 }, + { custom_data: { workspace_id: "ws-1" } }, + ); + await handleLsWebhook(signedRequest(event)); + expect(expectUpdate()[0]).toMatchObject({ plan: "pro", founding: true }); + }); + + it("maps order_created via first_order_item.variant_id and keeps a paid workspace active", async () => { + const event = { + meta: { event_name: "order_created", custom_data: { workspace_id: "ws-1" } }, + data: { + type: "orders", + id: "order_1", + attributes: { + user_email: "maya@focus.fm", + customer_id: 42, + status: "paid", + first_order_item: { variant_id: 111, product_name: "EpisodeKit Pro", variant_name: "Pro monthly" }, + }, + }, + }; + const res = await handleLsWebhook(signedRequest(event)); + expect(res.status).toBe(200); + expect(expectUpdate()[0]).toMatchObject({ + plan: "pro", + planSource: "ls", + founding: false, + status: "active", + lsCustomerId: "42", + }); + // Orders carry no renewal date; the following subscription_created sets it. + expect(expectUpdate()[0].currentPeriodEnd).toBeUndefined(); + expect(expectUpdate()[0].lsSubscriptionId).toBeUndefined(); + }); + + it("falls back to the billing email when custom_data is missing", async () => { + const event = subscriptionEvent("subscription_created", {}); + await handleLsWebhook(signedRequest(event)); + expect(mocked.getWorkspaceByOwnerEmail).toHaveBeenCalledWith("maya@focus.fm"); + expect(mocked.updateWorkspacePlan).toHaveBeenCalledWith("ws-1", expect.objectContaining({ plan: "pro" })); + }); + + it("answers 200 unmatched without applying anything when no workspace resolves", async () => { + mocked.getWorkspaceById.mockResolvedValue(null); + mocked.getWorkspaceByOwnerEmail.mockResolvedValue(null); + const event = subscriptionEvent("subscription_created", {}, { custom_data: { workspace_id: "ws-x" } }); + const res = await handleLsWebhook(signedRequest(event)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, unmatched: true }); + expect(mocked.updateWorkspacePlan).not.toHaveBeenCalled(); + }); + + it("maps subscription_cancelled to cancelled but keeps Pro until ends_at (U15)", async () => { + const event = subscriptionEvent( + "subscription_cancelled", + { status: "cancelled", ends_at: "2026-11-30T00:00:00.000000Z", cancelled: true }, + { custom_data: { workspace_id: "ws-1" } }, + ); + await handleLsWebhook(signedRequest(event)); + expect(expectUpdate()[0]).toMatchObject({ + plan: "pro", + status: "cancelled", + currentPeriodEnd: new Date("2026-11-30T00:00:00.000Z"), + }); + }); + + it("maps subscription_expired to a downgrade (access ended)", async () => { + const event = subscriptionEvent( + "subscription_expired", + { status: "expired", ends_at: "2026-11-30T00:00:00.000000Z" }, + { custom_data: { workspace_id: "ws-1" } }, + ); + await handleLsWebhook(signedRequest(event)); + expect(expectUpdate()[0]).toMatchObject({ plan: "free", status: "cancelled" }); + }); + + it("extends access on subscription_payment_success", async () => { + const event = subscriptionEvent( + "subscription_payment_success", + { status: "active", renews_at: "2026-12-27T00:00:00.000000Z" }, + { custom_data: { workspace_id: "ws-1" } }, + ); + await handleLsWebhook(signedRequest(event)); + expect(expectUpdate()[0]).toMatchObject({ + plan: "pro", + status: "active", + currentPeriodEnd: new Date("2026-12-27T00:00:00.000Z"), + }); + }); + + it("maps payment failure and pause to past_due", async () => { + await handleLsWebhook( + signedRequest( + subscriptionEvent("subscription_payment_failed", { status: "past_due" }, { custom_data: { workspace_id: "ws-1" } }), + ), + ); + await handleLsWebhook( + signedRequest(subscriptionEvent("subscription_paused", { status: "paused" }, { custom_data: { workspace_id: "ws-1" } })), + ); + const updates = expectUpdate(); + expect(updates[0]).toMatchObject({ plan: "pro", status: "past_due" }); + expect(updates[1]).toMatchObject({ plan: "pro", status: "past_due" }); + }); + + it("maps resumed/recovered subscriptions back to active", async () => { + await handleLsWebhook( + signedRequest( + subscriptionEvent("subscription_payment_recovered", { status: "active" }, { custom_data: { workspace_id: "ws-1" } }), + ), + ); + expect(expectUpdate()[0]).toMatchObject({ status: "active" }); + }); + + it("ignores unhandled events without touching workspaces", async () => { + const res = await handleLsWebhook( + signedRequest(subscriptionEvent("license_key_created", {}, { custom_data: { workspace_id: "ws-1" } })), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, ignored: "license_key_created" }); + expect(mocked.updateWorkspacePlan).not.toHaveBeenCalled(); + }); + + it("dedupes identical redeliveries via webhook_events (idempotent)", async () => { + const event = subscriptionEvent("subscription_created", {}, { custom_data: { workspace_id: "ws-1" } }); + await handleLsWebhook(signedRequest(event)); + mocked.isWebhookEventProcessed.mockResolvedValue(true); + const res = await handleLsWebhook(signedRequest(event)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true, duplicate: true }); + expect(mocked.updateWorkspacePlan).toHaveBeenCalledOnce(); + }); + + it("still records the id of a payload that fails to parse later stages", async () => { + // An unmatched payload is marked processed so LS redeliveries stay no-ops. + mocked.getWorkspaceById.mockResolvedValue(null); + mocked.getWorkspaceByOwnerEmail.mockResolvedValue(null); + const event = subscriptionEvent("subscription_created", {}, { custom_data: { workspace_id: "ws-x" } }); + await handleLsWebhook(signedRequest(event)); + expect(mocked.markWebhookEventProcessed).toHaveBeenCalled(); + }); +});