From 55ef8ab899ed19e54c23b666463c1fcd214fa411 Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:05:08 +0000 Subject: [PATCH] fix: billing gate broke for scale/enterprise and downgraded users (#794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscription check on plugins, connections, and usage pages was hardcoded to only look at `api_pro`. Users on scale or enterprise plans saw "Upgrade to Pro" and couldn't create plugin keys or manage connections. Also, the check only looked at product `status` but not the `allowed` flag from autumn — so users who downgraded but were still in their billing period got locked out early. --- .../components/add-document/connections.tsx | 22 +++----- .../integrations/connections-detail.tsx | 10 +++- .../integrations/plugins-detail.tsx | 15 +++-- .../components/settings/connections-mcp.tsx | 12 ++-- apps/web/hooks/use-memories-usage.ts | 14 +++-- apps/web/hooks/use-token-usage.ts | 18 +++--- bun.lock | 15 +++-- packages/lib/queries.ts | 56 +++++++++---------- 8 files changed, 84 insertions(+), 78 deletions(-) diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index be0d279a..29fcbf14 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -1,7 +1,12 @@ "use client" import { $fetch } from "@lib/api" -import { fetchConnectionsFeature } from "@lib/queries" +import { + DEFAULT_SUBSCRIPTION_STATUS, + fetchConnectionsFeature, + fetchSubscriptionStatus, + isAllowedFrom, +} from "@lib/queries" import type { ConnectionResponseSchema } from "@repo/validation/api" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" @@ -51,7 +56,9 @@ interface ConnectContentProps { export function ConnectContent({ selectedProject }: ConnectContentProps) { const queryClient = useQueryClient() const autumn = useCustomer() - const [isProUser, setIsProUser] = useState(false) + const { data: subscriptionStatus = DEFAULT_SUBSCRIPTION_STATUS } = + fetchSubscriptionStatus(autumn, !autumn.isLoading) + const isProUser = isAllowedFrom(subscriptionStatus, "api_pro") const [connectingProvider, setConnectingProvider] = useState(null) const [isUpgrading, setIsUpgrading] = useState(false) @@ -60,17 +67,6 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { connection: Connection | null }>({ open: false, connection: null }) - // Check Pro status - useEffect(() => { - if (!autumn.isLoading) { - setIsProUser( - autumn.customer?.products?.some( - (product) => product.id === "api_pro", - ) ?? false, - ) - } - }, [autumn.isLoading, autumn.customer]) - const handleUpgrade = async () => { setIsUpgrading(true) try { diff --git a/apps/web/components/integrations/connections-detail.tsx b/apps/web/components/integrations/connections-detail.tsx index f17f5826..0bbb573b 100644 --- a/apps/web/components/integrations/connections-detail.tsx +++ b/apps/web/components/integrations/connections-detail.tsx @@ -3,7 +3,11 @@ import { dmSans125ClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { $fetch } from "@lib/api" -import { fetchSubscriptionStatus } from "@lib/queries" +import { + DEFAULT_SUBSCRIPTION_STATUS, + fetchSubscriptionStatus, + isAllowedFrom, +} from "@lib/queries" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" @@ -181,11 +185,11 @@ export function ConnectionsDetail() { []) as Project[] const { - data: status = { api_pro: { allowed: false, status: null } }, + data: status = DEFAULT_SUBSCRIPTION_STATUS, isLoading: isCheckingStatus, } = fetchSubscriptionStatus(autumn, !autumn.isLoading) - const hasProProduct = status.api_pro?.status !== null + const hasProProduct = isAllowedFrom(status, "api_pro") const connectionsFeature = autumn.customer?.features?.connections const connectionsUsed = connectionsFeature?.usage ?? 0 diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index caf78b14..db330919 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -4,7 +4,11 @@ import { cn } from "@lib/utils" import { dmSans125ClassName } from "@/lib/fonts" import { authClient } from "@lib/auth" import { useAuth } from "@lib/auth-context" -import { fetchSubscriptionStatus } from "@lib/queries" +import { + DEFAULT_SUBSCRIPTION_STATUS, + fetchSubscriptionStatus, + isAllowedFrom, +} from "@lib/queries" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { @@ -17,7 +21,6 @@ import { ExternalLink, Key, Loader, - Plug, Trash2, Zap, } from "lucide-react" @@ -31,7 +34,6 @@ import { DialogTitle, DialogPortal, } from "@ui/components/dialog" -import { analytics } from "@/lib/analytics" interface PluginInfo { id: string @@ -108,11 +110,11 @@ export function PluginsDetail() { const [keyCopied, setKeyCopied] = useState(false) const { - data: status = { api_pro: { allowed: false, status: null } }, + data: status = DEFAULT_SUBSCRIPTION_STATUS, isLoading: isCheckingStatus, } = fetchSubscriptionStatus(autumn, !autumn.isLoading) - const hasProProduct = status.api_pro?.status !== null + const hasProProduct = isAllowedFrom(status, "api_pro") const { data: pluginsData } = useQuery({ queryFn: async () => { @@ -181,6 +183,9 @@ export function PluginsDetail() { credentials: "include", }) if (!res.ok) { + if (res.status === 403) { + throw new Error("A Pro plan is required to connect plugins.") + } const errorData = (await res.json().catch(() => ({}))) as { message?: string } diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 86b4edd0..fb89130e 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -3,7 +3,11 @@ import { dmSans125ClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { $fetch } from "@lib/api" -import { fetchSubscriptionStatus } from "@lib/queries" +import { + DEFAULT_SUBSCRIPTION_STATUS, + fetchSubscriptionStatus, + isAllowedFrom, +} from "@lib/queries" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" @@ -348,13 +352,11 @@ export default function ConnectionsMCP() { // Billing data const { - data: status = { - api_pro: { allowed: false, status: null }, - }, + data: status = DEFAULT_SUBSCRIPTION_STATUS, isLoading: isCheckingStatus, } = fetchSubscriptionStatus(autumn, !autumn.isLoading) - const hasProProduct = status.api_pro?.status !== null + const hasProProduct = isAllowedFrom(status, "api_pro") // Get connections data directly from autumn customer const connectionsFeature = autumn.customer?.features?.connections diff --git a/apps/web/hooks/use-memories-usage.ts b/apps/web/hooks/use-memories-usage.ts index cde17344..be9a5b21 100644 --- a/apps/web/hooks/use-memories-usage.ts +++ b/apps/web/hooks/use-memories-usage.ts @@ -1,16 +1,18 @@ -import { fetchMemoriesFeature, fetchSubscriptionStatus } from "@lib/queries" +import { + DEFAULT_SUBSCRIPTION_STATUS, + fetchMemoriesFeature, + fetchSubscriptionStatus, + isAllowedFrom, +} from "@lib/queries" import type { useCustomer } from "autumn-js/react" export function useMemoriesUsage(autumn: ReturnType) { const { - data: status = { - api_pro: { allowed: false, status: null }, - }, + data: status = DEFAULT_SUBSCRIPTION_STATUS, isLoading: isCheckingStatus, } = fetchSubscriptionStatus(autumn, !autumn.isLoading) - const proStatus = status.api_pro - const hasProProduct = proStatus?.status !== null + const hasProProduct = isAllowedFrom(status, "api_pro") const { data: memoriesCheck, isLoading: isLoadingMemories } = fetchMemoriesFeature(autumn, !isCheckingStatus && !autumn.isLoading) diff --git a/apps/web/hooks/use-token-usage.ts b/apps/web/hooks/use-token-usage.ts index 2fcd2b58..21962567 100644 --- a/apps/web/hooks/use-token-usage.ts +++ b/apps/web/hooks/use-token-usage.ts @@ -1,4 +1,8 @@ -import { fetchSubscriptionStatus } from "@lib/queries" +import { + DEFAULT_SUBSCRIPTION_STATUS, + fetchSubscriptionStatus, + isAllowedFrom, +} from "@lib/queries" import type { useCustomer } from "autumn-js/react" import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils" @@ -6,20 +10,16 @@ export type PlanType = "free" | "pro" | "scale" | "enterprise" export function useTokenUsage(autumn: ReturnType) { const { - data: status = { - api_pro: { allowed: false, status: null }, - api_scale: { allowed: false, status: null }, - api_enterprise: { allowed: false, status: null }, - }, + data: status = DEFAULT_SUBSCRIPTION_STATUS, isLoading: isCheckingStatus, } = fetchSubscriptionStatus(autumn, !autumn.isLoading) let currentPlan: PlanType = "free" - if (status.api_enterprise?.status !== null) { + if (isAllowedFrom(status, "api_enterprise")) { currentPlan = "enterprise" - } else if (status.api_scale?.status !== null) { + } else if (isAllowedFrom(status, "api_scale")) { currentPlan = "scale" - } else if (status.api_pro?.status !== null) { + } else if (isAllowedFrom(status, "api_pro")) { currentPlan = "pro" } diff --git a/bun.lock b/bun.lock index 3e587663..b98246e1 100644 --- a/bun.lock +++ b/bun.lock @@ -322,11 +322,12 @@ }, "packages/tools": { "name": "@supermemory/tools", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@ai-sdk/anthropic": "^2.0.25", "@ai-sdk/openai": "^2.0.23", "ai": "^5.0.29", + "lru-cache": "^11.2.6", "openai": "^4.104.0", "supermemory": "^3.0.0-alpha.26", "zod": "^4.1.5", @@ -3476,7 +3477,7 @@ "lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], @@ -4934,8 +4935,6 @@ "@mastra/core/hono-openapi": ["hono-openapi@1.3.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig=="], - "@mastra/core/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "@mastra/core/p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], "@mdx-js/mdx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -5198,6 +5197,8 @@ "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@vanilla-extract/integration/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -5470,6 +5471,8 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -6554,8 +6557,6 @@ "@opennextjs/cloudflare/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "@opennextjs/cloudflare/glob/path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "@puppeteer/browsers/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -6572,8 +6573,6 @@ "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "agents/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], diff --git a/packages/lib/queries.ts b/packages/lib/queries.ts index 7b621221..5f6d0493 100644 --- a/packages/lib/queries.ts +++ b/packages/lib/queries.ts @@ -8,20 +8,41 @@ import { $fetch } from "./api" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] +export const PLAN_TIERS = ["api_pro", "api_scale", "api_enterprise"] as const +export type PlanTier = (typeof PLAN_TIERS)[number] + +export type SubscriptionStatusMap = Record< + string, + { allowed: boolean; status: string | null } +> + +export const DEFAULT_SUBSCRIPTION_STATUS: SubscriptionStatusMap = { + api_pro: { allowed: false, status: null }, + api_scale: { allowed: false, status: null }, + api_enterprise: { allowed: false, status: null }, +} + +export function isAllowedFrom( + status: SubscriptionStatusMap, + minimumTier: PlanTier, +): boolean { + const minIndex = PLAN_TIERS.indexOf(minimumTier) + return PLAN_TIERS.slice(minIndex).some((tier) => { + const s = status[tier] + return s?.status === "active" + }) +} + export const fetchSubscriptionStatus = ( autumn: ReturnType, isEnabled: boolean, ) => useQuery({ queryFn: async () => { - const allPlans = ["api_pro", "api_scale", "api_enterprise"] - const statusMap: Record< - string, - { allowed: boolean; status: string | null } - > = {} + const statusMap: SubscriptionStatusMap = {} await Promise.all( - allPlans.map(async (plan) => { + PLAN_TIERS.map(async (plan) => { try { const res = autumn.check({ productId: plan, @@ -83,29 +104,6 @@ export const fetchConnectionsFeature = ( enabled: isEnabled, }) -// Product checks -export const fetchApiProProduct = (autumn: ReturnType) => - useQuery({ - queryFn: async () => { - const res = autumn.check({ productId: "api_pro" }) - return res.data - }, - queryKey: ["autumn-product", "api_pro"], - staleTime: 30 * 1000, // 30 seconds - gcTime: 5 * 60 * 1000, // 5 minutes - }) - -export const fetchProProduct = (autumn: ReturnType) => - useQuery({ - queryFn: async () => { - const res = autumn.check({ productId: "pro" }) - return res.data - }, - queryKey: ["autumn-product", "pro"], - staleTime: 30 * 1000, // 30 seconds - gcTime: 5 * 60 * 1000, // 5 minutes - }) - export const useDeleteDocument = (selectedProject: string) => { const queryClient = useQueryClient()