Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/backend/convex/apiKeys/helpers.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down
30 changes: 19 additions & 11 deletions packages/backend/convex/billing/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}

Expand Down
68 changes: 67 additions & 1 deletion packages/backend/convex/billing/plans.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
35 changes: 35 additions & 0 deletions packages/backend/convex/billing/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,6 +75,39 @@ export function isLifetimeSubscription(
);
}

function subscriptionTimestamp(subscription: SubscriptionPlanState): number {
return subscription.updatedAt ?? subscription.createdAt ?? 0;
}
Comment on lines +78 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid using maintenance timestamps to order entitlements

When a user has multiple subscription rows and an older row is repaired, migration/repair_subscriptions.ts sets that row's updatedAt to Date.now() even when merely correcting its customer ID; updateFromWebhook similarly timestamps updates by processing time. This selector therefore makes that historical row canonical over a genuinely newer subscription, so an old canceled row can revoke an active payer's Pro access or an old active row can restore access after cancellation. Use a billing-lifecycle/event ordering field, or keep non-lifecycle maintenance from changing the field used here.

Useful? React with 👍 / 👎.


/**
* 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<T extends SubscriptionPlanState>(
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.
Expand Down
13 changes: 9 additions & 4 deletions packages/backend/convex/bookmarks/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
11 changes: 4 additions & 7 deletions packages/backend/convex/chat/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
14 changes: 7 additions & 7 deletions packages/backend/convex/chat/queries.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
9 changes: 5 additions & 4 deletions packages/backend/convex/subscriptions/helpers.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines 12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include lifetime grants outside the recent-row window

When an account has more than 20 subscription rows, this newest-by-creation window can exclude its durable manual lifetime grant. In particular, grantLifetimeProByEmail patches the oldest by_user row, while all new entitlement readers take only the 20 newest rows before canonicalization; such a user is then treated as free for checkout, quotas, API access, and export despite the helper's lifetime-wins invariant. Query lifetime status separately or use an indexed selection that cannot truncate the winning row.

Useful? React with 👍 / 👎.


return deriveEffectivePlan(subscription);
return deriveEffectivePlan(getCanonicalSubscription(subscriptions));
},
});
13 changes: 9 additions & 4 deletions packages/backend/convex/subscriptions/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { components } from "../_generated/api";
import { authQuery } from "../functions";
import {
deriveEffectivePlan,
getCanonicalSubscription,
getLimits,
parseCustomLimits,
} from "../billing/plans";
Expand Down Expand Up @@ -67,10 +68,12 @@ export const getMine = authQuery({
handler: async (ctx): Promise<SubscriptionDTO | null> => {
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;

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading