Re-add free plan as the floor; remove subscribe gate (#321)

This commit is contained in:
Ben Senescu 2026-06-30 22:26:26 -04:00 committed by Ben Senescu
parent 819e33be07
commit b5bda159ea
13 changed files with 82 additions and 221 deletions

View File

@ -1,11 +0,0 @@
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,
});

View File

@ -38,8 +38,8 @@ export function getSubscribeRouteState(args: {
return "redirectToApp" as const; return "redirectToApp" as const;
} }
// Grandfathered free-plan users landing here outside the upgrade flow // Free-plan users landing here outside the upgrade flow belong in the app,
// belong in the app, not on the paywall. // not on the paywall.
if (args.hasManagedAccess && !args.isUpgradeFlow) { if (args.hasManagedAccess && !args.isUpgradeFlow) {
return "redirectToApp" as const; return "redirectToApp" as const;
} }

View File

@ -1,70 +0,0 @@
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 };
}

View File

@ -23,7 +23,6 @@ type PostSignupOnboardingProps = {
onBack: () => void; onBack: () => void;
onSkip: () => void; onSkip: () => void;
onFinish: (mcpSetupIntent: "yes" | "no") => void; onFinish: (mcpSetupIntent: "yes" | "no") => void;
onUpgradeAcknowledged: () => void;
isSaving: boolean; isSaving: boolean;
accountMenu: ReactNode; accountMenu: ReactNode;
}; };
@ -39,7 +38,6 @@ export function PostSignupOnboarding({
onBack, onBack,
onSkip, onSkip,
onFinish, onFinish,
onUpgradeAcknowledged,
isSaving, isSaving,
accountMenu, accountMenu,
}: PostSignupOnboardingProps) { }: PostSignupOnboardingProps) {
@ -55,55 +53,6 @@ export function PostSignupOnboarding({
const updateAnswers = (patch: Partial<OnboardingAnswers>) => const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
onAnswersChange({ ...answers, ...patch }); onAnswersChange({ ...answers, ...patch });
// After a successful checkout the user lands on the GSC step with
// `?checkout=success`. Show a one-time "you're in" screen (same layout as the
// steps) and only reveal the actual GSC step once they continue, which drops
// the param.
const justUpgraded =
step === 3 &&
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("checkout") === "success";
if (justUpgraded) {
return (
<div className="w-full max-w-md space-y-6">
{accountMenu}
<div className="text-center space-y-3">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<h1 className="text-xl font-semibold">Youre in! 🎉</h1>
<p className="text-sm text-base-content/60">
Your subscriptions active.
</p>
</div>
<div className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
<h2 className="text-lg font-semibold">
Finish setting up your account
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
Two quick steps left connect Google Search Console, then set up
MCP for your agent.
</p>
<div className="mt-5 flex justify-end">
<button
type="button"
className="btn btn-soft"
onClick={onUpgradeAcknowledged}
>
Continue
<ArrowRight className="size-4" />
</button>
</div>
</div>
</div>
);
}
return ( return (
<div className="w-full max-w-md space-y-6"> <div className="w-full max-w-md space-y-6">
{accountMenu} {accountMenu}

View File

@ -1,7 +1,9 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start"; import { tanstackStartCookies } from "better-auth/tanstack-start";
import { isDisposableEmailDomain } from "@/server/auth/disposable-email";
import * as d1Schema from "@/db/d1/schema"; import * as d1Schema from "@/db/d1/schema";
import { d1Db } from "@/db/d1/client"; import { d1Db } from "@/db/d1/client";
import { pgDb } from "@/db/pg/client"; import { pgDb } from "@/db/pg/client";
@ -84,6 +86,20 @@ function createAuth() {
databaseHooks: { databaseHooks: {
user: { user: {
create: { create: {
// Hosted only: keep cheap mass-signups off the free plan by rejecting
// throwaway-inbox domains before the user row is created. Self-hosted
// has no shared credit pool to protect, so it's left untouched.
before: async (user) => {
if (
isHostedAuthMode(env.AUTH_MODE) &&
isDisposableEmailDomain(user.email)
) {
throw new APIError("BAD_REQUEST", {
message: "Please sign up with a non-disposable email address.",
});
}
return { data: user };
},
after: async (user) => { after: async (user) => {
await syncHostedSignupContact(user); await syncHostedSignupContact(user);
}, },

View File

@ -2,7 +2,6 @@ import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect";
export const Route = createFileRoute("/_app")({ export const Route = createFileRoute("/_app")({
component: AppRouteLayout, component: AppRouteLayout,
@ -11,9 +10,8 @@ export const Route = createFileRoute("/_app")({
function AppRouteLayout() { function AppRouteLayout() {
const authGate = useHostedAuthRouteGuard(); const authGate = useHostedAuthRouteGuard();
useOnboardingRedirect(); useOnboardingRedirect();
const subscribeGate = useSubscribeRedirect();
if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) { if (!authGate.canRenderAuthenticatedContent) {
return null; return null;
} }

View File

@ -10,12 +10,9 @@ import {
onboardingAnswersQueryOptions, onboardingAnswersQueryOptions,
restoreOnboardingAnswers, restoreOnboardingAnswers,
} from "@/client/features/onboarding/onboardingModel"; } from "@/client/features/onboarding/onboardingModel";
import { managedAccessQueryOptions } from "@/client/features/billing/managed-access";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { queryClient } from "@/client/tanstack-db"; import { queryClient } from "@/client/tanstack-db";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
import { saveOnboardingAnswers } from "@/serverFunctions/onboarding"; import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z"; const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
@ -25,17 +22,9 @@ const clampStep = (step: number) =>
export const Route = createFileRoute("/_authenticated/onboarding/")({ export const Route = createFileRoute("/_authenticated/onboarding/")({
// Step lives in the URL so it survives refresh and works with back/forward. // Step lives in the URL so it survives refresh and works with back/forward.
// The subscribe route appends `checkout=success` when it sends a just-paid validateSearch: (search: Record<string, unknown>): { step: number } => {
// user back here; it triggers the one-time "You're in!" screen on the GSC
// step. Preserve it through validation so the router doesn't strip it.
validateSearch: (
search: Record<string, unknown>,
): { step: number; checkout?: string } => {
const raw = Number(search.step); const raw = Number(search.step);
return { return { step: Number.isFinite(raw) ? clampStep(raw) : 0 };
step: Number.isFinite(raw) ? clampStep(raw) : 0,
...(search.checkout === "success" ? { checkout: "success" } : {}),
};
}, },
// Send users who already finished onboarding home before rendering. Running // Send users who already finished onboarding home before rendering. Running
// this in beforeLoad (not a component effect) means it can't race with the // this in beforeLoad (not a component effect) means it can't race with the
@ -91,16 +80,6 @@ function OnboardingFlow({
const { step } = Route.useSearch(); const { step } = Route.useSearch();
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers); const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
// Self-hosted has no paywall. Hosted users hit the subscribe gate after the
// three intro questions, before the paid GSC/MCP connect steps.
const isHostedMode = isHostedClientAuthMode();
const accessQuery = useQuery({
...managedAccessQueryOptions(),
enabled: isHostedMode,
});
const needsSubscription =
isHostedMode && accessQuery.data?.hasManagedAccess === false;
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (extra: { mutationFn: (extra: {
mcpSetupIntent?: "yes" | "no"; mcpSetupIntent?: "yes" | "no";
@ -117,25 +96,6 @@ function OnboardingFlow({
const goToStep = (next: number) => const goToStep = (next: number) =>
void navigate({ to: "/onboarding", search: { step: clampStep(next) } }); void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
const advanceFromCurrentStep = () => {
const next = clampStep(step + 1);
// After the three intro questions, hosted users hit the subscribe paywall
// before the GSC/MCP connect steps. We return to step 3 (GSC) afterward;
// the subscribe route appends `checkout=success` once payment lands, which
// drives the one-time "You're in!" screen there.
// The strategy chat that used to sit here is parked for now; see
// /onboarding/chat (still routed) — we'll revisit it later.
if (step === 2 && needsSubscription) {
void navigate({
to: SUBSCRIBE_ROUTE,
search: { redirect: `/onboarding?step=${next}` },
replace: true,
});
return;
}
goToStep(next);
};
const handleNext = () => { const handleNext = () => {
if (step === 0) { if (step === 0) {
captureClientEvent("onboarding:interests_selected", { captureClientEvent("onboarding:interests_selected", {
@ -144,13 +104,13 @@ function OnboardingFlow({
}); });
} }
saveMutation.mutate({}); saveMutation.mutate({});
advanceFromCurrentStep(); goToStep(step + 1);
}; };
const handleSkip = () => { const handleSkip = () => {
saveMutation.mutate({}); saveMutation.mutate({});
captureClientEvent("onboarding:step_skipped", { step }); captureClientEvent("onboarding:step_skipped", { step });
advanceFromCurrentStep(); goToStep(step + 1);
}; };
const handleFinish = async (mcpSetupIntent: "yes" | "no") => { const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
@ -191,9 +151,6 @@ function OnboardingFlow({
onBack={() => goToStep(step - 1)} onBack={() => goToStep(step - 1)}
onSkip={handleSkip} onSkip={handleSkip}
onFinish={handleFinish} onFinish={handleFinish}
onUpgradeAcknowledged={() =>
void navigate({ to: "/onboarding", search: { step }, replace: true })
}
isSaving={saveMutation.isPending} isSaving={saveMutation.isPending}
accountMenu={<OnboardingAccountMenu email={email} />} accountMenu={<OnboardingAccountMenu email={email} />}
/> />

View File

@ -10,9 +10,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getSubscribeRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { MANAGED_ACCESS_QUERY_KEY } from "@/client/features/billing/managed-access";
import { normalizeAuthRedirect } from "@/lib/auth-redirect"; import { normalizeAuthRedirect } from "@/lib/auth-redirect";
import { queryClient } from "@/client/tanstack-db";
import { import {
AUTUMN_MANAGED_ACCESS_FEATURE_ID, AUTUMN_MANAGED_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_ID, AUTUMN_PAID_PLAN_ID,
@ -99,22 +97,11 @@ function SubscribePageContent() {
useEffect(() => { useEffect(() => {
if (subscribeRouteState === "redirectToApp") { if (subscribeRouteState === "redirectToApp") {
// The app layouts gate on this query; make sure they see fresh access
// state instead of a cached "no access" that would bounce back here.
void queryClient.invalidateQueries({
queryKey: MANAGED_ACCESS_QUERY_KEY,
});
const destination = redirect ?? "/"; const destination = redirect ?? "/";
const [destinationPath, destinationQuery] = destination.split("?"); const [destinationPath, destinationQuery] = destination.split("?");
const destinationSearch: Record<string, string> = destinationQuery const destinationSearch: Record<string, string> = destinationQuery
? Object.fromEntries(new URLSearchParams(destinationQuery)) ? Object.fromEntries(new URLSearchParams(destinationQuery))
: {}; : {};
// Tell the destination a fresh checkout just landed (the onboarding GSC
// step uses this to show its one-time "You're in!" screen). Only set it
// when payment actually completed, not speculatively at redirect time.
if (checkoutCompleted) {
destinationSearch.checkout = "success";
}
const goToApp = () => const goToApp = () =>
void navigate({ void navigate({
to: destinationPath, to: destinationPath,

View File

@ -8,7 +8,6 @@ import { useEffect } from "react";
import { setLastProjectId } from "@/client/lib/active-project"; import { setLastProjectId } from "@/client/lib/active-project";
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect";
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
import { getErrorCode } from "@/client/lib/error-messages"; import { getErrorCode } from "@/client/lib/error-messages";
import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
@ -44,7 +43,6 @@ function ProjectLayout() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const authGate = useHostedAuthRouteGuard(); const authGate = useHostedAuthRouteGuard();
useOnboardingRedirect(); useOnboardingRedirect();
const subscribeGate = useSubscribeRedirect();
// Remember this as the last-visited project for the landing redirect. // Remember this as the last-visited project for the landing redirect.
// Settings is excluded: editing another project's settings is // Settings is excluded: editing another project's settings is
@ -58,7 +56,7 @@ function ProjectLayout() {
setLastProjectId(projectId); setLastProjectId(projectId);
}, [projectId, isSettingsPage]); }, [projectId, isSettingsPage]);
if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) { if (!authGate.canRenderAuthenticatedContent) {
return null; return null;
} }

View File

@ -0,0 +1,53 @@
// A curated blocklist of the highest-volume disposable / throwaway email
// providers. The free plan grants real credit spend off nothing but a verified
// email, so the cheapest way to farm it is a temp-inbox service that can still
// receive the verification link. Blocking the busiest of those raises the cost
// of mass signups without touching legitimate users.
//
// This is deliberately a small, zero-dependency list — it is not exhaustive.
// For comprehensive coverage, swap this for the `disposable-email-domains`
// package or a captcha on signup (see the PR notes).
const DISPOSABLE_EMAIL_DOMAINS = new Set<string>([
"0clock.net",
"10minutemail.com",
"20minutemail.com",
"33mail.com",
"burnermail.io",
"dispostable.com",
"emailondeck.com",
"fakeinbox.com",
"getairmail.com",
"getnada.com",
"guerrillamail.com",
"guerrillamail.net",
"guerrillamail.org",
"inboxbear.com",
"inboxkitten.com",
"mail-temp.com",
"mailcatch.com",
"maildrop.cc",
"mailinator.com",
"mailnesia.com",
"moakt.com",
"mohmal.com",
"mytemp.email",
"sharklasers.com",
"spam4.me",
"temp-mail.io",
"temp-mail.org",
"tempmail.com",
"tempmail.dev",
"tempmailo.com",
"tempr.email",
"throwawaymail.com",
"trashmail.com",
"trashmail.de",
"yopmail.com",
"yopmail.fr",
"yopmail.net",
]);
export function isDisposableEmailDomain(email: string): boolean {
const domain = email.split("@").at(-1)?.trim().toLowerCase();
return domain !== undefined && DISPOSABLE_EMAIL_DOMAINS.has(domain);
}

View File

@ -20,7 +20,8 @@ export const startAudit = createServerFn({ method: "POST" })
.inputValidator((data: unknown) => startAuditSchema.parse(data)) .inputValidator((data: unknown) => startAuditSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
// The crawler runs on our Workers compute and isn't credit-metered, so // The crawler runs on our Workers compute and isn't credit-metered, so
// gate it on plan access in hosted mode (grandfathered free plans pass). // gate it on managed access in hosted mode. Free and paid plans both grant
// it; only customers with no Autumn product at all are turned away.
if ( if (
(await isHostedServerAuthMode()) && (await isHostedServerAuthMode()) &&
!(await customerHasManagedAccess(context.organizationId)) !(await customerHasManagedAccess(context.organizationId))

View File

@ -10,10 +10,6 @@ import {
isHostedServerAuthMode, isHostedServerAuthMode,
} from "@/server/lib/runtime-env"; } from "@/server/lib/runtime-env";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
import {
customerHasManagedAccess,
getOrCreateOrganizationCustomer,
} from "@/server/billing/subscription";
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list"; const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
const EVENT_PAGE_LIMIT = 1000; const EVENT_PAGE_LIMIT = 1000;
@ -52,20 +48,6 @@ export type BillingUsageEvent = {
properties: Record<string, z.infer<typeof billingUsagePropertySchema>>; properties: Record<string, z.infer<typeof billingUsagePropertySchema>>;
}; };
// Whether this organization may use the managed product at all. Both the
// legacy free plan (grandfathered users) and the paid base plan grant the
// feature; new customers get no default product and must subscribe.
export const getManagedAccessStatus = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) => {
if (!(await isHostedServerAuthMode())) {
return { hasManagedAccess: true };
}
const customer = await getOrCreateOrganizationCustomer(context);
return { hasManagedAccess: await customerHasManagedAccess(customer.id) };
});
export const getBillingUsageEvents = createServerFn({ method: "POST" }) export const getBillingUsageEvents = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => billingUsageRangeSchema.parse(data)) .inputValidator((data: unknown) => billingUsageRangeSchema.parse(data))

View File

@ -4,8 +4,9 @@ export const SUBSCRIBE_ROUTE = "/subscribe";
export const AUTUMN_PAID_PLAN_ID = "base-plan"; export const AUTUMN_PAID_PLAN_ID = "base-plan";
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up"; export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";
export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan"; export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
// Granted by both the legacy free plan (grandfathered users) and the paid // Granted by both the free plan (now the Autumn Default, so every non-paid
// base plan. New customers get no default product, so this is the paywall. // user gets it) and the paid base plan. It's the floor for using the managed
// service at all — paid-only features gate on AUTUMN_PAID_PLAN_FEATURE_ID.
export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access"; export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access";
// The shared usage-credit pool. Both DataForSEO and onboarding-LLM spend deduct // The shared usage-credit pool. Both DataForSEO and onboarding-LLM spend deduct
// from these (monthly usage_credits first, then rolled-over topup_credits). // from these (monthly usage_credits first, then rolled-over topup_credits).