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