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;