From 4fd605d956f36ae76368a90c03070a1846007af9 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Thu, 20 Aug 2026 23:06:53 -0700 Subject: [PATCH] feat(admin): grant/revoke comp Pro without faking a Stripe subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Needed to comp a user who hit the free-plan list cap. Writing a subscriptions row by hand would have been the obvious move and is a trap: stripeCustomerId is required, and both createCheckoutSession and createPortalSession pass that value straight to the Stripe API. An invented id means the user can never actually pay and their billing portal 500s — the opposite of a favour. referralProUntil is already the app's comp lever, honoured by getUserPlan (billing.ts) and by assertListQuota (lists.ts), and it leaves Stripe alone. These two internalMutations just make it operable from the CLI. Guards, because this writes real user billing state: email must match exactly one user (the schema does not make email unique, and granting to the wrong one of several is worse than refusing), and `until` must be in the future rather than silently granting nothing. Both return the previous value so a grant can be undone by hand if revoke is not enough. Verified on dev first — guards, happy path, and getUserPlan flipping to "pro" — then reverted the dev grant before touching prod. Co-Authored-By: Claude Opus 5 (1M context) --- convex/_generated/api.d.ts | 2 + convex/adminGrants.ts | 75 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 convex/adminGrants.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 44b6664..4c39f44 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as activity from "../activity.js"; import type * as activityHttp from "../activityHttp.js"; +import type * as adminGrants from "../adminGrants.js"; import type * as agentReadHttp from "../agentReadHttp.js"; import type * as apiKeys from "../apiKeys.js"; import type * as apiKeysHttp from "../apiKeysHttp.js"; @@ -90,6 +91,7 @@ import type { declare const fullApi: ApiFromModules<{ activity: typeof activity; activityHttp: typeof activityHttp; + adminGrants: typeof adminGrants; agentReadHttp: typeof agentReadHttp; apiKeys: typeof apiKeys; apiKeysHttp: typeof apiKeysHttp; diff --git a/convex/adminGrants.ts b/convex/adminGrants.ts new file mode 100644 index 0000000..5fff984 --- /dev/null +++ b/convex/adminGrants.ts @@ -0,0 +1,75 @@ +/** + * Manual Pro grants (comps, support gestures, apology credits). + * + * Uses referralProUntil rather than writing a subscriptions row. A hand-made + * subscription needs a stripeCustomerId, and both createCheckoutSession and + * createPortalSession pass that value straight to Stripe — an invented one + * would break the user's ability to ever actually pay, which is the opposite of + * a favour. referralProUntil is honoured by getUserPlan (billing.ts) and by the + * list quota (assertListQuota in lists.ts), and leaves Stripe untouched. + * + * internalMutation: callable from the CLI and other Convex functions, never + * from a client. + * + * npx convex run --prod adminGrants:grantProByEmail '{"email":"…","until":…}' + */ + +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; + +export const grantProByEmail = internalMutation({ + args: { + email: v.string(), + /** Epoch ms the grant expires. Must be in the future to have any effect. */ + until: v.number(), + }, + handler: async (ctx, args) => { + const matches = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", args.email)) + .collect(); + + if (matches.length === 0) throw new Error(`No user with email ${args.email}`); + // Email is not unique in the schema; granting to the wrong one of several + // is worse than refusing and making the caller name an id. + if (matches.length > 1) { + throw new Error( + `${matches.length} users share ${args.email} — refusing to guess which to grant` + ); + } + + const user = matches[0]; + const previous = user.referralProUntil ?? null; + + if (args.until <= Date.now()) { + throw new Error("`until` is in the past — that would grant nothing"); + } + + await ctx.db.patch(user._id, { referralProUntil: args.until }); + + return { + userId: user._id, + email: user.email, + previousReferralProUntil: previous, + referralProUntil: args.until, + expires: new Date(args.until).toISOString(), + }; + }, +}); + +/** Undo a grant — clears the field entirely rather than setting it to zero. */ +export const revokeProByEmail = internalMutation({ + args: { email: v.string() }, + handler: async (ctx, args) => { + const matches = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", args.email)) + .collect(); + if (matches.length !== 1) { + throw new Error(`Expected exactly one user for ${args.email}, found ${matches.length}`); + } + const previous = matches[0].referralProUntil ?? null; + await ctx.db.patch(matches[0]._id, { referralProUntil: undefined }); + return { userId: matches[0]._id, clearedReferralProUntil: previous }; + }, +});