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.tsxBlame259 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";
29
30const billing = new Hono<AuthEnv>();
31billing.use("*", softAuth);
32
33// ----- Personal billing page -----
34
35billing.get("/settings/billing", requireAuth, async (c) => {
36 const user = c.get("user")!;
37 const [quota, plans] = await Promise.all([
38 getUserQuota(user.id),
39 listPlans(),
40 ]);
41
42 const bar = (pct: number) => {
43 const color = pct >= 90 ? "var(--red)" : pct >= 70 ? "#f0b72f" : "var(--green)";
44 return (
45 <div
46 style="background:var(--bg-secondary);height:8px;border-radius:4px;overflow:hidden"
47 >
48 <div
49 style={`width:${pct}%;height:100%;background:${color};transition:width .2s`}
50 />
51 </div>
52 );
53 };
54
55 return c.html(
56 <Layout title="Billing — Gluecron" user={user}>
57 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
58 <h2>Billing & usage</h2>
59 <a href="/settings" class="btn btn-sm">
60 Back to settings
61 </a>
62 </div>
63
64 <div class="panel" style="padding:16px;margin-bottom:20px">
65 <div style="display:flex;justify-content:space-between;align-items:center">
66 <div>
67 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase">
68 Current plan
69 </div>
70 <div style="font-size:22px;font-weight:700">{quota.plan.name}</div>
71 <div style="font-size:13px;color:var(--text-muted)">
72 {formatPrice(quota.plan.priceCents)}
73 </div>
74 </div>
75 <div style="text-align:right;font-size:12px;color:var(--text-muted)">
76 {quota.cycleStart
77 ? `Cycle started ${new Date(quota.cycleStart).toLocaleDateString()}`
78 : "No cycle recorded"}
79 </div>
80 </div>
81 </div>
82
83 <h3>Usage this cycle</h3>
84 <div class="panel" style="padding:16px;margin-bottom:20px">
85 <div class="form-group">
86 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
87 <span>Storage</span>
88 <span style="color:var(--text-muted);font-family:var(--font-mono)">
89 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB
90 </span>
91 </div>
92 {bar(quota.percent.storage)}
93 </div>
94 <div class="form-group">
95 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
96 <span>AI tokens (monthly)</span>
97 <span style="color:var(--text-muted);font-family:var(--font-mono)">
98 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} /{" "}
99 {quota.plan.aiTokensMonthly.toLocaleString()}
100 </span>
101 </div>
102 {bar(quota.percent.aiTokens)}
103 </div>
104 <div class="form-group" style="margin-bottom:0">
105 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
106 <span>Bandwidth (monthly)</span>
107 <span style="color:var(--text-muted);font-family:var(--font-mono)">
108 {quota.usage.bandwidthGbUsedThisMonth} /{" "}
109 {quota.plan.bandwidthGbMonthly} GB
110 </span>
111 </div>
112 {bar(quota.percent.bandwidth)}
113 </div>
114 </div>
115
116 <h3>Available plans</h3>
117 <div
118 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px"
119 >
120 {plans.map((p) => {
121 const isCurrent = p.slug === quota.planSlug;
122 return (
123 <div
124 class="panel"
125 style={`padding:16px${isCurrent ? ";border-color:var(--green)" : ""}`}
126 >
127 <div style="font-size:16px;font-weight:700">{p.name}</div>
128 <div style="font-size:18px;margin:6px 0">
129 {formatPrice(p.priceCents)}
130 </div>
131 <div style="font-size:12px;color:var(--text-muted);line-height:1.6">
132 <div>{p.repoLimit.toLocaleString()} repos</div>
133 <div>{p.storageMbLimit.toLocaleString()} MB storage</div>
134 <div>{p.aiTokensMonthly.toLocaleString()} AI tokens/mo</div>
135 <div>{p.bandwidthGbMonthly} GB bandwidth/mo</div>
136 <div>
137 {p.privateRepos ? "Private repos ✓" : "Public repos only"}
138 </div>
139 </div>
140 {isCurrent && (
141 <div
142 style="margin-top:10px;font-size:11px;color:var(--green);font-weight:600"
143 >
144 CURRENT PLAN
145 </div>
146 )}
147 </div>
148 );
149 })}
150 </div>
151 <p style="font-size:12px;color:var(--text-muted);margin-top:12px">
152 To change plans, contact a site administrator.
153 </p>
154 </Layout>
155 );
156});
157
158// ----- Admin billing panel -----
159
160billing.get("/admin/billing", async (c) => {
161 const user = c.get("user");
162 if (!user) return c.redirect("/login?next=/admin/billing");
163 if (!(await isSiteAdmin(user.id))) {
164 return c.html(
165 <Layout title="Forbidden" user={user}>
166 <div class="empty-state">
167 <h2>403 — Not a site admin</h2>
168 </div>
169 </Layout>,
170 403
171 );
172 }
173
174 const plans = await listPlans();
175 const rows = await db
176 .select({
177 id: users.id,
178 username: users.username,
179 planSlug: userQuotas.planSlug,
180 storageMbUsed: userQuotas.storageMbUsed,
181 aiTokensUsedThisMonth: userQuotas.aiTokensUsedThisMonth,
182 })
183 .from(users)
184 .leftJoin(userQuotas, eq(users.id, userQuotas.userId))
185 .orderBy(desc(users.createdAt))
186 .limit(200);
187
188 return c.html(
189 <Layout title="Admin — Billing" user={user}>
190 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
191 <h2>Billing — all users</h2>
192 <a href="/admin" class="btn btn-sm">
193 Back
194 </a>
195 </div>
196 <div class="panel">
197 {rows.length === 0 ? (
198 <div class="panel-empty">No users.</div>
199 ) : (
200 rows.map((r) => (
201 <div class="panel-item" style="justify-content:space-between">
202 <div style="flex:1;min-width:0">
203 <a href={`/${r.username}`} style="font-weight:600">
204 {r.username}
205 </a>
206 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
207 Plan: <strong>{r.planSlug || "free"}</strong> ·{" "}
208 {r.storageMbUsed || 0} MB ·{" "}
209 {(r.aiTokensUsedThisMonth || 0).toLocaleString()} tokens
210 </div>
211 </div>
212 <form
9e2c6dfClaude213 method="post"
8f50ed0Claude214 action={`/admin/billing/${r.id}/plan`}
215 style="display:flex;gap:6px;align-items:center"
216 >
217 <select name="slug" style="font-size:12px">
218 {plans.map((p) => (
219 <option
220 value={p.slug}
221 selected={(r.planSlug || "free") === p.slug}
222 >
223 {p.name}
224 </option>
225 ))}
226 </select>
227 <button type="submit" class="btn btn-sm">
228 Set
229 </button>
230 </form>
231 </div>
232 ))
233 )}
234 </div>
235 </Layout>
236 );
237});
238
239billing.post("/admin/billing/:userId/plan", async (c) => {
240 const user = c.get("user");
241 if (!user) return c.redirect("/login?next=/admin/billing");
242 if (!(await isSiteAdmin(user.id))) {
243 return c.text("Forbidden", 403);
244 }
245 const userId = c.req.param("userId");
246 const body = await c.req.parseBody();
247 const slug = String(body.slug || "free");
248 await setUserPlan(userId, slug);
249 await audit({
250 userId: user.id,
251 action: "admin.billing.set_plan",
252 targetType: "user",
253 targetId: userId,
254 metadata: { plan: slug },
255 });
256 return c.redirect("/admin/billing");
257});
258
259export default billing;