Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/FOR-ABID.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-preview-domain>/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.
37 changes: 37 additions & 0 deletions docs/relaunch/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Suspense>`; 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 `<SignIn/>/<SignUp/>` 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 `<title>` + `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
Expand Down
21 changes: 21 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
54 changes: 49 additions & 5 deletions web/app/(app)/app/settings/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
18 changes: 18 additions & 0 deletions web/app/api/admin/billing/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading