From 1f81dafffe96f29f7f062ab6c87fb6036fb016e0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 00:56:00 +0000 Subject: [PATCH 1/2] Make billing states agree between Settings and the AI functions (T4: QB-02, QB-13, QB-14, QB-15, QB-08, QB-17, SC-03) One resolver, supabase/functions/_shared/billing-state.ts, decides an account's plan for the AI functions, the Settings card and the settings store: trial, extended access, Pro (renewing or set to cancel), payment failed (in or past a 14-day grace from the first failure), legacy free, trial ended, subscription ended. - A failed renewal no longer downgrades the account; the webhook records subscriptions.payment_failed_at and Settings offers the billing portal, never a second Checkout. - Refusals carry trial_ended, payment_failed or subscription_ended; the client still reads the old trial_expired. - Pro activation and cancellation keep a promo grant that ends later; discount promos check expiry and their redemption cap. - The weekly cap counts the full logged cost, BYOK Claude included. - checkRateLimit(null) refuses. Migration 20261023020000_subscription_payment_failed_at.sql is additive. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 2 +- .../recipes/billing-states.spec.ts | 144 ++++++++ docs/architecture.md | 6 +- e2e/full-stack/billing.spec.ts | 21 ++ .../subscription/LimitReachedModal.tsx | 61 +++- .../subscription/SubscriptionStatus.tsx | 107 ++++++ src/hooks/use-subscription-error.ts | 14 +- src/hooks/use-subscription.test.tsx | 69 +++- src/hooks/use-subscription.ts | 84 +++-- src/lib/ai/features/session-quota.ts | 6 +- src/lib/ai/limit-message.ts | 22 +- src/lib/ai/request.ts | 4 +- src/lib/stores/settings-store.ts | 25 +- src/locales/en.json | 24 +- src/locales/es.json | 24 +- src/locales/hu.json | 24 +- src/pages/SettingsPage.tsx | 60 +--- src/test/checkout-security.test.ts | 10 + .../components/LimitReachedModal.test.tsx | 75 ++++- .../components/SubscriptionStatus.test.tsx | 114 +++++++ src/test/settings/settings-migration.test.ts | 34 +- .../functions/_shared/billing-state.test.ts | 136 ++++++++ supabase/functions/_shared/billing-state.ts | 225 +++++++++++++ supabase/functions/_shared/cost.test.ts | 24 +- supabase/functions/_shared/cost.ts | 24 +- .../functions/_shared/entitlements.test.ts | 115 ++++++- supabase/functions/_shared/entitlements.ts | 129 ++++--- supabase/functions/_shared/usage-limits.ts | 7 +- supabase/functions/ai-stream/handler_test.ts | 1 + supabase/functions/ai-stream/index.ts | 4 +- supabase/functions/ai-stream/index_test.ts | 1 + .../functions/ai/generate-honesty_test.ts | 1 + .../functions/ai/handler-research.test.ts | 10 +- supabase/functions/ai/index.ts | 4 +- .../create-checkout/checkout-security.ts | 21 +- supabase/functions/create-checkout/index.ts | 82 +++-- supabase/functions/stripe-webhook/handlers.ts | 317 ++++++++++++++++++ .../functions/stripe-webhook/handlers_test.ts | 229 +++++++++++++ supabase/functions/stripe-webhook/index.ts | 273 +-------------- ...3020000_subscription_payment_failed_at.sql | 15 + 40 files changed, 1980 insertions(+), 568 deletions(-) create mode 100644 .claude/skills/verify-debatelab/recipes/billing-states.spec.ts create mode 100644 src/components/features/subscription/SubscriptionStatus.tsx create mode 100644 src/test/components/SubscriptionStatus.test.tsx create mode 100644 supabase/functions/_shared/billing-state.test.ts create mode 100644 supabase/functions/_shared/billing-state.ts create mode 100644 supabase/functions/stripe-webhook/handlers.ts create mode 100644 supabase/functions/stripe-webhook/handlers_test.ts create mode 100644 supabase/migrations/20261023020000_subscription_payment_failed_at.sql diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 340ad2948..83bdfb16d 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -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`). diff --git a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts new file mode 100644 index 000000000..2df3ee2d2 --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts @@ -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; + subscription?: Record; + 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"); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 66bd7c3b1..070f59cd8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/e2e/full-stack/billing.spec.ts b/e2e/full-stack/billing.spec.ts index 5128c25a0..252fc4c21 100644 --- a/e2e/full-stack/billing.spec.ts +++ b/e2e/full-stack/billing.spec.ts @@ -127,6 +127,24 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin await page.goto("/app/settings"); await expect(page.getByText("Pro", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Manage Subscription" })).toBeVisible(); + await expect(page.getByTestId("billing-date")).toContainText("Renews on"); + + // A failed renewal keeps Pro while Stripe retries, and Settings asks for a + // new payment method instead of selling a second subscription (QB-13). + await fake.stripeSubscription(subscriptionObject(subscriptionId, "past_due")); + const failed = await sendWebhook({ + id: `evt_${randomUUID()}`, + object: "event", + type: "invoice.payment_failed", + data: { object: { id: `in_e2e_${randomUUID()}`, object: "invoice", subscription: subscriptionId } }, + }); + expect(failed.status).toBe(200); + expect(await tier(userId)).toBe("pro"); + await page.reload(); + await expect(page.getByText("Payment failed", { exact: true })).toBeVisible(); + await expect(page.getByText(/Pro access continues until/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Manage Subscription" })).toBeVisible(); + await expect(page.getByRole("button", { name: /Upgrade to Pro/ })).toHaveCount(0); await fake.stripeSubscription(subscriptionObject(subscriptionId, "canceled")); const canceled = await sendWebhook(subscriptionEvent("customer.subscription.deleted", subscriptionId, "canceled")); @@ -141,4 +159,7 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin await page.reload(); await expect(page.getByText("Expired", { exact: true })).toBeVisible(); + // A former subscriber is told the subscription ended, not that a free trial did (QB-14). + await expect(page.getByText(/Your Pro subscription ended on/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Resubscribe" })).toBeVisible(); }); diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index 4776191f3..93eb20d28 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -1,11 +1,20 @@ import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { AlertTriangle, Zap, Clock } from 'lucide-react'; +import { AlertTriangle, Zap, Clock, CreditCard } from 'lucide-react'; import { useSubscription } from '@/hooks/use-subscription'; +import { formatEuropeanDate } from '@/lib/date-format'; import { subscriptionLimitMessage } from '@/lib/ai/error-display'; -export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'weekly_budget_reached'; +/** trial_expired is what functions older than the other access codes sent; it shows as trial_ended. */ +export type LimitModalVariant = + | 'limit_reached' + | 'trial_ended' + | 'trial_expired' + | 'payment_failed' + | 'subscription_ended' + | 'weekly_budget_reached'; interface LimitReachedModalProps { open: boolean; @@ -18,38 +27,60 @@ interface LimitReachedModalProps { onUpgrade: () => void; } +const TITLES: Record = { + limit_reached: 'limitModal.limitReached', + trial_ended: 'limitModal.trialEnded', + trial_expired: 'limitModal.trialEnded', + payment_failed: 'limitModal.paymentFailed', + subscription_ended: 'limitModal.subscriptionEnded', + weekly_budget_reached: 'limitModal.weeklyReached', +}; + export function LimitReachedModal({ open, onOpenChange, variant, limit, current, units, onUpgrade }: LimitReachedModalProps) { const { t } = useTranslation(); - const { isPro } = useSubscription(); - const isTrialExpired = variant === 'trial_expired'; + const navigate = useNavigate(); + const { isPro, billingState, billingDate } = useSubscription(); + const accessEnded = variant === 'trial_ended' || variant === 'trial_expired' || variant === 'subscription_ended'; const isWeeklyBudget = variant === 'weekly_budget_reached'; // A Pro student who used today's included requests already pays. Selling Pro again is the wrong next step. - const paidDailyCap = isPro && !isTrialExpired; + const paidDailyCap = isPro && variant === 'limit_reached'; - const title = isTrialExpired - ? t('limitModal.trialEnded') - : isWeeklyBudget - ? t('limitModal.weeklyReached') - : t('limitModal.limitReached'); - const description = subscriptionLimitMessage({ code: variant, limit, current, units }); + const endedOn = variant === 'subscription_ended' && billingState === 'subscription_ended' && billingDate + ? formatEuropeanDate(billingDate) + : null; + const description = endedOn + ? t('limitModal.subscriptionEndedOnDesc', { date: endedOn }) + : subscriptionLimitMessage({ code: variant, limit, current, units }); return (
- {isTrialExpired ? ( + {accessEnded ? ( ) : ( )}
- {title} + {t(TITLES[variant])} {description}
- {isWeeklyBudget || paidDailyCap ? ( + {variant === 'payment_failed' ? ( + // Stripe is still retrying the subscription they have; a second + // Checkout would charge twice. Settings opens the billing portal. + <> + + + + ) : isWeeklyBudget || paidDailyCap ? ( // Upgrading does not lift the spend cap, and a paid plan already has Pro. + ) : ( + + )} +
+ +

{description}

+ + ); +} diff --git a/src/hooks/use-subscription-error.ts b/src/hooks/use-subscription-error.ts index 7e915b10e..b868a4864 100644 --- a/src/hooks/use-subscription-error.ts +++ b/src/hooks/use-subscription-error.ts @@ -16,6 +16,14 @@ interface SubscriptionErrorState { handleSubscriptionError: (error: unknown) => boolean; } +const ACCESS_VARIANTS: ReadonlySet = new Set(['trial_ended', 'payment_failed', 'subscription_ended', 'weekly_budget_reached']); + +function variantForCode(code: string | undefined): LimitModalVariant { + // Functions deployed before the separate access codes send trial_expired. + if (code === 'trial_expired') return 'trial_ended'; + return code && ACCESS_VARIANTS.has(code) ? code as LimitModalVariant : 'limit_reached'; +} + /** * Hook that detects subscription/model errors and opens the appropriate modal. * Returns true if the error was a subscription error (handled), false otherwise. @@ -32,11 +40,7 @@ export function useSubscriptionError(): SubscriptionErrorState { const handleSubscriptionError = useCallback((error: unknown): boolean => { if (isAISubscriptionError(error)) { const subError = error as AISubscriptionError; - if (subError.code === 'trial_expired' || subError.code === 'weekly_budget_reached') { - setLimitModalVariant(subError.code); - } else { - setLimitModalVariant('limit_reached'); - } + setLimitModalVariant(variantForCode(subError.code)); setLimitModalLimit(subError.limit); setLimitModalCurrent(subError.current); setLimitModalUnits(subError.units); diff --git a/src/hooks/use-subscription.test.tsx b/src/hooks/use-subscription.test.tsx index 23fd530eb..9985c5a4e 100644 --- a/src/hooks/use-subscription.test.tsx +++ b/src/hooks/use-subscription.test.tsx @@ -9,6 +9,10 @@ let rateLimitResult: { data: { limit_value: number } | null; error: { message: s error: null, }; +const ACTIVE_TRIAL: Record = { subscription_tier: 'trial', daily_limit_override: null, trial_ends_at: '2099-01-01T00:00:00.000Z', grace_period_ends_at: null }; +let settingsRow: Record = ACTIVE_TRIAL; +let subscriptionRows: Record[] = []; + // ai_usage_today: the server's counter. Missing (an older server) by default. let usageToday: { data: number | null; error: { message: string } | null } = { data: null, error: { message: 'function not found' } }; @@ -27,11 +31,11 @@ function builder(table: string) { }, limit: () => api, maybeSingle: () => Promise.resolve(table === 'user_settings' - ? { data: { subscription_tier: 'trial', daily_limit_override: null, trial_ends_at: '2099-01-01T00:00:00.000Z', grace_period_ends_at: null }, error: null } + ? { data: settingsRow, error: null } : table === 'rate_limits' ? rateLimitResult : { data: null, error: null }), then: (resolve: (value: unknown) => void, reject?: (reason: unknown) => void) => Promise.resolve( table === 'subscriptions' - ? { data: [], error: null } + ? { data: subscriptionRows, error: null } : table === 'ai_requests' ? { count: 1, error: null } : { data: null, error: null }, @@ -63,6 +67,8 @@ describe('useSubscription rate limits', () => { orders.length = 0; rateLimitResult = { data: { limit_value: 12 }, error: null }; usageToday = { data: null, error: { message: 'function not found' } }; + settingsRow = ACTIVE_TRIAL; + subscriptionRows = []; }); it('reads the account limit ordered by created_at and uses it', async () => { @@ -98,3 +104,62 @@ describe('useSubscription rate limits', () => { expect(result.current.remainingToday).toBe(9); }); }); + +// The hook resolves the same state as the AI functions (billing-state.ts); +// these pin the rows it reads and what it hands Settings. +describe('useSubscription billing states', () => { + const DAY = 24 * 60 * 60 * 1000; + const at = (offsetDays: number) => new Date(Date.now() + offsetDays * DAY).toISOString(); + + beforeEach(() => { + rateLimitResult = { data: null, error: null }; + usageToday = { data: 0, error: null }; + subscriptionRows = []; + }); + + async function load() { + const { result } = renderHook(() => useSubscription(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + return result.current; + } + + it('shows an override on an expired trial as extended access, not Expired (QB-02)', async () => { + settingsRow = { subscription_tier: 'trial', daily_limit_override: 5, trial_ends_at: at(-1), grace_period_ends_at: null }; + const state = await load(); + expect(state.billingState).toBe('extended_access'); + expect(state.dailyLimit).toBe(5); + expect(state.remainingToday).toBe(5); + }); + + it('keeps a past-due payer on Pro with the portal, even with the tier the old webhook wrote (QB-13)', async () => { + settingsRow = { subscription_tier: 'expired', daily_limit_override: null, trial_ends_at: at(-30), grace_period_ends_at: null }; + subscriptionRows = [{ stripe_customer_id: 'cus_1', status: 'past_due', current_period_start: at(-2), current_period_end: at(28) }]; + const state = await load(); + expect(state.billingState).toBe('payment_failed'); + expect(state.billingDateKind).toBe('grace_ends'); + expect(state.isPro).toBe(true); + expect(state.dailyLimit).toBe(50); + expect(state.hasOpenSubscription).toBe(true); + }); + + it('reads cancel_at_period_end and dates a plan set to cancel (QB-15)', async () => { + const periodEnd = at(10); + settingsRow = { subscription_tier: 'pro', daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: null }; + subscriptionRows = [{ stripe_customer_id: 'cus_1', status: 'active', current_period_start: at(-20), current_period_end: periodEnd, cancel_at_period_end: true }]; + const state = await load(); + expect(state.billingState).toBe('pro_canceling'); + expect(state.billingDate).toBe(periodEnd); + expect(state.billingDateKind).toBe('cancels'); + }); + + it('dates a former subscription and gives no access (QB-14)', async () => { + const endedAt = at(-3); + settingsRow = { subscription_tier: 'expired', daily_limit_override: null, trial_ends_at: at(-60), grace_period_ends_at: null }; + subscriptionRows = [{ stripe_customer_id: 'cus_1', status: 'canceled', current_period_start: at(-33), current_period_end: at(-3), ended_at: endedAt }]; + const state = await load(); + expect(state.billingState).toBe('subscription_ended'); + expect(state.billingDate).toBe(endedAt); + expect(state.dailyLimit).toBe(0); + expect(state.hasOpenSubscription).toBe(false); + }); +}); diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index 4b18b0b80..2f982a3d5 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -1,11 +1,29 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/lib/supabase/client'; import { useAuth } from '@/lib/supabase/AuthProvider'; +import { + planDailyLimit, + resolveBillingState, + type BillingDateKind, + type BillingStateName, + type BillingSubscriptionRow, +} from '../../supabase/functions/_shared/billing-state'; export type SubscriptionTier = 'trial' | 'pro' | 'legacy_free' | 'expired'; +export type { BillingDateKind, BillingStateName }; + +// The server's tier defaults (supabase/functions/_shared/usage-limits.ts). +const DEFAULT_LIMITS = { pro: 50, trial: 5 }; interface SubscriptionState { tier: SubscriptionTier; + /** The state the server resolves too (billing-state.ts); Settings shows it. */ + billingState: BillingStateName; + /** The date that goes with the state: trial end, renewal, cancellation, payment grace end, or when Pro ended. */ + billingDate: string | null; + billingDateKind: BillingDateKind | null; + /** A Stripe subscription can still bill, so the next step is Manage subscription, not Checkout. */ + hasOpenSubscription: boolean; isPro: boolean; isTrialActive: boolean; isExpired: boolean; @@ -22,10 +40,8 @@ interface SubscriptionState { checkRemainingToday: () => Promise; } -interface BillingRow { +interface BillingRow extends BillingSubscriptionRow { stripe_customer_id?: string | null; - status?: string | null; - current_period_end?: string | null; } type SubscriptionSnapshot = Omit & { @@ -35,6 +51,10 @@ type SubscriptionSnapshot = Omit { let snapshot = { ...SIGNED_IN_FALLBACK }; + let hasAccess = false; try { const { data, error: settingsError } = await supabase @@ -68,17 +93,16 @@ async function fetchSubscription(userId: string): Promise } if (data) { - const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier; - const limitOrDefault = (defaultLimit: number) => data.daily_limit_override ?? defaultLimit; - const now = new Date(); let billingRows: BillingRow[] = []; const { data: billingData, error: billingError } = await supabase .from('subscriptions') - .select('stripe_customer_id, status, current_period_end') + // `*`: the frontend ships on merge, before migrations, so naming a new + // column (payment_failed_at) would fail this read until it lands. + .select('*') .eq('user_id', userId) .order('current_period_end', { ascending: false, nullsFirst: false }) - .limit(5); + .limit(10); if (billingError) { console.warn('[useSubscription] Failed to load Stripe billing state:', billingError.message); @@ -86,44 +110,30 @@ async function fetchSubscription(userId: string): Promise billingRows = billingData || []; } - const activeStripeSubscription = billingRows.some((row) => - (row.status === 'active' || row.status === 'trialing') && - row.current_period_end && - new Date(row.current_period_end) > now - ); - const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at) : null; - const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at) : null; - const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd); - + // The same resolver the AI functions use, so Settings never says + // "Expired" to an account the server serves (QB-02). + const billing = resolveBillingState(data, billingRows); + hasAccess = billing.hasAccess; snapshot = { ...snapshot, + tier: billing.tier, + billingState: billing.state, + billingDate: billing.date, + billingDateKind: billing.dateKind, + hasOpenSubscription: billing.hasOpenSubscription, + dailyLimit: planDailyLimit(billing, data.daily_limit_override, DEFAULT_LIMITS), hasStripeBilling: billingRows.some((row) => Boolean(row.stripe_customer_id)), trialEndsAt: data.trial_ends_at, gracePeriodEndsAt: data.grace_period_ends_at, }; - - if (rawTier === 'legacy_free') { - snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired'; - snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0); - } else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) { - snapshot.tier = 'pro'; - snapshot.dailyLimit = limitOrDefault(50); - } else if (rawTier === 'trial' && trialEnd && now < trialEnd) { - snapshot.tier = 'trial'; - snapshot.dailyLimit = limitOrDefault(5); - } else if (hasActiveTrialGift) { - snapshot.tier = 'trial'; - snapshot.dailyLimit = limitOrDefault(5); - } else { - snapshot.dailyLimit = limitOrDefault(0); - } } // The server lets a per-account rate_limits row replace the plan's limit // (getConfiguredDailyLimit). Global rows are server-only, so the remaining // count is an estimate: warn with it, and let the server's 402 decide. let limitKnown = true; - if (data && !(snapshot.tier === 'expired' && snapshot.dailyLimit === 0)) { + // An account with no access is refused before the server reads rate_limits. + if (data && hasAccess) { const { data: ownLimit, error: limitError } = await supabase .from('rate_limits') .select('limit_value') @@ -195,7 +205,7 @@ export function useSubscription(): SubscriptionState { const snapshot = userId ? query.isError ? SIGNED_IN_FALLBACK : query.data ?? SIGNED_IN_FALLBACK : SIGNED_OUT_SNAPSHOT; - const { tier, dailyLimit, dailyUsed, trialEndsAt, gracePeriodEndsAt, hasStripeBilling } = snapshot; + const { tier, billingState, billingDate, billingDateKind, hasOpenSubscription, dailyLimit, dailyUsed, trialEndsAt, gracePeriodEndsAt, hasStripeBilling } = snapshot; const usageKnown = Boolean(userId) && !query.isError && snapshot.usageKnown; const isPro = tier === 'pro' || tier === 'legacy_free'; @@ -204,6 +214,10 @@ export function useSubscription(): SubscriptionState { return { tier, + billingState, + billingDate, + billingDateKind, + hasOpenSubscription, isPro, isTrialActive, isExpired, diff --git a/src/lib/ai/features/session-quota.ts b/src/lib/ai/features/session-quota.ts index f90355f3b..688e02e61 100644 --- a/src/lib/ai/features/session-quota.ts +++ b/src/lib/ai/features/session-quota.ts @@ -1,12 +1,12 @@ import type { ProviderConfig } from '../providers/types'; import { getActiveAccessToken, invokeAuthenticatedFunction } from '@/lib/supabase/client'; import { AIModelRestrictionError, AISubscriptionError, type AIError } from '../errors'; -import { localizedLimitMessage } from '../limit-message'; +import { isSubscriptionRefusalCode, localizedLimitMessage } from '../limit-message'; /** What a new Crossfire or Sparring round costs, and whether today's quota covers it. */ export interface SessionQuota { allowed: boolean; - /** Why the server would refuse: limit_reached, weekly_budget_reached, trial_expired, upgrade_required, banned. */ + /** Why the server would refuse: limit_reached, weekly_budget_reached, trial_ended, payment_failed, subscription_ended (trial_expired from older functions), upgrade_required, banned. */ code?: string; limit?: number; current?: number; @@ -76,7 +76,7 @@ export function sessionQuotaRemaining(quota: SessionQuota | null | undefined): n export function sessionQuotaError(quota: SessionQuota | null | undefined): AIError | null { if (!quota || quota.allowed) return null; if (quota.code === 'upgrade_required') return new AIModelRestrictionError('This model requires a Pro subscription.'); - if (quota.code !== 'limit_reached' && quota.code !== 'weekly_budget_reached' && quota.code !== 'trial_expired') return null; + if (!isSubscriptionRefusalCode(quota.code)) return null; const details = { code: quota.code, limit: quota.limit, current: quota.current, units: quota.units }; return new AISubscriptionError(localizedLimitMessage('', details), { statusCode: 402, ...details }); } diff --git a/src/lib/ai/limit-message.ts b/src/lib/ai/limit-message.ts index 9625a0d20..067ee083c 100644 --- a/src/lib/ai/limit-message.ts +++ b/src/lib/ai/limit-message.ts @@ -7,13 +7,33 @@ export interface LimitDetails { units?: number; } +/** + * Codes the AI functions refuse an account's access or quota with. trial_expired + * is what functions deployed before trial_ended, payment_failed and + * subscription_ended sent for all three; it reads as trial_ended. + */ +const SUBSCRIPTION_REFUSAL_CODES = new Set([ + 'limit_reached', + 'weekly_budget_reached', + 'trial_ended', + 'trial_expired', + 'payment_failed', + 'subscription_ended', +]); + +export function isSubscriptionRefusalCode(code: unknown): code is string { + return typeof code === 'string' && SUBSCRIPTION_REFUSAL_CODES.has(code); +} + /** * The translated explanation of a quota refusal. AISubscriptionError carries it * as its message, so a page that shows error.message directly is translated too; * the server's own reason is English only. */ export function subscriptionLimitMessage({ code, limit, current, units }: LimitDetails): string { - if (code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); + if (code === 'trial_ended' || code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); + if (code === 'payment_failed') return i18n.t('limitModal.paymentFailedDesc'); + if (code === 'subscription_ended') return i18n.t('limitModal.subscriptionEndedDesc'); if (code === 'weekly_budget_reached') return i18n.t('limitModal.weeklyReachedDesc'); const weight = units ?? 1; // A model heavier than the whole daily limit never fits, so waiting does not help. diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 6d6da1ff0..3e2d987d4 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -10,7 +10,7 @@ import { toast } from '@/hooks/use-toast'; import { captureException, captureProductEvent } from '@/lib/observability'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import i18n from '@/i18n'; -import { localizedLimitMessage } from './limit-message'; +import { isSubscriptionRefusalCode, localizedLimitMessage } from './limit-message'; import type { WorkspaceToolContext } from '@/lib/workspace/tool-context'; import { AIError, @@ -281,7 +281,7 @@ export async function callSupabaseAI( const errorCode = data.code as string | undefined; // Subscription errors (402 / trial expired / limit reached) - if (errorCode === 'trial_expired' || errorCode === 'limit_reached' || errorCode === 'weekly_budget_reached') { + if (isSubscriptionRefusalCode(errorCode)) { const details = { code: errorCode, limit: typeof data.limit === 'number' ? data.limit : undefined, diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index e24d052b1..59445f696 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { applyAppLanguage } from '@/i18n'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; +import { resolveBillingState, type BillingSettingsRow } from '../../../supabase/functions/_shared/billing-state'; import { AUTO_FAST, AUTO_PRO, @@ -143,26 +144,22 @@ function featureConfigsAreCanonical(value: unknown, expected: Record): Promise { - const tier = settings.subscription_tier; - const graceEnd = typeof settings.grace_period_ends_at === 'string' - ? new Date(settings.grace_period_ends_at) - : null; - if ((tier === 'pro' || tier === 'legacy_free') && graceEnd && graceEnd > new Date()) return true; - if (tier !== 'pro') return false; - const userId = await getUserId(); if (!userId) return null; + // `*` so the read works before and after 20261023020000 (payment_failed_at). const { data, error } = await supabase .from('subscriptions') - .select('status, current_period_end') + .select('*') .eq('user_id', userId); - if (error) return null; - return Boolean(data?.some((subscription) => - (subscription.status === 'active' || subscription.status === 'trialing') - && subscription.current_period_end - && new Date(subscription.current_period_end) > new Date() - )); + // Settings alone can prove Pro (promo, legacy grace); without the rows, a "no" is unknown. + if (resolveBillingState(settings as BillingSettingsRow, error ? [] : data).plan === 'pro') return true; + return error ? null : false; } async function getUserId() { diff --git a/src/locales/en.json b/src/locales/en.json index ad12179f1..91125f44d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -518,7 +518,20 @@ "deleteAccountSuccessDesc": "Your account and all of its data have been permanently deleted.", "deleteAccountFailed": "Account not deleted", "signingOut": "Signing out…", - "deletingData": "Deleting…" + "deletingData": "Deleting…", + "tierExtended": "Extended access", + "tierPaymentFailed": "Payment failed", + "extendedAccessDesc_one": "Extended access is active: {{count}} AI use a day.", + "extendedAccessDesc_other": "Extended access is active: {{count}} AI uses a day.", + "renewsOn": "Renews on {{date}}", + "cancelsOn": "Cancels on {{date}}", + "endsOn": "Ends on {{date}}", + "paymentFailedDesc": "Payment failed: update your payment method to keep Pro.", + "paymentGraceUntil": "Pro access continues until {{date}} while we retry the payment.", + "paymentFailedNoAccess": "Payment failed: update your payment method to restore AI access.", + "subscriptionEndedOn": "Your Pro subscription ended on {{date}}. Resubscribe to keep using AI tools.", + "subscriptionEnded": "Your Pro subscription has ended. Resubscribe to keep using AI tools.", + "resubscribe": "Resubscribe" }, "login": { "title": "Sign in to DebateLab", @@ -1375,7 +1388,14 @@ "upgrade": "Upgrade to Pro - $5/mo", "upgradeNote": "More AI prep access + high reasoning", "notNow": "Not now", - "gotIt": "OK" + "gotIt": "OK", + "paymentFailed": "Payment Failed", + "paymentFailedDesc": "Your last payment failed. Update your payment method in Settings to keep using DebateLab.", + "subscriptionEnded": "Pro Subscription Ended", + "subscriptionEndedDesc": "Your Pro subscription has ended. Resubscribe to keep using DebateLab.", + "subscriptionEndedOnDesc": "Your Pro subscription ended on {{date}}. Resubscribe to keep using DebateLab.", + "updatePayment": "Update payment method", + "resubscribe": "Resubscribe" }, "modelModal": { "title": "Pro Reasoning", diff --git a/src/locales/es.json b/src/locales/es.json index 2168cca84..2fd977bdc 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -518,7 +518,20 @@ "deleteAccountSuccessDesc": "Tu cuenta y todos sus datos se han eliminado para siempre.", "deleteAccountFailed": "La cuenta no se eliminó", "signingOut": "Cerrando sesión…", - "deletingData": "Eliminando…" + "deletingData": "Eliminando…", + "tierExtended": "Acceso ampliado", + "tierPaymentFailed": "Pago fallido", + "extendedAccessDesc_one": "Tienes acceso ampliado: {{count}} uso de IA al día.", + "extendedAccessDesc_other": "Tienes acceso ampliado: {{count}} usos de IA al día.", + "renewsOn": "Se renueva el {{date}}", + "cancelsOn": "Se cancela el {{date}}", + "endsOn": "Termina el {{date}}", + "paymentFailedDesc": "El pago falló: actualiza tu método de pago para conservar Pro.", + "paymentGraceUntil": "Conservas el acceso Pro hasta el {{date}} mientras reintentamos el cobro.", + "paymentFailedNoAccess": "El pago falló: actualiza tu método de pago para recuperar el acceso a la IA.", + "subscriptionEndedOn": "Tu suscripción Pro terminó el {{date}}. Vuelve a suscribirte para seguir usando las herramientas de IA.", + "subscriptionEnded": "Tu suscripción Pro ha terminado. Vuelve a suscribirte para seguir usando las herramientas de IA.", + "resubscribe": "Volver a suscribirme" }, "login": { "title": "Iniciar sesión en DebateLab", @@ -1375,7 +1388,14 @@ "upgrade": "Actualizar a Pro - $5/mes", "upgradeNote": "Más acceso a preparación con IA + razonamiento alto", "notNow": "Ahora no", - "gotIt": "Entendido" + "gotIt": "Entendido", + "paymentFailed": "Pago fallido", + "paymentFailedDesc": "Tu último pago falló. Actualiza tu método de pago en Ajustes para seguir usando DebateLab.", + "subscriptionEnded": "Suscripción Pro finalizada", + "subscriptionEndedDesc": "Tu suscripción Pro ha terminado. Vuelve a suscribirte para seguir usando DebateLab.", + "subscriptionEndedOnDesc": "Tu suscripción Pro terminó el {{date}}. Vuelve a suscribirte para seguir usando DebateLab.", + "updatePayment": "Actualizar método de pago", + "resubscribe": "Volver a suscribirme" }, "modelModal": { "title": "Razonamiento Pro", diff --git a/src/locales/hu.json b/src/locales/hu.json index abc15bf14..76cd788b0 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -518,7 +518,20 @@ "deleteAccountSuccessDesc": "A fiókodat és minden adatát véglegesen töröltük.", "deleteAccountFailed": "A fiók nem lett törölve", "signingOut": "Kijelentkezés…", - "deletingData": "Törlés…" + "deletingData": "Törlés…", + "tierExtended": "Bővített hozzáférés", + "tierPaymentFailed": "Sikertelen fizetés", + "extendedAccessDesc_one": "Bővített hozzáférésed van: napi {{count}} MI-használat.", + "extendedAccessDesc_other": "Bővített hozzáférésed van: napi {{count}} MI-használat.", + "renewsOn": "Megújul: {{date}}", + "cancelsOn": "Lemondva, lejár: {{date}}", + "endsOn": "Vége: {{date}}", + "paymentFailedDesc": "Sikertelen fizetés: frissítsd a fizetési módodat, hogy megtartsd a Pro-t.", + "paymentGraceUntil": "Amíg újrapróbáljuk a terhelést, a Pro-hozzáférésed megmarad eddig: {{date}}.", + "paymentFailedNoAccess": "Sikertelen fizetés: frissítsd a fizetési módodat az MI-hozzáférés visszaállításához.", + "subscriptionEndedOn": "A Pro-előfizetésed véget ért ({{date}}). Fizess elő újra az MI-eszközök további használatához.", + "subscriptionEnded": "A Pro-előfizetésed véget ért. Fizess elő újra az MI-eszközök további használatához.", + "resubscribe": "Újra előfizetek" }, "login": { "title": "Bejelentkezés a DebateLab-ba", @@ -1375,7 +1388,14 @@ "upgrade": "Frissítés Pro-ra - $5/hó", "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés", "notNow": "Most nem", - "gotIt": "Rendben" + "gotIt": "Rendben", + "paymentFailed": "Sikertelen fizetés", + "paymentFailedDesc": "Az utolsó fizetésed sikertelen volt. Frissítsd a fizetési módodat a Beállításokban a DebateLab további használatához.", + "subscriptionEnded": "A Pro-előfizetés véget ért", + "subscriptionEndedDesc": "A Pro-előfizetésed véget ért. Fizess elő újra a DebateLab további használatához.", + "subscriptionEndedOnDesc": "A Pro-előfizetésed véget ért ({{date}}). Fizess elő újra a DebateLab további használatához.", + "updatePayment": "Fizetési mód frissítése", + "resubscribe": "Újra előfizetek" }, "modelModal": { "title": "Pro érvelés", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 4f6042d66..20e716d40 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Download, UserX } from 'lucide-react'; +import { Settings, Lock, Trash2, Crown, Shield, Download, UserX } from 'lucide-react'; import type { User } from '@supabase/supabase-js'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -15,7 +15,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { useSessionStore } from '@/lib/stores/session-store'; import { useDebateStore } from '@/lib/stores/debate-store'; @@ -29,8 +28,8 @@ import { useExternalRedirectPending } from '@/hooks/use-external-redirect-pendin import { redirectToPortal } from '@/lib/stripe'; import { deleteAccount, downloadAccountData } from '@/lib/account'; import { PricingModal } from '@/components/features/subscription/PricingModal'; +import { SubscriptionStatus } from '@/components/features/subscription/SubscriptionStatus'; import { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentSwitcher'; -import { formatEuropeanDate } from '@/lib/date-format'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { PageHeader } from '@/components/layout/PageHeader'; @@ -42,14 +41,6 @@ import { } from '@/lib/billing/checkout-return'; -// Tinted badges keep their label readable (white on amber or green was about 2:1). -const tierLabels: Record = { - trial: { labelKey: 'settings.tierTrial', color: 'border-blue-500/30 bg-blue-500/15 text-blue-800 dark:text-blue-300' }, - pro: { labelKey: 'settings.tierPro', color: 'border-amber-500/30 bg-amber-500/15 text-amber-800 dark:text-amber-300' }, - legacy_free: { labelKey: 'settings.tierLegacyFree', color: 'border-success/30 bg-success/15 text-success' }, - expired: { labelKey: 'settings.tierExpired', color: 'border-destructive/30 bg-destructive/10 text-destructive' }, -}; - /** How this account signs in, from what Auth recorded for it (not only this session). */ function signInMethodKey(user: User | null): string | null { const meta = user?.app_metadata; @@ -247,17 +238,13 @@ export default function SettingsPage() { } }; - const tierInfo = tierLabels[subscription.tier] || tierLabels.expired; - const canManageStripeBilling = subscription.tier === 'pro' && subscription.hasStripeBilling; - const upgradeLabel = subscription.tier === 'pro' ? t('settings.setUpBilling') : t('settings.upgradeToPro'); - return (
} title={t('settings.title')} /> {/* Subscription Status */} - +
@@ -271,36 +258,17 @@ export default function SettingsPage() {

) : ( <> -
-
- {t(tierInfo.labelKey)} - {subscription.tier === 'trial' && subscription.trialEndsAt && ( - - {t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)} - - )} -
- {canManageStripeBilling ? ( - - ) : ( - - )} -
- -

- {t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')} -

+ void handleManageSubscription()} + onUpgrade={() => setPricingOpen(true)} + /> {subscription.remainingToday !== null && subscription.dailyLimit > 0 && (

{t('dailyLimit.usage', { diff --git a/src/test/checkout-security.test.ts b/src/test/checkout-security.test.ts index cd3ca437e..1d85bd560 100644 --- a/src/test/checkout-security.test.ts +++ b/src/test/checkout-security.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + discountPromoRefusal, findCustomerOwnedByUser, hasOpenSubscription, isAllowedCheckoutRedirectUrl, @@ -40,4 +41,13 @@ describe("checkout security", () => { expect(hasOpenSubscription([])).toBe(false); expect(hasOpenSubscription(null)).toBe(false); }); + + it("refuses a discount promo past its expiry or its redemption cap (QB-08)", () => { + const now = new Date("2026-10-22T12:00:00.000Z"); + expect(discountPromoRefusal({ expires_at: "2026-10-21T00:00:00.000Z" }, now)).toBe("expired"); + expect(discountPromoRefusal({ max_redemptions: 10, redemptions_count: 10 }, now)).toBe("exhausted"); + expect(discountPromoRefusal({ max_redemptions: 0, redemptions_count: null }, now)).toBe("exhausted"); + expect(discountPromoRefusal({ expires_at: "2026-10-23T00:00:00.000Z", max_redemptions: 10, redemptions_count: 9 }, now)).toBeNull(); + expect(discountPromoRefusal({ expires_at: null, max_redemptions: null }, now)).toBeNull(); + }); }); diff --git a/src/test/components/LimitReachedModal.test.tsx b/src/test/components/LimitReachedModal.test.tsx index dca1c5331..7b72bcf5e 100644 --- a/src/test/components/LimitReachedModal.test.tsx +++ b/src/test/components/LimitReachedModal.test.tsx @@ -1,25 +1,43 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { LimitReachedModal } from '@/components/features/subscription/LimitReachedModal'; +import { LimitReachedModal, type LimitModalVariant } from '@/components/features/subscription/LimitReachedModal'; -const subscription = vi.hoisted(() => ({ isPro: false })); +const subscription = vi.hoisted(() => ({ + isPro: false, + billingState: 'trial' as string, + billingDate: null as string | null, +})); vi.mock('@/hooks/use-subscription', () => ({ - useSubscription: () => ({ isPro: subscription.isPro, loading: false, tier: subscription.isPro ? 'pro' : 'trial' }), + useSubscription: () => ({ + isPro: subscription.isPro, + loading: false, + tier: subscription.isPro ? 'pro' : 'trial', + billingState: subscription.billingState, + billingDate: subscription.billingDate, + }), })); -function renderModal(variant: 'limit_reached' | 'trial_expired' = 'limit_reached') { +function renderModal(variant: LimitModalVariant = 'limit_reached') { const onUpgrade = vi.fn(); const onOpenChange = vi.fn(); render( - , + + + + )} /> + Settings page

} /> + + , ); return { onUpgrade, onOpenChange }; } @@ -27,6 +45,8 @@ function renderModal(variant: 'limit_reached' | 'trial_expired' = 'limit_reached describe('LimitReachedModal', () => { beforeEach(() => { subscription.isPro = false; + subscription.billingState = 'trial'; + subscription.billingDate = null; }); it('offers Pro when a free plan hits the daily cap', () => { @@ -44,9 +64,34 @@ describe('LimitReachedModal', () => { expect(onUpgrade).not.toHaveBeenCalled(); }); - it('still offers Pro when the trial has ended', () => { - subscription.isPro = false; - renderModal('trial_expired'); + it.each(['trial_expired', 'trial_ended'] as const)('still offers Pro when the trial has ended (%s)', (variant) => { + renderModal(variant); + expect(screen.getByRole('heading', { name: 'Free Trial Ended' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upgrade to Pro - $5/mo' })).toBeInTheDocument(); }); + + it('sends a failed payment to Settings instead of a second Checkout', () => { + // QB-13: a past-due subscription is still being retried; Checkout would bill twice. + subscription.isPro = false; + subscription.billingState = 'payment_failed'; + const { onUpgrade, onOpenChange } = renderModal('payment_failed'); + expect(screen.getByRole('heading', { name: 'Payment Failed' })).toBeInTheDocument(); + expect(screen.queryByText(/free trial/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Upgrade to Pro/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Update payment method' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onUpgrade).not.toHaveBeenCalled(); + expect(screen.getByText('Settings page')).toBeInTheDocument(); + }); + + it('tells a former subscriber when Pro ended and offers to resubscribe', () => { + // QB-14: not "Your 3-day free trial has ended". + subscription.billingState = 'subscription_ended'; + subscription.billingDate = '2026-10-01T12:00:00.000Z'; + const { onUpgrade } = renderModal('subscription_ended'); + expect(screen.getByRole('heading', { name: 'Pro Subscription Ended' })).toBeInTheDocument(); + expect(screen.getByText(/Your Pro subscription ended on 01\/10\/2026/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Resubscribe' })); + expect(onUpgrade).toHaveBeenCalled(); + }); }); diff --git a/src/test/components/SubscriptionStatus.test.tsx b/src/test/components/SubscriptionStatus.test.tsx new file mode 100644 index 000000000..926902480 --- /dev/null +++ b/src/test/components/SubscriptionStatus.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SubscriptionStatus, type SubscriptionStatusProps } from '@/components/features/subscription/SubscriptionStatus'; + +function renderStatus(props: Partial) { + render( + , + ); +} + +const DATE = '2026-11-22T12:00:00.000Z'; + +// Each state's badge, the date beside it, the sentence below, and the one button. +const CASES: Array<{ + name: string; + props: Partial; + badge: string; + date: string | null; + text: RegExp; + button: string; +}> = [ + { name: 'trial', props: { billingState: 'trial', billingDate: DATE, billingDateKind: 'ends' }, badge: 'Free Trial', date: 'Ends on 22/11/2026', text: /AI access is active/, button: 'Upgrade to Pro' }, + { + // QB-02: an override on an expired trial works, so it is not "Expired". + name: 'extended access', + props: { billingState: 'extended_access', dailyLimit: 10 }, + badge: 'Extended access', + date: null, + text: /Extended access is active: 10 AI uses a day\./, + button: 'Upgrade to Pro', + }, + { + name: 'Pro renewing', + props: { billingState: 'pro', billingDate: DATE, billingDateKind: 'renews', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, + badge: 'Pro', + date: 'Renews on 22/11/2026', + text: /AI access is active/, + button: 'Manage Subscription', + }, + { + // QB-15: a plan set to cancel says so. + name: 'Pro set to cancel', + props: { billingState: 'pro_canceling', billingDate: DATE, billingDateKind: 'cancels', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, + badge: 'Pro', + date: 'Cancels on 22/11/2026', + text: /AI access is active/, + button: 'Manage Subscription', + }, + { + // QB-13: the portal, never a second Checkout. + name: 'past due in grace', + props: { billingState: 'payment_failed', billingDate: DATE, billingDateKind: 'grace_ends', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, + badge: 'Payment failed', + date: null, + text: /Payment failed: update your payment method to keep Pro\. Pro access continues until 22\/11\/2026/, + button: 'Manage Subscription', + }, + { + name: 'past due after grace', + props: { billingState: 'payment_failed', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 0 }, + badge: 'Payment failed', + date: null, + text: /update your payment method to restore AI access/, + button: 'Manage Subscription', + }, + { + // QB-14: a former subscriber is not told their free trial ended. + name: 'subscription ended', + props: { billingState: 'subscription_ended', billingDate: DATE, billingDateKind: 'ended', hasStripeBilling: true, dailyLimit: 0 }, + badge: 'Expired', + date: null, + text: /Your Pro subscription ended on 22\/11\/2026\. Resubscribe/, + button: 'Resubscribe', + }, + { name: 'trial ended', props: { billingState: 'trial_ended', dailyLimit: 0 }, badge: 'Expired', date: null, text: /Your access has expired/, button: 'Upgrade to Pro' }, + { + name: 'legacy free', + props: { billingState: 'legacy_free', billingDate: DATE, billingDateKind: 'ends', dailyLimit: 50 }, + badge: 'Legacy Free', + date: 'Ends on 22/11/2026', + text: /AI access is active/, + button: 'Upgrade to Pro', + }, + { + name: 'lifetime promo Pro', + props: { billingState: 'pro', dailyLimit: 50 }, + badge: 'Pro', + date: null, + text: /AI access is active/, + button: 'Set Up Billing', + }, +]; + +describe('SubscriptionStatus', () => { + it.each(CASES)('$name', ({ props, badge, date, text, button }) => { + renderStatus(props); + expect(screen.getByText(badge, { exact: true })).toBeInTheDocument(); + if (date) expect(screen.getByTestId('billing-date')).toHaveTextContent(date); + else expect(screen.queryByTestId('billing-date')).not.toBeInTheDocument(); + expect(screen.getByText(text)).toBeInTheDocument(); + expect(screen.getAllByRole('button').map((element) => element.textContent)).toEqual([button]); + }); +}); diff --git a/src/test/settings/settings-migration.test.ts b/src/test/settings/settings-migration.test.ts index e9bdc3bb8..3b4f99bfd 100644 --- a/src/test/settings/settings-migration.test.ts +++ b/src/test/settings/settings-migration.test.ts @@ -4,6 +4,7 @@ type Row = Record; const db = vi.hoisted(() => ({ row: {} as Row, + subscriptions: [] as Row[], onGetUser: undefined as (() => void) | undefined, })); @@ -38,10 +39,12 @@ vi.mock('@/lib/supabase/client', () => { return { data: { user: { id: 'user-1' } } }; }, }, - from: () => ({ - select: () => ({ single: async () => ({ data: { ...db.row }, error: null as null }) }), - update, - }), + from: (table: string) => table === 'subscriptions' + ? { select: () => ({ eq: async () => ({ data: db.subscriptions, error: null as null }) }) } + : { + select: () => ({ single: async () => ({ data: { ...db.row }, error: null as null }) }), + update, + }, }, }; }); @@ -57,6 +60,7 @@ const savedWithTemperature = Object.fromEntries(features.map((feature) => [ describe('settings hydration cleanup', () => { beforeEach(() => { db.onGetUser = undefined; + db.subscriptions = []; db.row = { user_id: 'user-1', subscription_tier: 'trial', @@ -94,4 +98,26 @@ describe('settings hydration cleanup', () => { const saved = db.row.feature_configs as Record; expect(saved.ask.model).toBe('meta/muse-spark-1.3-contributor'); }); + + it('keeps a past-due payer\'s Pro model choices through the payment grace', async () => { + // The webhook leaves the tier alone on a failed renewal; the AI functions still serve Pro. + const proChoices = Object.fromEntries(features.map((feature) => [ + feature, + { provider: 'openrouter', model: 'anthropic/claude-sonnet-5.5', reasoningEffort: 'medium' }, + ])); + db.row = { ...db.row, subscription_tier: 'pro', feature_configs: proChoices }; + db.subscriptions = [{ + status: 'past_due', + current_period_start: new Date(Date.now() - 2 * 86_400_000).toISOString(), + current_period_end: new Date(Date.now() + 28 * 86_400_000).toISOString(), + }]; + + await useSettingsStore.getState().hydrateFromServer(); + expect((db.row.feature_configs as Record).ask.model).toBe('anthropic/claude-sonnet-5.5'); + + // Without the subscription the same account is not Pro, and the choice is reset. + db.subscriptions = []; + await useSettingsStore.getState().hydrateFromServer(); + expect((db.row.feature_configs as Record).ask.model).not.toBe('anthropic/claude-sonnet-5.5'); + }); }); diff --git a/supabase/functions/_shared/billing-state.test.ts b/supabase/functions/_shared/billing-state.test.ts new file mode 100644 index 000000000..1d892bae7 --- /dev/null +++ b/supabase/functions/_shared/billing-state.test.ts @@ -0,0 +1,136 @@ +// The plan resolver the AI functions and the Settings card share. Each state +// the QA report found shown or refused wrongly (QB-02, QB-13, QB-14, QB-15) +// is pinned here with the rows that produced it. + +import { + type BillingSettingsRow, + type BillingSubscriptionRow, + PAYMENT_GRACE_DAYS, + planDailyLimit, + resolveBillingState, +} from "./billing-state.ts"; + +const NOW = new Date("2026-10-22T12:00:00.000Z"); +const DAY = 24 * 60 * 60 * 1000; +const at = (days: number) => new Date(NOW.getTime() + days * DAY).toISOString(); +const LIMITS = { pro: 50, trial: 5 }; + +function settings(overrides: BillingSettingsRow): BillingSettingsRow { + return { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: null, ...overrides }; +} + +function check( + name: string, + row: BillingSettingsRow | null, + subscriptions: BillingSubscriptionRow[], + expected: { state: string; access: boolean; refusal?: string | null; limit: number; date?: string | null; dateKind?: string | null; open?: boolean }, +) { + Deno.test(name, () => { + const result = resolveBillingState(row, subscriptions, NOW); + const actual = { + state: result.state, + access: result.hasAccess, + refusal: result.refusal, + limit: planDailyLimit(result, row?.daily_limit_override, LIMITS), + date: result.date, + dateKind: result.dateKind, + open: result.hasOpenSubscription, + }; + const wanted = { + state: expected.state, + access: expected.access, + refusal: expected.refusal ?? null, + limit: expected.limit, + date: expected.date ?? null, + dateKind: expected.dateKind ?? null, + open: expected.open ?? false, + }; + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + throw new Error(`expected ${JSON.stringify(wanted)}\n got ${JSON.stringify(actual)}`); + } + }); +} + +check("a running trial", settings({ trial_ends_at: at(2) }), [], { state: "trial", access: true, limit: 5, date: at(2), dateKind: "ends" }); + +check("an expired trial with no override has no access", settings({ trial_ends_at: at(-1) }), [], { + state: "trial_ended", access: false, refusal: "trial_ended", limit: 0, +}); + +check("an expired trial with an override is extended access, not expired (QB-02)", settings({ trial_ends_at: at(-1), daily_limit_override: 5 }), [], { + state: "extended_access", access: true, limit: 5, +}); + +check("the extra_limit promo (an override with no trial) is extended access too", settings({ subscription_tier: "expired", daily_limit_override: 15 }), [], { + state: "extended_access", access: true, limit: 15, +}); + +check("an override of 0 grants nothing", settings({ trial_ends_at: at(-1), daily_limit_override: 0 }), [], { + state: "trial_ended", access: false, refusal: "trial_ended", limit: 0, +}); + +check("Stripe Pro that renews", settings({ subscription_tier: "pro" }), [ + { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: false }, +], { state: "pro", access: true, limit: 50, date: at(20), dateKind: "renews", open: true }); + +check("Stripe Pro set to cancel (QB-15)", settings({ subscription_tier: "pro" }), [ + { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: true }, +], { state: "pro_canceling", access: true, limit: 50, date: at(20), dateKind: "cancels", open: true }); + +check("a past-due payer keeps Pro during the grace (QB-13)", settings({ subscription_tier: "pro", trial_ends_at: at(-40) }), [ + { status: "past_due", current_period_start: at(-3), current_period_end: at(27) }, +], { state: "payment_failed", access: true, limit: 50, date: at(PAYMENT_GRACE_DAYS - 3), dateKind: "grace_ends", open: true }); + +check("the grace also covers rows the old webhook downgraded to expired", settings({ subscription_tier: "expired" }), [ + { status: "past_due", current_period_start: at(-1), current_period_end: at(29) }, +], { state: "payment_failed", access: true, limit: 50, date: at(PAYMENT_GRACE_DAYS - 1), dateKind: "grace_ends", open: true }); + +check("a past-due payer after the grace is refused with payment_failed", settings({ subscription_tier: "pro" }), [ + { status: "past_due", current_period_start: at(-PAYMENT_GRACE_DAYS - 1), current_period_end: at(15) }, +], { state: "payment_failed", access: false, refusal: "payment_failed", limit: 0, open: true }); + +check("the grace counts from the first failure, not a newer period start", settings({ subscription_tier: "pro" }), [ + { status: "past_due", current_period_start: at(-1), current_period_end: at(29), payment_failed_at: at(-PAYMENT_GRACE_DAYS - 1) }, +], { state: "payment_failed", access: false, refusal: "payment_failed", limit: 0, open: true }); + +check("unpaid (Stripe stopped retrying) gets no grace", settings({ subscription_tier: "pro" }), [ + { status: "unpaid", current_period_start: at(-2), current_period_end: at(28) }, +], { state: "payment_failed", access: false, refusal: "payment_failed", limit: 0, open: true }); + +check("a former subscriber is told the subscription ended, with its date (QB-14)", settings({ subscription_tier: "expired", trial_ends_at: at(-60) }), [ + { status: "canceled", current_period_start: at(-40), current_period_end: at(-10), ended_at: at(-10), canceled_at: at(-12) }, +], { state: "subscription_ended", access: false, refusal: "subscription_ended", limit: 0, date: at(-10), dateKind: "ended" }); + +check("promo Pro outlives a canceled subscription (QB-08)", settings({ subscription_tier: "pro", grace_period_ends_at: at(20) }), [ + { status: "canceled", current_period_start: at(-40), current_period_end: at(-10), ended_at: at(-10) }, +], { state: "pro", access: true, limit: 50, date: at(20), dateKind: "ends" }); + +check("a renewing Stripe plan shows its renewal over a promo grant it overlaps", settings({ subscription_tier: "pro", grace_period_ends_at: at(20) }), [ + { status: "active", current_period_start: at(-1), current_period_end: at(29) }, +], { state: "pro", access: true, limit: 50, date: at(29), dateKind: "renews", open: true }); + +check("lifetime promo Pro shows no end date", settings({ subscription_tier: "pro", grace_period_ends_at: "9999-12-31T23:59:59.999Z", daily_limit_override: 50 }), [], { + state: "pro", access: true, limit: 50, +}); + +check("legacy free in its grace period", settings({ subscription_tier: "legacy_free", grace_period_ends_at: at(5) }), [], { + state: "legacy_free", access: true, limit: 50, date: at(5), dateKind: "ends", +}); + +check("legacy free after its grace has no access", settings({ subscription_tier: "legacy_free", grace_period_ends_at: at(-5) }), [], { + state: "trial_ended", access: false, refusal: "trial_ended", limit: 0, +}); + +check("after the payment grace, the payer's own running trial still applies (QB-17)", settings({ subscription_tier: "pro", trial_ends_at: at(1) }), [ + { status: "past_due", current_period_start: at(-PAYMENT_GRACE_DAYS - 1), current_period_end: at(15) }, +], { state: "trial", access: true, limit: 5, date: at(1), dateKind: "ends", open: true }); + +check("a paid Stripe period counts whatever the tier says (old downgrade, gift trial to a payer)", settings({ subscription_tier: "trial", trial_ends_at: at(2) }), [ + { status: "active", current_period_start: at(-10), current_period_end: at(20) }, +], { state: "pro", access: true, limit: 50, date: at(20), dateKind: "renews", open: true }); + +check("a row past due before the failure was stamped keeps its older period start", settings({ subscription_tier: "expired" }), [ + { status: "past_due", current_period_start: at(-PAYMENT_GRACE_DAYS - 1), current_period_end: at(15), payment_failed_at: at(-1) }, +], { state: "payment_failed", access: false, refusal: "payment_failed", limit: 0, open: true }); + +check("a missing settings row fails closed", null, [], { state: "trial_ended", access: false, refusal: "trial_ended", limit: 0 }); diff --git a/supabase/functions/_shared/billing-state.ts b/supabase/functions/_shared/billing-state.ts new file mode 100644 index 000000000..0d3326e77 --- /dev/null +++ b/supabase/functions/_shared/billing-state.ts @@ -0,0 +1,225 @@ +// The one place that decides what plan an account is on. The AI functions +// (through entitlements.ts) and the browser (src/hooks/use-subscription.ts) +// both call resolveBillingState, so the server's refusal and the Settings card +// always describe the same state. No imports: the browser bundles this file. + +/** The subscription_status values Stripe can still bill or retry. */ +export const OPEN_SUBSCRIPTION_STATUSES: ReadonlySet = new Set(["active", "trialing", "past_due", "unpaid", "paused"]); + +/** + * How long a past-due subscription keeps Pro, counted from the first failed + * payment (subscriptions.payment_failed_at, which the webhook sets once and + * clears on recovery). Stripe retries the invoice for about two weeks with its + * default Smart Retries; when the retries run out it marks the subscription + * canceled or unpaid (Dashboard > Billing > Subscriptions and emails > + * "Manage failed payments"), and the webhook moves the account on from there. + * If that setting leaves the subscription past due instead, access still ends + * here: the grace does not restart with each new period. When the period + * start is earlier (a row past due before the column existed) it counts from + * that instead; never from current_period_end, which Stripe has already moved + * a whole period ahead by the time the renewal fails. + */ +export const PAYMENT_GRACE_DAYS = 14; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * - trial: the free trial (or a gift trial) is running. + * - extended_access: no plan, but daily_limit_override grants AI uses. Admins + * set it for expired trials on purpose, and the extra_limit promo writes it + * with no trial at all; both are deliberate grants, not leaks (QB-02). + * - pro / pro_canceling: Stripe Pro (renewing or set to cancel at period end), + * or promo Pro. + * - payment_failed: a Stripe subscription is past due or unpaid. Pro access + * continues for PAYMENT_GRACE_DAYS while Stripe retries (`hasAccess`). + * - legacy_free: pre-billing accounts in their grace period. + * - trial_ended / subscription_ended: no access. subscription_ended means the + * account once paid through Stripe, so it is told to resubscribe rather than + * that a free trial ended. + */ +export type BillingStateName = + | "trial" + | "extended_access" + | "pro" + | "pro_canceling" + | "payment_failed" + | "legacy_free" + | "trial_ended" + | "subscription_ended"; + +/** Why the AI functions refuse an account with no access. The client localizes each. */ +export type AccessRefusalCode = "trial_ended" | "payment_failed" | "subscription_ended"; + +/** The daily-limit default and model access a state gets before overrides. */ +export type BillingPlan = "pro" | "trial" | "none"; + +export interface BillingSettingsRow { + subscription_tier?: string | null; + daily_limit_override?: number | null; + trial_ends_at?: string | null; + grace_period_ends_at?: string | null; +} + +export interface BillingSubscriptionRow { + status?: string | null; + current_period_start?: string | null; + current_period_end?: string | null; + cancel_at_period_end?: boolean | null; + ended_at?: string | null; + canceled_at?: string | null; + payment_failed_at?: string | null; +} + +/** + * - ends: a trial, a promo Pro grant or a legacy grace period ends then. + * - renews / cancels: a Stripe Pro period ends then and renews, or does not. + * - grace_ends: a past-due payer keeps Pro until then. + * - ended: the Stripe subscription ended then. + */ +export type BillingDateKind = "ends" | "renews" | "cancels" | "grace_ends" | "ended"; + +export interface BillingState { + state: BillingStateName; + plan: BillingPlan; + /** True when the account may use AI today (before quota). */ + hasAccess: boolean; + /** Set when hasAccess is false: the code the AI functions refuse with. */ + refusal: AccessRefusalCode | null; + /** The settings tier the old code reported: the client's tier mirror and checkout confirmation read it. */ + tier: "trial" | "pro" | "legacy_free" | "expired"; + /** The date the Settings card shows, and what it means (null for lifetime promo Pro). */ + date: string | null; + dateKind: BillingDateKind | null; + /** A Stripe subscription can still bill: offer Manage subscription, never a second Checkout. */ + hasOpenSubscription: boolean; +} + +// Lifetime promo codes store 9999-12-31; there is no end date to show. +const LIFETIME_AFTER = Date.UTC(9000, 0, 1); + +function time(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function latest(rows: BillingSubscriptionRow[], key: (row: BillingSubscriptionRow) => number | null): BillingSubscriptionRow | null { + let best: BillingSubscriptionRow | null = null; + let bestTime = -Infinity; + for (const row of rows) { + const value = key(row) ?? -Infinity; + if (best === null || value > bestTime) { + best = row; + bestTime = value; + } + } + return best; +} + +/** When a past-due subscription stops granting Pro. */ +export function paymentGraceEndsAt(row: BillingSubscriptionRow): number | null { + // The earlier of the two: a row that was already past due before the + // webhook stamped payment_failed_at keeps its older period start. + const starts = [time(row.payment_failed_at), time(row.current_period_start)].filter((value): value is number => value !== null); + return starts.length ? Math.min(...starts) + PAYMENT_GRACE_DAYS * DAY_MS : null; +} + +/** + * Resolves an account's plan from its user_settings row and its Stripe + * subscription rows. First match wins: + * 1. Stripe Pro (an active or trialing period not yet over, whatever the tier); + * 2. promo Pro (tier pro, grace_period_ends_at in the future); + * 3. legacy_free in its grace period; + * 4. a past-due or unpaid subscription inside the payment grace (Pro); + * 5. a running free or gift trial (trial_ends_at in the future); + * 6. extended access (daily_limit_override above 0); + * 7. no access: payment_failed while a subscription is still past due or + * unpaid, subscription_ended when one has ended, otherwise trial_ended. + * + * Trial fallback after a failed payment (QB-17): the webhook leaves + * trial_ends_at alone, so someone who subscribed during their free trial and + * then failed a payment keeps Pro through the grace (4 comes before 5). If the + * grace ends while their own free trial is still running, they fall back to + * that trial's limits until it ends: the trial was theirs before they paid, + * and a failed payment does not take it away. + */ +export function resolveBillingState( + settings: BillingSettingsRow | null, + subscriptions: BillingSubscriptionRow[] | null | undefined, + now: Date = new Date(), +): BillingState { + const rows = subscriptions ?? []; + const nowMs = now.getTime(); + const hasOpenSubscription = rows.some((row) => typeof row.status === "string" && OPEN_SUBSCRIPTION_STATUSES.has(row.status)); + const result = ( + state: BillingStateName, + plan: BillingPlan, + tier: BillingState["tier"], + dated: [BillingDateKind, string | null | undefined] | null, + refusal: AccessRefusalCode | null = null, + ): BillingState => ({ + state, + plan, + hasAccess: refusal === null, + refusal, + tier, + date: dated?.[1] ?? null, + dateKind: dated?.[1] ? dated[0] : null, + hasOpenSubscription, + }); + + // A settings row exists for every account (on_auth_user_created). A missing + // one is untrusted, so it gets no access instead of a fresh trial. + if (!settings) return result("trial_ended", "none", "expired", null, "trial_ended"); + + const tier = settings.subscription_tier || "trial"; + const promoEnd = time(settings.grace_period_ends_at); + const trialEnd = time(settings.trial_ends_at); + const override = settings.daily_limit_override; + + // Stripe Pro counts whatever the tier says, and comes before a promo grant + // (QB-08 keeps both) so the card shows the renewal date the payer is billed on. Only the webhook (service role) + // writes subscriptions rows, while the tier can lag it: an invoice paid + // before its subscription.updated event arrives, an account the old webhook + // downgraded on a failed payment, or an admin gift trial given to a payer. + { + const current = latest( + rows.filter((row) => (row.status === "active" || row.status === "trialing") && (time(row.current_period_end) ?? 0) > nowMs), + (row) => time(row.current_period_end), + ); + if (current) { + return current.cancel_at_period_end + ? result("pro_canceling", "pro", "pro", ["cancels", current.current_period_end]) + : result("pro", "pro", "pro", ["renews", current.current_period_end]); + } + } + + if (tier === "pro" && promoEnd !== null && nowMs < promoEnd) { + return result("pro", "pro", "pro", promoEnd >= LIFETIME_AFTER ? null : ["ends", settings.grace_period_ends_at]); + } + if (tier === "legacy_free" && promoEnd !== null && nowMs < promoEnd) { + return result("legacy_free", "pro", "legacy_free", ["ends", settings.grace_period_ends_at]); + } + + const failing = latest(rows.filter((row) => row.status === "past_due" || row.status === "unpaid"), paymentGraceEndsAt); + const graceEnd = failing ? paymentGraceEndsAt(failing) : null; + if (failing?.status === "past_due" && graceEnd !== null && nowMs < graceEnd) { + return result("payment_failed", "pro", "pro", ["grace_ends", new Date(graceEnd).toISOString()]); + } + + if (trialEnd !== null && nowMs < trialEnd) return result("trial", "trial", "trial", ["ends", settings.trial_ends_at]); + + if (typeof override === "number" && override > 0) return result("extended_access", "none", "expired", null); + + if (failing) return result("payment_failed", "none", "expired", null, "payment_failed"); + const ended = latest(rows.filter((row) => row.status === "canceled"), (row) => time(row.ended_at ?? row.canceled_at ?? row.current_period_end)); + if (ended) { + return result("subscription_ended", "none", "expired", ["ended", ended.ended_at ?? ended.canceled_at ?? ended.current_period_end], "subscription_ended"); + } + return result("trial_ended", "none", "expired", null, "trial_ended"); +} + +/** The daily AI limit before rate_limits rows: the override, else the plan's default. */ +export function planDailyLimit(state: Pick, override: number | null | undefined, defaults: { pro: number; trial: number }): number { + if (typeof override === "number") return override; + return state.plan === "pro" ? defaults.pro : state.plan === "trial" ? defaults.trial : 0; +} diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index 6b051488c..65572d7ea 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -274,20 +274,20 @@ Deno.test("ElevenLabs cost fails closed for an unlisted model, missing usage, or if (calculateRequestCost({ provider: "elevenlabs", model: "eleven_v4", usage: { incomplete: true } }).costUsd !== null) throw new Error("incomplete work was priced"); }); -Deno.test("the weekly cap counts OpenRouter credit spend, not BYOK provider charges", () => { - // A BYOK Claude call: OpenRouter charges only its fee (0 here); the provider bills our own key. - const byok = normalizeOpenRouterUsage({ prompt_tokens: 1500, completion_tokens: 600, cost: 0, is_byok: true }); - // A Claude call that fell back to OpenRouter credits counts in full. +Deno.test("the weekly cap counts the full charge, BYOK provider charges included", () => { + // QB-17: a BYOK Claude call costs us the provider's charge even when OpenRouter's fee is 0. + const byok = normalizeOpenRouterUsage({ ...BYOK_USAGE, cost: 0 }); const credits = normalizeOpenRouterUsage({ prompt_tokens: 1500, completion_tokens: 600, cost: 0.012, is_byok: false }); const gpt = normalizeOpenRouterUsage({ prompt_tokens: 1000, completion_tokens: 500, cost: 0.004 }); - if (calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-opus-5.5", usage: byok }).capCostUsd !== 0) { - throw new Error("a BYOK call counted toward the cap"); + const byokCost = calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-opus-5.5", usage: byok }); + if (byokCost.capCostUsd !== byokCost.costUsd || byokCost.costUsd !== 0.0004882) { + throw new Error(`a BYOK call counted ${byokCost.capCostUsd} toward the cap but cost ${byokCost.costUsd}`); } if (calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-opus-5.5", usage: credits }).capCostUsd !== 0.012) { throw new Error("a credit-billed Claude call did not count"); } const merged = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-6-luna", usage: mergeNormalizedUsage([gpt, byok]) }); - if (Math.abs((merged.capCostUsd ?? -1) - 0.004) > 1e-12) throw new Error(`merged cap spend wrong: ${merged.capCostUsd}`); + if (Math.abs((merged.capCostUsd ?? -1) - 0.0044882) > 1e-12) throw new Error(`merged cap spend wrong: ${merged.capCostUsd}`); }); Deno.test("a usage with a separate provider charge counts it toward the cap in full", () => { @@ -319,11 +319,17 @@ Deno.test("ElevenLabs voice cost counts toward the cap", () => { if (result.capCostUsd !== result.costUsd || result.costUsd === null) throw new Error("voice cost missing from the cap"); }); -Deno.test("a BYOK call logs the provider charge but counts only OpenRouter's fee toward the cap", () => { +Deno.test("a BYOK call counts no less than its logged cost toward the cap", () => { const byok = normalizeOpenRouterUsage({ ...BYOK_USAGE, cost: 0.0001 }); const result = calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-sonnet-5.5", usage: byok }); if (Math.abs((result.costUsd ?? -1) - 0.0005882) > 1e-12) throw new Error(`logged cost wrong: ${result.costUsd}`); - if (result.capCostUsd !== 0.0001) throw new Error(`cap counted the BYOK provider charge: ${result.capCostUsd}`); + if (result.capCostUsd !== result.costUsd) throw new Error(`cap ${result.capCostUsd} is below the logged cost ${result.costUsd}`); + // Without the upstream figure the cost is unknown; the cap still counts the fee it does know. + const { cost_details: _details, ...withoutUpstream } = BYOK_USAGE; + const unknownUpstream = normalizeOpenRouterUsage({ ...withoutUpstream, cost: 0.0001 }); + if (unknownUpstream.reportedCostUsd !== undefined || unknownUpstream.capCostUsd !== 0.0001) { + throw new Error("a BYOK call without the upstream charge did not fall back to the fee"); + } // A BYOK usage with no fee figure stays unknown for both, never the provider charge alone. const noFee = normalizeOpenRouterUsage({ ...BYOK_USAGE, cost: undefined }); if (noFee.reportedCostUsd !== undefined || noFee.capCostUsd !== undefined) throw new Error("a BYOK call without a fee got a cost"); diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index 5c709fbe9..c1725f431 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -39,11 +39,11 @@ export interface NormalizedUsage { /** OpenRouter's authoritative account charge, in USD. */ reportedCostUsd?: number; /** - * The part of reportedCostUsd that counts toward the weekly spend cap. For - * OpenRouter it is `usage.cost`, what OpenRouter took from our credits; a - * BYOK call billed to our own provider key adds only OpenRouter's fee. - * Undefined means "same as reportedCostUsd" (Exa, ElevenLabs, merged calls - * that did not set it). + * What counts toward the weekly spend cap. For OpenRouter it is the full + * charge (reportedCostUsd), BYOK provider charges included; only when a BYOK + * response lacks the upstream figure does it fall back to OpenRouter's fee, + * the known lower bound. Undefined means "same as reportedCostUsd" (Exa, + * ElevenLabs, merged calls that did not set it). */ capCostUsd?: number; /** Characters ElevenLabs billed for a speech request. */ @@ -107,10 +107,11 @@ export function normalizeOpenRouterUsage(usage: Record): Normal // The logged charge: for a BYOK call, OpenRouter's fee plus what the // provider billed our key (calculator v2). reportedCostUsd: openRouterCostOrUndefined(usage), - // usage.cost is OpenRouter's credit charge. For a BYOK call it is only the - // OpenRouter fee, so Claude on our own Anthropic key stays out of the cap, - // and a call that fell back to OpenRouter credits counts in full. - capCostUsd: reportedCostOrUndefined(usage.cost), + // The weekly cap counts the same charge, so it is never lower than the + // logged cost (QB-17: counting only OpenRouter's fee left Claude's BYOK + // provider charge out, so a call logged at $0.037-0.042 added $0.033). + // A BYOK response without the upstream figure counts the fee alone. + capCostUsd: openRouterCostOrUndefined(usage) ?? reportedCostOrUndefined(usage.cost), raw: usage, }; } @@ -282,8 +283,9 @@ export function chargedAgainstCap(usage?: NormalizedUsage): boolean { /** * Records a refunded request as adding nothing to the weekly spend cap. Only - * for usage that chargedAgainstCap() cleared: a request OpenRouter charged - * credits for is never refunded, so no real spend is hidden from the cap. + * for usage that chargedAgainstCap() cleared: a request with any known charge + * (OpenRouter credits or our BYOK provider key) keeps it, so no real spend is + * hidden from the cap. */ export function excludeFromCap(usage?: NormalizedUsage): NormalizedUsage { return { ...usage, capCostUsd: 0 }; diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index 38504e68a..935b09fc6 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -20,7 +20,7 @@ const past = () => new Date(Date.now() - DAY).toISOString(); interface Routes { user_settings?: unknown; user_roles?: unknown; - subscriptions?: unknown; + subscriptions?: unknown | ((url: URL) => Response); rate_limits?: unknown | ((url: URL) => Response); quota?: unknown; onQuota?: (body: Record) => void; @@ -33,7 +33,9 @@ async function withSupabase(routes: Routes, run: () => Promise): Promise { }); }); -Deno.test("anonymous callers bypass the rate limit", async () => { - const result = await checkRateLimit(null); - assertEquals(result.allowed, true, "allowed"); +Deno.test("a request without a user is refused, not waved through (SC-03)", async () => { + let quotaConsumed = false; + await withSupabase({ + get quota() { quotaConsumed = true; return [{ allowed: true, current_count: 1 }]; }, + }, async () => { + const result = await checkRateLimit(null); + assertEquals(result.allowed, false, "allowed"); + assertEquals(result.code, "unauthenticated", "code"); + assertEquals(result.statusCode, 401, "statusCode"); + assertEquals(quotaConsumed, false, "quota must not be consumed without a user"); + }); }); Deno.test("an exhausted quota reports the limit-reached code", async () => { @@ -144,17 +154,102 @@ Deno.test("an exhausted quota reports the limit-reached code", async () => { }); }); -Deno.test("expired access is refused with the trial-expired code", async () => { +const DAY_MS = 24 * 60 * 60 * 1000; +const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS).toISOString(); + +// Each account without access is refused with a code naming why (QB-13, QB-14). +for (const [name, routes, code] of [ + ["an expired trial", { user_settings: { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: past(), grace_period_ends_at: null } }, "trial_ended"], + ["a past-due payer after the grace", { + user_settings: { subscription_tier: "pro", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: null }, + subscriptions: [{ status: "past_due", current_period_start: daysFromNow(-20), current_period_end: daysFromNow(10) }], + }, "payment_failed"], + ["a former subscriber", { + user_settings: { subscription_tier: "expired", daily_limit_override: null, trial_ends_at: daysFromNow(-60), grace_period_ends_at: null }, + subscriptions: [{ status: "canceled", current_period_start: daysFromNow(-40), current_period_end: daysFromNow(-10), ended_at: daysFromNow(-10) }], + }, "subscription_ended"], +] as const) { + Deno.test(`${name} is refused with ${code}`, async () => { + let quotaConsumed = false; + await withSupabase({ + ...routes, + get quota() { quotaConsumed = true; return [{ allowed: true, current_count: 1 }]; }, + }, async () => { + const result = await checkRateLimit("user-1"); + assertEquals(result.allowed, false, "allowed"); + assertEquals(result.code, code, "code"); + assertEquals(result.statusCode, 402, "statusCode"); + assertEquals(quotaConsumed, false, "quota must not be consumed"); + }); + }); +} + +Deno.test("an override on an expired trial keeps working as extended access (QB-02)", async () => { + // Adam's decision: daily_limit_override on an expired trial is a deliberate grant. + let reserved: Record | undefined; await withSupabase({ - user_settings: { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: past(), grace_period_ends_at: null }, + user_settings: { subscription_tier: "trial", daily_limit_override: 5, trial_ends_at: past(), grace_period_ends_at: null }, + onQuota: (body) => { reserved = body; }, }, async () => { + const info = await getSubscriptionInfo("user-1"); + assertEquals(info.state, "extended_access", "state"); + assertEquals(info.refusal, null, "refusal"); const result = await checkRateLimit("user-1"); - assertEquals(result.allowed, false, "allowed"); - assertEquals(result.code, "trial_expired", "code"); - assertEquals(result.statusCode, 402, "statusCode"); + assertEquals(result.allowed, true, "allowed"); + assertEquals(result.canAccessProModels, false, "canAccessProModels"); + assertEquals(reserved?.p_limit, 5, "limit reserved against"); + }); +}); + +Deno.test("a past-due payer keeps Pro through the payment grace (QB-13)", async () => { + await withSupabase({ + // The tier the old webhook wrote on the first failed invoice. + user_settings: { subscription_tier: "expired", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: null }, + subscriptions: [{ status: "past_due", current_period_start: daysFromNow(-2), current_period_end: daysFromNow(28) }], + }, async () => { + const info = await getSubscriptionInfo("user-1"); + assertEquals(info.state, "payment_failed", "state"); + assertEquals(info.dailyLimit, 50, "dailyLimit"); + assertEquals(info.canAccessProModels, true, "canAccessProModels"); + assertEquals((await checkRateLimit("user-1", "anthropic/claude-sonnet-5.5")).allowed, true, "allowed"); }); }); +Deno.test("a failed subscriptions read still serves access the settings row proves", async () => { + const originalWarn = console.warn; + console.warn = () => {}; + try { + await withSupabase({ + user_settings: { subscription_tier: "trial", daily_limit_override: null, trial_ends_at: future(), grace_period_ends_at: null }, + subscriptions: () => json({ message: "boom", code: "XX000" }, 500), + }, async () => { + assertEquals((await checkRateLimit("user-1")).allowed, true, "allowed"); + }); + } finally { + console.warn = originalWarn; + } +}); + +Deno.test("a failed subscriptions read fails closed", async () => { + const originalError = console.error; + const originalWarn = console.warn; + console.error = () => {}; + console.warn = () => {}; + try { + await withSupabase({ + user_settings: { subscription_tier: "pro", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: null }, + subscriptions: () => json({ message: "boom", code: "XX000" }, 500), + }, async () => { + const result = await checkRateLimit("user-1"); + assertEquals(result.allowed, false, "allowed"); + assertEquals(result.statusCode, 500, "statusCode"); + }); + } finally { + console.error = originalError; + console.warn = originalWarn; + } +}); + const proUser = { subscription_tier: "pro", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: future() }; Deno.test("an expensive model reserves its weighted units and the weekly cap", async () => { diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index e486e2a59..e70ba8b22 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -11,6 +11,7 @@ import { type ModelSelection, } from "./model-catalog.ts"; import { WEEKLY_AI_BUDGET_USD, PRO_DAILY_LIMIT, quotaUnitsFor, SESSION_QUOTA, TRIAL_DAILY_LIMIT } from "./usage-limits.ts"; +import { type AccessRefusalCode, type BillingStateName, planDailyLimit, resolveBillingState } from "./billing-state.ts"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, @@ -23,7 +24,12 @@ export interface RateLimitResult { reason?: string; limit?: number; current?: number; - code?: "limit_reached" | "weekly_budget_reached" | "trial_expired" | "banned" | "upgrade_required"; + /** + * trial_ended, payment_failed and subscription_ended replace the single + * trial_expired code older builds sent for every account without access. + * The client still accepts trial_expired from functions deployed before them. + */ + code?: "limit_reached" | "weekly_budget_reached" | AccessRefusalCode | "banned" | "upgrade_required" | "unauthenticated"; /** Quota units this request needed; more than 1 for expensive models. */ units?: number; statusCode?: number; @@ -54,6 +60,10 @@ export function parseQuotaSessionKey(value: unknown): string | null { export interface SubscriptionInfo { tier: string; // 'trial' | 'pro' | 'legacy_free' | 'expired' + /** The resolved state the Settings card shows too; see billing-state.ts. */ + state: BillingStateName; + /** Why an account without access is refused; null while it has access. */ + refusal: AccessRefusalCode | null; dailyLimit: number; canAccessProModels: boolean; trialEndsAt: string | null; @@ -82,71 +92,36 @@ export async function getSubscriptionInfo(userId: string): Promise user.daily_limit_override ?? defaultLimit; - const now = new Date(); - const expired = { tier: "expired", dailyLimit: limitOrDefault(0), canAccessProModels: false, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - const activeGiftTrial = user.trial_ends_at ? new Date(user.trial_ends_at) : null; - const giftTrialInfo = activeGiftTrial && now < activeGiftTrial - ? { tier: "trial", dailyLimit: limitOrDefault(TRIAL_DAILY_LIMIT), canAccessProModels: false, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at } - : null; - - // Legacy free users in grace period get pro-level access - if (tier === "legacy_free") { - const graceEnd = user.grace_period_ends_at ? new Date(user.grace_period_ends_at) : null; - if (graceEnd && now < graceEnd) { - return { tier: "legacy_free", dailyLimit: limitOrDefault(PRO_DAILY_LIMIT), canAccessProModels: true, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - if (giftTrialInfo) return giftTrialInfo; - // Grace period expired — treat as expired - return expired; - } - - // Pro subscribers or promo Pro access. Stripe Pro must have an active - // subscription period; promo Pro uses grace_period_ends_at, with lifetime - // promo codes represented by a far-future date. - if (tier === "pro") { - const promoEnd = user.grace_period_ends_at ? new Date(user.grace_period_ends_at) : null; - if (promoEnd && now < promoEnd) { - return { tier: "pro", dailyLimit: limitOrDefault(PRO_DAILY_LIMIT), canAccessProModels: true, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - - const { data: subscriptions } = await supabase - .from("subscriptions") - .select("status, current_period_end") - .eq("user_id", userId) - .in("status", ["active", "trialing"]) - .order("current_period_end", { ascending: false }) - .limit(1); - - const activeSubscription = subscriptions?.find((sub) => - sub.current_period_end && new Date(sub.current_period_end) > now - ); - - if (activeSubscription) { - return { tier: "pro", dailyLimit: limitOrDefault(PRO_DAILY_LIMIT), canAccessProModels: true, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - - if (giftTrialInfo) return giftTrialInfo; - return expired; - } - - // Trial users - if (tier === "trial") { - const trialEnd = user.trial_ends_at ? new Date(user.trial_ends_at) : null; - if (trialEnd && now < trialEnd) { - return { tier: "trial", dailyLimit: limitOrDefault(TRIAL_DAILY_LIMIT), canAccessProModels: false, trialEndsAt: user.trial_ends_at, gracePeriodEndsAt: user.grace_period_ends_at }; - } - // Trial expired - return expired; + if (!user) return { tier: "expired", dailyLimit: 0, state: "trial_ended", refusal: "trial_ended", canAccessProModels: false, trialEndsAt: null, gracePeriodEndsAt: null }; + + // Every state but promo Pro needs the Stripe rows: a past-due payer keeps + // Pro through the payment grace, and a former subscriber is refused with + // subscription_ended rather than told their trial ended. + // `*` so a function deployed before 20261023020000 (payment_failed_at) reads + // the columns that exist instead of failing every request. + const { data: subscriptions, error } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", userId) + .order("current_period_end", { ascending: false }) + .limit(10); + // Without the rows, settings alone can still prove access (promo Pro, a + // trial, an override). An account they cannot prove is not told its trial + // ended when it may be paying: the check fails and the caller answers 500. + const billing = resolveBillingState(user, error ? [] : subscriptions); + if (error) { + console.warn("[ENTITLEMENTS] subscriptions lookup failed:", error.message); + if (!billing.hasAccess) throw new Error(`Subscription lookup failed: ${error.message}`); } - - if (giftTrialInfo) return giftTrialInfo; - - // Expired - return expired; + return { + tier: billing.tier, + state: billing.state, + refusal: billing.refusal, + dailyLimit: planDailyLimit(billing, user.daily_limit_override, { pro: PRO_DAILY_LIMIT, trial: TRIAL_DAILY_LIMIT }), + canAccessProModels: billing.plan === "pro", + trialEndsAt: user.trial_ends_at, + gracePeriodEndsAt: user.grace_period_ends_at, + }; } async function readRateLimit(userId: string | null, limitType: string): Promise { @@ -180,6 +155,13 @@ export async function getConfiguredDailyLimit(userId: string, fallbackLimit: num return fallbackLimit; } +// Logged and returned for API callers; the browser shows its own translation of the code. +const ACCESS_REFUSAL_REASONS: Record = { + trial_ended: "Your free trial has ended. Upgrade to Pro to continue using DebateLab.", + payment_failed: "Your last payment failed. Update your payment method to keep using DebateLab.", + subscription_ended: "Your Pro subscription has ended. Resubscribe to continue using DebateLab.", +}; + type AccessCheck = | { denied: RateLimitResult } | { denied?: undefined; tier: string; dailyLimit: number; canAccessProModels: boolean }; @@ -191,14 +173,13 @@ async function checkAccess(userId: string, model?: ModelSelection): Promise { - if (!userId) return { allowed: true }; // Anonymous users not rate limited (for now) + // Every caller requires a signed-in user first. Without one there is no + // account to check or charge, so refuse rather than wave the request through. + if (!userId) return { allowed: false, reason: "Sign in to use AI tools.", code: "unauthenticated", statusCode: 401 }; try { const access = await checkAccess(userId, model); diff --git a/supabase/functions/_shared/usage-limits.ts b/supabase/functions/_shared/usage-limits.ts index 1da4dd81d..703dd195d 100644 --- a/supabase/functions/_shared/usage-limits.ts +++ b/supabase/functions/_shared/usage-limits.ts @@ -24,10 +24,9 @@ export const MODEL_QUOTA_UNITS: Readonly> = { /** * Weekly cap on a user's AI spend, in USD, per calendar week starting Monday - * 00:00 UTC. Only ai_requests.cap_cost_usd counts: OpenRouter credit charges - * plus Exa and ElevenLabs. Calls billed to our own BYOK key (Claude on the - * Anthropic Max plan) add only OpenRouter's fee, usually 0. Set to null to - * turn the cap off. + * 00:00 UTC. Only ai_requests.cap_cost_usd counts: the full logged cost of + * each request, including Claude calls billed to our own BYOK Anthropic key, + * plus Exa and ElevenLabs. Set to null to turn the cap off. */ export const WEEKLY_AI_BUDGET_USD: number | null = 2; diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index e24c80e45..1538d3d9c 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -71,6 +71,7 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a grace_period_ends_at: null, }), { headers: { "Content-Type": "application/json" } }); } + if (url.pathname.includes("/rest/v1/subscriptions")) return new Response("[]", { headers: { "Content-Type": "application/json" } }); if (url.pathname.includes("/rest/v1/rate_limits")) { if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return new Response(JSON.stringify({ limit_value: 50 }), { diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index f0b37b892..2388ec880 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -413,8 +413,8 @@ export async function handleRequest(req: Request): Promise { const answerFinished = (streamSawDone || streamSawFinishReason) && streamFinishReason !== "length"; const refunded = !answerFinished && accumulated.length < REFUND_MAX_DELIVERED_CHARS && await refundQuotaReservation(user.id, rateLimit); - // The weekly cap counts real spend, so a refunded run's OpenRouter - // cost still counts. Only a Claude refusal that cost nothing is logged at 0. + // The weekly cap counts real spend, so a refunded run's cost (BYOK + // provider charges included) still counts. Only a Claude refusal that cost nothing is logged at 0. const freeRefusal = claudeUnavailable && !err.afterOutput && !accumulated && !chargedAgainstCap(observedUsage); const notCharged = refunded ? { charged: false } : {}; if (claudeUnavailable) { diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index 0c5e5fae5..2d1d896fc 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -95,6 +95,7 @@ async function runScenario(scenario: Scenario) { grace_period_ends_at: null, }); } + if (url.pathname.includes("/rest/v1/subscriptions")) return Response.json([]); if (url.pathname.includes("/rest/v1/rate_limits")) { if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return jsonResponse({ limit_value: 50 }); diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index 3a00a56bf..00ba03609 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_test.ts @@ -77,6 +77,7 @@ Deno.test("generate marks every contention when research has no citable sources" grace_period_ends_at: null, }); } + if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); if (url.pathname.includes("/rest/v1/rate_limits")) { if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return jsonResponse({ limit_value: 50 }); diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index f0773f52e..0b134df2e 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -198,6 +198,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac grace_period_ends_at: null, }); } + if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); if (url.pathname.includes("/rest/v1/rate_limits")) { if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return jsonResponse({ limit_value: 50 }); @@ -339,6 +340,7 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn grace_period_ends_at: null, }); } + if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); if (url.pathname.includes("/rest/v1/rate_limits")) { if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return jsonResponse({ limit_value: 50 }); @@ -476,13 +478,17 @@ function supabaseTestResponse(url: URL): Response | undefined { if (url.origin !== "https://supabase.test") return undefined; if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); + const tier = (globalThis as typeof globalThis & { __testTier?: string }).__testTier || "trial"; if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ - subscription_tier: (globalThis as typeof globalThis & { __testTier?: string }).__testTier || "trial", + subscription_tier: tier, daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null, }); - if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }]); + // A paid Stripe period means Pro whatever the tier, so only the Pro account has one. + if (url.pathname.includes("/rest/v1/subscriptions")) { + return jsonResponse(tier === "pro" ? [{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }] : []); + } if (url.pathname.includes("/rest/v1/rate_limits")) { if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return jsonResponse({ limit_value: 50 }); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index ecf4b3077..f79a0d58d 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -898,8 +898,8 @@ export async function handleRequest(req: Request): Promise { const refundable = !outputSaved && !answerFinished && deliveredChars < REFUND_MAX_DELIVERED_CHARS; // Refund before answering, so a retry sent straight away sees it. const refunded = refundable && await refundQuotaReservation(userId, rateLimit); - // The weekly cap counts real spend, so a refunded run's OpenRouter cost - // still counts. Only a Claude refusal that cost nothing is logged at 0. + // The weekly cap counts real spend, so a refunded run's cost (BYOK + // provider charges included) still counts. Only a Claude refusal that cost nothing is logged at 0. const freeRefusal = claudeUnavailable && !aborted && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && !researchDidWork && !researchAccountingIncomplete && !chargedAgainstCap(errorUsage); await logUsage( diff --git a/supabase/functions/create-checkout/checkout-security.ts b/supabase/functions/create-checkout/checkout-security.ts index 9c8028bfb..9e3a4a1f3 100644 --- a/supabase/functions/create-checkout/checkout-security.ts +++ b/supabase/functions/create-checkout/checkout-security.ts @@ -1,3 +1,5 @@ +import { OPEN_SUBSCRIPTION_STATUSES } from "../_shared/billing-state.ts"; + type StripeCustomerLike = { id: string; // The npm Stripe types mark a live customer's `deleted` as `void`, and a @@ -26,7 +28,6 @@ export function isAllowedCheckoutRedirectUrl(value: string): boolean { } } -const OPEN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid", "paused"]); /** True when this user already has a Stripe subscription that can still bill them. */ export function hasOpenSubscription(rows: Array<{ status?: string | null }> | null | undefined): boolean { @@ -39,3 +40,21 @@ export function findCustomerOwnedByUser( ): string | null { return customers.find((customer) => customer.deleted !== true && customer.metadata?.user_id === userId)?.id ?? null; } + +export interface DiscountPromo { + expires_at?: string | null; + max_redemptions?: number | null; + redemptions_count?: number | null; +} + +/** + * Why a discount promo cannot be used at Checkout, or null when it can. The + * same checks redeem_promo_code_atomic applies to every other promo type: + * the code's expiry and its redemption cap (QB-08). + */ +export function discountPromoRefusal(promo: DiscountPromo, now: Date = new Date()): "expired" | "exhausted" | null { + const expires = promo.expires_at ? Date.parse(promo.expires_at) : Number.NaN; + if (!Number.isNaN(expires) && expires < now.getTime()) return "expired"; + if (typeof promo.max_redemptions === "number" && (promo.redemptions_count ?? 0) >= promo.max_redemptions) return "exhausted"; + return null; +} diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index da2abbe85..6922a8a07 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -6,6 +6,7 @@ import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { createEdgeStripe, Stripe } from "../_shared/stripe.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { + discountPromoRefusal, findCustomerOwnedByUser, hasOpenSubscription, isAllowedCheckoutRedirectUrl, @@ -271,6 +272,52 @@ export async function handleRequest(req: Request): Promise { }); } + // Check a promo code before any Stripe customer is created for this attempt. + let allowPromotionCodes = false; + if (promoCode) { + const { data: promo, error: promoLookupError } = await supabase.from("promo_codes") + .select("*") + .eq("code", promoCode.toUpperCase()) + .eq("is_active", true) + .maybeSingle(); + + if (promoLookupError) { + console.warn(`[CHECKOUT] Promo lookup failed`); + } + + if (promo && promo.type === "discount") { + const refusal = discountPromoRefusal(promo); + if (refusal) { + return new Response(JSON.stringify({ + error: refusal === "expired" ? "This promo code has expired" : "This promo code has reached its usage limit", + code: refusal === "expired" ? "promo_expired" : "promo_exhausted", + }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { data: existingRedemption, error: redemptionLookupError } = await supabase.from("user_promos") + .select("id") + .eq("user_id", user.id) + .eq("promo_code_id", promo.id) + .maybeSingle(); + + if (redemptionLookupError) { + console.warn(`[CHECKOUT] Promo redemption lookup failed`); + } + + if (existingRedemption) { + return new Response(JSON.stringify({ error: "You've already redeemed this promo code" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + allowPromotionCodes = true; + } + } + // Get or create Stripe customer let customerId = await findCustomerIdByUser({ id: user.id, email: user.email }); if (!customerId) { @@ -309,42 +356,9 @@ export async function handleRequest(req: Request): Promise { subscription_data: { metadata: { user_id: user.id }, }, + ...(allowPromotionCodes && { allow_promotion_codes: true }), }; - // Apply promo code if provided - if (promoCode) { - const { data: promo, error: promoLookupError } = await supabase.from("promo_codes") - .select("*") - .eq("code", promoCode.toUpperCase()) - .eq("is_active", true) - .maybeSingle(); - - if (promoLookupError) { - console.warn(`[CHECKOUT] Promo lookup failed`); - } - - if (promo && promo.type === "discount") { - const { data: existingRedemption, error: redemptionLookupError } = await supabase.from("user_promos") - .select("id") - .eq("user_id", user.id) - .eq("promo_code_id", promo.id) - .maybeSingle(); - - if (redemptionLookupError) { - console.warn(`[CHECKOUT] Promo redemption lookup failed`); - } - - if (existingRedemption) { - return new Response(JSON.stringify({ error: "You've already redeemed this promo code" }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - sessionParams.allow_promotion_codes = true; - } - } - const session = await stripe.checkout.sessions.create(sessionParams); console.log(`[CHECKOUT] Session created for ${user.id}: ${session.id} (price=${resolvedPrice.id}, source=${resolvedPrice.source})`); diff --git a/supabase/functions/stripe-webhook/handlers.ts b/supabase/functions/stripe-webhook/handlers.ts new file mode 100644 index 000000000..5c5f378f5 --- /dev/null +++ b/supabase/functions/stripe-webhook/handlers.ts @@ -0,0 +1,317 @@ +// Stripe event handlers: sync a subscription's state into `subscriptions` and +// the account's plan into `user_settings`. index.ts verifies and claims the +// event; these run once per claimed event. They take their clients as +// arguments so tests can drive them with fixture events. + +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.114.0"; +import type { Stripe } from "../_shared/stripe.ts"; +import { accountPresence, type AccountPresence } from "./account-presence.ts"; + +export interface WebhookDeps { + supabase: SupabaseClient; + stripe: { + subscriptions: { + retrieve: (id: string) => Promise; + cancel: (id: string) => Promise; + }; + }; + now?: () => Date; +} + +const iso = (seconds: number) => new Date(seconds * 1000).toISOString(); + +/** + * A promo grant (free_month, lifetime_pro) lives in grace_period_ends_at. It is + * worth keeping while it ends later than now: a Stripe subscription that + * starts or ends in the meantime must not wipe it (QB-08). + */ +export function promoGrantActive(gracePeriodEndsAt: string | null | undefined, now: Date): boolean { + if (!gracePeriodEndsAt) return false; + const end = Date.parse(gracePeriodEndsAt); + return !Number.isNaN(end) && end > now.getTime(); +} + +export function createWebhookHandlers({ supabase, stripe, now = () => new Date() }: WebhookDeps) { + async function lookupAccount(userId: string): Promise { + const { data, error } = await supabase.auth.admin.getUserById(userId); + const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" + ? error.status + : null; + return accountPresence({ + user: data?.user ?? null, + errorStatus: status, + errorMessage: error?.message ?? null, + }); + } + + async function cancelSubscriptionIfPresent(subscriptionId: string): Promise { + try { + await stripe.subscriptions.cancel(subscriptionId); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "resource_missing") return; + throw error; + } + } + + /** + * Marks the account Pro. grace_period_ends_at is left alone: a promo grant + * that runs past this subscription keeps working after it ends, and a lapsed + * one is ignored by the resolver anyway (billing-state.ts). Upsert (not + * update) so a missing user_settings row doesn't silently no-op and leave a + * paying user without entitlements. + */ + async function grantPro(userId: string, failure: string) { + const { error } = await supabase.from("user_settings").upsert({ + user_id: userId, + subscription_tier: "pro", + updated_at: now().toISOString(), + }, { onConflict: "user_id" }); + if (error) { + console.error(`[WEBHOOK] ${failure}:`, error); + throw new Error(`${failure}: ${error.message}`); + } + } + + /** + * Records when a run of failed payments began (on: true, kept if already + * set) or that it is over (on: false). The past-due grace counts from it. + * A database without 20261023020000 has no column: the grace then counts + * from the period start, so a failed write is logged, never retried. + */ + async function markPaymentFailure(subscriptionId: string, on: boolean) { + const update = supabase.from("subscriptions") + .update({ payment_failed_at: on ? now().toISOString() : null }) + .eq("stripe_subscription_id", subscriptionId); + const { error } = on ? await update.is("payment_failed_at", null) : await update.not("payment_failed_at", "is", null); + if (error) console.warn(`[WEBHOOK] Could not ${on ? "record" : "clear"} payment_failed_at: ${error.message}`); + } + + async function userIdForSubscription(subscriptionId: string): Promise { + const { data } = await supabase.from("subscriptions") + .select("user_id") + .eq("stripe_subscription_id", subscriptionId) + .maybeSingle(); + return data?.user_id ?? null; + } + + async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { + const userId = session.metadata?.user_id; + if (!userId) { + throw new Error("checkout.session.completed missing user_id in metadata"); + } + + if (!session.subscription) { + // A checkout.session.completed event without a subscription means the + // session wasn't our subscription checkout (e.g. mode "payment"/"setup"). + // Nothing for this handler to do; don't retrieve/upsert on a missing id. + console.log(`[WEBHOOK] checkout.session.completed for user ${userId} has no subscription (mode=${session.mode}); skipping`); + return; + } + const subscriptionId = session.subscription as string; + const customerId = session.customer as string; + + console.log(`[WEBHOOK] Checkout completed for user ${userId}, sub: ${subscriptionId}`); + + // Deleting an account cancels billing it can see. A Checkout that finishes + // in that window would otherwise keep charging a subscription with no user, + // and the database insert fails forever. Cancel it and acknowledge the event. + const presence = await lookupAccount(userId); + if (presence === "unknown") { + throw new Error("Could not confirm the account for this checkout"); + } + if (presence === "missing") { + console.warn(`[WEBHOOK] Checkout completed after the account was deleted; canceling ${subscriptionId}`); + await cancelSubscriptionIfPresent(subscriptionId); + return; + } + + // Fetch the full subscription from Stripe + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + const subscriptionRow = { + user_id: userId, + stripe_customer_id: customerId, + stripe_subscription_id: subscriptionId, + status: subscription.status as string, + price_id: subscription.items.data[0]?.price.id, + quantity: subscription.items.data[0]?.quantity, + cancel_at_period_end: subscription.cancel_at_period_end, + current_period_start: iso(subscription.current_period_start), + current_period_end: iso(subscription.current_period_end), + trial_start: subscription.trial_start ? iso(subscription.trial_start) : null, + trial_end: subscription.trial_end ? iso(subscription.trial_end) : null, + }; + + const { error: subError } = await supabase.from("subscriptions") + .upsert(subscriptionRow, { onConflict: "stripe_subscription_id" }); + + if (subError) { + console.error("[WEBHOOK] Failed to upsert subscription:", subError); + throw new Error(`Failed to upsert subscription: ${subError.message}`); + } + + await grantPro(userId, "Failed to update user tier"); + console.log(`[WEBHOOK] User ${userId} upgraded to Pro`); + } + + async function handlePaymentSucceeded(invoice: Stripe.Invoice) { + const subscriptionId = invoice.subscription as string; + if (!subscriptionId) return; + + // Fetch the full subscription to get current period + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + // Take the status from Stripe too: a late payment event must not mark a + // since-canceled subscription active again. + const { error } = await supabase.from("subscriptions").update({ + status: subscription.status, + current_period_start: iso(subscription.current_period_start), + current_period_end: iso(subscription.current_period_end), + }).eq("stripe_subscription_id", subscriptionId); + + if (error) { + console.error("[WEBHOOK] Failed to update subscription on payment success:", error); + throw new Error(`Failed to update subscription on payment success: ${error.message}`); + } + if (subscription.status === "active" || subscription.status === "trialing") { + await markPaymentFailure(subscriptionId, false); + // A recovered payment may arrive before its subscription.updated event, + // and the old webhook downgraded the tier on the failure. + const userId = await userIdForSubscription(subscriptionId); + if (userId) await grantPro(userId, "Failed to restore Pro after payment"); + } + + console.log(`[WEBHOOK] Payment succeeded for subscription ${subscriptionId}`); + } + + /** + * A failed renewal does not end Pro. Stripe keeps the subscription past_due + * and retries the invoice; the account keeps Pro for PAYMENT_GRACE_DAYS from + * the first failure (billing-state.ts) and Settings asks for a new payment + * method. When Stripe gives up it sends customer.subscription.updated + * (unpaid) or .deleted, and those handlers end access. The tier stays as it + * is: rewriting it to "expired" here told paying users their free trial had + * ended and offered them a second subscription (QB-13). + */ + async function handlePaymentFailed(invoice: Stripe.Invoice) { + const subscriptionId = invoice.subscription as string; + if (!subscriptionId) return; + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const { error: subError } = await supabase.from("subscriptions").update({ + status: subscription.status, + cancel_at_period_end: subscription.cancel_at_period_end, + current_period_start: iso(subscription.current_period_start), + current_period_end: iso(subscription.current_period_end), + }).eq("stripe_subscription_id", subscriptionId); + + if (subError) { + console.error("[WEBHOOK] Failed to update subscription on payment failure:", subError); + throw new Error(`Failed to update subscription on payment failure: ${subError.message}`); + } + // Retries of the same invoice keep the first failure's time. + if (subscription.status === "past_due" || subscription.status === "unpaid") await markPaymentFailure(subscriptionId, true); + + console.log(`[WEBHOOK] Payment failed for subscription ${subscriptionId}; now ${subscription.status}`); + } + + async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + const subscriptionId = subscription.id; + const userId = await userIdForSubscription(subscriptionId); + const endedAt = subscription.ended_at ? iso(subscription.ended_at) : now().toISOString(); + + // Update subscription record + const { error: subError } = await supabase.from("subscriptions").update({ + status: "canceled", + ended_at: endedAt, + canceled_at: subscription.canceled_at ? iso(subscription.canceled_at) : endedAt, + }).eq("stripe_subscription_id", subscriptionId); + + if (subError) { + console.error("[WEBHOOK] Failed to update subscription on deletion:", subError); + throw new Error(`Failed to update subscription on deletion: ${subError.message}`); + } + + if (!userId) return; + + // A promo grant that ends later than now outlives the subscription. + const { data: settings, error: settingsError } = await supabase.from("user_settings") + .select("grace_period_ends_at") + .eq("user_id", userId) + .maybeSingle(); + if (settingsError) throw new Error(`Failed to read user settings on cancellation: ${settingsError.message}`); + if (promoGrantActive(settings?.grace_period_ends_at, now())) { + console.log(`[WEBHOOK] User ${userId} subscription canceled; promo Pro continues`); + return; + } + + // Downgrade user. Upsert so a missing settings row doesn't silently no-op. + const { error: userError } = await supabase.from("user_settings").upsert({ + user_id: userId, + subscription_tier: "expired", + updated_at: now().toISOString(), + }, { onConflict: "user_id" }); + + if (userError) { + console.error("[WEBHOOK] Failed to downgrade user on cancellation:", userError); + throw new Error(`Failed to downgrade user on cancellation: ${userError.message}`); + } + + console.log(`[WEBHOOK] User ${userId} subscription canceled`); + } + + async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + const subscriptionId = subscription.id; + + const { error } = await supabase.from("subscriptions").update({ + status: subscription.status as string, + cancel_at_period_end: subscription.cancel_at_period_end, + current_period_start: iso(subscription.current_period_start), + current_period_end: iso(subscription.current_period_end), + cancel_at: subscription.cancel_at ? iso(subscription.cancel_at) : null, + canceled_at: subscription.canceled_at ? iso(subscription.canceled_at) : null, + ended_at: subscription.ended_at ? iso(subscription.ended_at) : null, + }).eq("stripe_subscription_id", subscriptionId); + + if (error) { + console.error("[WEBHOOK] Failed to update subscription:", error); + throw new Error(`Failed to update subscription: ${error.message}`); + } + + // If subscription went active (or trialing), ensure user is pro. past_due and unpaid + // leave the tier alone: the resolver reads the status itself. + if (subscription.status === "active" || subscription.status === "trialing") { + await markPaymentFailure(subscriptionId, false); + const userId = await userIdForSubscription(subscriptionId); + if (userId) await grantPro(userId, "Failed to upgrade user after subscription update"); + } + + console.log(`[WEBHOOK] Subscription ${subscriptionId} updated to ${subscription.status}`); + } + + return { + async handle(type: string, object: unknown) { + switch (type) { + case "checkout.session.completed": + await handleCheckoutCompleted(object as Stripe.Checkout.Session); + break; + case "invoice.payment_succeeded": + await handlePaymentSucceeded(object as Stripe.Invoice); + break; + case "invoice.payment_failed": + await handlePaymentFailed(object as Stripe.Invoice); + break; + case "customer.subscription.deleted": + case "customer.subscription.updated": { + const subscription = object as Stripe.Subscription; + if (subscription.status === "canceled") await handleSubscriptionDeleted(subscription); + else await handleSubscriptionUpdated(subscription); + break; + } + default: + console.log(`[WEBHOOK] Unhandled event type: ${type}`); + } + }, + }; +} diff --git a/supabase/functions/stripe-webhook/handlers_test.ts b/supabase/functions/stripe-webhook/handlers_test.ts new file mode 100644 index 000000000..f801340cb --- /dev/null +++ b/supabase/functions/stripe-webhook/handlers_test.ts @@ -0,0 +1,229 @@ +// Fixture Stripe events through the webhook handlers, against an in-memory +// PostgREST. Pins what each event writes, and that the result resolves to the +// state Settings and the AI functions show (QB-08, QB-13, QB-15). + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.114.0"; +import type { Stripe } from "../_shared/stripe.ts"; +import { resolveBillingState } from "../_shared/billing-state.ts"; +import { createWebhookHandlers } from "./handlers.ts"; + +type Row = Record; + +const NOW = new Date("2026-10-22T12:00:00.000Z"); +const DAY_S = 24 * 60 * 60; +const nowS = Math.floor(NOW.getTime() / 1000); +const iso = (seconds: number) => new Date(seconds * 1000).toISOString(); +const USER = "11111111-1111-4111-8111-111111111111"; +const SUB = "sub_fixture"; +const LIFETIME = "9999-12-31T23:59:59.999+00:00"; + +function assertEquals(actual: unknown, expected: unknown, message: string) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +/** Applies eq. filters and records every write, the way supabase-js sends them. */ +function fakeDatabase(tables: Record) { + const writes: Array<{ table: string; method: string; body: Row }> = []; + const matches = (row: Row, params: URLSearchParams) => { + for (const [key, value] of params) { + if (["select", "order", "limit", "on_conflict", "columns"].includes(key)) continue; + const empty = row[key] === null || row[key] === undefined; + if (value === "is.null") { + if (!empty) return false; + } else if (value === "not.is.null") { + if (empty) return false; + } else if (!value.startsWith("eq.") || String(row[key]) !== value.slice(3)) return false; + } + return true; + }; + const fetchImpl = async (input: Request | URL | string, init?: RequestInit): Promise => { + const request = input instanceof Request ? input : new Request(String(input), init); + const url = new URL(request.url); + if (url.pathname.startsWith("/auth/v1/admin/users/")) { + return Response.json({ id: url.pathname.split("/").pop(), email: "student@example.test" }); + } + const table = url.pathname.match(/\/rest\/v1\/([a-z_]+)$/)?.[1]; + if (!table || !(table in tables)) throw new Error(`Unexpected request: ${request.method} ${url}`); + const rows = tables[table]; + if (request.method === "GET") { + const found = rows.filter((row) => matches(row, url.searchParams)); + if ((request.headers.get("accept") ?? "").includes("vnd.pgrst.object")) { + return found.length === 1 ? Response.json(found[0]) : Response.json({ code: "PGRST116" }, { status: 406 }); + } + return Response.json(found); + } + const body = await request.json() as Row; + writes.push({ table, method: request.method, body }); + if (request.method === "PATCH") { + for (const row of rows.filter((row) => matches(row, url.searchParams))) Object.assign(row, body); + } else if (request.method === "POST") { + const key = url.searchParams.get("on_conflict") ?? "id"; + const existing = rows.find((row) => row[key] === body[key]); + if (existing) Object.assign(existing, body); + else rows.push({ ...body }); + } + return new Response(null, { status: 204 }); + }; + return { fetchImpl, writes }; +} + +function stripeSubscription(overrides: Partial): Stripe.Subscription { + return { + id: SUB, + status: "active", + cancel_at_period_end: false, + current_period_start: nowS - 10 * DAY_S, + current_period_end: nowS + 20 * DAY_S, + cancel_at: null, + canceled_at: null, + ended_at: null, + trial_start: null, + trial_end: null, + items: { data: [{ price: { id: "price_fixture" }, quantity: 1 }] }, + ...overrides, + } as unknown as Stripe.Subscription; +} + +function setup(settings: Row, subscription: Row, stripeState: Stripe.Subscription) { + const tables: Record<"user_settings" | "subscriptions", Row[]> = { + user_settings: [{ user_id: USER, daily_limit_override: null, trial_ends_at: null, ...settings }], + subscriptions: [{ user_id: USER, stripe_subscription_id: SUB, stripe_customer_id: "cus_fixture", ...subscription }], + }; + const db = fakeDatabase(tables); + const supabase = createClient("https://supabase.test", "service-role-key", { + global: { fetch: db.fetchImpl as typeof fetch }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + const stripe = { + subscriptions: { + retrieve: (id: string) => Promise.resolve(id === SUB ? stripeState : Promise.reject(new Error("unknown subscription"))), + cancel: () => Promise.resolve({}), + }, + }; + const handlers = createWebhookHandlers({ supabase, stripe, now: () => NOW }); + const state = () => resolveBillingState(tables.user_settings[0], tables.subscriptions, NOW); + return { tables, writes: db.writes, handlers, state }; +} + +async function quietly(run: () => Promise): Promise { + const log = console.log; + console.log = () => {}; + try { + return await run(); + } finally { + console.log = log; + } +} + +const ACTIVE_ROW = { status: "active", current_period_start: iso(nowS - 40 * DAY_S), current_period_end: iso(nowS - DAY_S), cancel_at_period_end: false }; + +Deno.test("invoice.payment_failed keeps Pro through the grace and does not touch the tier (QB-13)", async () => { + // Stripe has already opened the new period when the renewal invoice fails. + const failed = stripeSubscription({ status: "past_due", current_period_start: nowS - DAY_S, current_period_end: nowS + 29 * DAY_S }); + const { tables, writes, handlers, state } = setup({ subscription_tier: "pro", grace_period_ends_at: null }, ACTIVE_ROW, failed); + await quietly(() => handlers.handle("invoice.payment_failed", { id: "in_fixture", subscription: SUB })); + + assertEquals(writes.filter((write) => write.table === "user_settings"), [], "user_settings writes"); + assertEquals(tables.user_settings[0].subscription_tier, "pro", "tier"); + assertEquals(tables.subscriptions[0].status, "past_due", "status"); + assertEquals(tables.subscriptions[0].current_period_start, iso(nowS - DAY_S), "period start"); + assertEquals(tables.subscriptions[0].payment_failed_at, NOW.toISOString(), "first failure recorded"); + const resolved = state(); + assertEquals([resolved.state, resolved.hasAccess, resolved.hasOpenSubscription], ["payment_failed", true, true], "resolved state"); +}); + +Deno.test("a retried failure keeps the first failure's time, so the grace cannot restart", async () => { + // Stripe left the subscription past due into a new period; the run of failures began 20 days ago. + const firstFailure = iso(nowS - 20 * DAY_S); + const failed = stripeSubscription({ status: "past_due", current_period_start: nowS - DAY_S, current_period_end: nowS + 29 * DAY_S }); + const { tables, handlers, state } = setup( + { subscription_tier: "pro", grace_period_ends_at: null }, + { status: "past_due", current_period_start: iso(nowS - 31 * DAY_S), current_period_end: iso(nowS - DAY_S), payment_failed_at: firstFailure }, + failed, + ); + await quietly(() => handlers.handle("invoice.payment_failed", { id: "in_fixture_2", subscription: SUB })); + assertEquals(tables.subscriptions[0].payment_failed_at, firstFailure, "first failure kept"); + const resolved = state(); + assertEquals([resolved.state, resolved.hasAccess, resolved.refusal], ["payment_failed", false, "payment_failed"], "resolved state"); +}); + +Deno.test("invoice.payment_succeeded clears the failure and restores Pro", async () => { + const recovered = stripeSubscription({ status: "active" }); + const { tables, handlers, state } = setup( + // The tier the old webhook wrote on the failure. + { subscription_tier: "expired", grace_period_ends_at: null }, + { status: "past_due", current_period_start: iso(nowS - 10 * DAY_S), current_period_end: iso(nowS + 20 * DAY_S), payment_failed_at: iso(nowS - 3 * DAY_S) }, + recovered, + ); + await quietly(() => handlers.handle("invoice.payment_succeeded", { id: "in_fixture_3", subscription: SUB })); + assertEquals(tables.subscriptions[0].payment_failed_at, null, "failure cleared"); + assertEquals(tables.user_settings[0].subscription_tier, "pro", "tier restored"); + assertEquals(state().state, "pro", "resolved state"); +}); + +Deno.test("customer.subscription.updated records cancel_at_period_end and keeps a promo grant (QB-15, QB-08)", async () => { + const canceling = stripeSubscription({ cancel_at_period_end: true, cancel_at: nowS + 20 * DAY_S, canceled_at: nowS }); + const { tables, handlers, state } = setup( + { subscription_tier: "pro", grace_period_ends_at: LIFETIME }, + { ...ACTIVE_ROW, current_period_end: iso(nowS + 20 * DAY_S) }, + canceling, + ); + await quietly(() => handlers.handle("customer.subscription.updated", canceling)); + + assertEquals(tables.subscriptions[0].cancel_at_period_end, true, "cancel_at_period_end"); + assertEquals(tables.subscriptions[0].cancel_at, iso(nowS + 20 * DAY_S), "cancel_at"); + assertEquals(tables.user_settings[0].grace_period_ends_at, LIFETIME, "promo grant"); + assertEquals(tables.user_settings[0].subscription_tier, "pro", "tier"); + // The card shows the Stripe plan's cancellation; the lifetime grant keeps Pro after it. + assertEquals(state().state, "pro_canceling", "resolved state"); +}); + +Deno.test("customer.subscription.updated for a Stripe-only plan shows Cancels on (QB-15)", async () => { + const canceling = stripeSubscription({ cancel_at_period_end: true }); + const { handlers, state } = setup({ subscription_tier: "pro", grace_period_ends_at: null }, ACTIVE_ROW, canceling); + await quietly(() => handlers.handle("customer.subscription.updated", canceling)); + const resolved = state(); + assertEquals([resolved.state, resolved.dateKind, resolved.date], ["pro_canceling", "cancels", iso(nowS + 20 * DAY_S)], "resolved state"); +}); + +Deno.test("customer.subscription.deleted ends Stripe Pro with Stripe's end date (QB-14)", async () => { + const ended = stripeSubscription({ status: "canceled", ended_at: nowS - 60, canceled_at: nowS - 3 * DAY_S }); + const { tables, handlers, state } = setup({ subscription_tier: "pro", grace_period_ends_at: null }, ACTIVE_ROW, ended); + await quietly(() => handlers.handle("customer.subscription.deleted", ended)); + + assertEquals(tables.subscriptions[0].status, "canceled", "status"); + assertEquals(tables.subscriptions[0].ended_at, iso(nowS - 60), "ended_at"); + assertEquals(tables.user_settings[0].subscription_tier, "expired", "tier"); + const resolved = state(); + assertEquals([resolved.state, resolved.refusal, resolved.date], ["subscription_ended", "subscription_ended", iso(nowS - 60)], "resolved state"); +}); + +Deno.test("customer.subscription.deleted leaves a promo grant that ends later in place (QB-08)", async () => { + const promoEnd = iso(nowS + 15 * DAY_S); + const ended = stripeSubscription({ status: "canceled", ended_at: nowS }); + const { tables, writes, handlers, state } = setup({ subscription_tier: "pro", grace_period_ends_at: promoEnd }, ACTIVE_ROW, ended); + await quietly(() => handlers.handle("customer.subscription.deleted", ended)); + + assertEquals(writes.filter((write) => write.table === "user_settings"), [], "user_settings writes"); + assertEquals(tables.user_settings[0].subscription_tier, "pro", "tier"); + assertEquals(state().state, "pro", "resolved state"); +}); + +Deno.test("checkout.session.completed grants Pro without wiping a promo grant (QB-08)", async () => { + const active = stripeSubscription({}); + const { tables, writes, handlers } = setup({ subscription_tier: "pro", grace_period_ends_at: LIFETIME }, ACTIVE_ROW, active); + await quietly(() => handlers.handle("checkout.session.completed", { + id: "cs_fixture", + mode: "subscription", + subscription: SUB, + customer: "cus_fixture", + metadata: { user_id: USER }, + })); + + const settingsWrite = writes.find((write) => write.table === "user_settings"); + assertEquals(settingsWrite?.body.subscription_tier, "pro", "tier written"); + assertEquals("grace_period_ends_at" in (settingsWrite?.body ?? {}), false, "grace_period_ends_at left out of the write"); + assertEquals(tables.user_settings[0].grace_period_ends_at, LIFETIME, "promo grant"); +}); diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index 5b8f40bdc..0776f668d 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -8,7 +8,7 @@ import { processWebhookEvent, webhookFailureResponse, } from "./webhook-event-lifecycle.ts"; -import { accountPresence, type AccountPresence } from "./account-presence.ts"; +import { createWebhookHandlers } from "./handlers.ts"; // Validate required environment variables const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY"); @@ -28,6 +28,8 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); const WEBHOOK_SECRET = STRIPE_WEBHOOK_SECRET; +const handlers = createWebhookHandlers({ supabase, stripe }); + const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, stripe-signature", @@ -84,34 +86,7 @@ Deno.serve(async (req) => { if (error) throw new Error(`Failed to release webhook event: ${error.message}`); }, retrieveSubscription: (id) => stripe.subscriptions.retrieve(id), - handle: async (type, object) => { - switch (type) { - case "checkout.session.completed": { - const session = object as Stripe.Checkout.Session; - await handleCheckoutCompleted(session); - break; - } - case "invoice.payment_succeeded": { - const invoice = object as Stripe.Invoice; - await handlePaymentSucceeded(invoice); - break; - } - case "invoice.payment_failed": { - const invoice = object as Stripe.Invoice; - await handlePaymentFailed(invoice); - break; - } - case "customer.subscription.deleted": - case "customer.subscription.updated": { - const subscription = object as Stripe.Subscription; - if (subscription.status === "canceled") await handleSubscriptionDeleted(subscription); - else await handleSubscriptionUpdated(subscription); - break; - } - default: - console.log(`[WEBHOOK] Unhandled event type: ${event.type}`); - } - }, + handle: handlers.handle, }); if (outcome === "duplicate") { return new Response(JSON.stringify({ received: true, ...duplicateWebhookResponse() }), { @@ -143,243 +118,3 @@ Deno.serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); }); - -// ============================================================ -// EVENT HANDLERS -// ============================================================ - -async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { - const userId = session.metadata?.user_id; - if (!userId) { - throw new Error("checkout.session.completed missing user_id in metadata"); - } - - if (!session.subscription) { - // A checkout.session.completed event without a subscription means the - // session wasn't our subscription checkout (e.g. mode "payment"/"setup"). - // Nothing for this handler to do; don't retrieve/upsert on a missing id. - console.log(`[WEBHOOK] checkout.session.completed for user ${userId} has no subscription (mode=${session.mode}); skipping`); - return; - } - const subscriptionId = session.subscription as string; - const customerId = session.customer as string; - - console.log(`[WEBHOOK] Checkout completed for user ${userId}, sub: ${subscriptionId}`); - - // Deleting an account cancels billing it can see. A Checkout that finishes - // in that window would otherwise keep charging a subscription with no user, - // and the database insert fails forever. Cancel it and acknowledge the event. - const presence = await lookupAccount(userId); - if (presence === "unknown") { - throw new Error("Could not confirm the account for this checkout"); - } - if (presence === "missing") { - console.warn(`[WEBHOOK] Checkout completed after the account was deleted; canceling ${subscriptionId}`); - await cancelSubscriptionIfPresent(subscriptionId); - return; - } - - // Fetch the full subscription from Stripe - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - const subscriptionRow = { - user_id: userId, - stripe_customer_id: customerId, - stripe_subscription_id: subscriptionId, - status: subscription.status as string, - price_id: subscription.items.data[0]?.price.id, - quantity: subscription.items.data[0]?.quantity, - cancel_at_period_end: subscription.cancel_at_period_end, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - trial_start: subscription.trial_start ? new Date(subscription.trial_start * 1000).toISOString() : null, - trial_end: subscription.trial_end ? new Date(subscription.trial_end * 1000).toISOString() : null, - }; - - const { error: subError } = await supabase.from("subscriptions") - .upsert(subscriptionRow, { onConflict: "stripe_subscription_id" }); - - if (subError) { - console.error("[WEBHOOK] Failed to upsert subscription:", subError); - throw new Error(`Failed to upsert subscription: ${subError.message}`); - } - - // Update user tier to pro. Upsert (not update) so a missing user_settings - // row doesn't silently no-op and leave a paying user without entitlements. - const { error: userError } = await supabase.from("user_settings").upsert({ - user_id: userId, - subscription_tier: "pro", - grace_period_ends_at: null, - updated_at: new Date().toISOString(), - }, { onConflict: "user_id" }); - - if (userError) { - console.error("[WEBHOOK] Failed to update user tier:", userError); - throw new Error(`Failed to update user tier: ${userError.message}`); - } - - console.log(`[WEBHOOK] User ${userId} upgraded to Pro`); -} - -async function lookupAccount(userId: string): Promise { - const { data, error } = await supabase.auth.admin.getUserById(userId); - const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" - ? error.status - : null; - return accountPresence({ - user: data?.user ?? null, - errorStatus: status, - errorMessage: error?.message ?? null, - }); -} - -async function cancelSubscriptionIfPresent(subscriptionId: string): Promise { - try { - await stripe.subscriptions.cancel(subscriptionId); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; - if (code === "resource_missing") return; - throw error; - } -} - -async function handlePaymentSucceeded(invoice: Stripe.Invoice) { - const subscriptionId = invoice.subscription as string; - if (!subscriptionId) return; - - // Fetch the full subscription to get current period - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - // Take the status from Stripe too: a late payment event must not mark a - // since-canceled subscription active again. - const { error } = await supabase.from("subscriptions").update({ - status: subscription.status, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - }).eq("stripe_subscription_id", subscriptionId); - - if (error) { - console.error("[WEBHOOK] Failed to update subscription on payment success:", error); - throw new Error(`Failed to update subscription on payment success: ${error.message}`); - } - - console.log(`[WEBHOOK] Payment succeeded for subscription ${subscriptionId}`); -} - -async function handlePaymentFailed(invoice: Stripe.Invoice) { - const subscriptionId = invoice.subscription as string; - if (!subscriptionId) return; - - // Find the user for this subscription - const { data: sub } = await supabase.from("subscriptions") - .select("user_id") - .eq("stripe_subscription_id", subscriptionId) - .maybeSingle(); - - // Update subscription status - const { error: subError } = await supabase.from("subscriptions").update({ - status: "past_due", - }).eq("stripe_subscription_id", subscriptionId); - - if (subError) { - console.error("[WEBHOOK] Failed to update subscription on payment failure:", subError); - throw new Error(`Failed to update subscription on payment failure: ${subError.message}`); - } - - // Downgrade user immediately per spec. Upsert so a missing settings row - // doesn't silently no-op. - if (sub?.user_id) { - const { error: userError } = await supabase.from("user_settings").upsert({ - user_id: sub.user_id, - subscription_tier: "expired", - updated_at: new Date().toISOString(), - }, { onConflict: "user_id" }); - - if (userError) { - console.error("[WEBHOOK] Failed to downgrade user on payment failure:", userError); - } - - console.log(`[WEBHOOK] User ${sub.user_id} downgraded due to payment failure`); - } -} - -async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { - const subscriptionId = subscription.id; - - // Find the user for this subscription - const { data: sub } = await supabase.from("subscriptions") - .select("user_id") - .eq("stripe_subscription_id", subscriptionId) - .maybeSingle(); - - // Update subscription record - const { error: subError } = await supabase.from("subscriptions").update({ - status: "canceled", - ended_at: new Date().toISOString(), - canceled_at: new Date().toISOString(), - }).eq("stripe_subscription_id", subscriptionId); - - if (subError) { - console.error("[WEBHOOK] Failed to update subscription on deletion:", subError); - throw new Error(`Failed to update subscription on deletion: ${subError.message}`); - } - - // Downgrade user. Upsert so a missing settings row doesn't silently no-op. - if (sub?.user_id) { - const { error: userError } = await supabase.from("user_settings").upsert({ - user_id: sub.user_id, - subscription_tier: "expired", - updated_at: new Date().toISOString(), - }, { onConflict: "user_id" }); - - if (userError) { - console.error("[WEBHOOK] Failed to downgrade user on cancellation:", userError); - throw new Error(`Failed to downgrade user on cancellation: ${userError.message}`); - } - - console.log(`[WEBHOOK] User ${sub.user_id} subscription canceled`); - } -} - -async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { - const subscriptionId = subscription.id; - - const { error } = await supabase.from("subscriptions").update({ - status: subscription.status as string, - cancel_at_period_end: subscription.cancel_at_period_end, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at: subscription.cancel_at ? new Date(subscription.cancel_at * 1000).toISOString() : null, - canceled_at: subscription.canceled_at ? new Date(subscription.canceled_at * 1000).toISOString() : null, - ended_at: subscription.ended_at ? new Date(subscription.ended_at * 1000).toISOString() : null, - }).eq("stripe_subscription_id", subscriptionId); - - if (error) { - console.error("[WEBHOOK] Failed to update subscription:", error); - throw new Error(`Failed to update subscription: ${error.message}`); - } - - // If subscription went active, ensure user is pro - if (subscription.status === "active") { - const { data: sub } = await supabase.from("subscriptions") - .select("user_id") - .eq("stripe_subscription_id", subscriptionId) - .maybeSingle(); - - if (sub?.user_id) { - // Upsert so a missing settings row doesn't silently no-op. - const { error: userError } = await supabase.from("user_settings").upsert({ - user_id: sub.user_id, - subscription_tier: "pro", - grace_period_ends_at: null, - updated_at: new Date().toISOString(), - }, { onConflict: "user_id" }); - - if (userError) { - throw new Error(`Failed to upgrade user after subscription update: ${userError.message}`); - } - } - } - - console.log(`[WEBHOOK] Subscription ${subscriptionId} updated to ${subscription.status}`); -} diff --git a/supabase/migrations/20261023020000_subscription_payment_failed_at.sql b/supabase/migrations/20261023020000_subscription_payment_failed_at.sql new file mode 100644 index 000000000..804af19df --- /dev/null +++ b/supabase/migrations/20261023020000_subscription_payment_failed_at.sql @@ -0,0 +1,15 @@ +-- When a subscription's current run of failed payments began. Additive only. +-- +-- A past-due subscription keeps Pro for a grace period while Stripe retries +-- the invoice (supabase/functions/_shared/billing-state.ts). The grace is +-- counted from this time, not from the billing period, so a subscription that +-- Stripe leaves past due across several periods cannot reopen the grace each +-- period. The Stripe webhook sets it on the first invoice.payment_failed and +-- clears it when a payment succeeds or the subscription is active again. +-- NULL means no failure is recorded; rows from before this column fall back +-- to the period start. +ALTER TABLE public.subscriptions + ADD COLUMN IF NOT EXISTS payment_failed_at timestamptz; + +COMMENT ON COLUMN public.subscriptions.payment_failed_at IS + 'Start of the current run of failed payments; set by the Stripe webhook, cleared on recovery. Bounds the past-due Pro grace.'; From c801080408e8babf6e465285c345ac39ca81d969 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 01:11:25 +0000 Subject: [PATCH 2/2] Keep the payment grace exact and the promo date honest (T4 review round 3) - A failed clear of payment_failed_at throws so Stripe redelivers the event; only a missing column (migration not applied) is logged. - A Stripe plan set to cancel inside a longer promo grant shows the promo, not "Cancels on". Co-Authored-By: Claude Opus 5.5 --- .../functions/_shared/billing-state.test.ts | 4 +++ supabase/functions/_shared/billing-state.ts | 13 ++++--- supabase/functions/stripe-webhook/handlers.ts | 17 +++++++-- .../functions/stripe-webhook/handlers_test.ts | 36 ++++++++++++++++--- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/supabase/functions/_shared/billing-state.test.ts b/supabase/functions/_shared/billing-state.test.ts index 1d892bae7..b4cb3b19a 100644 --- a/supabase/functions/_shared/billing-state.test.ts +++ b/supabase/functions/_shared/billing-state.test.ts @@ -109,6 +109,10 @@ check("a renewing Stripe plan shows its renewal over a promo grant it overlaps", { status: "active", current_period_start: at(-1), current_period_end: at(29) }, ], { state: "pro", access: true, limit: 50, date: at(29), dateKind: "renews", open: true }); +check("a cancelling Stripe plan inside a longer promo grant shows the promo", settings({ subscription_tier: "pro", grace_period_ends_at: "9999-12-31T23:59:59.999Z" }), [ + { status: "active", current_period_start: at(-1), current_period_end: at(29), cancel_at_period_end: true }, +], { state: "pro", access: true, limit: 50, open: true }); + check("lifetime promo Pro shows no end date", settings({ subscription_tier: "pro", grace_period_ends_at: "9999-12-31T23:59:59.999Z", daily_limit_override: 50 }), [], { state: "pro", access: true, limit: 50, }); diff --git a/supabase/functions/_shared/billing-state.ts b/supabase/functions/_shared/billing-state.ts index 0d3326e77..4db265efc 100644 --- a/supabase/functions/_shared/billing-state.ts +++ b/supabase/functions/_shared/billing-state.ts @@ -177,16 +177,19 @@ export function resolveBillingState( const override = settings.daily_limit_override; // Stripe Pro counts whatever the tier says, and comes before a promo grant - // (QB-08 keeps both) so the card shows the renewal date the payer is billed on. Only the webhook (service role) - // writes subscriptions rows, while the tier can lag it: an invoice paid - // before its subscription.updated event arrives, an account the old webhook - // downgraded on a failed payment, or an admin gift trial given to a payer. + // (QB-08 keeps both) so the card shows the renewal date the payer is billed + // on; a plan set to cancel before the promo ends shows the promo instead. + // Only the webhook (service role) writes subscriptions rows, while the tier + // can lag it: an invoice paid before its subscription.updated event arrives, + // an account the old webhook downgraded on a failed payment, or an admin + // gift trial given to a payer. { const current = latest( rows.filter((row) => (row.status === "active" || row.status === "trialing") && (time(row.current_period_end) ?? 0) > nowMs), (row) => time(row.current_period_end), ); - if (current) { + const promoOutlasts = tier === "pro" && promoEnd !== null && promoEnd > (time(current?.current_period_end) ?? 0); + if (current && !(current.cancel_at_period_end && promoOutlasts)) { return current.cancel_at_period_end ? result("pro_canceling", "pro", "pro", ["cancels", current.current_period_end]) : result("pro", "pro", "pro", ["renews", current.current_period_end]); diff --git a/supabase/functions/stripe-webhook/handlers.ts b/supabase/functions/stripe-webhook/handlers.ts index 5c5f378f5..76f2a1598 100644 --- a/supabase/functions/stripe-webhook/handlers.ts +++ b/supabase/functions/stripe-webhook/handlers.ts @@ -31,6 +31,11 @@ export function promoGrantActive(gracePeriodEndsAt: string | null | undefined, n return !Number.isNaN(end) && end > now.getTime(); } +/** Postgres undefined_column, or PostgREST's unknown-column error in a write. */ +function isMissingColumn(error: { code?: string }): boolean { + return error.code === "42703" || error.code === "PGRST204"; +} + export function createWebhookHandlers({ supabase, stripe, now = () => new Date() }: WebhookDeps) { async function lookupAccount(userId: string): Promise { const { data, error } = await supabase.auth.admin.getUserById(userId); @@ -77,14 +82,22 @@ export function createWebhookHandlers({ supabase, stripe, now = () => new Date() * Records when a run of failed payments began (on: true, kept if already * set) or that it is over (on: false). The past-due grace counts from it. * A database without 20261023020000 has no column: the grace then counts - * from the period start, so a failed write is logged, never retried. + * from the period start, so that error is logged, not retried. Any other + * failure throws so Stripe redelivers: a failure time left behind would cut + * the grace short on the next failed payment. */ async function markPaymentFailure(subscriptionId: string, on: boolean) { const update = supabase.from("subscriptions") .update({ payment_failed_at: on ? now().toISOString() : null }) .eq("stripe_subscription_id", subscriptionId); const { error } = on ? await update.is("payment_failed_at", null) : await update.not("payment_failed_at", "is", null); - if (error) console.warn(`[WEBHOOK] Could not ${on ? "record" : "clear"} payment_failed_at: ${error.message}`); + if (!error) return; + const message = `Could not ${on ? "record" : "clear"} payment_failed_at: ${error.message}`; + if (isMissingColumn(error)) { + console.warn(`[WEBHOOK] ${message}`); + return; + } + throw new Error(message); } async function userIdForSubscription(subscriptionId: string): Promise { diff --git a/supabase/functions/stripe-webhook/handlers_test.ts b/supabase/functions/stripe-webhook/handlers_test.ts index f801340cb..0fc2d3475 100644 --- a/supabase/functions/stripe-webhook/handlers_test.ts +++ b/supabase/functions/stripe-webhook/handlers_test.ts @@ -26,6 +26,8 @@ function assertEquals(actual: unknown, expected: unknown, message: string) { /** Applies eq. filters and records every write, the way supabase-js sends them. */ function fakeDatabase(tables: Record) { const writes: Array<{ table: string; method: string; body: Row }> = []; + /** Set to an error code to fail every write of payment_failed_at. */ + const fail: { paymentFailedAtWrite?: string } = {}; const matches = (row: Row, params: URLSearchParams) => { for (const [key, value] of params) { if (["select", "order", "limit", "on_conflict", "columns"].includes(key)) continue; @@ -55,6 +57,9 @@ function fakeDatabase(tables: Record) { return Response.json(found); } const body = await request.json() as Row; + if (fail.paymentFailedAtWrite && "payment_failed_at" in body) { + return Response.json({ code: fail.paymentFailedAtWrite, message: "write failed" }, { status: 400 }); + } writes.push({ table, method: request.method, body }); if (request.method === "PATCH") { for (const row of rows.filter((row) => matches(row, url.searchParams))) Object.assign(row, body); @@ -66,7 +71,7 @@ function fakeDatabase(tables: Record) { } return new Response(null, { status: 204 }); }; - return { fetchImpl, writes }; + return { fetchImpl, writes, fail }; } function stripeSubscription(overrides: Partial): Stripe.Subscription { @@ -104,7 +109,7 @@ function setup(settings: Row, subscription: Row, stripeState: Stripe.Subscriptio }; const handlers = createWebhookHandlers({ supabase, stripe, now: () => NOW }); const state = () => resolveBillingState(tables.user_settings[0], tables.subscriptions, NOW); - return { tables, writes: db.writes, handlers, state }; + return { tables, writes: db.writes, fail: db.fail, handlers, state }; } async function quietly(run: () => Promise): Promise { @@ -149,6 +154,29 @@ Deno.test("a retried failure keeps the first failure's time, so the grace cannot assertEquals([resolved.state, resolved.hasAccess, resolved.refusal], ["payment_failed", false, "payment_failed"], "resolved state"); }); +Deno.test("a failed clear of payment_failed_at throws so Stripe redelivers, unless the column is missing", async () => { + for (const [code, throws] of [["XX000", true], ["PGRST204", false]] as const) { + const recovered = stripeSubscription({ status: "active" }); + const { handlers, fail } = setup( + { subscription_tier: "pro", grace_period_ends_at: null }, + { status: "past_due", current_period_start: iso(nowS - 10 * DAY_S), current_period_end: iso(nowS + 20 * DAY_S), payment_failed_at: iso(nowS - DAY_S) }, + recovered, + ); + fail.paymentFailedAtWrite = code; + let threw = false; + const warn = console.warn; + console.warn = () => {}; + try { + await quietly(() => handlers.handle("invoice.payment_succeeded", { id: "in_fixture_4", subscription: SUB })); + } catch { + threw = true; + } finally { + console.warn = warn; + } + assertEquals(threw, throws, `throws for ${code}`); + } +}); + Deno.test("invoice.payment_succeeded clears the failure and restores Pro", async () => { const recovered = stripeSubscription({ status: "active" }); const { tables, handlers, state } = setup( @@ -176,8 +204,8 @@ Deno.test("customer.subscription.updated records cancel_at_period_end and keeps assertEquals(tables.subscriptions[0].cancel_at, iso(nowS + 20 * DAY_S), "cancel_at"); assertEquals(tables.user_settings[0].grace_period_ends_at, LIFETIME, "promo grant"); assertEquals(tables.user_settings[0].subscription_tier, "pro", "tier"); - // The card shows the Stripe plan's cancellation; the lifetime grant keeps Pro after it. - assertEquals(state().state, "pro_canceling", "resolved state"); + // The lifetime grant keeps Pro past the cancellation, so the card does not say "Cancels on". + assertEquals(state().state, "pro", "resolved state"); }); Deno.test("customer.subscription.updated for a Stripe-only plan shows Cancels on (QB-15)", async () => {