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.

diff --git a/src/routes/_app/route.tsx b/src/routes/_app/route.tsx index 15582ab..a846f13 100644 --- a/src/routes/_app/route.tsx +++ b/src/routes/_app/route.tsx @@ -2,6 +2,7 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; +import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect"; export const Route = createFileRoute("/_app")({ component: AppRouteLayout, @@ -10,8 +11,9 @@ export const Route = createFileRoute("/_app")({ function AppRouteLayout() { const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); + const subscribeGate = useSubscribeRedirect(); - if (!authGate.canRenderAuthenticatedContent) { + if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) { return null; } diff --git a/src/routes/_authenticated.onboarding.tsx b/src/routes/_authenticated.onboarding.tsx index ddf08fc..5155486 100644 --- a/src/routes/_authenticated.onboarding.tsx +++ b/src/routes/_authenticated.onboarding.tsx @@ -11,13 +11,22 @@ import { onboardingAnswersQueryOptions, restoreOnboardingAnswers, } from "@/client/features/onboarding/onboardingModel"; +import { managedAccessQueryOptions } from "@/client/features/billing/managed-access"; import { captureClientEvent } from "@/client/lib/posthog"; import { queryClient } from "@/client/tanstack-db"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { SUBSCRIBE_ROUTE } from "@/shared/billing"; import { saveOnboardingAnswers } from "@/serverFunctions/onboarding"; const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z"; +// First step that requires a subscription. The earlier steps (interests, who +// you work for, how you found us) collect profiling answers we want even from +// users who bounce at the paywall, so the gate sits here — after them, before +// Search Console + MCP setup. +const SUBSCRIBE_GATE_STEP = 3; + const clampStep = (step: number) => Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP); @@ -81,6 +90,17 @@ function OnboardingFlow({ const { step } = Route.useSearch(); const [answers, setAnswers] = useState(initialAnswers); + // Self-hosted has no paywall; hosted users must subscribe before the gated + // steps. Answers from earlier steps are already saved, so a user who pays + // returns to the gated step with everything intact. + const isHostedMode = isHostedClientAuthMode(); + const accessQuery = useQuery({ + ...managedAccessQueryOptions(), + enabled: isHostedMode, + }); + const needsSubscription = + isHostedMode && accessQuery.data?.hasManagedAccess === false; + const saveMutation = useMutation({ mutationFn: (extra: { mcpSetupIntent?: "yes" | "no"; @@ -97,6 +117,21 @@ function OnboardingFlow({ const goToStep = (next: number) => void navigate({ to: "/onboarding", search: { step: clampStep(next) } }); + // Advance to the next step, but divert to the paywall when crossing into the + // first gated step. The just-saved answers let the user resume here on return. + const advanceFromCurrentStep = () => { + const next = clampStep(step + 1); + if (next >= SUBSCRIBE_GATE_STEP && needsSubscription) { + void navigate({ + to: SUBSCRIBE_ROUTE, + search: { redirect: `/onboarding?step=${SUBSCRIBE_GATE_STEP}` }, + replace: true, + }); + return; + } + goToStep(next); + }; + const handleNext = () => { if (step === 0) { captureClientEvent("onboarding:interests_selected", { @@ -105,13 +140,13 @@ function OnboardingFlow({ }); } saveMutation.mutate({}); - goToStep(step + 1); + advanceFromCurrentStep(); }; const handleSkip = () => { saveMutation.mutate({}); captureClientEvent("onboarding:step_skipped", { step }); - goToStep(step + 1); + advanceFromCurrentStep(); }; const handleFinish = async (mcpSetupIntent: "yes" | "no") => { diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx index c063de3..85e6b56 100644 --- a/src/routes/_authenticated.subscribe.tsx +++ b/src/routes/_authenticated.subscribe.tsx @@ -6,16 +6,38 @@ import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMen import { captureClientEvent } from "@/client/lib/posthog"; import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; -import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; +import { MANAGED_ACCESS_QUERY_KEY } from "@/client/features/billing/managed-access"; +import { normalizeAuthRedirect } from "@/lib/auth-redirect"; +import { queryClient } from "@/client/tanstack-db"; +import { + AUTUMN_MANAGED_ACCESS_FEATURE_ID, + AUTUMN_PAID_PLAN_ID, +} from "@/shared/billing"; import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions"; +const SUPPORT_EMAIL = "ben@openseo.so"; + +const PLAN_FEATURES = [ + "Keyword research, backlinks, rank tracking, and site audits", + "MCP server and agent skills for Claude, Cursor, and ChatGPT", + "Search Console integration that never uses credits", + "Includes $10.00 of Usage Credits each month", +]; + export const Route = createFileRoute("/_authenticated/subscribe")({ - validateSearch: (search: Record) => ({ + validateSearch: ( + search: Record, + ): { upgrade?: true; redirect?: string } => ({ upgrade: search.upgrade === true || search.upgrade === "true" ? true : undefined, + redirect: + typeof search.redirect === "string" + ? normalizeAuthRedirect(search.redirect) + : undefined, }), component: SubscribePage, }); @@ -30,7 +52,7 @@ function SubscribePage() { function SubscribePageContent() { const navigate = useNavigate(); - const { upgrade: isUpgradeFlow } = Route.useSearch(); + const { upgrade: isUpgradeFlow, redirect } = Route.useSearch(); const { data: session } = useSession(); const [isAttaching, setIsAttaching] = useState(false); const [error, setError] = useState(null); @@ -38,37 +60,80 @@ function SubscribePageContent() { typeof window !== "undefined" && new URLSearchParams(window.location.search).get("checkout") === "success"; + const hasSession = Boolean(session?.user?.id); const customerQuery = useCustomer({ queryOptions: { - enabled: Boolean(session?.user?.id), + enabled: hasSession, }, }); + // Read managed access from the already-loaded Autumn customer (local, no API + // call) instead of a separate server round-trip. Self-hosted has no Autumn + // customer, so mirror the server's "always granted" behavior there. + const hasManagedAccess = isHostedClientAuthMode() + ? customerQuery.check({ featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID }) + .allowed + : true; + const planStatus = getCustomerPlanStatus(customerQuery.data); const subscribeRouteState = getSubscribeRouteState({ - hasSession: Boolean(session?.user?.id), + hasSession, isCustomerLoading: customerQuery.isLoading, isCustomerError: customerQuery.isError, + hasManagedAccess, planStatus, + isUpgradeFlow: isUpgradeFlow === true, + checkoutCompleted, }); + // Autumn can lag Stripe by a few seconds after checkout; poll until the + // subscription shows up so the just-paid user isn't shown the paywall again. + const isFinalizing = subscribeRouteState === "finalizing"; + useEffect(() => { + if (!isFinalizing) return; + const interval = setInterval(() => { + void customerQuery.refetch(); + }, 2000); + return () => clearInterval(interval); + }, [customerQuery, isFinalizing]); + useEffect(() => { 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 [destinationPath, destinationQuery] = destination.split("?"); + const destinationSearch = destinationQuery + ? Object.fromEntries(new URLSearchParams(destinationQuery)) + : undefined; + const goToApp = () => + void navigate({ + to: destinationPath, + search: destinationSearch, + replace: true, + }); if (checkoutCompleted) { captureClientEvent("billing:checkout_success"); const attribution = getStoredRedditAttribution(); if (attribution) { void captureRedditConversionEvent({ data: { attribution, eventType: "PURCHASE" }, - }).finally(() => { - void navigate({ to: "/", replace: true }); - }); + }).finally(goToApp); return; } } - void navigate({ to: "/", replace: true }); + goToApp(); } - }, [checkoutCompleted, navigate, subscribeRouteState]); + }, [checkoutCompleted, navigate, redirect, subscribeRouteState]); + + useEffect(() => { + if (subscribeRouteState === "showPaywall" && !isUpgradeFlow) { + captureClientEvent("billing:paywall_viewed"); + } + }, [isUpgradeFlow, subscribeRouteState]); if ( subscribeRouteState === "loading" || @@ -77,6 +142,32 @@ function SubscribePageContent() { return null; } + if (subscribeRouteState === "finalizing") { + return ( +
+ OpenSEO +

+ Finalizing your subscription… +

+ +

+ This usually takes a few seconds. +

+

+ Taking longer?{" "} + + Email {SUPPORT_EMAIL} + + . +

+
+ ); + } + if (subscribeRouteState === "error") { return (
@@ -115,10 +206,12 @@ function SubscribePageContent() { try { captureClientEvent("billing:checkout_start"); + const successUrl = new URL(window.location.href); + successUrl.searchParams.set("checkout", "success"); await customerQuery.attach({ planId: AUTUMN_PAID_PLAN_ID, redirectMode: "always", - successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`, + successUrl: successUrl.toString(), }); } catch (err) { setError( @@ -162,11 +255,7 @@ function SubscribePageContent() {
    - {[ - "Access to all OpenSEO features", - "Do keyword research, backlink analysis and site audits", - "Includes $10.00 of Usage Credits each month", - ].map((item) => ( + {PLAN_FEATURES.map((item) => (
  • - Cancel anytime — no commitment. Powered by Stripe. + + + 30-day money-back guarantee + + + . Cancel anytime. Powered by Stripe.

    +

    + Questions?{" "} + + Email {SUPPORT_EMAIL} + + . +

    {isUpgradeFlow ? ( - ) : ( - <> -

    - Or try it free — you have $0.50 of credits to explore before - committing. -

    - - - )} + ) : null}
    ); diff --git a/src/routes/_project/p/$projectId/route.tsx b/src/routes/_project/p/$projectId/route.tsx index 79b198b..ec920c5 100644 --- a/src/routes/_project/p/$projectId/route.tsx +++ b/src/routes/_project/p/$projectId/route.tsx @@ -8,6 +8,7 @@ import { useEffect } from "react"; import { setLastProjectId } from "@/client/lib/active-project"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; +import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { getErrorCode } from "@/client/lib/error-messages"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; @@ -43,6 +44,7 @@ function ProjectLayout() { const { projectId } = Route.useParams(); const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); + const subscribeGate = useSubscribeRedirect(); // Remember this as the last-visited project for the landing redirect. // Settings is excluded: editing another project's settings is @@ -56,7 +58,7 @@ function ProjectLayout() { setLastProjectId(projectId); }, [projectId, isSettingsPage]); - if (!authGate.canRenderAuthenticatedContent) { + if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) { return null; } diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 9887ec1..afed771 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -1,5 +1,8 @@ import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; -import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing"; +import { + AUTUMN_MANAGED_ACCESS_FEATURE_ID, + AUTUMN_PAID_PLAN_FEATURE_ID, +} from "@/shared/billing"; import { autumn } from "@/server/billing/autumn"; import { AppError } from "@/server/lib/errors"; @@ -36,3 +39,12 @@ export async function customerHasPaidPlan(customerId: string) { return result.allowed; } + +export async function customerHasManagedAccess(customerId: string) { + const result = await autumn.check({ + customerId, + featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID, + }); + + return result.allowed; +} diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts index 0ab3c87..f2952eb 100644 --- a/src/server/mcp/tools/get-rank-tracker.ts +++ b/src/server/mcp/tools/get-rank-tracker.ts @@ -27,7 +27,7 @@ export const getRankTrackerTool = { config: { title: "Get rank tracker", description: - "Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, charges no credits. To trigger a new check, use the dashboard.", + "Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Uses no credits — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.", inputSchema, outputSchema: z .object({ diff --git a/src/server/mcp/tools/list-projects.ts b/src/server/mcp/tools/list-projects.ts index 8af01dd..1eccf35 100644 --- a/src/server/mcp/tools/list-projects.ts +++ b/src/server/mcp/tools/list-projects.ts @@ -13,7 +13,7 @@ export const listProjectsTool = { config: { title: "List projects", description: - "Lists all projects in the user's organization. Free — charges no credits. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.", + "Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.", inputSchema: {} as Record, outputSchema: { projects: z.array( diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts index 4fad7d0..44515e0 100644 --- a/src/server/mcp/tools/list-saved-keywords.ts +++ b/src/server/mcp/tools/list-saved-keywords.ts @@ -33,7 +33,7 @@ export const listSavedKeywordsTool = { config: { title: "List saved keywords", description: - "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, charges no credits. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.", + "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Uses no credits — reads from OpenSEO's database, no DataForSEO call. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.", inputSchema, outputSchema: { rows: z.array(looseObjectOutputSchema), diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts index ab0805f..65891af 100644 --- a/src/server/mcp/tools/save-keywords.ts +++ b/src/server/mcp/tools/save-keywords.ts @@ -43,7 +43,7 @@ export const saveKeywordsTool = { config: { title: "Save keywords", description: - "Save keywords to a project's saved-keywords list. Free — charges no credits. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.", + "Save keywords to a project's saved-keywords list. Uses no credits — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.", inputSchema, outputSchema: { projectId: z.string(), diff --git a/src/server/mcp/tools/search-console-tools.ts b/src/server/mcp/tools/search-console-tools.ts index 4b9c4fa..cee8a65 100644 --- a/src/server/mcp/tools/search-console-tools.ts +++ b/src/server/mcp/tools/search-console-tools.ts @@ -154,7 +154,7 @@ export const getSearchConsolePerformanceTool = { config: { title: "Get Google Search Console performance", description: - "Query the connected Search Console property's Search Analytics: clicks, impressions, CTR, and average position by query/page/country/device/date. First-party data — use it for what already ranks, near-ranking queries, and pages with real demand. ctr is a 0-1 fraction; position is a 1-based average; dates are Pacific Time; the last ~3 days may be incomplete. Read-only and free (no credits).", + "Query the connected Search Console property's Search Analytics: clicks, impressions, CTR, and average position by query/page/country/device/date. First-party data — use it for what already ranks, near-ranking queries, and pages with real demand. ctr is a 0-1 fraction; position is a 1-based average; dates are Pacific Time; the last ~3 days may be incomplete. Read-only; uses no credits.", inputSchema: perfInputSchema, outputSchema: { ok: z.boolean(), @@ -303,7 +303,7 @@ export const inspectUrlsTool = { config: { title: "Inspect URLs in Google Search Console", description: - "Run Google Search Console's URL Inspection on up to 10 URLs of the connected property: index/coverage state, last crawl time, Google-selected vs declared canonical, and mobile/rich-results verdicts. Use it to answer 'is this page indexed? why not?'. Per-URL failures are reported inline. Read-only and free (no credits).", + "Run Google Search Console's URL Inspection on up to 10 URLs of the connected property: index/coverage state, last crawl time, Google-selected vs declared canonical, and mobile/rich-results verdicts. Use it to answer 'is this page indexed? why not?'. Per-URL failures are reported inline. Read-only; uses no credits.", inputSchema: inspectInputSchema, outputSchema: { ok: z.boolean(), diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts index 181db9f..ea55644 100644 --- a/src/server/mcp/tools/whoami.ts +++ b/src/server/mcp/tools/whoami.ts @@ -23,7 +23,7 @@ export const whoamiTool = { config: { title: "Who am I", description: - "Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — charges no credits. Use this first to confirm connection context before choosing a project or running paid tools.", + "Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Uses no credits — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.", inputSchema: {} as Record, outputSchema: { userId: z.string(), diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index da133c2..6886783 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -1,7 +1,10 @@ import { createServerFn } from "@tanstack/react-start"; import { waitUntil } from "cloudflare:workers"; import { AuditService } from "@/server/features/audit/services/AuditService"; +import { customerHasManagedAccess } from "@/server/billing/subscription"; +import { AppError } from "@/server/lib/errors"; import { captureServerEvent } from "@/server/lib/posthog"; +import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { requireProjectContext } from "@/serverFunctions/middleware"; import { deleteAuditSchema, @@ -16,6 +19,15 @@ export const startAudit = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => startAuditSchema.parse(data)) .handler(async ({ data, context }) => { + // 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). + if ( + (await isHostedServerAuthMode()) && + !(await customerHasManagedAccess(context.organizationId)) + ) { + throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits"); + } + const result = await AuditService.startAudit({ actorUserId: context.userId, billingCustomer: context, diff --git a/src/serverFunctions/billing.ts b/src/serverFunctions/billing.ts index 329dc22..5c2d678 100644 --- a/src/serverFunctions/billing.ts +++ b/src/serverFunctions/billing.ts @@ -10,6 +10,10 @@ import { isHostedServerAuthMode, } from "@/server/lib/runtime-env"; 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 EVENT_PAGE_LIMIT = 1000; @@ -48,6 +52,20 @@ export type BillingUsageEvent = { properties: Record>; }; +// 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" }) .middleware(requireAuthenticatedContext) .inputValidator((data: unknown) => billingUsageRangeSchema.parse(data)) diff --git a/src/shared/billing.ts b/src/shared/billing.ts index 879dde9..876befe 100644 --- a/src/shared/billing.ts +++ b/src/shared/billing.ts @@ -4,6 +4,9 @@ export const SUBSCRIBE_ROUTE = "/subscribe"; export const AUTUMN_PAID_PLAN_ID = "base-plan"; export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up"; export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan"; +// Granted by both the legacy free plan (grandfathered users) and the paid +// base plan. New customers get no default product, so this is the paywall. +export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access"; export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits"; export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits"; export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000; diff --git a/web/content/legal/terms-and-conditions.md b/web/content/legal/terms-and-conditions.md index 9df2265..2c21a37 100644 --- a/web/content/legal/terms-and-conditions.md +++ b/web/content/legal/terms-and-conditions.md @@ -3,7 +3,7 @@ title: Terms and Conditions description: Terms and conditions for openseo.so. --- -**Last revised on: 4/1/2026** +**Last revised on: 6/13/2026** The website located at https://openseo.so (the “**Site**”), including the hosted OpenSEO services made available through the Site, is a copyrighted work belonging to Every App, Inc (“**Company**”, “**us**”, “**our**”, and “**we**”). These Terms apply to your use of the Site and the hosted OpenSEO services made available through it. For the avoidance of doubt, these Terms do not govern any self-hosted or open-source version of OpenSEO, which is made available separately under the MIT License. Certain features of the Site may be subject to additional guidelines, terms, or rules, which will be posted on the Site in connection with such features. All such additional terms, guidelines, and rules are incorporated by reference into these Terms. @@ -31,6 +31,8 @@ THESE TERMS OF USE (THESE “**TERMS**”) SET FORTH THE LEGALLY BINDING TERMS A 6. **Feedback.** If you provide Company with any feedback or suggestions regarding the Site (“**Feedback**”), you hereby assign to Company all rights in such Feedback and agree that Company shall have the right to use and fully exploit such Feedback and related information in any manner it deems appropriate. Company will treat any Feedback you provide to Company as non-confidential and non-proprietary. You agree that you will not submit to Company any information or ideas that you consider to be confidential or proprietary. + 7. **Subscriptions, Fees, and Refunds.** Access to certain features of the hosted OpenSEO service requires a paid subscription. Fees are billed in advance on a recurring basis and are non-refundable except as expressly provided in this Section or as required by applicable law. We offer a **30-day money-back guarantee**: if you are not satisfied with your paid subscription, you may request a full refund of your most recent subscription charge by emailing ben@openseo.so within thirty (30) days of that charge. The guarantee applies to subscription fees only and does not apply to separately purchased usage or top-up credits that have been consumed. Company reserves the right, in its sole discretion, to review each refund request and to decline a refund where it reasonably suspects fraud, abuse of the guarantee (including repeated subscribe-and-refund cycles), violation of these Terms or the Acceptable Use Policy, or other misuse of the Site or the refund policy. You may cancel your subscription at any time through your billing portal; your access will continue through the end of the then-current billing period, and no further charges will be made. + 3. **User Content** 1. **User Content.** “**User Content**” means any and all information and content that a user submits to, or uses with, the Site (e.g., content in the user’s profile or postings). You are solely responsible for your User Content. You assume all risks associated with use of your User Content, including any reliance on its accuracy, completeness or usefulness by others, or any disclosure of your User Content that personally identifies you or any third party. You hereby represent and warrant that your User Content does not violate our Acceptable Use Policy (defined in Section 3.3). You may not represent or imply to others that your User Content is in any way provided, sponsored or endorsed by Company. Since you alone are responsible for your User Content, you may expose yourself to liability if, for example, your User Content violates the Acceptable Use Policy. Company is not obligated to backup any User Content, and your User Content may be deleted at any time without prior notice. You are solely responsible for creating and maintaining your own backup copies of your User Content if you desire. diff --git a/web/content/marketing/google-search-console-mcp.mdx b/web/content/marketing/google-search-console-mcp.mdx index abc5241..d41a94c 100644 --- a/web/content/marketing/google-search-console-mcp.mdx +++ b/web/content/marketing/google-search-console-mcp.mdx @@ -7,7 +7,7 @@ Most Google Search Console (GSC) MCP servers are open source, but they take a lo ## Setup: no Google Cloud project -1. Sign up for OpenSEO. +1. Sign up for OpenSEO ($10/month). 2. Go through the onboarding. When prompted, click **Connect Search Console** and pick your property. 3. Add the OpenSEO MCP endpoint to your client (steps below) and approve the OpenSEO login when prompted. @@ -91,11 +91,11 @@ The Search Console tools use zero credits — Google doesn't charge you to read ### What does it cost? -The Search Console MCP tools are included with the $10/month OpenSEO plan and use zero credits. OpenSEO's other tools (keyword research, rank tracking, backlinks) use the usage credits included with the plan, through the same endpoint. Self-hosting is free. +The Search Console MCP tools are included with the $10/month OpenSEO plan (which comes with a 30-day money-back guarantee) and use zero credits. OpenSEO's other tools (keyword research, rank tracking, backlinks) use the usage credits included with the plan, through the same endpoint. Self-hosting is free. ### Is it open source? -Yes. OpenSEO is open source, so you can self-host the whole thing, including the Search Console MCP. The one-click connection here uses OpenSEO's hosted Google app. If you self-host, you bring your own Google OAuth client, the same Cloud-console step the hosted version saves you. Hosted means no setup; self-hosted means full control. +Yes. OpenSEO is open source, so you can self-host the whole thing for free, including the Search Console MCP. The one-click connection here uses OpenSEO's hosted Google app. If you self-host, you bring your own Google OAuth client, the same Cloud-console step the hosted version saves you. Hosted means no setup; self-hosted means full control. ### Is it read-only? diff --git a/web/src/components/comparison-table.tsx b/web/src/components/comparison-table.tsx index ec929f1..6ba3b08 100644 --- a/web/src/components/comparison-table.tsx +++ b/web/src/components/comparison-table.tsx @@ -35,7 +35,7 @@ const ROWS: { label: string; cells: Cell[] }[] = [ ], }, { - label: "Price", + label: "Cost to run", cells: [ { text: "Included in the $10/mo plan, zero credits (free to self-host)", diff --git a/web/src/components/money-back-guarantee.tsx b/web/src/components/money-back-guarantee.tsx new file mode 100644 index 0000000..071f9f6 --- /dev/null +++ b/web/src/components/money-back-guarantee.tsx @@ -0,0 +1,21 @@ +const REFUND_PROMISE = + "Not for you yet? Email ben@openseo.so within 30 days of your charge and we'll refund your subscription."; + +// Hover/focus tooltip (tabIndex makes it work on tap too). The full refund +// terms also live in the pricing FAQ, so the tooltip is reinforcement, not +// the only place the promise appears. +export function MoneyBackGuarantee() { + return ( + + + 30-day money-back guarantee + + + {REFUND_PROMISE} + + + ); +} diff --git a/web/src/routes/_marketing/google-search-console-mcp.tsx b/web/src/routes/_marketing/google-search-console-mcp.tsx index dfc62df..f912f9c 100644 --- a/web/src/routes/_marketing/google-search-console-mcp.tsx +++ b/web/src/routes/_marketing/google-search-console-mcp.tsx @@ -17,6 +17,18 @@ const softwareApplicationLd = { operatingSystem: "Web", url: toCanonicalUrl(PATH), description: frontmatter.description, + offers: { + "@type": "Offer", + price: "10.00", + priceCurrency: "USD", + priceSpecification: { + "@type": "UnitPriceSpecification", + price: "10.00", + priceCurrency: "USD", + billingDuration: 1, + unitCode: "MON", + }, + }, provider: { "@type": "Organization", name: "OpenSEO", @@ -62,6 +74,10 @@ function GoogleSearchConsoleMcpPage() { +

    + $10/month, 30-day money-back guarantee. Search Console tools never + use credits. +

    @@ -90,8 +106,8 @@ function GoogleSearchConsoleMcpCta() { Point your AI at your real search data

    - 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.