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.tsx

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.tsxBlame457 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F4 — Billing + quota UI.
3 *
4 * GET /settings/billing — personal quota view + plan table
5 * GET /admin/billing — site admin: user list + overrides
6 * POST /admin/billing/:userId/plan — set user's plan (audit-logged)
7 *
8 * All read operations degrade gracefully if the billing tables are empty
9 * (FALLBACK_PLANS in lib/billing.ts mirror the seed rows). Plan assignment
10 * is site-admin only; there is no self-service purchase flow here — that's
11 * Stripe's job, and deliberately out-of-scope for the v1 panel.
12 */
13
14import { Hono } from "hono";
15import { desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import { users, userQuotas } from "../db/schema";
18import { Layout } from "../views/layout";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { isSiteAdmin } from "../lib/admin";
22import { audit } from "../lib/notify";
23import {
24 formatPrice,
25 getUserQuota,
26 listPlans,
27 setUserPlan,
28} from "../lib/billing";
619109aClaude29import {
30 createBillingPortalSession,
31 createCheckoutSession,
32 findOrCreateCustomer,
33} from "../lib/stripe";
34import { config } from "../lib/config";
8f50ed0Claude35
36const billing = new Hono<AuthEnv>();
37billing.use("*", softAuth);
38
39// ----- Personal billing page -----
40
41billing.get("/settings/billing", requireAuth, async (c) => {
42 const user = c.get("user")!;
43 const [quota, plans] = await Promise.all([
44 getUserQuota(user.id),
45 listPlans(),
46 ]);
47
48 const bar = (pct: number) => {
49 const color = pct >= 90 ? "var(--red)" : pct >= 70 ? "#f0b72f" : "var(--green)";
50 return (
51 <div
52 style="background:var(--bg-secondary);height:8px;border-radius:4px;overflow:hidden"
53 >
54 <div
55 style={`width:${pct}%;height:100%;background:${color};transition:width .2s`}
56 />
57 </div>
58 );
59 };
60
5f2e749Claude61 // L8 — bigger usage bar styles (scoped, additive). The original panel
62 // bars below are left untouched.
63 const bigBar = (pct: number) => {
64 const color = pct >= 90 ? "var(--red)" : pct >= 70 ? "#f0b72f" : "var(--green)";
65 return (
66 <div
67 style="background:var(--bg-secondary);height:14px;border-radius:7px;overflow:hidden;border:1px solid var(--border-subtle)"
68 >
69 <div
70 style={`width:${pct}%;height:100%;background:${color};transition:width .3s`}
71 />
72 </div>
73 );
74 };
75
8f50ed0Claude76 return c.html(
77 <Layout title="Billing — Gluecron" user={user}>
78 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
79 <h2>Billing & usage</h2>
80 <a href="/settings" class="btn btn-sm">
81 Back to settings
82 </a>
83 </div>
84
5f2e749Claude85 {/* L8 — "here's what you've used this month" hero panel. */}
86 <div
87 class="panel"
88 style="padding:20px;margin-bottom:20px;background:linear-gradient(180deg,rgba(140,109,255,0.05),transparent 70%),var(--bg-elevated);border-color:rgba(140,109,255,0.20)"
89 >
90 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;margin-bottom:16px">
91 <div>
92 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.14em;font-family:var(--font-mono);margin-bottom:6px">
93 Hey, here's what you've used this month
94 </div>
95 <div style="font-size:14px;color:var(--text-muted)">
96 On the <strong style="color:var(--text-strong)">{quota.plan.name}</strong> plan —{" "}
97 {formatPrice(quota.plan.priceCents)}
98 </div>
99 </div>
100 {quota.planSlug === "free" && (
101 <form method="post" action="/billing/upgrade/pro">
102 <button type="submit" class="btn btn-primary">
103 Upgrade to Pro &rarr;
104 </button>
105 </form>
106 )}
107 </div>
108 <div style="display:flex;flex-direction:column;gap:14px">
109 <div>
110 <div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:13px">
111 <span style="color:var(--text-strong);font-weight:500">Repos</span>
112 <span style="color:var(--text-muted);font-family:var(--font-mono)">
113 {Math.min(quota.plan.repoLimit, quota.plan.repoLimit)} max on plan
114 </span>
115 </div>
116 {bigBar(0)}
117 </div>
118 <div>
119 <div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:13px">
120 <span style="color:var(--text-strong);font-weight:500">AI calls</span>
121 <span style="color:var(--text-muted);font-family:var(--font-mono)">
122 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} /{" "}
123 {quota.plan.aiTokensMonthly.toLocaleString()} ({quota.percent.aiTokens}%)
124 </span>
125 </div>
126 {bigBar(quota.percent.aiTokens)}
127 </div>
128 <div>
129 <div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:13px">
130 <span style="color:var(--text-strong);font-weight:500">Storage</span>
131 <span style="color:var(--text-muted);font-family:var(--font-mono)">
132 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB ({quota.percent.storage}%)
133 </span>
134 </div>
135 {bigBar(quota.percent.storage)}
136 </div>
137 </div>
138 </div>
139
8f50ed0Claude140 <div class="panel" style="padding:16px;margin-bottom:20px">
141 <div style="display:flex;justify-content:space-between;align-items:center">
142 <div>
143 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase">
144 Current plan
145 </div>
146 <div style="font-size:22px;font-weight:700">{quota.plan.name}</div>
147 <div style="font-size:13px;color:var(--text-muted)">
148 {formatPrice(quota.plan.priceCents)}
149 </div>
150 </div>
151 <div style="text-align:right;font-size:12px;color:var(--text-muted)">
152 {quota.cycleStart
153 ? `Cycle started ${new Date(quota.cycleStart).toLocaleDateString()}`
154 : "No cycle recorded"}
155 </div>
156 </div>
157 </div>
158
159 <h3>Usage this cycle</h3>
160 <div class="panel" style="padding:16px;margin-bottom:20px">
161 <div class="form-group">
162 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
163 <span>Storage</span>
164 <span style="color:var(--text-muted);font-family:var(--font-mono)">
165 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB
166 </span>
167 </div>
168 {bar(quota.percent.storage)}
169 </div>
170 <div class="form-group">
171 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
172 <span>AI tokens (monthly)</span>
173 <span style="color:var(--text-muted);font-family:var(--font-mono)">
174 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} /{" "}
175 {quota.plan.aiTokensMonthly.toLocaleString()}
176 </span>
177 </div>
178 {bar(quota.percent.aiTokens)}
179 </div>
180 <div class="form-group" style="margin-bottom:0">
181 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
182 <span>Bandwidth (monthly)</span>
183 <span style="color:var(--text-muted);font-family:var(--font-mono)">
184 {quota.usage.bandwidthGbUsedThisMonth} /{" "}
185 {quota.plan.bandwidthGbMonthly} GB
186 </span>
187 </div>
188 {bar(quota.percent.bandwidth)}
189 </div>
190 </div>
191
192 <h3>Available plans</h3>
193 <div
194 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px"
195 >
196 {plans.map((p) => {
197 const isCurrent = p.slug === quota.planSlug;
198 return (
199 <div
200 class="panel"
201 style={`padding:16px${isCurrent ? ";border-color:var(--green)" : ""}`}
202 >
203 <div style="font-size:16px;font-weight:700">{p.name}</div>
204 <div style="font-size:18px;margin:6px 0">
205 {formatPrice(p.priceCents)}
206 </div>
207 <div style="font-size:12px;color:var(--text-muted);line-height:1.6">
208 <div>{p.repoLimit.toLocaleString()} repos</div>
209 <div>{p.storageMbLimit.toLocaleString()} MB storage</div>
210 <div>{p.aiTokensMonthly.toLocaleString()} AI tokens/mo</div>
211 <div>{p.bandwidthGbMonthly} GB bandwidth/mo</div>
212 <div>
213 {p.privateRepos ? "Private repos ✓" : "Public repos only"}
214 </div>
215 </div>
216 {isCurrent && (
217 <div
218 style="margin-top:10px;font-size:11px;color:var(--green);font-weight:600"
219 >
220 CURRENT PLAN
221 </div>
222 )}
619109aClaude223 {!isCurrent && p.slug !== "free" && p.priceCents > 0 && (
224 <form
225 method="post"
226 action={`/billing/upgrade/${p.slug}`}
227 style="margin-top:10px"
228 >
229 <button
230 type="submit"
231 class="btn btn-primary"
232 style="width:100%;font-size:13px"
233 >
234 {quota.planSlug === "free" ? "Upgrade" : "Switch"} to {p.name}
235 </button>
236 </form>
237 )}
8f50ed0Claude238 </div>
239 );
240 })}
241 </div>
619109aClaude242 {quota.planSlug !== "free" && (
243 <div style="margin-top:16px">
244 <form method="post" action="/billing/manage" style="display:inline">
245 <button
246 type="submit"
247 class="btn"
248 style="font-size:13px"
249 >
250 Manage subscription (Stripe Customer Portal)
251 </button>
252 </form>
253 <span
254 style="font-size:12px;color:var(--text-muted);margin-left:12px"
255 >
256 — change card, download invoices, cancel anytime.
257 </span>
258 </div>
259 )}
260 {!process.env.STRIPE_SECRET_KEY && (
261 <p
262 style="font-size:12px;color:var(--text-muted);margin-top:12px;font-style:italic"
263 >
264 (Stripe not yet configured on this instance — upgrade buttons will
265 return a setup error. Run the Stripe Bootstrap workflow to enable.)
266 </p>
267 )}
5f2e749Claude268
269 {/* L8 — detailed plan comparison link, points at the public /pricing page. */}
270 <p style="font-size:13px;color:var(--text-muted);margin-top:24px;text-align:center">
271 Want the full breakdown of what's included?{" "}
272 <a href="/pricing" style="color:var(--accent);font-weight:500">
273 Detailed plan comparison &rarr;
274 </a>
275 </p>
8f50ed0Claude276 </Layout>
277 );
278});
279
619109aClaude280// ----- Upgrade flow (Stripe Checkout) -----
281
282billing.post("/billing/upgrade/:plan", requireAuth, async (c) => {
283 const user = c.get("user")!;
284 const planSlug = c.req.param("plan");
285 if (planSlug !== "pro" && planSlug !== "team" && planSlug !== "enterprise") {
286 return c.redirect("/settings/billing?error=invalid-plan");
287 }
288
289 const quota = await getUserQuota(user.id);
290 const customer = await findOrCreateCustomer({
291 userId: user.id,
292 email: user.email ?? `${user.username}@gluecron.local`,
293 existingCustomerId: quota.stripeCustomerId ?? null,
294 });
295 if (!customer.ok) {
296 console.error(`[billing/upgrade] customer: ${customer.error}`);
297 return c.redirect(
298 `/settings/billing?error=${encodeURIComponent(customer.error)}`
299 );
300 }
301
302 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
303 const session = await createCheckoutSession({
304 customerId: customer.customerId,
305 planSlug,
306 successUrl: `${base}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
307 cancelUrl: `${base}/billing/cancel`,
308 userId: user.id,
309 });
310 if (!session.ok) {
311 console.error(`[billing/upgrade] checkout: ${session.error}`);
312 return c.redirect(
313 `/settings/billing?error=${encodeURIComponent(session.error)}`
314 );
315 }
316
317 // Stash the customerId onto the quota row now (doesn't wait for webhook)
318 // so subsequent upgrades don't re-create a customer.
319 await db
320 .update(userQuotas)
321 .set({ stripeCustomerId: customer.customerId, updatedAt: new Date() })
322 .where(eq(userQuotas.userId, user.id));
323
324 return c.redirect(session.url, 303);
325});
326
327billing.get("/billing/success", requireAuth, async (c) => {
328 // The webhook does the actual plan assignment; this is just a landing page.
329 return c.redirect("/settings/billing?upgraded=1");
330});
331
332billing.get("/billing/cancel", requireAuth, async (c) => {
333 return c.redirect("/settings/billing?canceled=1");
334});
335
336billing.post("/billing/manage", requireAuth, async (c) => {
337 const user = c.get("user")!;
338 const quota = await getUserQuota(user.id);
339 if (!quota.stripeCustomerId) {
340 return c.redirect("/settings/billing?error=no-subscription");
341 }
342 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
343 const session = await createBillingPortalSession({
344 customerId: quota.stripeCustomerId,
345 returnUrl: `${base}/settings/billing`,
346 });
347 if (!session.ok) {
348 console.error(`[billing/manage] portal: ${session.error}`);
349 return c.redirect(
350 `/settings/billing?error=${encodeURIComponent(session.error)}`
351 );
352 }
353 return c.redirect(session.url, 303);
354});
355
8f50ed0Claude356// ----- Admin billing panel -----
357
358billing.get("/admin/billing", async (c) => {
359 const user = c.get("user");
360 if (!user) return c.redirect("/login?next=/admin/billing");
361 if (!(await isSiteAdmin(user.id))) {
362 return c.html(
363 <Layout title="Forbidden" user={user}>
364 <div class="empty-state">
365 <h2>403 — Not a site admin</h2>
366 </div>
367 </Layout>,
368 403
369 );
370 }
371
372 const plans = await listPlans();
373 const rows = await db
374 .select({
375 id: users.id,
376 username: users.username,
377 planSlug: userQuotas.planSlug,
378 storageMbUsed: userQuotas.storageMbUsed,
379 aiTokensUsedThisMonth: userQuotas.aiTokensUsedThisMonth,
380 })
381 .from(users)
382 .leftJoin(userQuotas, eq(users.id, userQuotas.userId))
383 .orderBy(desc(users.createdAt))
384 .limit(200);
385
386 return c.html(
387 <Layout title="Admin — Billing" user={user}>
388 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
389 <h2>Billing — all users</h2>
390 <a href="/admin" class="btn btn-sm">
391 Back
392 </a>
393 </div>
394 <div class="panel">
395 {rows.length === 0 ? (
396 <div class="panel-empty">No users.</div>
397 ) : (
398 rows.map((r) => (
399 <div class="panel-item" style="justify-content:space-between">
400 <div style="flex:1;min-width:0">
401 <a href={`/${r.username}`} style="font-weight:600">
402 {r.username}
403 </a>
404 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
405 Plan: <strong>{r.planSlug || "free"}</strong> ·{" "}
406 {r.storageMbUsed || 0} MB ·{" "}
407 {(r.aiTokensUsedThisMonth || 0).toLocaleString()} tokens
408 </div>
409 </div>
410 <form
9e2c6dfClaude411 method="post"
8f50ed0Claude412 action={`/admin/billing/${r.id}/plan`}
413 style="display:flex;gap:6px;align-items:center"
414 >
415 <select name="slug" style="font-size:12px">
416 {plans.map((p) => (
417 <option
418 value={p.slug}
419 selected={(r.planSlug || "free") === p.slug}
420 >
421 {p.name}
422 </option>
423 ))}
424 </select>
425 <button type="submit" class="btn btn-sm">
426 Set
427 </button>
428 </form>
429 </div>
430 ))
431 )}
432 </div>
433 </Layout>
434 );
435});
436
437billing.post("/admin/billing/:userId/plan", async (c) => {
438 const user = c.get("user");
439 if (!user) return c.redirect("/login?next=/admin/billing");
440 if (!(await isSiteAdmin(user.id))) {
441 return c.text("Forbidden", 403);
442 }
443 const userId = c.req.param("userId");
444 const body = await c.req.parseBody();
445 const slug = String(body.slug || "free");
446 await setUserPlan(userId, slug);
447 await audit({
448 userId: user.id,
449 action: "admin.billing.set_plan",
450 targetType: "user",
451 targetId: userId,
452 metadata: { plan: slug },
453 });
454 return c.redirect("/admin/billing");
455});
456
457export default billing;