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.tsxBlame1036 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.
f0b5874Claude12 *
13 * 2026 polish — gradient hairline hero, orb, eyebrow, featured current-plan
14 * card, usage bars with tabular-nums, plan-compare grid, all scoped under
15 * `.bill-*`.
8f50ed0Claude16 */
17
18import { Hono } from "hono";
19import { desc, eq } from "drizzle-orm";
20import { db } from "../db";
21import { users, userQuotas } from "../db/schema";
22import { Layout } from "../views/layout";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
26import { audit } from "../lib/notify";
27import {
28 formatPrice,
29 getUserQuota,
30 listPlans,
31 setUserPlan,
32} from "../lib/billing";
619109aClaude33import {
34 createBillingPortalSession,
35 createCheckoutSession,
36 findOrCreateCustomer,
37} from "../lib/stripe";
38import { config } from "../lib/config";
8f50ed0Claude39
40const billing = new Hono<AuthEnv>();
41billing.use("*", softAuth);
42
f0b5874Claude43/* ─────────────────────────────────────────────────────────────────────────
44 * Scoped CSS — every class prefixed `.bill-` so this surface can't bleed
45 * into other pages. Mirrors the gradient hero + section card patterns
46 * from admin-integrations.tsx, admin-ops.tsx, error-page.tsx.
47 * ───────────────────────────────────────────────────────────────────── */
48const styles = `
eed4684Claude49 .bill-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
f0b5874Claude50
51 /* ─── Hero ─── */
52 .bill-hero {
53 position: relative;
54 margin-bottom: var(--space-5);
55 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
56 background: var(--bg-elevated);
57 border: 1px solid var(--border);
58 border-radius: 18px;
59 overflow: hidden;
60 }
61 .bill-hero::before {
62 content: '';
63 position: absolute;
64 top: 0; left: 0; right: 0;
65 height: 2px;
66 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
67 opacity: 0.75;
68 pointer-events: none;
69 }
70 .bill-hero-orb {
71 position: absolute;
72 inset: -30% -10% auto auto;
73 width: 460px; height: 460px;
74 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
75 filter: blur(80px);
76 opacity: 0.7;
77 pointer-events: none;
78 z-index: 0;
79 }
80 .bill-hero-inner {
81 position: relative;
82 z-index: 1;
83 display: flex;
84 align-items: flex-end;
85 justify-content: space-between;
86 gap: var(--space-4);
87 flex-wrap: wrap;
88 }
89 .bill-hero-text { max-width: 680px; flex: 1; min-width: 240px; }
90 .bill-eyebrow {
91 display: inline-flex;
92 align-items: center;
93 gap: 8px;
94 font-family: var(--font-mono);
95 font-size: 11px;
96 letter-spacing: 0.18em;
97 text-transform: uppercase;
98 color: var(--text-muted);
99 font-weight: 600;
100 margin-bottom: 16px;
101 }
102 .bill-eyebrow-dot {
103 width: 8px; height: 8px;
104 border-radius: 9999px;
105 background: linear-gradient(135deg, #8c6dff, #36c5d6);
106 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
107 }
108 .bill-eyebrow strong { color: var(--accent); font-weight: 600; letter-spacing: 0.04em; }
109 .bill-title {
110 font-family: var(--font-display);
111 font-size: clamp(32px, 5vw, 48px);
112 font-weight: 800;
113 letter-spacing: -0.030em;
114 line-height: 1.05;
115 margin: 0 0 var(--space-3);
116 color: var(--text-strong);
117 }
118 .bill-title-grad {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .bill-sub {
126 font-size: 16px;
127 color: var(--text-muted);
128 margin: 0;
129 line-height: 1.55;
130 max-width: 580px;
131 }
132 .bill-hero-back {
133 display: inline-flex;
134 align-items: center;
135 gap: 6px;
136 padding: 8px 14px;
137 font-size: 12.5px;
138 color: var(--text-muted);
139 background: rgba(255,255,255,0.025);
140 border: 1px solid var(--border);
141 border-radius: 9px;
142 text-decoration: none;
143 font-weight: 500;
144 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
145 }
146 .bill-hero-back:hover {
147 border-color: var(--border-strong);
148 color: var(--text-strong);
149 background: rgba(255,255,255,0.04);
150 text-decoration: none;
151 }
152
153 /* ─── Banners ─── */
154 .bill-banner {
155 margin-bottom: var(--space-4);
156 padding: 10px 14px;
157 border-radius: 10px;
158 font-size: 13.5px;
159 border: 1px solid var(--border);
160 background: rgba(255,255,255,0.025);
161 color: var(--text);
162 display: flex;
163 align-items: center;
164 gap: 10px;
165 }
166 .bill-banner.is-ok {
167 border-color: rgba(52,211,153,0.40);
168 background: rgba(52,211,153,0.08);
169 color: #bbf7d0;
170 }
171 .bill-banner.is-error {
172 border-color: rgba(248,113,113,0.40);
173 background: rgba(248,113,113,0.08);
174 color: #fecaca;
175 }
176 .bill-banner-dot {
177 width: 8px; height: 8px;
178 border-radius: 9999px;
179 background: currentColor;
180 flex-shrink: 0;
181 }
182
183 /* ─── Current-plan featured card ─── */
184 .bill-current {
185 position: relative;
186 margin-bottom: var(--space-5);
187 padding: var(--space-5);
188 background: var(--bg-elevated);
189 border: 1px solid var(--border);
190 border-radius: 16px;
191 overflow: hidden;
192 }
193 .bill-current.is-featured {
194 border-color: rgba(140,109,255,0.40);
195 background: linear-gradient(180deg, rgba(140,109,255,0.05), var(--bg-elevated) 60%);
196 }
197 .bill-current.is-featured::before {
198 content: '';
199 position: absolute;
200 top: 0; left: 0; right: 0;
201 height: 2px;
202 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
203 opacity: 0.85;
204 pointer-events: none;
205 }
206 .bill-current-row {
207 display: flex;
208 align-items: flex-start;
209 justify-content: space-between;
210 gap: var(--space-4);
211 flex-wrap: wrap;
212 margin-bottom: var(--space-4);
213 }
214 .bill-current-head {
215 font-size: 11px;
216 color: var(--text-muted);
217 text-transform: uppercase;
218 letter-spacing: 0.14em;
219 font-family: var(--font-mono);
220 font-weight: 600;
221 margin-bottom: 6px;
222 }
223 .bill-current-name {
224 font-family: var(--font-display);
225 font-size: 26px;
226 font-weight: 800;
227 letter-spacing: -0.022em;
228 color: var(--text-strong);
229 margin: 0 0 4px;
230 }
231 .bill-current-price {
232 font-size: 14px;
233 color: var(--text-muted);
234 font-variant-numeric: tabular-nums;
235 }
236 .bill-current-cycle {
237 font-size: 12px;
238 color: var(--text-muted);
239 text-align: right;
240 font-variant-numeric: tabular-nums;
241 }
242 .bill-usage-list {
243 display: flex;
244 flex-direction: column;
245 gap: 14px;
246 }
247 .bill-usage-row .bill-usage-label {
248 display: flex;
249 align-items: center;
250 justify-content: space-between;
251 gap: 12px;
252 margin-bottom: 6px;
253 font-size: 13px;
254 }
255 .bill-usage-row .bill-usage-name {
256 color: var(--text-strong);
257 font-weight: 500;
258 }
259 .bill-usage-row .bill-usage-num {
260 color: var(--text-muted);
261 font-family: var(--font-mono);
262 font-variant-numeric: tabular-nums;
263 font-size: 12.5px;
264 }
265 .bill-bar {
266 background: var(--bg-secondary, rgba(0,0,0,0.20));
267 height: 12px;
268 border-radius: 7px;
269 overflow: hidden;
270 border: 1px solid var(--border);
271 }
272 .bill-bar-fill {
273 height: 100%;
274 border-radius: 7px;
275 transition: width 250ms ease;
276 }
277 .bill-bar-fill.is-ok { background: linear-gradient(90deg, #34d399, #10b981); }
278 .bill-bar-fill.is-warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
279 .bill-bar-fill.is-bad { background: linear-gradient(90deg, #f87171, #ef4444); }
280
281 /* ─── Section card (shared) ─── */
282 .bill-section {
283 margin-bottom: var(--space-5);
284 background: var(--bg-elevated);
285 border: 1px solid var(--border);
286 border-radius: 14px;
287 overflow: hidden;
288 }
289 .bill-section-head {
290 padding: var(--space-4) var(--space-5);
291 border-bottom: 1px solid var(--border);
292 display: flex;
293 align-items: flex-start;
294 justify-content: space-between;
295 gap: var(--space-3);
296 flex-wrap: wrap;
297 }
298 .bill-section-title {
299 margin: 0;
300 font-family: var(--font-display);
301 font-size: 16px;
302 font-weight: 700;
303 color: var(--text-strong);
304 letter-spacing: -0.014em;
305 }
306 .bill-section-sub {
307 margin: 4px 0 0;
308 font-size: 12.5px;
309 color: var(--text-muted);
310 }
311 .bill-section-body { padding: var(--space-4) var(--space-5); }
312
313 /* ─── Plan compare grid ─── */
314 .bill-plans {
315 display: grid;
316 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
317 gap: var(--space-3);
318 }
319 .bill-plan {
320 position: relative;
321 padding: var(--space-4);
322 background: var(--bg-elevated);
323 border: 1px solid var(--border);
324 border-radius: 12px;
325 display: flex;
326 flex-direction: column;
327 gap: 8px;
328 transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
329 }
330 .bill-plan:hover {
331 border-color: rgba(140,109,255,0.45);
332 transform: translateY(-2px);
333 box-shadow: 0 10px 28px -10px rgba(140,109,255,0.30);
334 }
335 .bill-plan.is-current {
336 border-color: rgba(52,211,153,0.50);
337 background: linear-gradient(180deg, rgba(52,211,153,0.05), var(--bg-elevated) 60%);
338 }
339 .bill-plan-badge {
340 position: absolute;
341 top: -10px; right: 14px;
342 padding: 3px 10px;
343 border-radius: 9999px;
344 font-size: 10.5px;
345 font-weight: 700;
346 letter-spacing: 0.06em;
347 text-transform: uppercase;
348 }
349 .bill-plan-badge.is-current {
350 background: rgba(52,211,153,0.18);
351 color: #6ee7b7;
352 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.50);
353 }
354 .bill-plan-name {
355 font-family: var(--font-display);
356 font-size: 17px;
357 font-weight: 700;
358 color: var(--text-strong);
359 margin: 0;
360 letter-spacing: -0.012em;
361 }
362 .bill-plan-price {
363 font-family: var(--font-display);
364 font-size: 22px;
365 font-weight: 800;
366 color: var(--text-strong);
367 letter-spacing: -0.020em;
368 margin: 2px 0 8px;
369 font-variant-numeric: tabular-nums;
370 }
371 .bill-plan-feats {
372 list-style: none;
373 padding: 0;
374 margin: 0 0 12px;
375 display: flex;
376 flex-direction: column;
377 gap: 6px;
378 font-size: 13px;
379 color: var(--text-muted);
380 line-height: 1.5;
381 flex: 1;
382 }
383 .bill-plan-feats li {
384 display: flex;
385 align-items: center;
386 gap: 6px;
387 font-variant-numeric: tabular-nums;
388 }
389 .bill-plan-feats .check {
390 flex-shrink: 0;
391 color: #34d399;
392 font-weight: 700;
393 }
394 .bill-plan-feats .x {
395 flex-shrink: 0;
396 color: var(--text-muted);
397 opacity: 0.6;
398 }
399 .bill-plan-action { margin-top: auto; }
400 .bill-plan-action form { margin: 0; }
401
402 /* ─── Buttons ─── */
403 .bill-btn {
404 display: inline-flex;
405 align-items: center;
406 justify-content: center;
407 gap: 6px;
408 padding: 9px 14px;
409 border-radius: 10px;
410 font-size: 13px;
411 font-weight: 600;
412 text-decoration: none;
413 border: 1px solid transparent;
414 cursor: pointer;
415 font: inherit;
416 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
417 line-height: 1;
418 width: 100%;
419 }
420 .bill-btn-primary {
421 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
422 color: #ffffff;
423 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
424 }
425 .bill-btn-primary:hover {
426 transform: translateY(-1px);
427 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
428 color: #ffffff;
429 text-decoration: none;
430 }
431 .bill-btn-ghost {
432 background: transparent;
433 color: var(--text);
434 border-color: var(--border-strong, var(--border));
435 width: auto;
436 }
437 .bill-btn-ghost:hover {
438 background: rgba(140,109,255,0.06);
439 border-color: rgba(140,109,255,0.45);
440 color: var(--text-strong);
441 text-decoration: none;
442 }
443 .bill-current-cta { display: inline-flex; }
444
445 /* ─── Payment + invoices section ─── */
446 .bill-pay-row {
447 display: flex;
448 align-items: center;
449 justify-content: space-between;
450 gap: var(--space-3);
451 flex-wrap: wrap;
452 }
453 .bill-pay-text {
454 font-size: 13px;
455 color: var(--text-muted);
456 line-height: 1.5;
457 max-width: 480px;
458 }
459 .bill-pay-text strong { color: var(--text); font-weight: 600; }
460
461 /* ─── Invoices list (empty) ─── */
462 .bill-invoices-empty {
463 padding: var(--space-4);
464 text-align: center;
465 color: var(--text-muted);
466 font-size: 13px;
467 border: 1px dashed var(--border);
468 border-radius: 12px;
469 background: rgba(255,255,255,0.012);
470 }
471 .bill-invoices-empty a { color: var(--accent); text-decoration: none; }
472 .bill-invoices-empty a:hover { text-decoration: underline; }
473
474 /* ─── Foot (link to /pricing) ─── */
475 .bill-foot {
476 margin-top: var(--space-5);
477 padding: var(--space-4);
478 text-align: center;
479 color: var(--text-muted);
480 font-size: 13px;
481 border: 1px dashed var(--border);
482 border-radius: 12px;
483 }
484 .bill-foot a { color: var(--accent); text-decoration: none; font-weight: 600; }
485 .bill-foot a:hover { text-decoration: underline; }
486
487 .bill-stripe-note {
488 font-size: 12.5px;
489 color: var(--text-muted);
490 margin: var(--space-3) 0 0;
491 font-style: italic;
492 line-height: 1.5;
493 }
494
495 /* ─── Admin list ─── */
496 .bill-admin-list {
497 background: var(--bg-elevated);
498 border: 1px solid var(--border);
499 border-radius: 14px;
500 overflow: hidden;
501 }
502 .bill-admin-row {
503 display: flex;
504 align-items: center;
505 justify-content: space-between;
506 gap: 12px;
507 padding: 14px 18px;
508 border-bottom: 1px solid var(--border);
509 flex-wrap: wrap;
510 }
511 .bill-admin-row:last-child { border-bottom: none; }
512 .bill-admin-user a {
513 font-weight: 600;
514 color: var(--text-strong);
515 text-decoration: none;
516 }
517 .bill-admin-user a:hover { color: var(--accent); }
518 .bill-admin-meta {
519 font-size: 12px;
520 color: var(--text-muted);
521 margin-top: 2px;
522 font-variant-numeric: tabular-nums;
523 }
524 .bill-admin-form {
525 display: flex;
526 gap: 8px;
527 align-items: center;
528 margin: 0;
529 }
530 .bill-admin-form select {
531 font-size: 12px;
532 padding: 5px 8px;
533 border-radius: 8px;
534 background: var(--bg-secondary, rgba(0,0,0,0.15));
535 border: 1px solid var(--border);
536 color: var(--text);
537 }
538 .bill-403 {
539 max-width: 540px;
540 margin: var(--space-12, 96px) auto;
541 padding: var(--space-6);
542 text-align: center;
543 background: var(--bg-elevated);
544 border: 1px solid var(--border);
545 border-radius: 16px;
546 }
547 .bill-403 h2 {
548 font-family: var(--font-display);
549 font-size: 22px;
550 margin: 0 0 8px;
551 color: var(--text-strong);
552 }
553`;
554
555function barClass(pct: number): string {
556 if (pct >= 90) return "is-bad";
557 if (pct >= 70) return "is-warn";
558 return "is-ok";
559}
560
561function IconWallet() {
562 return (
563 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
564 <path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
565 <path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
566 <rect x="16" y="12" width="6" height="5" rx="1" />
567 </svg>
568 );
569}
570function IconReceipt() {
571 return (
572 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
573 <path d="M4 2v20l3-2 3 2 3-2 3 2 3-2V2H4z" />
574 <line x1="8" y1="7" x2="16" y2="7" />
575 <line x1="8" y1="11" x2="16" y2="11" />
576 <line x1="8" y1="15" x2="13" y2="15" />
577 </svg>
578 );
579}
580function IconArrowLeft() {
581 return (
582 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
583 <line x1="19" y1="12" x2="5" y2="12" />
584 <polyline points="12 19 5 12 12 5" />
585 </svg>
586 );
587}
588
8f50ed0Claude589// ----- Personal billing page -----
590
591billing.get("/settings/billing", requireAuth, async (c) => {
592 const user = c.get("user")!;
593 const [quota, plans] = await Promise.all([
594 getUserQuota(user.id),
595 listPlans(),
596 ]);
597
f0b5874Claude598 const upgraded = c.req.query("upgraded") === "1";
599 const canceled = c.req.query("canceled") === "1";
600 const errorMsg = c.req.query("error");
601 const isPaid = quota.planSlug !== "free";
5f2e749Claude602
8f50ed0Claude603 return c.html(
604 <Layout title="Billing — Gluecron" user={user}>
f0b5874Claude605 <style dangerouslySetInnerHTML={{ __html: styles }} />
606 <div class="bill-wrap">
607 {/* ─── Hero ─── */}
608 <section class="bill-hero">
609 <div class="bill-hero-orb" aria-hidden="true" />
610 <div class="bill-hero-inner">
611 <div class="bill-hero-text">
612 <div class="bill-eyebrow">
613 <span class="bill-eyebrow-dot" aria-hidden="true" />
614 Billing · <strong>@{user.username}</strong>
615 </div>
616 <h1 class="bill-title">
617 <span class="bill-title-grad">Plan + usage.</span>
618 </h1>
619 <p class="bill-sub">
620 Your current plan, this cycle's usage, and one-click upgrades.
621 Cancel any time — your data stays on the free tier.
622 </p>
5f2e749Claude623 </div>
f0b5874Claude624 <a href="/settings" class="bill-hero-back">
625 <IconArrowLeft />
626 Back to settings
627 </a>
5f2e749Claude628 </div>
f0b5874Claude629 </section>
630
4cc2287Claude631 {/* Sub-link out to the AI usage dashboard — keeps the locked
632 settings-subnav unmodified while still surfacing the new page. */}
633 <div style="margin-bottom:var(--space-4);display:flex;gap:12px;flex-wrap:wrap;font-size:13px">
634 <a
635 href="/billing/usage"
636 style="color:var(--text-muted);text-decoration:none;padding:6px 12px;border-radius:9px;border:1px solid var(--border);background:rgba(255,255,255,0.02)"
637 >
638 AI usage + cost &rarr;
639 </a>
640 </div>
641
f0b5874Claude642 {upgraded && (
643 <div class="bill-banner is-ok" role="status">
644 <span class="bill-banner-dot" aria-hidden="true" />
645 Subscription updated — your new plan is active.
5f2e749Claude646 </div>
f0b5874Claude647 )}
648 {canceled && (
649 <div class="bill-banner" role="status">
650 <span class="bill-banner-dot" aria-hidden="true" />
651 Checkout canceled. You can upgrade any time.
5f2e749Claude652 </div>
f0b5874Claude653 )}
654 {errorMsg && (
655 <div class="bill-banner is-error" role="alert">
656 <span class="bill-banner-dot" aria-hidden="true" />
657 {decodeURIComponent(errorMsg)}
5f2e749Claude658 </div>
f0b5874Claude659 )}
5f2e749Claude660
f0b5874Claude661 {/* ─── Featured current-plan card ─── */}
662 <section class={"bill-current" + (isPaid ? " is-featured" : "")}>
663 <div class="bill-current-row">
664 <div>
665 <div class="bill-current-head">Current plan</div>
666 <h2 class="bill-current-name">{quota.plan.name}</h2>
667 <div class="bill-current-price">{formatPrice(quota.plan.priceCents)}</div>
8f50ed0Claude668 </div>
f0b5874Claude669 <div>
670 {quota.planSlug === "free" && (
671 <form method="post" action="/billing/upgrade/pro" class="bill-current-cta">
672 <button type="submit" class="bill-btn bill-btn-primary" style="width:auto">
673 Upgrade to Pro &rarr;
674 </button>
675 </form>
676 )}
677 <div class="bill-current-cycle" style="margin-top:8px">
678 {quota.cycleStart
679 ? `Cycle started ${new Date(quota.cycleStart).toLocaleDateString()}`
680 : "No cycle recorded"}
681 </div>
8f50ed0Claude682 </div>
683 </div>
684
f0b5874Claude685 <div class="bill-usage-list">
686 <div class="bill-usage-row">
687 <div class="bill-usage-label">
688 <span class="bill-usage-name">Storage</span>
689 <span class="bill-usage-num">
690 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB · {quota.percent.storage}%
691 </span>
692 </div>
693 <div class="bill-bar">
694 <div class={"bill-bar-fill " + barClass(quota.percent.storage)} style={`width:${quota.percent.storage}%`} />
695 </div>
696 </div>
697 <div class="bill-usage-row">
698 <div class="bill-usage-label">
699 <span class="bill-usage-name">AI tokens (monthly)</span>
700 <span class="bill-usage-num">
701 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} / {quota.plan.aiTokensMonthly.toLocaleString()} · {quota.percent.aiTokens}%
702 </span>
703 </div>
704 <div class="bill-bar">
705 <div class={"bill-bar-fill " + barClass(quota.percent.aiTokens)} style={`width:${quota.percent.aiTokens}%`} />
706 </div>
707 </div>
708 <div class="bill-usage-row">
709 <div class="bill-usage-label">
710 <span class="bill-usage-name">Bandwidth (monthly)</span>
711 <span class="bill-usage-num">
712 {quota.usage.bandwidthGbUsedThisMonth} / {quota.plan.bandwidthGbMonthly} GB · {quota.percent.bandwidth}%
713 </span>
714 </div>
715 <div class="bill-bar">
716 <div class={"bill-bar-fill " + barClass(quota.percent.bandwidth)} style={`width:${quota.percent.bandwidth}%`} />
717 </div>
718 </div>
8f50ed0Claude719 </div>
f0b5874Claude720 </section>
721
722 {/* ─── Plan compare grid ─── */}
723 <section class="bill-section">
724 <header class="bill-section-head">
725 <div>
726 <h3 class="bill-section-title">Available plans</h3>
727 <p class="bill-section-sub">
728 Switch any time — pro-rated mid-cycle. Cancel and you keep your data on the free tier.
729 </p>
730 </div>
731 </header>
732 <div class="bill-section-body">
733 <div class="bill-plans">
734 {plans.map((p) => {
735 const isCurrent = p.slug === quota.planSlug;
736 return (
737 <div class={"bill-plan" + (isCurrent ? " is-current" : "")}>
738 {isCurrent && (
739 <span class="bill-plan-badge is-current">Current</span>
740 )}
741 <h4 class="bill-plan-name">{p.name}</h4>
742 <div class="bill-plan-price">{formatPrice(p.priceCents)}</div>
743 <ul class="bill-plan-feats">
744 <li><span class="check">✓</span> {p.repoLimit.toLocaleString()} repos</li>
745 <li><span class="check">✓</span> {p.storageMbLimit.toLocaleString()} MB storage</li>
746 <li><span class="check">✓</span> {p.aiTokensMonthly.toLocaleString()} AI tokens/mo</li>
747 <li><span class="check">✓</span> {p.bandwidthGbMonthly} GB bandwidth/mo</li>
748 <li>
749 {p.privateRepos
750 ? <><span class="check">✓</span> Private repos</>
751 : <><span class="x">—</span> Public repos only</>}
752 </li>
753 </ul>
754 <div class="bill-plan-action">
755 {!isCurrent && p.slug !== "free" && p.priceCents > 0 && (
756 <form method="post" action={`/billing/upgrade/${p.slug}`}>
757 <button type="submit" class="bill-btn bill-btn-primary">
758 {quota.planSlug === "free" ? "Upgrade" : "Switch"} to {p.name}
759 </button>
760 </form>
761 )}
762 </div>
763 </div>
764 );
765 })}
766 </div>
8f50ed0Claude767 </div>
f0b5874Claude768 </section>
8f50ed0Claude769
f0b5874Claude770 {/* ─── Payment method section ─── */}
771 <section class="bill-section">
772 <header class="bill-section-head">
773 <div>
774 <h3 class="bill-section-title">
775 <IconWallet /> Payment method
776 </h3>
777 <p class="bill-section-sub">
778 Card, invoices, and cancellation are handled by Stripe's Customer Portal.
779 </p>
780 </div>
781 </header>
782 <div class="bill-section-body">
783 <div class="bill-pay-row">
784 <div class="bill-pay-text">
785 {isPaid ? (
786 <>Your card and billing address live in the <strong>Stripe Customer Portal</strong>. Update them, download invoices, or cancel any time.</>
787 ) : (
788 <>You're on the <strong>Free</strong> tier — no card on file. Upgrade above to set one.</>
789 )}
8f50ed0Claude790 </div>
f0b5874Claude791 {isPaid && (
792 <form method="post" action="/billing/manage" style="margin:0">
793 <button type="submit" class="bill-btn bill-btn-ghost">
794 Manage subscription &rarr;
619109aClaude795 </button>
796 </form>
797 )}
8f50ed0Claude798 </div>
f0b5874Claude799 {!process.env.STRIPE_SECRET_KEY && (
800 <p class="bill-stripe-note">
801 (Stripe not yet configured on this instance — upgrade buttons return a
802 setup error. Run the Stripe Bootstrap workflow to enable.)
803 </p>
804 )}
805 </div>
806 </section>
807
808 {/* ─── Invoices section (empty state — Stripe owns the list) ─── */}
809 <section class="bill-section">
810 <header class="bill-section-head">
811 <div>
812 <h3 class="bill-section-title">
813 <IconReceipt /> Invoices
814 </h3>
815 <p class="bill-section-sub">
816 Past invoices live in the Stripe Customer Portal — click through above.
817 </p>
818 </div>
819 </header>
820 <div class="bill-section-body">
821 <div class="bill-invoices-empty">
822 {isPaid
823 ? <>Receipts and PDFs are available in the <a href="#" onclick="document.querySelector('form[action=&quot;/billing/manage&quot;] button')?.click();return false;">Customer Portal</a>.</>
824 : <>You'll see invoices here once you upgrade to a paid plan.</>}
825 </div>
826 </div>
827 </section>
828
829 {/* ─── Foot (link to /pricing) ─── */}
830 <div class="bill-foot">
831 Want the full breakdown of what's included?{" "}
832 <a href="/pricing">Detailed plan comparison &rarr;</a>
619109aClaude833 </div>
f0b5874Claude834 </div>
8f50ed0Claude835 </Layout>
836 );
837});
838
619109aClaude839// ----- Upgrade flow (Stripe Checkout) -----
840
841billing.post("/billing/upgrade/:plan", requireAuth, async (c) => {
842 const user = c.get("user")!;
843 const planSlug = c.req.param("plan");
844 if (planSlug !== "pro" && planSlug !== "team" && planSlug !== "enterprise") {
845 return c.redirect("/settings/billing?error=invalid-plan");
846 }
847
848 const quota = await getUserQuota(user.id);
849 const customer = await findOrCreateCustomer({
850 userId: user.id,
851 email: user.email ?? `${user.username}@gluecron.local`,
852 existingCustomerId: quota.stripeCustomerId ?? null,
853 });
854 if (!customer.ok) {
855 console.error(`[billing/upgrade] customer: ${customer.error}`);
856 return c.redirect(
857 `/settings/billing?error=${encodeURIComponent(customer.error)}`
858 );
859 }
860
861 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
862 const session = await createCheckoutSession({
863 customerId: customer.customerId,
864 planSlug,
865 successUrl: `${base}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
866 cancelUrl: `${base}/billing/cancel`,
867 userId: user.id,
868 });
869 if (!session.ok) {
870 console.error(`[billing/upgrade] checkout: ${session.error}`);
871 return c.redirect(
872 `/settings/billing?error=${encodeURIComponent(session.error)}`
873 );
874 }
875
876 // Stash the customerId onto the quota row now (doesn't wait for webhook)
877 // so subsequent upgrades don't re-create a customer.
878 await db
879 .update(userQuotas)
880 .set({ stripeCustomerId: customer.customerId, updatedAt: new Date() })
881 .where(eq(userQuotas.userId, user.id));
882
883 return c.redirect(session.url, 303);
884});
885
886billing.get("/billing/success", requireAuth, async (c) => {
887 // The webhook does the actual plan assignment; this is just a landing page.
888 return c.redirect("/settings/billing?upgraded=1");
889});
890
891billing.get("/billing/cancel", requireAuth, async (c) => {
892 return c.redirect("/settings/billing?canceled=1");
893});
894
895billing.post("/billing/manage", requireAuth, async (c) => {
896 const user = c.get("user")!;
897 const quota = await getUserQuota(user.id);
898 if (!quota.stripeCustomerId) {
899 return c.redirect("/settings/billing?error=no-subscription");
900 }
901 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
902 const session = await createBillingPortalSession({
903 customerId: quota.stripeCustomerId,
904 returnUrl: `${base}/settings/billing`,
905 });
906 if (!session.ok) {
907 console.error(`[billing/manage] portal: ${session.error}`);
908 return c.redirect(
909 `/settings/billing?error=${encodeURIComponent(session.error)}`
910 );
911 }
912 return c.redirect(session.url, 303);
913});
914
8f50ed0Claude915// ----- Admin billing panel -----
916
917billing.get("/admin/billing", async (c) => {
918 const user = c.get("user");
919 if (!user) return c.redirect("/login?next=/admin/billing");
920 if (!(await isSiteAdmin(user.id))) {
921 return c.html(
922 <Layout title="Forbidden" user={user}>
f0b5874Claude923 <style dangerouslySetInnerHTML={{ __html: styles }} />
924 <div class="bill-403">
8f50ed0Claude925 <h2>403 — Not a site admin</h2>
f0b5874Claude926 <p>You don't have permission to view this page.</p>
8f50ed0Claude927 </div>
928 </Layout>,
929 403
930 );
931 }
932
933 const plans = await listPlans();
934 const rows = await db
935 .select({
936 id: users.id,
937 username: users.username,
938 planSlug: userQuotas.planSlug,
939 storageMbUsed: userQuotas.storageMbUsed,
940 aiTokensUsedThisMonth: userQuotas.aiTokensUsedThisMonth,
941 })
942 .from(users)
943 .leftJoin(userQuotas, eq(users.id, userQuotas.userId))
944 .orderBy(desc(users.createdAt))
945 .limit(200);
946
947 return c.html(
948 <Layout title="Admin — Billing" user={user}>
f0b5874Claude949 <style dangerouslySetInnerHTML={{ __html: styles }} />
950 <div class="bill-wrap">
951 <section class="bill-hero">
952 <div class="bill-hero-orb" aria-hidden="true" />
953 <div class="bill-hero-inner">
954 <div class="bill-hero-text">
955 <div class="bill-eyebrow">
956 <span class="bill-eyebrow-dot" aria-hidden="true" />
957 Admin · Billing
8f50ed0Claude958 </div>
f0b5874Claude959 <h1 class="bill-title">
960 <span class="bill-title-grad">All users.</span>
961 </h1>
962 <p class="bill-sub">
963 Override any user's plan. Every change is audit-logged under
964 <code style="font-family:var(--font-mono);font-size:13px;background:rgba(255,255,255,0.04);padding:1px 5px;border-radius:4px;margin-left:5px">admin.billing.set_plan</code>.
965 </p>
8f50ed0Claude966 </div>
f0b5874Claude967 <a href="/admin" class="bill-hero-back">
968 <IconArrowLeft />
969 Back to admin
970 </a>
971 </div>
972 </section>
973
974 <div class="bill-admin-list">
975 {rows.length === 0 ? (
976 <div class="bill-invoices-empty" style="margin:0;border:none">No users.</div>
977 ) : (
978 rows.map((r) => (
979 <div class="bill-admin-row">
980 <div class="bill-admin-user" style="flex:1;min-width:0">
981 <a href={`/${r.username}`}>{r.username}</a>
982 <div class="bill-admin-meta">
983 Plan: <strong>{r.planSlug || "free"}</strong> ·{" "}
984 {r.storageMbUsed || 0} MB ·{" "}
985 {(r.aiTokensUsedThisMonth || 0).toLocaleString()} tokens
986 </div>
987 </div>
988 <form
989 method="post"
990 action={`/admin/billing/${r.id}/plan`}
991 class="bill-admin-form"
992 >
993 <select name="slug">
994 {plans.map((p) => (
995 <option
996 value={p.slug}
997 selected={(r.planSlug || "free") === p.slug}
998 >
999 {p.name}
1000 </option>
1001 ))}
1002 </select>
1003 <button type="submit" class="bill-btn bill-btn-ghost" style="padding:6px 12px;font-size:12px">
1004 Set
1005 </button>
1006 </form>
1007 </div>
1008 ))
1009 )}
1010 </div>
8f50ed0Claude1011 </div>
1012 </Layout>
1013 );
1014});
1015
1016billing.post("/admin/billing/:userId/plan", async (c) => {
1017 const user = c.get("user");
1018 if (!user) return c.redirect("/login?next=/admin/billing");
1019 if (!(await isSiteAdmin(user.id))) {
1020 return c.text("Forbidden", 403);
1021 }
1022 const userId = c.req.param("userId");
1023 const body = await c.req.parseBody();
1024 const slug = String(body.slug || "free");
1025 await setUserPlan(userId, slug);
1026 await audit({
1027 userId: user.id,
1028 action: "admin.billing.set_plan",
1029 targetType: "user",
1030 targetId: userId,
1031 metadata: { plan: slug },
1032 });
1033 return c.redirect("/admin/billing");
1034});
1035
1036export default billing;