Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

billing.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

billing.tsBlame318 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F4 — Billing + quotas.
3 *
4 * Plans live in `billing_plans` (seeded with free/pro/team/enterprise by
5 * migration 0020). Each user has a row in `user_quotas` keyed by
6 * `plan_slug` + running usage counters (storage, AI tokens, bandwidth).
7 *
8 * getPlan(slug) — load a plan by slug
9 * getUserQuota(userId) — row + plan join, initialises on first read
10 * listPlans() — admin UI
11 * setUserPlan(userId, slug, byId) — admin override (audit-logged outside)
12 * bumpUsage(userId, field, delta) — fire-and-forget counter increment
13 * checkQuota(userId, field, amount) — boolean "allowed?" for pre-write gating
14 * repoCountForUser(userId) — counts owned repos (enforced at create)
15 * resetIfCycleExpired(userId) — flips cycleStart each month
16 *
17 * All helpers swallow DB errors (plan goes to "free" on failure) so billing is
18 * never a hard dependency for the primary request path.
19 */
20
21import { and, eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 billingPlans,
25 userQuotas,
26 repositories,
27 type BillingPlan,
28 type UserQuota,
29} from "../db/schema";
30
31export const DEFAULT_PLAN_SLUG = "free";
32
33/** Mirrors the seed rows in migration 0020 so billing works even pre-migration. */
34export const FALLBACK_PLANS: Record<string, Omit<BillingPlan, "id" | "createdAt">> = {
35 free: {
36 slug: "free",
37 name: "Free",
38 priceCents: 0,
39 repoLimit: 5,
40 storageMbLimit: 500,
41 aiTokensMonthly: 50_000,
42 bandwidthGbMonthly: 5,
43 privateRepos: false,
44 },
45 pro: {
46 slug: "pro",
47 name: "Pro",
48 priceCents: 900,
49 repoLimit: 50,
50 storageMbLimit: 5_000,
51 aiTokensMonthly: 500_000,
52 bandwidthGbMonthly: 50,
53 privateRepos: true,
54 },
55 team: {
56 slug: "team",
57 name: "Team",
58 priceCents: 2900,
59 repoLimit: 200,
60 storageMbLimit: 20_000,
61 aiTokensMonthly: 2_000_000,
62 bandwidthGbMonthly: 200,
63 privateRepos: true,
64 },
65 enterprise: {
66 slug: "enterprise",
67 name: "Enterprise",
68 priceCents: 9900,
69 repoLimit: 10_000,
70 storageMbLimit: 500_000,
71 aiTokensMonthly: 50_000_000,
72 bandwidthGbMonthly: 5_000,
73 privateRepos: true,
74 },
75};
76
77export async function listPlans(): Promise<
78 Array<Omit<BillingPlan, "id" | "createdAt">>
79> {
80 try {
81 const rows = await db.select().from(billingPlans).orderBy(billingPlans.priceCents);
82 if (rows.length > 0) return rows;
83 } catch {
84 // fall through
85 }
86 return Object.values(FALLBACK_PLANS);
87}
88
89export async function getPlan(
90 slug: string
91): Promise<Omit<BillingPlan, "id" | "createdAt">> {
92 try {
93 const [row] = await db
94 .select()
95 .from(billingPlans)
96 .where(eq(billingPlans.slug, slug))
97 .limit(1);
98 if (row) return row;
99 } catch {
100 // fall through
101 }
102 return FALLBACK_PLANS[slug] || FALLBACK_PLANS.free;
103}
104
105export type QuotaField =
106 | "storageMbUsed"
107 | "aiTokensUsedThisMonth"
108 | "bandwidthGbUsedThisMonth";
109
110export interface QuotaView {
111 planSlug: string;
112 plan: Omit<BillingPlan, "id" | "createdAt">;
113 usage: {
114 storageMbUsed: number;
115 aiTokensUsedThisMonth: number;
116 bandwidthGbUsedThisMonth: number;
117 };
118 cycleStart: Date | null;
619109aClaude119 /** Stripe linkage — populated once the user completes a checkout session. */
120 stripeCustomerId: string | null;
121 stripeSubscriptionId: string | null;
122 stripeSubscriptionStatus: string | null;
123 currentPeriodEnd: Date | null;
8f50ed0Claude124 percent: {
125 storage: number;
126 aiTokens: number;
127 bandwidth: number;
128 };
129}
130
131/** Loads the quota row, inserting a free-plan row on first read. */
132export async function getUserQuota(userId: string): Promise<QuotaView> {
133 let row: UserQuota | undefined;
134 try {
135 const [r] = await db
136 .select()
137 .from(userQuotas)
138 .where(eq(userQuotas.userId, userId))
139 .limit(1);
140 row = r;
141 if (!row) {
142 await db
143 .insert(userQuotas)
144 .values({ userId, planSlug: DEFAULT_PLAN_SLUG })
145 .onConflictDoNothing();
146 const [r2] = await db
147 .select()
148 .from(userQuotas)
149 .where(eq(userQuotas.userId, userId))
150 .limit(1);
151 row = r2;
152 }
153 } catch {
154 // fall through
155 }
156
157 const planSlug = row?.planSlug || DEFAULT_PLAN_SLUG;
158 const plan = await getPlan(planSlug);
159 const usage = {
160 storageMbUsed: row?.storageMbUsed || 0,
161 aiTokensUsedThisMonth: row?.aiTokensUsedThisMonth || 0,
162 bandwidthGbUsedThisMonth: row?.bandwidthGbUsedThisMonth || 0,
163 };
164 const pct = (used: number, limit: number) =>
165 limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
166
167 return {
168 planSlug,
169 plan,
170 usage,
171 cycleStart: (row?.cycleStart as Date | null) || null,
619109aClaude172 stripeCustomerId: (row?.stripeCustomerId as string | null) || null,
173 stripeSubscriptionId: (row?.stripeSubscriptionId as string | null) || null,
174 stripeSubscriptionStatus:
175 (row?.stripeSubscriptionStatus as string | null) || null,
176 currentPeriodEnd: (row?.currentPeriodEnd as Date | null) || null,
8f50ed0Claude177 percent: {
178 storage: pct(usage.storageMbUsed, plan.storageMbLimit),
179 aiTokens: pct(usage.aiTokensUsedThisMonth, plan.aiTokensMonthly),
180 bandwidth: pct(usage.bandwidthGbUsedThisMonth, plan.bandwidthGbMonthly),
181 },
182 };
183}
184
185export async function setUserPlan(
186 userId: string,
187 planSlug: string
188): Promise<boolean> {
189 try {
190 await db
191 .insert(userQuotas)
192 .values({ userId, planSlug })
193 .onConflictDoUpdate({
194 target: userQuotas.userId,
195 set: { planSlug, updatedAt: new Date() },
196 });
197 return true;
198 } catch (err) {
199 console.error("[billing] setUserPlan:", err);
200 return false;
201 }
202}
203
204/** Fire-and-forget counter bump. Returns the new value on success. */
205export async function bumpUsage(
206 userId: string,
207 field: QuotaField,
208 delta: number
209): Promise<number | null> {
210 if (!userId || delta === 0) return null;
211 const column =
212 field === "storageMbUsed"
213 ? userQuotas.storageMbUsed
214 : field === "aiTokensUsedThisMonth"
215 ? userQuotas.aiTokensUsedThisMonth
216 : userQuotas.bandwidthGbUsedThisMonth;
217 try {
218 await db
219 .insert(userQuotas)
220 .values({
221 userId,
222 planSlug: DEFAULT_PLAN_SLUG,
223 [field]: delta,
224 } as any)
225 .onConflictDoUpdate({
226 target: userQuotas.userId,
227 set: {
228 [field]: sql`${column} + ${delta}`,
229 updatedAt: new Date(),
230 } as any,
231 });
232 const [r] = await db
233 .select({ n: column })
234 .from(userQuotas)
235 .where(eq(userQuotas.userId, userId))
236 .limit(1);
237 return Number(r?.n || 0);
238 } catch (err) {
239 console.error("[billing] bumpUsage:", err);
240 return null;
241 }
242}
243
244/** True if the user has budget left for an action costing `amount` against `field`. */
245export async function checkQuota(
246 userId: string,
247 field: QuotaField,
248 amount: number = 1
249): Promise<boolean> {
250 try {
251 const { plan, usage } = await getUserQuota(userId);
252 if (field === "storageMbUsed")
253 return usage.storageMbUsed + amount <= plan.storageMbLimit;
254 if (field === "aiTokensUsedThisMonth")
255 return usage.aiTokensUsedThisMonth + amount <= plan.aiTokensMonthly;
256 if (field === "bandwidthGbUsedThisMonth")
257 return usage.bandwidthGbUsedThisMonth + amount <= plan.bandwidthGbMonthly;
258 return true;
259 } catch {
260 return true; // fail-open on billing errors
261 }
262}
263
264export async function repoCountForUser(userId: string): Promise<number> {
265 try {
266 const [r] = await db
267 .select({ n: sql<number>`count(*)::int` })
268 .from(repositories)
269 .where(eq(repositories.ownerId, userId));
270 return Number(r?.n || 0);
271 } catch {
272 return 0;
273 }
274}
275
276/** True if creating another repo would exceed the plan's repoLimit. */
277export async function wouldExceedRepoLimit(userId: string): Promise<boolean> {
278 try {
279 const [quota, count] = await Promise.all([
280 getUserQuota(userId),
281 repoCountForUser(userId),
282 ]);
283 return count >= quota.plan.repoLimit;
284 } catch {
285 return false;
286 }
287}
288
289/** Resets monthly counters if >30 days have passed since cycleStart. */
290export async function resetIfCycleExpired(userId: string): Promise<boolean> {
291 try {
292 const [row] = await db
293 .select({ cycleStart: userQuotas.cycleStart })
294 .from(userQuotas)
295 .where(eq(userQuotas.userId, userId))
296 .limit(1);
297 if (!row?.cycleStart) return false;
298 const age = Date.now() - new Date(row.cycleStart).getTime();
299 if (age < 30 * 24 * 60 * 60 * 1000) return false;
300 await db
301 .update(userQuotas)
302 .set({
303 aiTokensUsedThisMonth: 0,
304 bandwidthGbUsedThisMonth: 0,
305 cycleStart: new Date(),
306 updatedAt: new Date(),
307 })
308 .where(eq(userQuotas.userId, userId));
309 return true;
310 } catch {
311 return false;
312 }
313}
314
315export function formatPrice(cents: number): string {
316 if (cents === 0) return "Free";
317 return `$${(cents / 100).toFixed(2)}/mo`;
318}