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.tsBlame308 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;
119 percent: {
120 storage: number;
121 aiTokens: number;
122 bandwidth: number;
123 };
124}
125
126/** Loads the quota row, inserting a free-plan row on first read. */
127export async function getUserQuota(userId: string): Promise<QuotaView> {
128 let row: UserQuota | undefined;
129 try {
130 const [r] = await db
131 .select()
132 .from(userQuotas)
133 .where(eq(userQuotas.userId, userId))
134 .limit(1);
135 row = r;
136 if (!row) {
137 await db
138 .insert(userQuotas)
139 .values({ userId, planSlug: DEFAULT_PLAN_SLUG })
140 .onConflictDoNothing();
141 const [r2] = await db
142 .select()
143 .from(userQuotas)
144 .where(eq(userQuotas.userId, userId))
145 .limit(1);
146 row = r2;
147 }
148 } catch {
149 // fall through
150 }
151
152 const planSlug = row?.planSlug || DEFAULT_PLAN_SLUG;
153 const plan = await getPlan(planSlug);
154 const usage = {
155 storageMbUsed: row?.storageMbUsed || 0,
156 aiTokensUsedThisMonth: row?.aiTokensUsedThisMonth || 0,
157 bandwidthGbUsedThisMonth: row?.bandwidthGbUsedThisMonth || 0,
158 };
159 const pct = (used: number, limit: number) =>
160 limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
161
162 return {
163 planSlug,
164 plan,
165 usage,
166 cycleStart: (row?.cycleStart as Date | null) || null,
167 percent: {
168 storage: pct(usage.storageMbUsed, plan.storageMbLimit),
169 aiTokens: pct(usage.aiTokensUsedThisMonth, plan.aiTokensMonthly),
170 bandwidth: pct(usage.bandwidthGbUsedThisMonth, plan.bandwidthGbMonthly),
171 },
172 };
173}
174
175export async function setUserPlan(
176 userId: string,
177 planSlug: string
178): Promise<boolean> {
179 try {
180 await db
181 .insert(userQuotas)
182 .values({ userId, planSlug })
183 .onConflictDoUpdate({
184 target: userQuotas.userId,
185 set: { planSlug, updatedAt: new Date() },
186 });
187 return true;
188 } catch (err) {
189 console.error("[billing] setUserPlan:", err);
190 return false;
191 }
192}
193
194/** Fire-and-forget counter bump. Returns the new value on success. */
195export async function bumpUsage(
196 userId: string,
197 field: QuotaField,
198 delta: number
199): Promise<number | null> {
200 if (!userId || delta === 0) return null;
201 const column =
202 field === "storageMbUsed"
203 ? userQuotas.storageMbUsed
204 : field === "aiTokensUsedThisMonth"
205 ? userQuotas.aiTokensUsedThisMonth
206 : userQuotas.bandwidthGbUsedThisMonth;
207 try {
208 await db
209 .insert(userQuotas)
210 .values({
211 userId,
212 planSlug: DEFAULT_PLAN_SLUG,
213 [field]: delta,
214 } as any)
215 .onConflictDoUpdate({
216 target: userQuotas.userId,
217 set: {
218 [field]: sql`${column} + ${delta}`,
219 updatedAt: new Date(),
220 } as any,
221 });
222 const [r] = await db
223 .select({ n: column })
224 .from(userQuotas)
225 .where(eq(userQuotas.userId, userId))
226 .limit(1);
227 return Number(r?.n || 0);
228 } catch (err) {
229 console.error("[billing] bumpUsage:", err);
230 return null;
231 }
232}
233
234/** True if the user has budget left for an action costing `amount` against `field`. */
235export async function checkQuota(
236 userId: string,
237 field: QuotaField,
238 amount: number = 1
239): Promise<boolean> {
240 try {
241 const { plan, usage } = await getUserQuota(userId);
242 if (field === "storageMbUsed")
243 return usage.storageMbUsed + amount <= plan.storageMbLimit;
244 if (field === "aiTokensUsedThisMonth")
245 return usage.aiTokensUsedThisMonth + amount <= plan.aiTokensMonthly;
246 if (field === "bandwidthGbUsedThisMonth")
247 return usage.bandwidthGbUsedThisMonth + amount <= plan.bandwidthGbMonthly;
248 return true;
249 } catch {
250 return true; // fail-open on billing errors
251 }
252}
253
254export async function repoCountForUser(userId: string): Promise<number> {
255 try {
256 const [r] = await db
257 .select({ n: sql<number>`count(*)::int` })
258 .from(repositories)
259 .where(eq(repositories.ownerId, userId));
260 return Number(r?.n || 0);
261 } catch {
262 return 0;
263 }
264}
265
266/** True if creating another repo would exceed the plan's repoLimit. */
267export async function wouldExceedRepoLimit(userId: string): Promise<boolean> {
268 try {
269 const [quota, count] = await Promise.all([
270 getUserQuota(userId),
271 repoCountForUser(userId),
272 ]);
273 return count >= quota.plan.repoLimit;
274 } catch {
275 return false;
276 }
277}
278
279/** Resets monthly counters if >30 days have passed since cycleStart. */
280export async function resetIfCycleExpired(userId: string): Promise<boolean> {
281 try {
282 const [row] = await db
283 .select({ cycleStart: userQuotas.cycleStart })
284 .from(userQuotas)
285 .where(eq(userQuotas.userId, userId))
286 .limit(1);
287 if (!row?.cycleStart) return false;
288 const age = Date.now() - new Date(row.cycleStart).getTime();
289 if (age < 30 * 24 * 60 * 60 * 1000) return false;
290 await db
291 .update(userQuotas)
292 .set({
293 aiTokensUsedThisMonth: 0,
294 bandwidthGbUsedThisMonth: 0,
295 cycleStart: new Date(),
296 updatedAt: new Date(),
297 })
298 .where(eq(userQuotas.userId, userId));
299 return true;
300 } catch {
301 return false;
302 }
303}
304
305export function formatPrice(cents: number): string {
306 if (cents === 0) return "Free";
307 return `$${(cents / 100).toFixed(2)}/mo`;
308}