From 9abed9278fa9fce1706440f014e95ec4de7f6fb4 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:21:04 -0400 Subject: [PATCH] Remove free trial: subscription paywall + 30-day money-back guarantee (#251) --- src/client/features/billing/managed-access.ts | 11 ++ .../features/billing/route-state.test.ts | 67 +++++--- src/client/features/billing/route-state.ts | 17 +- .../features/billing/useSubscribeRedirect.ts | 70 ++++++++ .../features/gsc/GscReEngagementModal.tsx | 2 +- .../gsc/SearchConsoleConnectionCard.tsx | 2 +- src/routes/_app/billing.tsx | 2 +- src/routes/_app/route.tsx | 4 +- src/routes/_authenticated.onboarding.tsx | 39 ++++- src/routes/_authenticated.subscribe.tsx | 155 ++++++++++++++---- src/routes/_project/p/$projectId/route.tsx | 4 +- src/server/billing/subscription.ts | 14 +- src/server/mcp/tools/get-rank-tracker.ts | 2 +- src/server/mcp/tools/list-projects.ts | 2 +- src/server/mcp/tools/list-saved-keywords.ts | 2 +- src/server/mcp/tools/save-keywords.ts | 2 +- src/server/mcp/tools/search-console-tools.ts | 4 +- src/server/mcp/tools/whoami.ts | 2 +- src/serverFunctions/audit.ts | 12 ++ src/serverFunctions/billing.ts | 18 ++ src/shared/billing.ts | 3 + web/content/legal/terms-and-conditions.md | 4 +- .../marketing/google-search-console-mcp.mdx | 6 +- web/src/components/comparison-table.tsx | 2 +- web/src/components/money-back-guarantee.tsx | 21 +++ .../_marketing/google-search-console-mcp.tsx | 20 ++- web/src/routes/_marketing/pricing.tsx | 26 ++- 27 files changed, 430 insertions(+), 83 deletions(-) create mode 100644 src/client/features/billing/managed-access.ts create mode 100644 src/client/features/billing/useSubscribeRedirect.ts create mode 100644 web/src/components/money-back-guarantee.tsx diff --git a/src/client/features/billing/managed-access.ts b/src/client/features/billing/managed-access.ts new file mode 100644 index 0000000..e146f78 --- /dev/null +++ b/src/client/features/billing/managed-access.ts @@ -0,0 +1,11 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getManagedAccessStatus } from "@/serverFunctions/billing"; + +export const MANAGED_ACCESS_QUERY_KEY = ["managedAccessStatus"]; + +export const managedAccessQueryOptions = () => + queryOptions({ + queryKey: MANAGED_ACCESS_QUERY_KEY, + queryFn: () => getManagedAccessStatus(), + staleTime: 30_000, + }); diff --git a/src/client/features/billing/route-state.test.ts b/src/client/features/billing/route-state.test.ts index 1559d05..73b63eb 100644 --- a/src/client/features/billing/route-state.test.ts +++ b/src/client/features/billing/route-state.test.ts @@ -46,36 +46,57 @@ describe("getBillingRouteState", () => { }); describe("getSubscribeRouteState", () => { + const base = { + hasSession: true, + isCustomerLoading: false, + isCustomerError: false, + hasManagedAccess: false, + planStatus: "free" as const, + isUpgradeFlow: false, + checkoutCompleted: false, + }; + it("shows an error state on billing lookup failures", () => { - expect( - getSubscribeRouteState({ - hasSession: true, - isCustomerLoading: false, - isCustomerError: true, - planStatus: "free", - }), - ).toBe("error"); + expect(getSubscribeRouteState({ ...base, isCustomerError: true })).toBe( + "error", + ); }); - it("redirects paying customers away from onboarding", () => { - expect( - getSubscribeRouteState({ - hasSession: true, - isCustomerLoading: false, - isCustomerError: false, - planStatus: "paid", - }), - ).toBe("redirectToApp"); + it("keeps the page blank while billing data is still loading", () => { + expect(getSubscribeRouteState({ ...base, isCustomerLoading: true })).toBe( + "loading", + ); }); - it("shows welcome page for free plan users", () => { + it("redirects paying customers into the app", () => { + expect(getSubscribeRouteState({ ...base, planStatus: "paid" })).toBe( + "redirectToApp", + ); + }); + + it("redirects grandfathered free-plan users into the app outside the upgrade flow", () => { + expect(getSubscribeRouteState({ ...base, hasManagedAccess: true })).toBe( + "redirectToApp", + ); + }); + + it("shows the paywall to grandfathered users in the upgrade flow", () => { expect( getSubscribeRouteState({ - hasSession: true, - isCustomerLoading: false, - isCustomerError: false, - planStatus: "free", + ...base, + hasManagedAccess: true, + isUpgradeFlow: true, }), - ).toBe("showWelcome"); + ).toBe("showPaywall"); + }); + + it("finalizes instead of re-showing the paywall right after checkout", () => { + expect(getSubscribeRouteState({ ...base, checkoutCompleted: true })).toBe( + "finalizing", + ); + }); + + it("shows the paywall to users without managed access", () => { + expect(getSubscribeRouteState(base)).toBe("showPaywall"); }); }); diff --git a/src/client/features/billing/route-state.ts b/src/client/features/billing/route-state.ts index a83748d..fff08ac 100644 --- a/src/client/features/billing/route-state.ts +++ b/src/client/features/billing/route-state.ts @@ -21,7 +21,10 @@ export function getSubscribeRouteState(args: { hasSession: boolean; isCustomerLoading: boolean; isCustomerError: boolean; + hasManagedAccess: boolean; planStatus: PlanStatus; + isUpgradeFlow: boolean; + checkoutCompleted: boolean; }) { if (!args.hasSession || args.isCustomerLoading) { return "loading" as const; @@ -35,5 +38,17 @@ export function getSubscribeRouteState(args: { return "redirectToApp" as const; } - return "showWelcome" as const; + // Grandfathered free-plan users landing here outside the upgrade flow + // belong in the app, not on the paywall. + if (args.hasManagedAccess && !args.isUpgradeFlow) { + return "redirectToApp" as const; + } + + // Back from Stripe but Autumn hasn't reflected the subscription yet — poll + // instead of showing the paywall again (whose only CTA is paying twice). + if (args.checkoutCompleted) { + return "finalizing" as const; + } + + return "showPaywall" as const; } diff --git a/src/client/features/billing/useSubscribeRedirect.ts b/src/client/features/billing/useSubscribeRedirect.ts new file mode 100644 index 0000000..f5ed6dd --- /dev/null +++ b/src/client/features/billing/useSubscribeRedirect.ts @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { managedAccessQueryOptions } from "@/client/features/billing/managed-access"; +import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel"; +import { useSession } from "@/lib/auth-client"; +import { + isEmailVerificationBypassed, + isHostedClientAuthMode, +} from "@/lib/auth-mode"; +import { SUBSCRIBE_ROUTE } from "@/shared/billing"; + +// Account-management pages stay reachable without a subscription so gated +// users can change settings, read docs, or contact support (no dead ends). +const GATE_EXEMPT_PATH_PREFIXES = ["/settings", "/support", "/help"]; + +// Sends hosted users without managed access (no plan in Autumn) to the +// subscribe paywall. Runs only after onboarding completes so the onboarding +// redirect always wins first, and fails open on query errors — billing being +// down must not lock paying users out (spend is gated server-side anyway). +export function useSubscribeRedirect() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { data: session } = useSession(); + const isHostedMode = isHostedClientAuthMode(); + const isEmailVerified = + session?.user?.emailVerified === true || isEmailVerificationBypassed(); + const isEligible = + isHostedMode && Boolean(session?.user?.id) && isEmailVerified; + + const onboardingQuery = useQuery({ + ...onboardingAnswersQueryOptions(), + enabled: isEligible, + }); + const hasCompletedOnboarding = Boolean(onboardingQuery.data?.completedAt); + + const accessQuery = useQuery({ + ...managedAccessQueryOptions(), + enabled: isEligible && hasCompletedOnboarding, + }); + + const isExemptPath = GATE_EXEMPT_PATH_PREFIXES.some((prefix) => + pathname.startsWith(prefix), + ); + const shouldRedirect = + isEligible && + hasCompletedOnboarding && + accessQuery.data?.hasManagedAccess === false && + !isExemptPath; + + useEffect(() => { + if (!shouldRedirect) return; + void navigate({ + to: SUBSCRIBE_ROUTE, + search: { redirect: pathname }, + replace: true, + }); + }, [navigate, pathname, shouldRedirect]); + + // Hold rendering until we know whether the user may see the app, and while + // a redirect is imminent. This avoids flashing gated pages (which would + // fire their data queries and create default projects for users who never + // pass the paywall). + const isBlocking = + isEligible && + hasCompletedOnboarding && + (accessQuery.isLoading || shouldRedirect); + + return { isBlocking }; +} diff --git a/src/client/features/gsc/GscReEngagementModal.tsx b/src/client/features/gsc/GscReEngagementModal.tsx index 76a3217..6146379 100644 --- a/src/client/features/gsc/GscReEngagementModal.tsx +++ b/src/client/features/gsc/GscReEngagementModal.tsx @@ -107,7 +107,7 @@ export function GscReEngagementModal({
Bring your real clicks, impressions, and rankings into OpenSEO and - query them from Claude or Codex over MCP. It's free. + query them from Claude or Codex over MCP. It never uses credits.
diff --git a/src/client/features/gsc/SearchConsoleConnectionCard.tsx b/src/client/features/gsc/SearchConsoleConnectionCard.tsx index 8d5e650..4a52b1a 100644 --- a/src/client/features/gsc/SearchConsoleConnectionCard.tsx +++ b/src/client/features/gsc/SearchConsoleConnectionCard.tsx @@ -138,7 +138,7 @@ export function SearchConsoleConnectionCard({ ) : (- Real clicks, impressions, and rankings. Free. + Real clicks, impressions, and rankings. No credits used.
+ + This usually takes a few seconds. +
++ Taking longer?{" "} + + Email {SUPPORT_EMAIL} + + . +
+- Cancel anytime — no commitment. Powered by Stripe. + + + 30-day money-back guarantee + + + . Cancel anytime. Powered by Stripe.
+ Questions?{" "} + + Email {SUPPORT_EMAIL} + + . +
{isUpgradeFlow ? ( Back to app - ) : ( - <> -- Or try it free — you have $0.50 of credits to explore before - committing. -
- - > - )} + ) : null}+ $10/month, 30-day money-back guarantee. Search Console tools never + use credits. +
- Free to connect. No Google Cloud project. Works with Claude, Codex, - OpenClaw, OpenCode, and Gemini. + No Google Cloud project. Zero credits to read your own data. Works with + Claude, Codex, OpenClaw, OpenCode, and Gemini.
+