feat: allow free plan users to set up rank tracking (#116)

This commit is contained in:
Ben Senescu 2026-04-16 02:19:23 -04:00 committed by GitHub
parent aeafa5649d
commit 86d3714846
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 129 additions and 110 deletions

View File

@ -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,6 +29,7 @@ export function ActionsMenu({
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border border-base-300 bg-base-100 shadow-lg py-1 min-w-[160px]">
{!checkDisabled && (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
@ -38,6 +41,7 @@ export function ActionsMenu({
<Play className="size-3.5" />
{isRunning ? "Running..." : "Check Now"}
</button>
)}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {

View File

@ -41,12 +41,12 @@ export function RankTrackingConfigModal({
const [step, setStep] = useState<"config" | "keywords">("config");
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
existingConfig?.devices ?? "both",
existingConfig?.devices ?? "mobile",
);
const [locationCode, setLocationCode] = useState(
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
);
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 20);
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40);
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
existingConfig?.scheduleInterval ?? "weekly",
);

View File

@ -1,6 +1,8 @@
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import {
getLatestRankResults,
estimateRankCheckCost,
@ -15,6 +17,9 @@ import {
SlidersHorizontal,
Smartphone,
} from "lucide-react";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
import { captureClientEvent } from "@/client/lib/posthog";
import { RankTrackingTable } from "./RankTrackingTable";
import { exportRankTrackingCsv } from "./RankTrackingTableParts";
@ -48,7 +53,41 @@ function isComparePeriod(v: string): v is ComparePeriod {
return COMPARE_PERIODS.has(v);
}
export function RankTrackingDomainDetail({
export function RankTrackingDomainDetail(props: {
config: RankTrackingConfig;
projectId: string;
onBack: () => void;
onEdit: () => void;
}) {
return (
<AutumnProvider>
<RankTrackingDomainDetailInner {...props} />
</AutumnProvider>
);
}
function FreePlanAlert({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
<span>
We only start to track keyword positions once you{" "}
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="link font-medium"
>
upgrade to the paid plan
</Link>
.
</span>
</div>
);
}
function RankTrackingDomainDetailInner({
config,
projectId,
onBack,
@ -59,6 +98,14 @@ export function RankTrackingDomainDetail({
onBack: () => void;
onEdit: () => void;
}) {
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const queryClient = useQueryClient();
const [showAddKeywords, setShowAddKeywords] = useState(false);
const [showFilters, setShowFilters] = useState(false);
@ -180,6 +227,8 @@ export function RankTrackingDomainDetail({
</div>
)}
<FreePlanAlert visible={isFreePlan} />
{/* Results card */}
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
{/* Domain header */}
@ -322,6 +371,7 @@ export function RankTrackingDomainDetail({
}}
isRunning={isBusy}
hasData={filtered.length > 0}
checkDisabled={isFreePlan}
/>
</div>

View File

@ -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`,
);

View File

@ -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(
() => ({
const { checkMock, getOrCreateMock } = vi.hoisted(() => ({
checkMock: vi.fn(),
getOrCreateMock: vi.fn(),
isHostedServerAuthModeMock: 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 () => {

View File

@ -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");
}
}

View File

@ -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<EnsuredUserContext> = 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",

View File

@ -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,6 +155,11 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
let checkTriggered = false;
if (result.addedIds.length > 0) {
const isHosted = await isHostedServerAuthMode();
const hasPaidPlan =
!isHosted || (await customerHasPaidPlan(context.organizationId));
if (hasPaidPlan) {
try {
const triggerResult = await RankTrackingService.triggerCheck({
configId: data.configId,
@ -173,6 +188,7 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
}
}
}
}
return { ...result, checkTriggered };
});

View File

@ -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;