diff --git a/packages/backend/convex/apiKeys/helpers.ts b/packages/backend/convex/apiKeys/helpers.ts index 4ba8406d..8114dfbb 100644 --- a/packages/backend/convex/apiKeys/helpers.ts +++ b/packages/backend/convex/apiKeys/helpers.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { internalQuery } from "../_generated/server"; -import { deriveEffectivePlan } from "../billing/plans"; +import { deriveEffectivePlan, getCanonicalSubscription } from "../billing/plans"; /** * getActiveSubscriptionForUser — internalQuery @@ -12,10 +12,12 @@ import { deriveEffectivePlan } from "../billing/plans"; export const getActiveSubscriptionForUser = internalQuery({ args: { userId: v.string() }, handler: async (ctx, args) => { - const sub = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .first(); + .order("desc") + .take(20); + const sub = getCanonicalSubscription(subscriptions); const plan = deriveEffectivePlan(sub); if (plan !== "pro") { return null; diff --git a/packages/backend/convex/billing/limits.ts b/packages/backend/convex/billing/limits.ts index e625878e..b5843003 100644 --- a/packages/backend/convex/billing/limits.ts +++ b/packages/backend/convex/billing/limits.ts @@ -12,7 +12,12 @@ import { components } from "../_generated/api"; import type { MutationCtx } from "../_generated/server"; import { internalMutation } from "../functions"; import { throwLimitReached } from "../utils/errors"; -import { deriveEffectivePlan, getLimits, PLANS } from "./plans"; +import { + deriveEffectivePlan, + getCanonicalSubscription, + getLimits, + PLANS, +} from "./plans"; /** * startOfMonth — UTC start-of-month in milliseconds. @@ -39,14 +44,15 @@ export async function assertCanCreateBookmark( }); const metadata = (user as { metadata?: unknown } | null)?.metadata; - // 2. Get active subscription for this user. - const subscription = await ctx.db + // 2. Get canonical subscription for this user. + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .first(); + .order("desc") + .take(20); // 3. Derive plan. - const plan = deriveEffectivePlan(subscription); + const plan = deriveEffectivePlan(getCanonicalSubscription(subscriptions)); // 4. Compute effective limits (custom overrides plan defaults). const limits = getLimits(plan as "free" | "pro", metadata); @@ -95,12 +101,13 @@ export async function assertCanRunProcessing( }); const metadata = (user as { metadata?: unknown } | null)?.metadata; - const subscription = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .first(); + .order("desc") + .take(20); - const plan = deriveEffectivePlan(subscription); + const plan = deriveEffectivePlan(getCanonicalSubscription(subscriptions)); const limits = getLimits(plan as "free" | "pro", metadata); @@ -160,13 +167,14 @@ export async function shouldSendLimitEmail( | undefined; // 2. Check subscription status. - const subscription = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .first(); + .order("desc") + .take(20); // Only applies to free plan users. - if (deriveEffectivePlan(subscription) === "pro") { + if (deriveEffectivePlan(getCanonicalSubscription(subscriptions)) === "pro") { return false; } diff --git a/packages/backend/convex/billing/plans.test.ts b/packages/backend/convex/billing/plans.test.ts index 9c126a1d..60076356 100644 --- a/packages/backend/convex/billing/plans.test.ts +++ b/packages/backend/convex/billing/plans.test.ts @@ -1,5 +1,8 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { deriveEffectivePlan, getLimits } from "./plans"; +import { deriveEffectivePlan, getCanonicalSubscription, getLimits } from "./plans"; describe("deriveEffectivePlan", () => { it("defaults missing subscriptions to free", () => { @@ -52,6 +55,69 @@ describe("deriveEffectivePlan", () => { }); }); +describe("getCanonicalSubscription", () => { + it("does not let an older active row grant Pro after a newer cancellation", () => { + const subscription = getCanonicalSubscription([ + { + plan: "pro", + provider: "stripe", + status: "active", + createdAt: 100, + }, + { + plan: "free", + provider: "stripe", + status: "canceled", + createdAt: 200, + }, + ]); + + expect(subscription?.status).toBe("canceled"); + expect(deriveEffectivePlan(subscription)).toBe("free"); + }); + + it("keeps a manual lifetime grant canonical regardless of later billing rows", () => { + const subscription = getCanonicalSubscription([ + { + plan: "pro", + provider: "manual", + status: "lifetime", + createdAt: 100, + }, + { + plan: "free", + provider: "stripe", + status: "canceled", + createdAt: 200, + }, + ]); + + expect(subscription?.provider).toBe("manual"); + expect(deriveEffectivePlan(subscription)).toBe("pro"); + }); +}); + +describe("subscription entitlement call sites", () => { + it("uses canonical subscription selection for chat limit paths", () => { + for (const relativePath of [ + "../chat/mutations.ts", + "../chat/queries.ts", + "../users/queries.ts", + ]) { + const source = readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + relativePath, + ), + "utf8", + ); + + expect(source).toContain("getCanonicalSubscription("); + expect(source).not.toMatch(/allSubs\.some\([\s\S]*deriveEffectivePlan/); + } + }); +}); + describe("getLimits", () => { it("honors Better Auth component custom metadata", () => { expect( diff --git a/packages/backend/convex/billing/plans.ts b/packages/backend/convex/billing/plans.ts index 0ccc0aec..e60b02e4 100644 --- a/packages/backend/convex/billing/plans.ts +++ b/packages/backend/convex/billing/plans.ts @@ -39,6 +39,8 @@ export type SubscriptionPlanState = { plan?: string | null; provider?: "stripe" | "appstore" | "manual" | null; status?: string | null; + createdAt?: number | null; + updatedAt?: number | null; }; // Plain numeric shape (NOT the `as const` literal union) so merged/custom // limits and runtime-computed values assign cleanly. @@ -73,6 +75,39 @@ export function isLifetimeSubscription( ); } +function subscriptionTimestamp(subscription: SubscriptionPlanState): number { + return subscription.updatedAt ?? subscription.createdAt ?? 0; +} + +/** + * Pick the one subscription row that should drive entitlement/limits. + * Manual lifetime grants are durable and win over billing-provider rows; + * otherwise the newest row wins so an old active row cannot keep granting Pro + * after a later cancellation/downgrade row exists. + */ +export function getCanonicalSubscription( + subscriptions: readonly T[], +): T | null { + let best: T | null = null; + + for (const subscription of subscriptions) { + if (isLifetimeSubscription(subscription)) { + if (!best || !isLifetimeSubscription(best)) { + best = subscription; + continue; + } + } else if (best && isLifetimeSubscription(best)) { + continue; + } + + if (!best || subscriptionTimestamp(subscription) > subscriptionTimestamp(best)) { + best = subscription; + } + } + + return best; +} + /** * Derive the effective entitlement from the canonical subscription row. * A stored plan name or an active-looking status alone must never grant Pro. diff --git a/packages/backend/convex/bookmarks/mutations.ts b/packages/backend/convex/bookmarks/mutations.ts index a5533bad..599e556c 100644 --- a/packages/backend/convex/bookmarks/mutations.ts +++ b/packages/backend/convex/bookmarks/mutations.ts @@ -19,7 +19,11 @@ import { assertCanRunProcessing, shouldSendLimitEmail, } from "../billing/limits"; -import { deriveEffectivePlan, getLimits } from "../billing/plans"; +import { + deriveEffectivePlan, + getCanonicalSubscription, + getLimits, +} from "../billing/plans"; import { buildBookmarkDetailDTO, type BookmarkDetailDTO, @@ -574,12 +578,13 @@ export const exportCsv = authMutation({ const userId = ctx.user.id; // Check export permission. - const subscription = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q: any) => q.eq("userId", userId)) - .first(); + .order("desc") + .take(20); - const plan = deriveEffectivePlan(subscription); + const plan = deriveEffectivePlan(getCanonicalSubscription(subscriptions)); const dbUser = await ctx.runQuery(components.betterAuth.data.getUserById, { userId, }); diff --git a/packages/backend/convex/chat/mutations.ts b/packages/backend/convex/chat/mutations.ts index a7b88bf6..ffe4beb5 100644 --- a/packages/backend/convex/chat/mutations.ts +++ b/packages/backend/convex/chat/mutations.ts @@ -3,7 +3,7 @@ import { v } from "convex/values"; import { components, internal } from "../_generated/api"; import { internalMutation } from "../_generated/server"; import { authMutation } from "../functions"; -import { deriveEffectivePlan } from "../billing/plans"; +import { deriveEffectivePlan, getCanonicalSubscription } from "../billing/plans"; import { throwNotFound } from "../utils/errors"; import { startOfMonth } from "./usage"; import type { Id } from "../_generated/dataModel"; @@ -40,12 +40,9 @@ export const checkAndIncrementUsage = internalMutation({ const allSubs = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .take(10); - const plan = allSubs.some( - (subscription) => deriveEffectivePlan(subscription) === "pro", - ) - ? "pro" - : "free"; + .order("desc") + .take(20); + const plan = deriveEffectivePlan(getCanonicalSubscription(allSubs)); // 3. Fetch user metadata for custom limits. const user = await ctx.runQuery(components.betterAuth.data.getUserById, { diff --git a/packages/backend/convex/chat/queries.ts b/packages/backend/convex/chat/queries.ts index 5b21635c..303cb8ac 100644 --- a/packages/backend/convex/chat/queries.ts +++ b/packages/backend/convex/chat/queries.ts @@ -1,7 +1,10 @@ import { v } from "convex/values"; import { components } from "../_generated/api"; import { internalQuery } from "../_generated/server"; -import { deriveEffectivePlan } from "../billing/plans"; +import { + deriveEffectivePlan, + getCanonicalSubscription, +} from "../billing/plans"; import { authQuery } from "../functions"; import { startOfMonth } from "./usage"; import type { Doc } from "../_generated/dataModel"; @@ -168,12 +171,9 @@ export const getChatUsage = authQuery({ const allSubs = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .take(10); - const plan = allSubs.some( - (subscription) => deriveEffectivePlan(subscription) === "pro", - ) - ? "pro" - : "free"; + .order("desc") + .take(20); + const plan = deriveEffectivePlan(getCanonicalSubscription(allSubs)); // Fetch user metadata for custom limits. const user = await ctx.runQuery(components.betterAuth.data.getUserById, { diff --git a/packages/backend/convex/subscriptions/helpers.ts b/packages/backend/convex/subscriptions/helpers.ts index 29a16191..374e6323 100644 --- a/packages/backend/convex/subscriptions/helpers.ts +++ b/packages/backend/convex/subscriptions/helpers.ts @@ -1,17 +1,18 @@ import { v } from "convex/values"; import { internalQuery } from "../_generated/server"; -import { deriveEffectivePlan } from "../billing/plans"; +import { deriveEffectivePlan, getCanonicalSubscription } from "../billing/plans"; /** Server-only entitlement check for actions that cannot access ctx.db. */ export const getEffectivePlanForUser = internalQuery({ args: { userId: v.string() }, returns: v.union(v.literal("free"), v.literal("pro")), handler: async (ctx, { userId }) => { - const subscription = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .first(); + .order("desc") + .take(20); - return deriveEffectivePlan(subscription); + return deriveEffectivePlan(getCanonicalSubscription(subscriptions)); }, }); diff --git a/packages/backend/convex/subscriptions/queries.ts b/packages/backend/convex/subscriptions/queries.ts index 5862940e..bd56a8d8 100644 --- a/packages/backend/convex/subscriptions/queries.ts +++ b/packages/backend/convex/subscriptions/queries.ts @@ -7,6 +7,7 @@ import { components } from "../_generated/api"; import { authQuery } from "../functions"; import { deriveEffectivePlan, + getCanonicalSubscription, getLimits, parseCustomLimits, } from "../billing/plans"; @@ -67,10 +68,12 @@ export const getMine = authQuery({ handler: async (ctx): Promise => { const { user } = ctx; - const sub = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.id)) - .first(); + .order("desc") + .take(20); + const sub = getCanonicalSubscription(subscriptions); if (!sub) return null; @@ -101,10 +104,12 @@ export const getUserPlan = authQuery({ const { user } = ctx; // 1. Fetch subscription (may be null → free). - const sub = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.id)) - .first(); + .order("desc") + .take(20); + const sub = getCanonicalSubscription(subscriptions); // 2. Derive plan from subscription status. const plan = deriveEffectivePlan(sub); diff --git a/packages/backend/convex/users/queries.ts b/packages/backend/convex/users/queries.ts index 5cc3276c..a7f6488d 100644 --- a/packages/backend/convex/users/queries.ts +++ b/packages/backend/convex/users/queries.ts @@ -1,5 +1,6 @@ import { deriveEffectivePlan, + getCanonicalSubscription, getLimits as getPlansLimits, parseCustomLimits, } from "../billing/plans"; @@ -21,11 +22,13 @@ export const getLimits = authQuery({ handler: async (ctx) => { const userId = ctx.user.id; - // Fetch active subscription - const sub = await ctx.db + // Fetch canonical subscription + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", userId)) - .first(); + .order("desc") + .take(20); + const sub = getCanonicalSubscription(subscriptions); const plan = deriveEffectivePlan(sub); @@ -38,9 +41,9 @@ export const getLimits = authQuery({ const subscription = sub ? { - id: sub._id as string, + id: (sub as { _id: string })._id, status: sub.status ?? "unknown", - periodEnd: sub.periodEnd ?? null, + periodEnd: (sub as { periodEnd?: number | null }).periodEnd ?? null, } : null; @@ -69,10 +72,12 @@ export const getLimits = authQuery({ export const getOnboardingFlowState = authQuery({ args: {}, handler: async (ctx) => { - const subscription = await ctx.db + const subscriptions = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", ctx.user.id)) - .first(); + .order("desc") + .take(20); + const subscription = getCanonicalSubscription(subscriptions); return deriveOnboardingFlowState({ onboarding: ctx.user.onboarding,