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
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (extended access, Pro set to cancel, payment failed, subscription ended) shown the same way by Settings and the AI functions, in English and Hungarian at the project width and 360 px (`recipes/billing-states.spec.ts`); signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).

Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
144 changes: 144 additions & 0 deletions .claude/skills/verify-debatelab/recipes/billing-states.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, test } from "./fixtures";

// Feature map: the Billing entry in features/README.md. Each state is written
// straight into user_settings and subscriptions (what the Stripe webhook
// leaves behind), then Settings and an AI request must agree on it.

const DAY = 24 * 60 * 60 * 1000;
const at = (days: number) => new Date(Date.now() + days * DAY).toISOString();

type State = {
name: string;
settings: Record<string, unknown>;
subscription?: Record<string, unknown>;
badge: { en: string; hu: string };
text: { en: RegExp; hu: RegExp };
button: { en: string; hu: string };
};

const STATES: State[] = [
{
// QB-02: an override on an expired trial is a deliberate grant.
name: "extended-access",
settings: { subscription_tier: "trial", trial_ends_at: at(-1), daily_limit_override: 5 },
badge: { en: "Extended access", hu: "Bővített hozzáférés" },
text: { en: /Extended access is active: 5 AI uses a day/, hu: /Bővített hozzáférésed van: napi 5 MI-használat/ },
button: { en: "Upgrade to Pro", hu: "Váltás Pro-ra" },
},
{
// QB-15
name: "pro-canceling",
settings: { subscription_tier: "pro" },
subscription: { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: true },
badge: { en: "Pro", hu: "Pro" },
text: { en: /Cancels on/, hu: /Lemondva, lejár:/ },
button: { en: "Manage Subscription", hu: "Előfizetés kezelése" },
},
{
// QB-13: the tier the old webhook wrote on the first failed invoice.
name: "past-due-grace",
settings: { subscription_tier: "expired" },
subscription: { status: "past_due", current_period_start: at(-2), current_period_end: at(28) },
badge: { en: "Payment failed", hu: "Sikertelen fizetés" },
text: { en: /Pro access continues until/, hu: /a Pro-hozzáférésed megmarad eddig/ },
button: { en: "Manage Subscription", hu: "Előfizetés kezelése" },
},
{
// QB-14
name: "subscription-ended",
settings: { subscription_tier: "expired", trial_ends_at: at(-60) },
subscription: { status: "canceled", current_period_start: at(-40), current_period_end: at(-10), ended_at: at(-10) },
badge: { en: "Expired", hu: "Lejárt" },
text: { en: /Your Pro subscription ended on/, hu: /A Pro-előfizetésed véget ért/ },
button: { en: "Resubscribe", hu: "Újra előfizetek" },
},
];

async function setState(userId: string, state: State) {
await admin.from("subscriptions").delete().eq("user_id", userId);
const { error } = await admin.from("user_settings").update({
subscription_tier: "trial",
trial_ends_at: null,
grace_period_ends_at: null,
daily_limit_override: null,
...state.settings,
}).eq("user_id", userId);
if (error) throw error;
if (state.subscription) {
const { error: subError } = await admin.from("subscriptions").insert({
user_id: userId,
stripe_customer_id: `cus_verify_${randomUUID()}`,
stripe_subscription_id: `sub_verify_${randomUUID()}`,
...state.subscription,
});
if (subError) throw subError;
}
}

async function brainstorm(page: Page) {
await page.goto("/app/brainstorm");
await page.getByLabel("Motion / Topic *").fill("Public transport should be free");
await page.getByRole("button", { name: "Start Brainstorming" }).click();
await chooseForMotion(page, "once");
}

test("bill-states: Settings and the AI functions agree on each billing state, in English and Hungarian", async ({ signedIn: page, user, evidence }) => {
const userId = await user.userId();
const viewport = page.viewportSize();

for (const language of ["en", "hu"] as const) {
await page.goto("/app/settings");
if (language === "hu") {
await page.getByLabel("Interface Language").click();
await page.getByRole("option", { name: "Magyar" }).click();
await expect(page.getByText("Előfizetés", { exact: true })).toBeVisible();
}
for (const state of STATES) {
await setState(userId, state);
await page.goto("/app/settings");
const card = page.getByTestId("subscription-card");
await expect(card.getByText(state.badge[language], { exact: true })).toBeVisible();
await expect(card.getByText(state.text[language])).toBeVisible();
await expect(card.getByRole("button", { name: state.button[language] })).toBeVisible();
// QB-02: never "Expired · Your access has expired" beside a working plan.
await expect(card.getByText(language === "en" ? "Your access has expired" : "A hozzáférésed lejárt", { exact: false })).toHaveCount(0);
await evidence.shot(`${language}-${state.name}`);
if (viewport && viewport.width > 360) {
await page.setViewportSize({ width: 360, height: 780 });
await evidence.shot(`${language}-${state.name}-360`);
await page.setViewportSize(viewport);
}
}
}
await evidence.step("Each state shows its own badge, sentence and single button in EN and HU");

// Back to English for the AI checks.
await page.goto("/app/settings");
await page.getByLabel("Felület nyelve").click();
await page.getByRole("option", { name: "English" }).click();

// QB-02: extended access really is served.
await setState(userId, STATES[0]);
const served = aiResponse(page, "brainstorm");
await brainstorm(page);
expect(((await served).output.topFive as unknown[]).length).toBeGreaterThan(0);
await evidence.shot("ai-extended-access-served");

// QB-13: past the grace, a past-due payer is refused with payment_failed and sent to Settings, not Checkout.
await setState(userId, {
...STATES[2],
subscription: { status: "past_due", current_period_start: at(-20), current_period_end: at(10) },
});
await brainstorm(page);
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Payment Failed", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT });
await expect(dialog.getByText(/free trial/i)).toHaveCount(0);
await expect(dialog.getByRole("button", { name: /Upgrade to Pro/ })).toHaveCount(0);
await evidence.shot("ai-payment-failed-refused");
await dialog.getByRole("button", { name: "Update payment method" }).click();
await expect(page).toHaveURL(/\/app\/settings$/);
await expect(page.getByText("Payment failed", { exact: true })).toBeVisible();
await evidence.step("Extended access is served; a past-due payer after the grace gets Payment Failed and goes to Settings");
});
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,15 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Claude models run only on DebateLab's Anthropic key (OpenRouter BYOK), with no fallback provider; see [`supabase/README.md`](../supabase/README.md). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's `cost` is only its fee, so the logged cost adds `cost_details.upstream_inference_cost`; the weekly cap still counts only `cost`. Exa searches and page reads report their charge as `costDollars.total`; it joins the request's row, with one `raw_usage.calls` entry per Exa call. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.
`resolveBillingState` in [`supabase/functions/_shared/billing-state.ts`](../supabase/functions/_shared/billing-state.ts) decides an account's plan from `user_settings` and its `subscriptions` rows. The AI functions (through `entitlements.ts`) and the Settings card (through `src/hooks/use-subscription.ts`) both call it, so they agree on the state: trial, extended access (an expired trial or the `extra_limit` promo with `daily_limit_override` above 0), Pro, Pro set to cancel, payment failed, legacy free, trial ended, or subscription ended. A failed renewal keeps Pro for 14 days from the first failed payment while Stripe retries (`PAYMENT_GRACE_DAYS`, counted from `subscriptions.payment_failed_at`, which the webhook sets on the first `invoice.payment_failed` and clears on recovery); the webhook no longer downgrades the account on a failed invoice. An account without access is refused with `trial_ended`, `payment_failed`, or `subscription_ended` (older builds sent `trial_expired` for all three, and the client still accepts it). Settings offers Manage subscription, never a second Checkout, while a Stripe subscription can still bill.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Claude models run only on DebateLab's Anthropic key (OpenRouter BYOK), with no fallback provider; see [`supabase/README.md`](../supabase/README.md). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's `cost` is only its fee, so the logged cost adds `cost_details.upstream_inference_cost`, and the weekly cap counts the same full charge. Exa searches and page reads report their charge as `costDollars.total`; it joins the request's row, with one `raw_usage.calls` entry per Exa call. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.

Every model call asks for JSON. Calls whose shape the server checks in code (the Crossfire next turn and debrief, the Ask search decision, and research follow-up queries) send a strict JSON Schema, so OpenRouter routes them only to endpoints that enforce it; the other features use plain JSON mode. [`supabase/functions/ai/provider.ts`](../supabase/functions/ai/provider.ts) fixes common slips in a reply locally (markdown fences, prose around the object, trailing commas, raw line breaks in strings) before it pays for one repair retry, and a reply the provider cut off at its cap is retried once with twice the budget, not with the repair prompt.

`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.

The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; Auto Pro counts as its most expensive candidate because the quota is reserved before routing). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: what OpenRouter charged against our credits plus Exa and ElevenLabs charges. A call billed to our own BYOK provider key (Claude on the Anthropic plan) adds only OpenRouter's fee, usually 0, while a Claude call that falls back to OpenRouter credits counts in full. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it).
The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; Auto Pro counts as its most expensive candidate because the quota is reserved before routing). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it).

Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through `claim_ai_session_quota` ([`20261022220000_ai_quota_sessions.sql`](../supabase/migrations/20261022220000_ai_quota_sessions.sql)), keyed by the Sparring `sessionId` or the Crossfire round id the client sends as `quotaSession` beside `input`; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (`SESSION_QUOTA` in `usage-limits.ts`), so a reused id cannot buy unlimited AI. A Crossfire request without `quotaSession` (an older client) is charged per call. Before a round starts, both pages send a `quotaCheck` request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts.

Expand Down
Loading
Loading