From 86d37148469c7240cebe40a7b4ecc823e804bdf8 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Thu, 16 Apr 2026 02:19:23 -0400
Subject: [PATCH] feat: allow free plan users to set up rank tracking (#116)
---
.../features/rank-tracking/ActionsMenu.tsx | 26 ++++---
.../rank-tracking/RankTrackingConfigModal.tsx | 4 +-
.../RankTrackingDomainDetail.tsx | 52 +++++++++++++-
src/server.ts | 9 +--
src/server/billing/subscription.test.ts | 53 ++++-----------
src/server/billing/subscription.ts | 20 +-----
src/serverFunctions/middleware.ts | 4 --
src/serverFunctions/rank-tracking.ts | 68 ++++++++++++-------
src/shared/billing.ts | 3 +-
9 files changed, 129 insertions(+), 110 deletions(-)
diff --git a/src/client/features/rank-tracking/ActionsMenu.tsx b/src/client/features/rank-tracking/ActionsMenu.tsx
index 280ab7d..5074fb7 100644
--- a/src/client/features/rank-tracking/ActionsMenu.tsx
+++ b/src/client/features/rank-tracking/ActionsMenu.tsx
@@ -7,12 +7,14 @@ export function ActionsMenu({
onCopyKeywords,
isRunning,
hasData,
+ checkDisabled,
}: {
onCheckNow: () => void;
onExport: () => void;
onCopyKeywords: () => void;
isRunning: boolean;
hasData: boolean;
+ checkDisabled?: boolean;
}) {
const [open, setOpen] = useState(false);
return (
@@ -27,17 +29,19 @@ export function ActionsMenu({
<>
setOpen(false)} />
-
+ {!checkDisabled && (
+
+ )}
)}
+
+
{/* Results card */}
{/* Domain header */}
@@ -322,6 +371,7 @@ export function RankTrackingDomainDetail({
}}
isRunning={isBusy}
hasData={filtered.length > 0}
+ checkDisabled={isFreePlan}
/>
diff --git a/src/server.ts b/src/server.ts
index b61ba2c..b5cde11 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -4,7 +4,7 @@ import {
} from "@tanstack/react-start/server";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
-import { customerHasManagedServiceAccess } from "@/server/billing/subscription";
+import { customerHasPaidPlan } from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { computeNextCheckAt } from "@/shared/rank-tracking";
@@ -29,11 +29,8 @@ export default {
for (const config of dueConfigs) {
try {
- // Skip configs whose org no longer has paid access
- if (
- isHosted &&
- !(await customerHasManagedServiceAccess(config.organizationId))
- ) {
+ // Skip configs whose org doesn't have a paid plan
+ if (isHosted && !(await customerHasPaidPlan(config.organizationId))) {
console.log(
`[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`,
);
diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts
index 306ff5e..ca93e59 100644
--- a/src/server/billing/subscription.test.ts
+++ b/src/server/billing/subscription.test.ts
@@ -1,13 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID } from "@/shared/billing";
+import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
-const { checkMock, getOrCreateMock, isHostedServerAuthModeMock } = vi.hoisted(
- () => ({
- checkMock: vi.fn(),
- getOrCreateMock: vi.fn(),
- isHostedServerAuthModeMock: vi.fn(),
- }),
-);
+const { checkMock, getOrCreateMock } = vi.hoisted(() => ({
+ checkMock: vi.fn(),
+ getOrCreateMock: vi.fn(),
+}));
vi.mock("@/server/billing/autumn", () => ({
autumn: {
@@ -19,13 +16,12 @@ vi.mock("@/server/billing/autumn", () => ({
}));
vi.mock("@/server/lib/runtime-env", () => ({
- isHostedServerAuthMode: isHostedServerAuthModeMock,
+ isHostedServerAuthMode: vi.fn(),
}));
import {
- customerHasManagedServiceAccess,
+ customerHasPaidPlan,
getOrCreateOrganizationCustomer,
- requireManagedServiceAccess,
} from "./subscription";
describe("subscription billing", () => {
@@ -33,46 +29,21 @@ describe("subscription billing", () => {
vi.clearAllMocks();
});
- it("checks the managed service access entitlement", async () => {
+ it("checks the paid plan entitlement", async () => {
checkMock.mockResolvedValue({ allowed: true });
- await expect(customerHasManagedServiceAccess("org_123")).resolves.toBe(
- true,
- );
+ await expect(customerHasPaidPlan("org_123")).resolves.toBe(true);
expect(checkMock).toHaveBeenCalledWith({
customerId: "org_123",
- featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
+ featureId: AUTUMN_PAID_PLAN_FEATURE_ID,
});
});
- it("skips the managed service check outside hosted mode", async () => {
- isHostedServerAuthModeMock.mockResolvedValue(false);
-
- await expect(
- requireManagedServiceAccess({
- organizationId: "org_123",
- userId: "user_123",
- userEmail: "alice@example.com",
- }),
- ).resolves.toBeUndefined();
-
- expect(getOrCreateMock).not.toHaveBeenCalled();
- expect(checkMock).not.toHaveBeenCalled();
- });
-
- it("throws payment required when the org lacks managed service access", async () => {
- isHostedServerAuthModeMock.mockResolvedValue(true);
- getOrCreateMock.mockResolvedValue({ id: "org_123" });
+ it("returns false when org lacks paid plan", async () => {
checkMock.mockResolvedValue({ allowed: false });
- await expect(
- requireManagedServiceAccess({
- organizationId: "org_123",
- userId: "user_123",
- userEmail: "alice@example.com",
- }),
- ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
+ await expect(customerHasPaidPlan("org_123")).resolves.toBe(false);
});
it("looks up the billing customer by organization id", async () => {
diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts
index 0408eab..9887ec1 100644
--- a/src/server/billing/subscription.ts
+++ b/src/server/billing/subscription.ts
@@ -1,8 +1,7 @@
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
-import { AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID } from "@/shared/billing";
+import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
import { autumn } from "@/server/billing/autumn";
import { AppError } from "@/server/lib/errors";
-import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
export type BillingCustomerContext = Pick<
EnsuredUserContext,
@@ -29,24 +28,11 @@ export async function getOrCreateOrganizationCustomer(
};
}
-export async function customerHasManagedServiceAccess(customerId: string) {
+export async function customerHasPaidPlan(customerId: string) {
const result = await autumn.check({
customerId,
- featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
+ featureId: AUTUMN_PAID_PLAN_FEATURE_ID,
});
return result.allowed;
}
-
-export async function requireManagedServiceAccess(
- context: BillingCustomerContext,
-) {
- if (!(await isHostedServerAuthMode())) {
- return;
- }
-
- const customer = await getOrCreateOrganizationCustomer(context);
- if (!(await customerHasManagedServiceAccess(customer.id))) {
- throw new AppError("PAYMENT_REQUIRED");
- }
-}
diff --git a/src/serverFunctions/middleware.ts b/src/serverFunctions/middleware.ts
index 5ee8434..05a7d72 100644
--- a/src/serverFunctions/middleware.ts
+++ b/src/serverFunctions/middleware.ts
@@ -4,7 +4,6 @@ import { AppError } from "@/server/lib/errors";
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
-import { requireManagedServiceAccess } from "@/server/billing/subscription";
const ensuredUserContextSchema: z.ZodType
= z.object({
userId: z.string(),
@@ -32,7 +31,6 @@ export const globalServerFunctionMiddleware = [
export const requireAuthenticatedContext = [
createMiddleware({ type: "function" }).server(async ({ next, context }) => {
const authenticatedContext = getAuthenticatedContext(context);
- await requireManagedServiceAccess(authenticatedContext);
return next({
context: authenticatedContext,
@@ -44,8 +42,6 @@ export const requireProjectContext = [
createMiddleware({ type: "function" }).server(async ({ next, context }) => {
const authenticatedContext = getAuthenticatedContext(context);
- await requireManagedServiceAccess(authenticatedContext);
-
if (!authenticatedContext.project) {
throw new AppError(
"INTERNAL_ERROR",
diff --git a/src/serverFunctions/rank-tracking.ts b/src/serverFunctions/rank-tracking.ts
index 5ea6952..b4b061d 100644
--- a/src/serverFunctions/rank-tracking.ts
+++ b/src/serverFunctions/rank-tracking.ts
@@ -3,7 +3,9 @@ import { waitUntil } from "cloudflare:workers";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
-import { asAppError } from "@/server/lib/errors";
+import { AppError, asAppError } from "@/server/lib/errors";
+import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
+import { customerHasPaidPlan } from "@/server/billing/subscription";
import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
@@ -83,6 +85,14 @@ export const triggerRankCheck = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => triggerCheckSchema.parse(data))
.handler(async ({ data, context }) => {
+ const isHosted = await isHostedServerAuthMode();
+ if (isHosted && !(await customerHasPaidPlan(context.organizationId))) {
+ throw new AppError(
+ "PAYMENT_REQUIRED",
+ "Upgrade to the paid plan to run rank checks",
+ );
+ }
+
const result = await RankTrackingService.triggerCheck({
configId: data.configId,
projectId: context.projectId,
@@ -145,31 +155,37 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
let checkTriggered = false;
if (result.addedIds.length > 0) {
- try {
- const triggerResult = await RankTrackingService.triggerCheck({
- configId: data.configId,
- projectId: context.projectId,
- billingCustomer: context,
- keywordIds: result.addedIds,
- });
- checkTriggered = triggerResult.ok;
- if (!triggerResult.ok) {
- console.info(
- "[rank-tracking] auto-check skipped: %s",
- triggerResult.reason,
- );
- }
- } catch (err) {
- const appErr = asAppError(err);
- if (appErr?.code === "INSUFFICIENT_CREDITS") {
- console.info(
- "[rank-tracking] auto-check skipped: insufficient credits",
- );
- } else {
- console.error(
- "[rank-tracking] auto-check after keyword add failed:",
- err,
- );
+ const isHosted = await isHostedServerAuthMode();
+ const hasPaidPlan =
+ !isHosted || (await customerHasPaidPlan(context.organizationId));
+
+ if (hasPaidPlan) {
+ try {
+ const triggerResult = await RankTrackingService.triggerCheck({
+ configId: data.configId,
+ projectId: context.projectId,
+ billingCustomer: context,
+ keywordIds: result.addedIds,
+ });
+ checkTriggered = triggerResult.ok;
+ if (!triggerResult.ok) {
+ console.info(
+ "[rank-tracking] auto-check skipped: %s",
+ triggerResult.reason,
+ );
+ }
+ } catch (err) {
+ const appErr = asAppError(err);
+ if (appErr?.code === "INSUFFICIENT_CREDITS") {
+ console.info(
+ "[rank-tracking] auto-check skipped: insufficient credits",
+ );
+ } else {
+ console.error(
+ "[rank-tracking] auto-check after keyword add failed:",
+ err,
+ );
+ }
}
}
}
diff --git a/src/shared/billing.ts b/src/shared/billing.ts
index 900d1bb..71d94fe 100644
--- a/src/shared/billing.ts
+++ b/src/shared/billing.ts
@@ -3,8 +3,7 @@ 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_MANAGED_SERVICE_ACCESS_FEATURE_ID =
- "managed_service_access";
+export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
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;