Repository navigation
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) - #207
Conversation
…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 <noreply@anthropic.com>
…nd 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 <noreply@anthropic.com>
|
Warning Review limit reachedEnable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Next included review available in 54 minutes. View limit details
ℹ️ Recent review info
📝 Walkthrough
Note 🎁 Summarized by CodeRabbit Free
Comment |
Stack checks at c801080 (previously pending)All local, on the shared Supabase stack once the lock freed. All of them passed.
Screenshots (desktop 1280 px, 360 px, and Pixel 5)Extended access (QB-02), EN desktop and HU 360 px: Payment failed in grace (QB-13), EN desktop and HU 360 px, and the AI refusal once the grace is over: Pro set to cancel (QB-15), EN desktop and HU Pixel 5: Subscription ended (QB-14), HU desktop and EN 360 px: All 26 images, covering each state in EN and HU at 1280 px, 360 px and Pixel 5, are on |
…02, PF-04, PF-05, PF-06, PF-09) (#215) * Cache every public icon file for 30 days C3 (#199) added icon-1024.png and the 192/512/maskable manifest icons, which still fell back to revalidating on every request. The test now reads public/ so a new icon size can't miss the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Load only the active locale and keep app-only code off public pages - Spanish and Hungarian strings are their own chunks, fetched when chosen; English stays in the entry as the default and fallback. The first render waits for the chosen language, and a later choice wins over one still loading. - AppLayout (header, navigation, debate dialogs) loads only under /app. mountApp starts the page chunk and the layout while the entry starts, instead of when React first reaches them. - The landing reveal is a CSS transition, so / no longer loads framer-motion's 118 KB chunk. - The session check and layout load show the route's skeleton instead of a full-screen spinner. - check-build-boundaries fails if the es/hu catalogs or AppLayout re-enter the entry or the landing, login, home, generate or attack graphs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Open the main app pages offline and keep each page's own HTML The build writes a precache list into sw.js from the Vite manifest: the shell's scripts and styles, the signed-in layout, Home, Library, History, Timer and Settings, the es/hu strings and Inter's Latin fonts. A missing manifest key fails the build. Offline, app routes get the precached app-shell.html (the paths vercel.json rewrites) and a public page gets only its own HTML, stored under its own path. Before, every navigation was stored as /index.html, so any route could come back as the last page visited. /auth/callback, Supabase paths and /_vercel/ stay network-only. Caches carry the build's id and the next build's worker deletes them. vite preview applies the vercel.json rewrites, so the offline test loads what production serves. The test's async waitForFunction calls passed at once without checking (Playwright treats the returned Promise as truthy); they now poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hold Supabase reads while offline instead of retrying them Offline, supabase-js retried every failed read three more times, so opening Settings sent about 70 doomed requests in 30 seconds (user_roles four times, user_settings 18). REST, Functions and Storage reads now wait for the 'online' event and go out once; an abort still ends them. Writes fail at once as before, and Auth is left alone because auth-js holds its session lock during a user lookup. TanStack Query also starts with the browser's real online state, so a page opened offline pauses its queries instead of running them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Don't reload for a missing chunk while offline Choosing a language whose strings were never cached, offline, failed its import and triggered the stale-deploy reload, which without a service worker replaces the page with the browser's offline error. Offline the chunk is unreachable, not stale, so the error stands and the app keeps the current language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Treat /expocit/sources as a public page offline, and always leave the startup shell /expocit/sources is prerendered and Vercel serves that file before the rewrite, so offline it now gets its own stored HTML instead of the shell (whose page chunk is not precached). vite preview serves prerendered pages before the rewrites too. The non-prerendered first render now also runs if waiting for the language rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disable Upgrade Now while offline and say why Checkout needs the network. Offline the button stays disabled with a short notice under it (one new string, pricingModal.offlineUpgrade, in all three catalogs) and re-enables on the 'online' event. Done here now that T4 (#207) has merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Load every language before the flowchart opens T9 (#206) recognises flowchart starter text saved in any language by comparing it with each catalog. With Spanish and Hungarian loaded only on demand, a chart saved in Hungarian and opened in English kept its Hungarian starter text. The flowchart route's loader now fetches every catalog before the page renders. Only that page (and a hover prefetch of it) pays for them, and they are precached for offline. The flowchart tests now load through the route loader; without this change four of them fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review round 1: stop retries offline instead of holding reads - Offline Supabase reads are no longer held until 'online'. A held read kept the token of whoever started it and could replay after sign-out, and a browser that wrongly reports offline would never send it. Every request is now sent; one that fails while the browser is offline ends as an abort, which postgrest-js does not retry. TanStack Query is no longer seeded from navigator.onLine; failed queries refetch on 'online' instead. - A language chosen offline before its strings were cached is applied when the connection returns. - The first render waits at most 3 s for the language's strings. - The service worker's build id also covers the unhashed public files it serves cache-first (theme-init.js, icons), and an offline public page prefers the copy saved on the last visit over the one stored at install. - The landing page's code is precached with its HTML, so / opened offline is not replaced by the error page. - test:sw now visits /auth/callback before checking it is never cached, and checks the precached files sit in the install-time shell cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review round 2: load layout and page together after sign-in - After a client-side navigation into /app (sign-in, the callback), the page chunk now starts with the layout chunk instead of after it. - The offline no-retry rule also matches reads through the dev:preview staging proxy path. - hero-visual.jpg and placeholder.svg also feed the worker's build id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>








Summary
Batch T4 from the 22 Oct QA report: billing states and entitlement correctness (QB-02, QB-13, QB-14, QB-15, QB-08, QB-17, SC-03). Rebased on main 508f243 (T5 #201, T7 #203 and T2 #202 merged).
One resolver,
resolveBillingStateinsupabase/functions/_shared/billing-state.ts, now decides what plan an account is on. The AI functions call it throughentitlements.ts, and the browser calls the same file throughsrc/hooks/use-subscription.tsand the settings store, so the Settings card and the server's refusal always describe the same state. The states are trial, extended access, Pro, Pro set to cancel, payment failed (in grace / after grace), legacy free, trial ended, subscription ended.Issues
QB-02 (expired trial + override shows "Expired" beside a working usage line)
extended_accessmeans no running plan anddaily_limit_overrideabove 0. The server still serves these accounts. Settings shows the green "Extended access" badge and "Extended access is active: N AI uses a day." with no "Expired" text. Upgrade to Pro is still offered.extra_limitpromo writes an override with no trial; it resolves to the sameextended_accessstate (unit testthe extra_limit promo ... is extended access too).trial_ended).QB-13 (past-due payer told their trial ended, offered a second subscription)
invoice.payment_failedno longer rewritessubscription_tiertoexpired. It records Stripe's status and period and setssubscriptions.payment_failed_aton the first failure. Retries keep that time;invoice.payment_succeededor anactive/trialingupdate clears it, and a successful payment also re-grants Pro. A failed clear throws so Stripe redelivers the event; only a missing column (migration not applied) is just logged.past_duesubscription keeps Pro forPAYMENT_GRACE_DAYS= 14 from the earlier ofpayment_failed_atandcurrent_period_start, never fromcurrent_period_end.unpaidgets no grace. When Stripe gives up it sendscustomer.subscription.updated(unpaid) or.deleted, which end access as before.subscription_tiersays. The tier can lag: an invoice paid before itssubscription.updatedarrives, an account the old webhook downgraded, or an admin gift trial given to a payer. Only the webhook (service role) writessubscriptions. RLS allows users SELECT only, and no SECURITY DEFINER function writes it, so this grants nothing a user can forge.src/lib/stores/settings-store.tsdecides whether to keep saved Pro-only model choices with the same resolver, so a past-due payer in the grace keeps their choices instead of having them reset to defaults and saved.expired+ apast_duerow) resolve topayment_failedtoo, so the fix applies to them without a data change.active,trialing,past_due,unpaid,paused), the same setcreate-checkoutalready refuses with 409.trial_ended,payment_failedorsubscription_ended(HTTP 402, with the Englishreasonkept for API callers). The client accepts all three plus the oldtrial_expired(read astrial_ended) for functions deployed before this change. The limit dialog localizes each in EN/ES/HU;payment_failedoffers "Update payment method", which goes to Settings, never to Checkout.QB-14 (former subscribers told "Your 3-day free trial has ended")
subscription_endedstate and code. Settings says "Your Pro subscription ended on . Resubscribe to keep using AI tools." with a Resubscribe button; the AI dialog says "Pro Subscription Ended" with the date.customer.subscription.deletednow stores Stripe'sended_at/canceled_atinstead of the webhook's clock.QB-15 (cancelling Pro looks like renewing Pro, no date)
cancel_at_period_endand shows "Renews on " or "Cancels on " beside the Pro badge (also "Ends on " for trials, promo Pro and legacy grace). Dates use the existing locale formatter (formatEuropeanDate, en-GB/es-ES/hu-HU). No price strings.subscriptionswithselect('*'), because the frontend ships on merge before the migration. Naming the new column would fail the read until the migration lands.QB-08 (webhook and promo edge cases wipe grants)
grace_period_ends_at: null. The resolver ignores a lapsed grant, so leaving it in place is safe.customer.subscription.deletedkeeps the account Pro when its promo grant ends later than now.discountpromos increate-checkoutnow refuse an expired code (promo_expired) or one at its cap (promo_exhausted) before any Stripe customer is created, the same rulesredeem_promo_code_atomicapplies to the other promo types. Left open: the discount path never records a redemption (redeem_promo_code_atomicreturns before writinguser_promosor bumpingredemptions_count), so the cap only bites if an admin sets the counter. Recording redemptions needs an atomic increment oncheckout.session.completed; I did not add it here.QB-17 (weekly cap undercounts; trial fallback undocumented)
WEEKLY_AI_BUDGET_USD. The QA sawcap_cost_usd0.033 againstcost_usd0.037–0.042 per Haiku call, so the change adds roughly 10–25% cap spend per BYOK call (the missing Anthropic part). If the cap should keep its current reach, raiseWEEKLY_AI_BUDGET_USDinsupabase/functions/_shared/usage-limits.ts.cap_cost_usdnow equals the full logged charge: OpenRouter's fee plusupstream_inference_costfor BYOK Claude calls. Before, the cap counted only OpenRouter's fee (about 0), so Claude on our own Anthropic key barely moved it. A BYOK response without the upstream figure still counts the fee, the known lower bound.cost.test.tsassertions that pinned the old exclusion are rewritten as the new spec, not loosened.docs/architecture.mdandusage-limits.tsupdated.refundabledoesn't read the cap. The cap logging changes in one case only: a failed BYOK call that reported a real upstream charge now counts toward the weekly cap instead of being logged at 0.resolveBillingState's comment. The webhook leavestrial_ends_atalone. A payer who fails a payment keeps Pro through the grace (the grace is checked before the trial). If the grace ends while their own free trial is still running, they fall back to that trial's limits until it ends, because the trial was theirs before they paid.SC-03 (
checkRateLimit(null)returned allowed)unauthenticated/ 401 and reserves nothing. Test added.Migration
supabase/migrations/20261023020000_subscription_payment_failed_at.sql:ALTER TABLE public.subscriptions ADD COLUMN IF NOT EXISTS payment_failed_at timestamptzplus a comment. Not destructive, and no backfill. Both the client and the functions work with or without it.Edge functions to deploy after merge
ai,ai-stream,voice(import_shared/entitlements.ts),stripe-webhook,create-checkout. Apply the migration first. If the column is missing, the webhook logs it and carries on.Verification
These are local runs. GitHub Actions minutes are used up, so CI did not run. A local pass proves only the local commit.
npm run verify(every step: docs check, lint incl. knip and repo rules, typecheck, unit, collaboration, edge, build with prerender/sitemap/check-site/build boundaries,test:sw, desktop Chromium@smokee2e) at head c801080, rebased on main 508f243: exit 0.check-site: 48 HTML files, sitemap and llms.txt resolve; no problemstest:sw: upgradeActivated, firstVisitedRouteOffline, unvisitedRouteShowsRecovery, intentPrefetchedRouteOffline, offlineLogo all trueThe same full verify also passed after each earlier fix round (before the rebase: 494 unit, 448 to 455 edge, 43 smoke).
New or changed tests:
supabase/functions/_shared/billing-state.test.ts: the resolver table, one case per state (trial, extended access incl. extra_limit, override 0, Pro renewing, Pro cancelling, past due in grace / after grace / grace from first failure / pre-column rows, unpaid, subscription ended, promo over canceled sub, lifetime promo, legacy free in and after grace, trial fallback after the grace, tier lagging a paid period, cancelling plan inside a longer promo, missing row).supabase/functions/_shared/entitlements.test.ts: each refusal code; extended access served; past-due payer keeps Pro; SC-03 null user refused with no quota used; failed subscriptions read serves only what settings prove.supabase/functions/stripe-webhook/handlers_test.ts: fixture events forinvoice.payment_failed, a retried failure,invoice.payment_succeeded,customer.subscription.updated(cancel_at_period_end, with and without a promo),customer.subscription.deleted(with and without a promo),checkout.session.completedkeeping a promo, and a failedpayment_failed_atclear.supabase/functions/_shared/cost.test.ts: the two BYOK cap tests rewritten to the new rule (the cap equals the full logged cost), not loosened.src/hooks/use-subscription.test.tsx(state from rows, QB-02/13/14/15),src/test/components/SubscriptionStatus.test.tsx(badge, date and the single button for each state),src/test/components/LimitReachedModal.test.tsx(old and new trial codes, payment failed goes to Settings, subscription ended with date),src/test/settings/settings-migration.test.ts(past-due payer keeps Pro model choices),src/test/checkout-security.test.ts(discount expiry and cap).ai,ai-stream) now stubsubscriptions, which the entitlement check reads for every account.Stack checks, run once the shared Supabase lock freed (details and screenshots in this comment):
./.superset/db-gate.sh: exit 0. pgTAPFiles=26, Tests=500, Result: PASS, with the new migration applied.e2e/full-stackande2e/workspace-collaborationon desktop and mobile Chromium: 71 passed, 1 skipped (mobile-only skip), includingbilling.spec.ts.verify-debatelabdrive recipes/billing-statesandrecipes/billingondesktop-chromiumandmobile-chromium: all passed. EN and HU screenshots at 1280 px, 360 px and Pixel 5 are onpr-assets/t4-billing-states.Review rounds
Each round was one fresh-context Claude Opus subagent reading the full diff against main.
payment_failed_at(migration above).pro(old-webhook downgrades, gift trials to payers). Fixed: any active or trialing period counts, and a successful payment re-grants Pro. M2 is the QB-17 cap decision (above, Adam's call). Lows fixed: grace start covers pre-column rows; a failed subscriptions read no longer blocks accounts the settings row proves. Lows noted: deploy order, display precedence.trialingupdates grant Pro and clear the failure. Lows noted:paused(unreachable), deploy window.payment_failed_atthrows for redelivery; a cancelling plan inside a longer promo shows the promo. Lows noted below (pricing dialog, admin lists).Left open / manual checks
aifunction refuses them with the old trial message. Apply the migration, then deploystripe-webhook,ai,ai-stream,voiceandcreate-checkoutstraight after the merge.subscription_tier, so an extended-access account shows "expired". That is display only and not changed here.pausedcounts as an open subscription (Settings shows Manage) but is refused astrial_ended. It can't happen today because no Stripe trials or pausing are configured.extra_limituser whose subscription has lapsed shows "Extended access" rather than a payment prompt; access is correct.daily_limit_override(e.g. theextra_limit15) caps a paying Pro account below 50. That was already true on main.🤖 Generated with Claude Code
Summary by CodeRabbit