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.tsxBlame379 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
61 return c.html(
62 <Layout title="Billing — Gluecron" user={user}>
63 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
64 <h2>Billing & usage</h2>
65 <a href="/settings" class="btn btn-sm">
66 Back to settings
67 </a>
68 </div>
69
70 <div class="panel" style="padding:16px;margin-bottom:20px">
71 <div style="display:flex;justify-content:space-between;align-items:center">
72 <div>
73 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase">
74 Current plan
75 </div>
76 <div style="font-size:22px;font-weight:700">{quota.plan.name}</div>
77 <div style="font-size:13px;color:var(--text-muted)">
78 {formatPrice(quota.plan.priceCents)}
79 </div>
80 </div>
81 <div style="text-align:right;font-size:12px;color:var(--text-muted)">
82 {quota.cycleStart
83 ? `Cycle started ${new Date(quota.cycleStart).toLocaleDateString()}`
84 : "No cycle recorded"}
85 </div>
86 </div>
87 </div>
88
89 <h3>Usage this cycle</h3>
90 <div class="panel" style="padding:16px;margin-bottom:20px">
91 <div class="form-group">
92 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
93 <span>Storage</span>
94 <span style="color:var(--text-muted);font-family:var(--font-mono)">
95 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB
96 </span>
97 </div>
98 {bar(quota.percent.storage)}
99 </div>
100 <div class="form-group">
101 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
102 <span>AI tokens (monthly)</span>
103 <span style="color:var(--text-muted);font-family:var(--font-mono)">
104 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} /{" "}
105 {quota.plan.aiTokensMonthly.toLocaleString()}
106 </span>
107 </div>
108 {bar(quota.percent.aiTokens)}
109 </div>
110 <div class="form-group" style="margin-bottom:0">
111 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
112 <span>Bandwidth (monthly)</span>
113 <span style="color:var(--text-muted);font-family:var(--font-mono)">
114 {quota.usage.bandwidthGbUsedThisMonth} /{" "}
115 {quota.plan.bandwidthGbMonthly} GB
116 </span>
117 </div>
118 {bar(quota.percent.bandwidth)}
119 </div>
120 </div>
121
122 <h3>Available plans</h3>
123 <div
124 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px"
125 >
126 {plans.map((p) => {
127 const isCurrent = p.slug === quota.planSlug;
128 return (
129 <div
130 class="panel"
131 style={`padding:16px${isCurrent ? ";border-color:var(--green)" : ""}`}
132 >
133 <div style="font-size:16px;font-weight:700">{p.name}</div>
134 <div style="font-size:18px;margin:6px 0">
135 {formatPrice(p.priceCents)}
136 </div>
137 <div style="font-size:12px;color:var(--text-muted);line-height:1.6">
138 <div>{p.repoLimit.toLocaleString()} repos</div>
139 <div>{p.storageMbLimit.toLocaleString()} MB storage</div>
140 <div>{p.aiTokensMonthly.toLocaleString()} AI tokens/mo</div>
141 <div>{p.bandwidthGbMonthly} GB bandwidth/mo</div>
142 <div>
143 {p.privateRepos ? "Private repos ✓" : "Public repos only"}
144 </div>
145 </div>
146 {isCurrent && (
147 <div
148 style="margin-top:10px;font-size:11px;color:var(--green);font-weight:600"
149 >
150 CURRENT PLAN
151 </div>
152 )}
619109aClaude153 {!isCurrent && p.slug !== "free" && p.priceCents > 0 && (
154 <form
155 method="post"
156 action={`/billing/upgrade/${p.slug}`}
157 style="margin-top:10px"
158 >
159 <button
160 type="submit"
161 class="btn btn-primary"
162 style="width:100%;font-size:13px"
163 >
164 {quota.planSlug === "free" ? "Upgrade" : "Switch"} to {p.name}
165 </button>
166 </form>
167 )}
8f50ed0Claude168 </div>
169 );
170 })}
171 </div>
619109aClaude172 {quota.planSlug !== "free" && (
173 <div style="margin-top:16px">
174 <form method="post" action="/billing/manage" style="display:inline">
175 <button
176 type="submit"
177 class="btn"
178 style="font-size:13px"
179 >
180 Manage subscription (Stripe Customer Portal)
181 </button>
182 </form>
183 <span
184 style="font-size:12px;color:var(--text-muted);margin-left:12px"
185 >
186 — change card, download invoices, cancel anytime.
187 </span>
188 </div>
189 )}
190 {!process.env.STRIPE_SECRET_KEY && (
191 <p
192 style="font-size:12px;color:var(--text-muted);margin-top:12px;font-style:italic"
193 >
194 (Stripe not yet configured on this instance — upgrade buttons will
195 return a setup error. Run the Stripe Bootstrap workflow to enable.)
196 </p>
197 )}
8f50ed0Claude198 </Layout>
199 );
200});
201
619109aClaude202// ----- Upgrade flow (Stripe Checkout) -----
203
204billing.post("/billing/upgrade/:plan", requireAuth, async (c) => {
205 const user = c.get("user")!;
206 const planSlug = c.req.param("plan");
207 if (planSlug !== "pro" && planSlug !== "team" && planSlug !== "enterprise") {
208 return c.redirect("/settings/billing?error=invalid-plan");
209 }
210
211 const quota = await getUserQuota(user.id);
212 const customer = await findOrCreateCustomer({
213 userId: user.id,
214 email: user.email ?? `${user.username}@gluecron.local`,
215 existingCustomerId: quota.stripeCustomerId ?? null,
216 });
217 if (!customer.ok) {
218 console.error(`[billing/upgrade] customer: ${customer.error}`);
219 return c.redirect(
220 `/settings/billing?error=${encodeURIComponent(customer.error)}`
221 );
222 }
223
224 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
225 const session = await createCheckoutSession({
226 customerId: customer.customerId,
227 planSlug,
228 successUrl: `${base}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
229 cancelUrl: `${base}/billing/cancel`,
230 userId: user.id,
231 });
232 if (!session.ok) {
233 console.error(`[billing/upgrade] checkout: ${session.error}`);
234 return c.redirect(
235 `/settings/billing?error=${encodeURIComponent(session.error)}`
236 );
237 }
238
239 // Stash the customerId onto the quota row now (doesn't wait for webhook)
240 // so subsequent upgrades don't re-create a customer.
241 await db
242 .update(userQuotas)
243 .set({ stripeCustomerId: customer.customerId, updatedAt: new Date() })
244 .where(eq(userQuotas.userId, user.id));
245
246 return c.redirect(session.url, 303);
247});
248
249billing.get("/billing/success", requireAuth, async (c) => {
250 // The webhook does the actual plan assignment; this is just a landing page.
251 return c.redirect("/settings/billing?upgraded=1");
252});
253
254billing.get("/billing/cancel", requireAuth, async (c) => {
255 return c.redirect("/settings/billing?canceled=1");
256});
257
258billing.post("/billing/manage", requireAuth, async (c) => {
259 const user = c.get("user")!;
260 const quota = await getUserQuota(user.id);
261 if (!quota.stripeCustomerId) {
262 return c.redirect("/settings/billing?error=no-subscription");
263 }
264 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
265 const session = await createBillingPortalSession({
266 customerId: quota.stripeCustomerId,
267 returnUrl: `${base}/settings/billing`,
268 });
269 if (!session.ok) {
270 console.error(`[billing/manage] portal: ${session.error}`);
271 return c.redirect(
272 `/settings/billing?error=${encodeURIComponent(session.error)}`
273 );
274 }
275 return c.redirect(session.url, 303);
276});
277
8f50ed0Claude278// ----- Admin billing panel -----
279
280billing.get("/admin/billing", async (c) => {
281 const user = c.get("user");
282 if (!user) return c.redirect("/login?next=/admin/billing");
283 if (!(await isSiteAdmin(user.id))) {
284 return c.html(
285 <Layout title="Forbidden" user={user}>
286 <div class="empty-state">
287 <h2>403 — Not a site admin</h2>
288 </div>
289 </Layout>,
290 403
291 );
292 }
293
294 const plans = await listPlans();
295 const rows = await db
296 .select({
297 id: users.id,
298 username: users.username,
299 planSlug: userQuotas.planSlug,
300 storageMbUsed: userQuotas.storageMbUsed,
301 aiTokensUsedThisMonth: userQuotas.aiTokensUsedThisMonth,
302 })
303 .from(users)
304 .leftJoin(userQuotas, eq(users.id, userQuotas.userId))
305 .orderBy(desc(users.createdAt))
306 .limit(200);
307
308 return c.html(
309 <Layout title="Admin — Billing" user={user}>
310 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
311 <h2>Billing — all users</h2>
312 <a href="/admin" class="btn btn-sm">
313 Back
314 </a>
315 </div>
316 <div class="panel">
317 {rows.length === 0 ? (
318 <div class="panel-empty">No users.</div>
319 ) : (
320 rows.map((r) => (
321 <div class="panel-item" style="justify-content:space-between">
322 <div style="flex:1;min-width:0">
323 <a href={`/${r.username}`} style="font-weight:600">
324 {r.username}
325 </a>
326 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
327 Plan: <strong>{r.planSlug || "free"}</strong> ·{" "}
328 {r.storageMbUsed || 0} MB ·{" "}
329 {(r.aiTokensUsedThisMonth || 0).toLocaleString()} tokens
330 </div>
331 </div>
332 <form
9e2c6dfClaude333 method="post"
8f50ed0Claude334 action={`/admin/billing/${r.id}/plan`}
335 style="display:flex;gap:6px;align-items:center"
336 >
337 <select name="slug" style="font-size:12px">
338 {plans.map((p) => (
339 <option
340 value={p.slug}
341 selected={(r.planSlug || "free") === p.slug}
342 >
343 {p.name}
344 </option>
345 ))}
346 </select>
347 <button type="submit" class="btn btn-sm">
348 Set
349 </button>
350 </form>
351 </div>
352 ))
353 )}
354 </div>
355 </Layout>
356 );
357});
358
359billing.post("/admin/billing/:userId/plan", async (c) => {
360 const user = c.get("user");
361 if (!user) return c.redirect("/login?next=/admin/billing");
362 if (!(await isSiteAdmin(user.id))) {
363 return c.text("Forbidden", 403);
364 }
365 const userId = c.req.param("userId");
366 const body = await c.req.parseBody();
367 const slug = String(body.slug || "free");
368 await setUserPlan(userId, slug);
369 await audit({
370 userId: user.id,
371 action: "admin.billing.set_plan",
372 targetType: "user",
373 targetId: userId,
374 metadata: { plan: slug },
375 });
376 return c.redirect("/admin/billing");
377});
378
379export default billing;