From 632e9e9b58dd1d2b05889bd96e904abac7f9b141 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 11:36:45 +0200 Subject: [PATCH 1/3] fix(web): keep lapsed subscribers out of new-user onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An org whose subscription was cancelled and then expired got bounced into the quick-start wizard — returning customers asked "what's your role?" and offered demo seed data, with no obvious way to resubscribe. The root gate only ever looked at live billing state: `hasSelectedPlan` resolves to `isActivePlanSubscription`, so a never-subscribed org and an expired one failed it identically and both landed on /quick-start. Nothing else distinguished them — wizard progress is localStorage-only and `org_onboarding_state.onboardingCompletedAt` is dead code nothing writes. Autumn keeps lapsed subscription rows on the customer, so that history is the missing signal. Split `isPlanSubscription` (status-blind) out of `isActivePlanSubscription`, which now composes it and is unchanged in behaviour — the API's cache TTL shares that gate and must not drift. On top of it, `getLapsedPlan`/`hasLapsedPlan` identify an org that held a plan and holds none now, picking the subscription that ended last. The gate becomes "has a plan OR had one", so only an org with no plan history reaches onboarding. A lapsed org keeps the app behind a non-dismissible reactivation banner; ingestion is already refused for them by the gateway (402, no active subscription), so no write-blocking is added here. /quick-start bounces lapsed orgs out, and /select-plan drops the trial pitch for reactivation copy. The localStorage anti-flash flag now records the broader "may render the app" condition, so unsubscribing no longer costs a boot splash every load. --- apps/web/src/atoms/selected-plan-atoms.ts | 18 +++--- .../billing/subscription-ended-banner.tsx | 50 +++++++++++++++++ .../components/layout/dashboard-layout.tsx | 2 + apps/web/src/lib/billing/plan-gating.test.ts | 56 +++++++++++++++++++ apps/web/src/lib/billing/plan-gating.ts | 35 +++++++++++- apps/web/src/routes/__root.tsx | 34 ++++++----- apps/web/src/routes/quick-start.tsx | 18 +++++- apps/web/src/routes/select-plan.tsx | 31 +++++++--- packages/domain/src/billing.test.ts | 22 ++++++++ packages/domain/src/billing.ts | 17 +++++- 10 files changed, 245 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/billing/subscription-ended-banner.tsx diff --git a/apps/web/src/atoms/selected-plan-atoms.ts b/apps/web/src/atoms/selected-plan-atoms.ts index ce0975ac8..0cba1704d 100644 --- a/apps/web/src/atoms/selected-plan-atoms.ts +++ b/apps/web/src/atoms/selected-plan-atoms.ts @@ -3,14 +3,16 @@ import { Schema } from "effect" import { localStorageRuntime } from "@/lib/services/common/storage-runtime" /** - * Per-org memory of "this org was last seen holding an active selected plan", - * used by the `__root` gate to optimistically render the dashboard while the - * Autumn customer query is still loading. Keyed by orgId so a brand-new org - * (fresh signup) starts with no record — taking the no-flash "wait for the plan - * to settle" path — while a returning paid org skips straight to the dashboard. - * Cleared the moment an org is seen planless (e.g. after unsubscribing), so that - * case flashes the dashboard at most once and then reverts to the wait path. - * See MAP-45. + * Per-org memory of "this org was last seen entitled to render the app", used by + * the `__root` gate to optimistically render the dashboard while the Autumn + * customer query is still loading. Keyed by orgId so a brand-new org (fresh + * signup) starts with no record — taking the no-flash "wait for the plan to + * settle" path — while a returning org skips straight to the dashboard. + * + * "Entitled" is broader than "holds an active plan": a lapsed subscriber also + * renders the app (behind the reactivation banner), so unsubscribing no longer + * clears the flag. Only an org seen with no plan history at all clears it, which + * is exactly the org the gate sends to onboarding. See MAP-45. */ const selectedPlanKnownAtomFamily = Atom.family((orgId: string) => Atom.kvs({ diff --git a/apps/web/src/components/billing/subscription-ended-banner.tsx b/apps/web/src/components/billing/subscription-ended-banner.tsx new file mode 100644 index 000000000..4c85353b3 --- /dev/null +++ b/apps/web/src/components/billing/subscription-ended-banner.tsx @@ -0,0 +1,50 @@ +import { Link } from "@tanstack/react-router" +import { useMapleCustomer } from "@/hooks/use-maple-customer" + +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" +import { Button } from "@maple/ui/components/ui/button" +import { CircleWarningIcon } from "@/components/icons" +import { getLapsedPlan } from "@/lib/billing/plan-gating" + +// Dev-only escape hatch so the banner can be eyeballed without a lapsed Autumn +// customer: load any page with `?subscription_ended_preview=1`. Compiled out of +// production builds (import.meta.env.DEV). +function previewLapsed(): boolean { + if (!import.meta.env.DEV || typeof window === "undefined") return false + return new URLSearchParams(window.location.search).get("subscription_ended_preview") === "1" +} + +/** + * Critical alert shown in the app shell when the org held a plan and no longer + * does — cancelled, expired, or otherwise lapsed. These are returning customers, + * so they keep the app (their history is still here to read); what they've lost + * is ingestion, which the gateway rejects with a 402 until a plan is active + * again. Non-dismissible — it stays put until they resubscribe. + */ +export function SubscriptionEndedBanner() { + const { data: customer } = useMapleCustomer() + const lapsed = getLapsedPlan(customer) + const preview = previewLapsed() + + if (!lapsed && !preview) return null + + const planName = lapsed?.plan?.name ?? lapsed?.planId ?? null + + return ( +
+ + + Subscription ended + + {planName ? `Your ${planName} plan has ended.` : "Your plan has ended."} Your existing + data is still here, but new telemetry is being rejected until you pick a plan. + + + + + +
+ ) +} diff --git a/apps/web/src/components/layout/dashboard-layout.tsx b/apps/web/src/components/layout/dashboard-layout.tsx index e2564e47b..b346183bd 100644 --- a/apps/web/src/components/layout/dashboard-layout.tsx +++ b/apps/web/src/components/layout/dashboard-layout.tsx @@ -20,6 +20,7 @@ import { openGlobalChat } from "@/components/chat/global-chat-sheet" import { ConnectButton } from "@/components/header/connect-button" import { QuotaBanner } from "@/components/billing/quota-banner" import { PaymentFailedBanner } from "@/components/billing/payment-failed-banner" +import { SubscriptionEndedBanner } from "@/components/billing/subscription-ended-banner" import { AppUpdateBanner } from "@/components/layout/app-update-banner" import { Link, defaultParseSearch } from "@tanstack/react-router" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" @@ -164,6 +165,7 @@ function Body({ children }: { children: React.ReactNode }) { whether or not the deployment uses Clerk, and self-hosted installs have the same long-lived-tab problem. */} + {isClerkAuthEnabled && } {isClerkAuthEnabled && } {isClerkAuthEnabled && } {children} diff --git a/apps/web/src/lib/billing/plan-gating.test.ts b/apps/web/src/lib/billing/plan-gating.test.ts index f1c5c6a7b..29c7736b1 100644 --- a/apps/web/src/lib/billing/plan-gating.test.ts +++ b/apps/web/src/lib/billing/plan-gating.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest" import type { BillingBalance, BillingCustomer, BillingSubscription, CatalogPlan } from "@maple/domain/http" import { getFeatureQuotas, + getLapsedPlan, getLegacyPlanInfo, getPastDueSubscription, getQuotaStatus, hasBringYourOwnCloudAddOn, + hasLapsedPlan, hasSelectedPlan, isLegacyPlan, isUsableCustomer, @@ -110,6 +112,60 @@ describe("hasSelectedPlan", () => { }) }) +describe("getLapsedPlan / hasLapsedPlan", () => { + it("returns nothing for an org that never held a plan", () => { + expect(hasLapsedPlan(buildCustomer([]))).toBe(false) + expect(hasLapsedPlan(buildCustomer([buildSubscription({ status: "expired", addOn: true })]))).toBe( + false, + ) + expect( + hasLapsedPlan(buildCustomer([buildSubscription({ status: "expired", autoEnable: true })])), + ).toBe(false) + expect( + hasLapsedPlan( + buildCustomer([ + buildSubscription({ + status: "expired", + planId: "free", + plan: { name: "Free", archived: false }, + }), + ]), + ), + ).toBe(false) + }) + + it("returns the plan for an org whose subscription lapsed", () => { + const expired = buildCustomer([buildSubscription({ status: "expired" })]) + const canceled = buildCustomer([buildSubscription({ status: "canceled" })]) + + expect(hasLapsedPlan(expired)).toBe(true) + expect(hasLapsedPlan(canceled)).toBe(true) + expect(getLapsedPlan(expired)?.planId).toBe("starter") + }) + + it("returns nothing while an active plan exists, even alongside an expired one", () => { + const customer = buildCustomer([ + buildSubscription({ planId: "old", status: "expired" }), + buildSubscription({ planId: "startup", status: "active" }), + ]) + expect(hasLapsedPlan(customer)).toBe(false) + }) + + it("picks the most recently ended subscription", () => { + const customer = buildCustomer([ + buildSubscription({ planId: "old", status: "expired", currentPeriodEnd: 1_000 }), + buildSubscription({ planId: "recent", status: "expired", currentPeriodEnd: 5_000 }), + ]) + expect(getLapsedPlan(customer)?.planId).toBe("recent") + }) + + it("fails open on an unusable customer, so an Autumn error is never read as lapsed", () => { + expect(hasLapsedPlan(null)).toBe(false) + expect(hasLapsedPlan(undefined)).toBe(false) + expect(hasLapsedPlan({ id: "cus_1" } as unknown as Customer)).toBe(false) + }) +}) + describe("hasBringYourOwnCloudAddOn", () => { it("returns false when customer is missing", () => { expect(hasBringYourOwnCloudAddOn(null)).toBe(false) diff --git a/apps/web/src/lib/billing/plan-gating.ts b/apps/web/src/lib/billing/plan-gating.ts index 11d7e0963..bd2728c14 100644 --- a/apps/web/src/lib/billing/plan-gating.ts +++ b/apps/web/src/lib/billing/plan-gating.ts @@ -1,4 +1,4 @@ -import { isActivePlanSubscription } from "@maple/domain/billing" +import { isActivePlanSubscription, isPlanSubscription } from "@maple/domain/billing" import type { BillingBalance, BillingCustomer, BillingSubscription, CatalogPlan } from "@maple/domain/http" type Customer = BillingCustomer @@ -72,6 +72,39 @@ export function hasSelectedPlan(customer: Customer | null | undefined): boolean return getActivePlan(customer) !== null } +/** + * The plan this org used to hold and no longer does — cancelled, expired, or + * otherwise lapsed. Non-null only when there is no active plan, so a returning + * customer is distinguishable from a brand-new org whose customer carries no + * plan history at all. That distinction is the whole point: without it the + * `__root` gate bounces a lapsed subscriber into the new-user onboarding wizard. + * + * Autumn keeps lapsed subscription rows on the customer, so this reads history + * straight off the payload. When several have lapsed, the one that ended last + * wins — it's the plan the customer actually remembers being on. + */ +export function getLapsedPlan(customer: Customer | null | undefined): Subscription | null { + if (!isUsableCustomer(customer)) return null + if (getActivePlan(customer) !== null) return null + + let lapsed: Subscription | null = null + for (const sub of customer.subscriptions) { + if (!isPlanSubscription(sub)) continue + if (lapsed === null) { + lapsed = sub + continue + } + // `currentPeriodEnd` is optional upstream; a row that carries one is + // always a better answer than one that doesn't. + if ((sub.currentPeriodEnd ?? -1) > (lapsed.currentPeriodEnd ?? -1)) lapsed = sub + } + return lapsed +} + +export function hasLapsedPlan(customer: Customer | null | undefined): boolean { + return getLapsedPlan(customer) !== null +} + export interface TrialStatus { isTrialing: boolean daysRemaining: number | null diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2c8aeaf64..ddeb7338f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,7 +10,7 @@ import { } from "@tanstack/react-router" import { selectedPlanKnownAtomFor } from "@/atoms/selected-plan-atoms" import { useAtom } from "@/lib/effect-atom" -import { hasSelectedPlan, isUsableCustomer } from "@/lib/billing/plan-gating" +import { hasLapsedPlan, hasSelectedPlan, isUsableCustomer } from "@/lib/billing/plan-gating" import { isFixturePath, isPublicPath } from "@/lib/public-routes" import { parseRedirectUrl } from "@/lib/redirect-utils" import { AnchoredToastProvider, ToastProvider } from "@maple/ui/components/ui/toast" @@ -141,23 +141,27 @@ function ClerkReverseRedirects() { const redirectUrl = pathname + (searchStr ?? "") const selectedPlan = hasSelectedPlan(customer) + // An org that held a plan and no longer does. Never onboarded again — it gets + // the app plus the reactivation banner (`SubscriptionEndedBanner`) instead. + const lapsedPlan = hasLapsedPlan(customer) + const mayRenderApp = selectedPlan || lapsedPlan // Per-org, localStorage-backed memory (effect-atom KVS) of whether this org - // was last seen on an active selected plan. Drives the optimistic "render the + // was last seen entitled to render the app. Drives the optimistic "render the // dashboard while the plan is still loading" fast path below. Falls back to an // inert in-memory atom while there's no org (org-less / still-settling auth). - const [knownSelectedPlan, setKnownSelectedPlan] = useAtom(selectedPlanKnownAtomFor(orgId)) + const [knownMayRenderApp, setKnownMayRenderApp] = useAtom(selectedPlanKnownAtomFor(orgId)) // Once the customer query settles to a usable payload, record whether this - // org holds an active selected plan, so the flag only ever reflects a - // genuinely-known plan state — skip while loading or on an error/unusable - // payload so a transient blip can't flip it. A planless settle (e.g. - // unsubscribe) clears it here, ending the optimistic flash. See MAP-45. + // org may render the app, so the flag only ever reflects a genuinely-known + // billing state — skip while loading or on an error/unusable payload so a + // transient blip can't flip it. A never-subscribed settle clears it here, + // ending the optimistic flash. See MAP-45. useEffect(() => { if (!isSignedIn || !orgId || isCustomerLoading) return if (customerError || !isUsableCustomer(customer)) return - setKnownSelectedPlan(selectedPlan) - }, [isSignedIn, orgId, isCustomerLoading, customerError, customer, selectedPlan, setKnownSelectedPlan]) + setKnownMayRenderApp(mayRenderApp) + }, [isSignedIn, orgId, isCustomerLoading, customerError, customer, mayRenderApp, setKnownMayRenderApp]) if (isSignedIn && pathname === "/sign-in") { const target = getRedirectTarget(searchStr) @@ -199,19 +203,19 @@ function ClerkReverseRedirects() { // Plan not yet known (query still loading/retrying). Allowed-without-plan // routes render their own onboarding UI, so let them through. For every // other route, only optimistically render the dashboard when this browser - // already knows the org holds a selected plan — otherwise show a loading + // already knows the org may render the app — otherwise show a loading // screen until the query settles, so we never flash the dashboard before - // bouncing a planless user to /quick-start. The flag is cleared on - // unsubscribe, so that case flashes once and then takes the wait path. + // bouncing a never-subscribed user to /quick-start. if (isCustomerLoading && !quotaPreview) { - if (ALLOWED_WITHOUT_PLAN.includes(pathname) || knownSelectedPlan) { + if (ALLOWED_WITHOUT_PLAN.includes(pathname) || knownMayRenderApp) { return } return } - // Plan known (or dev quota preview): apply the gate. - if (!selectedPlan && !quotaPreview && !ALLOWED_WITHOUT_PLAN.includes(pathname)) { + // Plan known (or dev quota preview): apply the gate. Only an org that has + // never held a plan is sent to onboarding — a lapsed one gets the app. + if (!mayRenderApp && !quotaPreview && !ALLOWED_WITHOUT_PLAN.includes(pathname)) { return } if (selectedPlan && pathname === "/select-plan") { diff --git a/apps/web/src/routes/quick-start.tsx b/apps/web/src/routes/quick-start.tsx index 5b3989c6f..48483c8df 100644 --- a/apps/web/src/routes/quick-start.tsx +++ b/apps/web/src/routes/quick-start.tsx @@ -4,13 +4,14 @@ import { useAuth } from "@clerk/clerk-react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { useMapleCustomer } from "@/hooks/use-maple-customer" +import { BootSplash } from "@/components/boot-splash" import { OnboardingLayout } from "@/components/onboarding/onboarding-layout" import { QUALIFY_QUESTIONS, StepQualifyQuestion } from "@/components/onboarding/step-qualify" import { StepPlan } from "@/components/onboarding/step-plan" import { StepDemo } from "@/components/onboarding/step-demo" import { useQuickStart, type StepId } from "@/hooks/use-quick-start" -import { hasSelectedPlan } from "@/lib/billing/plan-gating" +import { hasLapsedPlan, hasSelectedPlan } from "@/lib/billing/plan-gating" import { STEP_IDS, type RoleOption } from "@/atoms/quick-start-atoms" export const Route = createFileRoute("/quick-start")({ @@ -34,8 +35,12 @@ function QuickStartPage() { setDemoDataRequested, } = useQuickStart(orgId) - const { data: customer } = useMapleCustomer() + const { data: customer, isLoading: isCustomerLoading } = useMapleCustomer() const planSelected = hasSelectedPlan(customer) + // A returning subscriber whose plan lapsed can still land here by bookmark or + // back button. They have already onboarded, so send them to the app — the + // reactivation banner there is what they need, not the new-user wizard. + const planLapsed = hasLapsedPlan(customer) // "plan" completion is the live Autumn plan state, never a persisted flag. // A stale flag would disagree with __root.tsx's no-plan guard and trap the @@ -53,7 +58,14 @@ function QuickStartPage() { } const direction = currentStepNumber >= stepWindow[0] ? 1 : -1 - if (onboardingComplete) { + // Wait for the customer before rendering a step: deciding from an unsettled + // query flashes "what's your role?" at a returning subscriber before the + // bail-out below can fire. + if (isCustomerLoading) { + return + } + + if (onboardingComplete || planLapsed) { return } diff --git a/apps/web/src/routes/select-plan.tsx b/apps/web/src/routes/select-plan.tsx index f0bea5c4a..6c1f33e28 100644 --- a/apps/web/src/routes/select-plan.tsx +++ b/apps/web/src/routes/select-plan.tsx @@ -5,7 +5,7 @@ import { Schema } from "effect" import { RocketIcon } from "@/components/icons" import { BootSplash } from "@/components/boot-splash" import { PricingCards } from "@/components/settings/pricing-cards" -import { hasSelectedPlan } from "@/lib/billing/plan-gating" +import { hasLapsedPlan, hasSelectedPlan } from "@/lib/billing/plan-gating" import { TRIAL_DURATION_DAYS } from "@/lib/billing/plans" import { parseRedirectUrl } from "@/lib/redirect-utils" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" @@ -19,6 +19,14 @@ export const Route = createFileRoute("/select-plan")({ validateSearch: Schema.toStandardSchemaV1(SelectPlanSearch), }) +// Dev-only escape hatch, mirroring `SubscriptionEndedBanner`'s: load the page +// with `?subscription_ended_preview=1` to review the reactivation framing without +// a lapsed Autumn customer. Compiled out of production builds. +function previewLapsed(): boolean { + if (!import.meta.env.DEV || typeof window === "undefined") return false + return new URLSearchParams(window.location.search).get("subscription_ended_preview") === "1" +} + function resolveRedirectTarget(target: string | undefined): string { if (!target) return "/" return target.startsWith("/") ? target : "/" @@ -59,6 +67,10 @@ function SelectPlanPageInner() { return } + // A returning subscriber whose plan lapsed is not trial-eligible and does not + // need the pitch — they need to restart ingestion. Same cards, different frame. + const isReactivating = previewLapsed() || hasLapsedPlan(customer) + return (
{/* Premium Background Grid / Glow */} @@ -72,16 +84,19 @@ function SelectPlanPageInner() {
-
- - {TRIAL_DURATION_DAYS}-day free trial -
+ {!isReactivating && ( +
+ + {TRIAL_DURATION_DAYS}-day free trial +
+ )}

- Start your free trial + {isReactivating ? "Pick up where you left off" : "Start your free trial"}

- Try Maple free for {TRIAL_DURATION_DAYS} days. You won't be charged until the trial - ends. Cancel anytime. + {isReactivating + ? "Your data is still here. Choose a plan to start ingesting again — cancel anytime." + : `Try Maple free for ${TRIAL_DURATION_DAYS} days. You won't be charged until the trial ends. Cancel anytime.`}

diff --git a/packages/domain/src/billing.test.ts b/packages/domain/src/billing.test.ts index 3931d11ec..f0b3b0657 100644 --- a/packages/domain/src/billing.test.ts +++ b/packages/domain/src/billing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { cycleSpend, isActivePlanSubscription, + isPlanSubscription, isPricedPlan, overageUnits, projectCycleSpend, @@ -119,6 +120,27 @@ describe("isActivePlanSubscription", () => { }) }) +describe("isPlanSubscription", () => { + it("is true for a real base plan whatever its status", () => { + expect(isPlanSubscription({ planId: "startup", status: "active" })).toBe(true) + expect(isPlanSubscription({ planId: "startup", status: "expired" })).toBe(true) + expect(isPlanSubscription({ planId: "startup", status: "canceled" })).toBe(true) + expect(isPlanSubscription({ planId: "startup", status: "scheduled" })).toBe(true) + }) + + it("is false for add-on, auto-enabled, and free plans", () => { + expect(isPlanSubscription({ planId: "byoc", status: "expired", addOn: true })).toBe(false) + expect(isPlanSubscription({ planId: "starter", status: "expired", autoEnable: true })).toBe(false) + expect(isPlanSubscription({ planId: "free", status: "expired" })).toBe(false) + expect(isPlanSubscription({ planId: "x", status: "expired", plan: { name: "Free" } })).toBe(false) + }) + + it("is false for missing input", () => { + expect(isPlanSubscription(null)).toBe(false) + expect(isPlanSubscription(undefined)).toBe(false) + }) +}) + describe("resolveSubscriptionPlan", () => { const catalogStartup = { id: "startup", diff --git a/packages/domain/src/billing.ts b/packages/domain/src/billing.ts index 98a643b01..97e9e7cc1 100644 --- a/packages/domain/src/billing.ts +++ b/packages/domain/src/billing.ts @@ -14,12 +14,23 @@ export interface PlanGatingSubscription { readonly plan?: { readonly name?: string | null } | null } -/** Active, and not an add-on / auto-enabled / legacy-free tier. Trials count — Autumn reports them as `active`. */ -export function isActivePlanSubscription(sub: PlanGatingSubscription | null | undefined): boolean { +/** + * A real plan subscription — not an add-on, an auto-enabled entitlement, or the + * legacy free tier — whatever its status. Status-blind on purpose: a lapsed + * (`expired` / `canceled`) row still identifies a returning customer, which is + * what tells the web gate to keep them in the app rather than dropping them back + * into new-user onboarding. + */ +export function isPlanSubscription(sub: PlanGatingSubscription | null | undefined): boolean { if (!sub) return false if (sub.addOn || sub.autoEnable) return false if (sub.planId?.toLowerCase() === "free" || sub.plan?.name?.toLowerCase() === "free") return false - return sub.status === "active" + return true +} + +/** Active, and not an add-on / auto-enabled / legacy-free tier. Trials count — Autumn reports them as `active`. */ +export function isActivePlanSubscription(sub: PlanGatingSubscription | null | undefined): boolean { + return isPlanSubscription(sub) && sub?.status === "active" } // Cycle pricing From 891d57fcde1ece5903fcf07c5961e45b96a2ce13 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 11:41:25 +0200 Subject: [PATCH 2/3] fix(web): reuse the shared error-payload fixture in the lapsed-plan tests `as unknown as Customer` tripped the anti-slop chained-assertion gate. The malformed-payload case it was covering already has a home: the shared `errorPayload` fixture and the "gating helpers never throw" test. Assert `hasLapsedPlan` there instead, and keep the local test to the no-customer case. --- apps/web/src/lib/billing/plan-gating.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/billing/plan-gating.test.ts b/apps/web/src/lib/billing/plan-gating.test.ts index 29c7736b1..6deb0a497 100644 --- a/apps/web/src/lib/billing/plan-gating.test.ts +++ b/apps/web/src/lib/billing/plan-gating.test.ts @@ -159,10 +159,10 @@ describe("getLapsedPlan / hasLapsedPlan", () => { expect(getLapsedPlan(customer)?.planId).toBe("recent") }) - it("fails open on an unusable customer, so an Autumn error is never read as lapsed", () => { + it("returns nothing when there is no customer at all", () => { expect(hasLapsedPlan(null)).toBe(false) expect(hasLapsedPlan(undefined)).toBe(false) - expect(hasLapsedPlan({ id: "cus_1" } as unknown as Customer)).toBe(false) + expect(getLapsedPlan(null)).toBeNull() }) }) @@ -217,6 +217,10 @@ describe("malformed / error-shaped customer payloads", () => { it("gating helpers never throw on an error payload and fail closed", () => { expect(() => hasSelectedPlan(errorPayload)).not.toThrow() expect(hasSelectedPlan(errorPayload)).toBe(false) + // An Autumn error must never read as "this org used to have a plan" — that + // would wave a brand-new org past the onboarding gate. + expect(() => hasLapsedPlan(errorPayload)).not.toThrow() + expect(hasLapsedPlan(errorPayload)).toBe(false) expect(hasBringYourOwnCloudAddOn(errorPayload)).toBe(false) expect(isUsageBasedPlan(errorPayload)).toBe(false) expect(getQuotaStatus(errorPayload)).toBe("ok") From c93e36f7f3f057560ca353d683000f54fc009b0a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 11:52:05 +0200 Subject: [PATCH 3/3] perf(api): give lapsed customers their own cache TTL tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No active plan" covered two states with opposite cache economics, and both got the 5s unsettled TTL: an org seconds from its first checkout, and one that lapsed weeks ago. The short TTL exists for the first — it stops a just-subscribed user being stranded on the gate while the Stripe→Autumn sync lands — but it sends every page load of the second straight upstream for an answer that will not change for weeks. That is a new hot path as of the previous commit: lapsed orgs now browse the app instead of being parked on /quick-start, and the customer read is on every page load. Prod traces put `autumn.request` at p50 94ms / p95 603ms, and the cached route at p50 6ms / p95 14ms, so each avoidable miss is a ~100ms first-paint tax and a ~600ms tail one. Split the tiers on plan history, which is the same signal the web gate now reads: active -> 300s, lapsed -> 60s, never-subscribed -> 5s. A resubscribe stays visible promptly (attach invalidates the entry outright, and 60s bounds the worst case), while the upstream call rate for a lapsed org drops ~12x. --- .../src/routes/internal/billing.http.test.ts | 33 ++++++++- .../api/src/services/billing/autumn-client.ts | 70 ++++++++++++++----- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/apps/api/src/routes/internal/billing.http.test.ts b/apps/api/src/routes/internal/billing.http.test.ts index aa46cd8c5..ffc95409b 100644 --- a/apps/api/src/routes/internal/billing.http.test.ts +++ b/apps/api/src/routes/internal/billing.http.test.ts @@ -6,10 +6,12 @@ import { Env } from "@/platform/Env" import { CUSTOMER_CACHE_BUCKET, CUSTOMER_CACHE_TTL_SECONDS, + CUSTOMER_CACHE_LAPSED_TTL_SECONDS, CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS, readCustomerCached, resolveAttachConflict, responseHasActivePlan, + responseHasPlanHistory, } from "@/services/billing/autumn-client" import { AutumnClient, type AutumnResult } from "@/services/billing/autumn-http" import { @@ -47,6 +49,10 @@ const activePlanResponse = { subscriptions: [{ planId: "startup", status: "active", trialEndsAt: 9_999_999_999_000, addOn: false }], } const noPlanResponse = { id: ORG, subscriptions: [] } +const lapsedPlanResponse = { + id: ORG, + subscriptions: [{ planId: "startup", status: "expired", addOn: false }], +} // `AutumnClient` reads its credentials from `Env` and captures the HttpClient at // layer build; the fetch stub is provided as the `FetchHttpClient.Fetch` @@ -238,7 +244,7 @@ describe("readCustomerCached", () => { }), ) - it.effect("caches a planless customer for the short TTL so the gate re-checks soon", () => + it.effect("caches a never-subscribed customer for the short TTL — checkout is imminent", () => Effect.gen(function* () { const { cache, puts } = makeRecordingBackend() const run = Effect.succeed({ statusCode: 200, response: noPlanResponse }) @@ -247,6 +253,15 @@ describe("readCustomerCached", () => { }), ) + it.effect("caches a lapsed customer for the middle TTL — durably planless, not mid-signup", () => + Effect.gen(function* () { + const { cache, puts } = makeRecordingBackend() + const run = Effect.succeed({ statusCode: 200, response: lapsedPlanResponse }) + yield* readCustomerCached(cache, ORG, run) + assert.deepStrictEqual(puts, [CUSTOMER_CACHE_LAPSED_TTL_SECONDS]) + }), + ) + it.effect("treats an error-shaped 200 (no subscriptions array) as unsettled → short TTL", () => Effect.gen(function* () { const { cache, puts } = makeRecordingBackend() @@ -361,6 +376,22 @@ describe("responseHasActivePlan", () => { }) }) +describe("responseHasPlanHistory", () => { + it("separates a lapsed customer from one that never subscribed", () => { + assert.isTrue(responseHasPlanHistory(lapsedPlanResponse)) + assert.isTrue(responseHasPlanHistory(activePlanResponse)) + assert.isFalse(responseHasPlanHistory(noPlanResponse)) + assert.isFalse(responseHasPlanHistory({ id: ORG })) + }) + + it("ignores add-on, auto-enabled and free rows — they never gated anything", () => { + assert.isFalse( + responseHasPlanHistory({ subscriptions: [{ planId: "byoc", status: "expired", addOn: true }] }), + ) + assert.isFalse(responseHasPlanHistory({ subscriptions: [{ planId: "free", status: "expired" }] })) + }) +}) + describe("resolveAttachConflict", () => { const conflict = new BillingConflictError({ message: "Customer already has this plan", diff --git a/apps/api/src/services/billing/autumn-client.ts b/apps/api/src/services/billing/autumn-client.ts index 9ec3e2899..a4a8eb844 100644 --- a/apps/api/src/services/billing/autumn-client.ts +++ b/apps/api/src/services/billing/autumn-client.ts @@ -1,6 +1,10 @@ import { Effect, Schema } from "effect" import type { EdgeCacheServiceApi } from "@maple/cache" -import { isActivePlanSubscription } from "@maple/domain/billing" +import { + isActivePlanSubscription, + isPlanSubscription, + type PlanGatingSubscription, +} from "@maple/domain/billing" import { BillingConflictError, BillingCustomer, @@ -29,21 +33,59 @@ import type { AutumnResult, AutumnTransportFailure } from "./autumn-http" export const CUSTOMER_CACHE_BUCKET = "autumn-customer" export const CUSTOMER_CACHE_TTL_SECONDS = 300 -// Short TTL for a customer with no active plan: caching that "no plan" snapshot -// for the full 5 min would strand a just-subscribed user on the /quick-start -// gate until the post-checkout Stripe→Autumn sync lands. Re-check soon instead. +// Short TTL for a customer with no plan history at all: this org is mid-signup +// and about to check out, and caching that "no plan" snapshot for the full 5 min +// would strand it on the /quick-start gate until the post-checkout Stripe→Autumn +// sync lands. Re-check almost immediately instead. export const CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS = 5 +// A lapsed customer (held a plan, holds none now) is planless *durably* — that +// state normally persists for weeks, so the 5s unsettled TTL would send every +// one of their page loads upstream (p50 ~94ms, p95 ~603ms) for an answer that +// hasn't changed. They browse the app now rather than being parked on the +// onboarding gate, so this is a real hot path. A minute keeps a resubscribe +// visible promptly — `attach` also invalidates the entry outright — while +// cutting the upstream call rate ~12x. +export const CUSTOMER_CACHE_LAPSED_TTL_SECONDS = 60 + /** * Does this raw `getOrCreateCustomer` response carry an active, non-add-on, * non-free plan? Delegates to the shared `isActivePlanSubscription` gate * (@maple/domain/billing) so the cache TTL can't drift from the web redirect gate. */ -export const responseHasActivePlan = (response: unknown): boolean => { - if (typeof response !== "object" || response === null) return false +export const responseHasActivePlan = (response: unknown): boolean => + subscriptionsOf(response).some(isActivePlanSubscription) + +/** + * Has this org ever held a real plan, whatever its status? Mirrors the web + * `hasLapsedPlan` gate — together with `responseHasActivePlan` it separates a + * never-subscribed org (short TTL, checkout imminent) from a lapsed one + * (durably planless, so worth caching). + */ +export const responseHasPlanHistory = (response: unknown): boolean => + subscriptionsOf(response).some(isPlanSubscription) + +// Every field of `PlanGatingSubscription` is optional, so "is an object" is the +// whole shape check — the gating predicates answer for themselves what a missing +// `status` or `planId` means. Anything else in the array is not a subscription. +const isSubscriptionLike = (value: unknown): value is PlanGatingSubscription => + typeof value === "object" && value !== null + +const subscriptionsOf = (response: unknown): ReadonlyArray => { + if (typeof response !== "object" || response === null) return [] const subscriptions = (response as { subscriptions?: unknown }).subscriptions - if (!Array.isArray(subscriptions)) return false - return subscriptions.some((sub) => isActivePlanSubscription(sub)) + return Array.isArray(subscriptions) ? subscriptions.filter(isSubscriptionLike) : [] +} + +/** + * How long a `getOrCreateCustomer` response stays cached. Three tiers, because + * "no active plan" covers two states with opposite cache economics: an org + * seconds away from its first checkout, and one that lapsed weeks ago. + */ +const customerCacheTtl = (response: unknown): number => { + if (responseHasActivePlan(response)) return CUSTOMER_CACHE_TTL_SECONDS + if (responseHasPlanHistory(response)) return CUSTOMER_CACHE_LAPSED_TTL_SECONDS + return CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS } // Sentinel keeping non-200 Autumn responses out of the edge cache: the compute @@ -58,10 +100,9 @@ class UncacheableAutumnResult extends Schema.TaggedError, @@ -73,10 +114,7 @@ export const readCustomerCached = ( { bucket: CUSTOMER_CACHE_BUCKET, key: orgId, - ttlSeconds: (result: AutumnResult) => - responseHasActivePlan(result.response) - ? CUSTOMER_CACHE_TTL_SECONDS - : CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS, + ttlSeconds: (result: AutumnResult) => customerCacheTtl(result.response), }, runAutumn.pipe( Effect.flatMap((res) =>