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

admin-stripe.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.

admin-stripe.tsxBlame410 lines · 1 contributor
7293c67Claude1/**
2 * GET /admin/stripe — Stripe subscription sync dashboard
3 * POST /admin/stripe/:userId/sync — update local plan to match Stripe
4 *
5 * Shows all non-free users, their local plan, and live Stripe status.
6 * Mismatches (local says pro/team but Stripe cancelled, or vice versa)
7 * are highlighted with a "Fix" button.
8 *
9 * If STRIPE_SECRET_KEY is not set, only local data is shown.
10 *
11 * Gated by isSiteAdmin (same pattern as all other admin sub-pages).
12 */
13
14import { Hono } from "hono";
15import { desc, eq, ne } from "drizzle-orm";
16import { db } from "../db";
17import { userQuotas, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { isSiteAdmin } from "../lib/admin";
22import { getSubscription, planSlugFromSubscription } from "../lib/stripe";
23import { audit } from "../lib/notify";
24
25const adminStripe = new Hono<AuthEnv>();
26adminStripe.use("*", softAuth);
27
28async function gate(c: any): Promise<{ user: any } | Response> {
29 const user = c.get("user");
30 if (!user) return c.redirect("/login?next=/admin/stripe");
31 if (!(await isSiteAdmin(user.id))) {
32 return c.html(
33 <Layout title="Forbidden" user={user}>
34 <div style="max-width:540px;margin:80px auto;padding:32px;text-align:center;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px;">
35 <h2 style="font-family:var(--font-display);font-size:22px;margin:0 0 8px;color:var(--text-strong);">403 — Not a site admin</h2>
36 <p style="color:var(--text-muted);margin:0;font-size:14px;">You don't have permission to view this page.</p>
37 </div>
38 </Layout>,
39 403
40 );
41 }
42 return { user };
43}
44
45const styles = `
46 .adm-stripe-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
47
48 .adm-stripe-hero {
49 position: relative;
50 margin-bottom: var(--space-5);
51 padding: var(--space-5) var(--space-6);
52 background: var(--bg-elevated);
53 border: 1px solid var(--border);
54 border-radius: 16px;
55 overflow: hidden;
56 }
57 .adm-stripe-hero::before {
58 content: '';
59 position: absolute;
60 top: 0; left: 0; right: 0;
61 height: 2px;
62 background: linear-gradient(90deg, transparent 0%, #818cf8 30%, #38bdf8 70%, transparent 100%);
63 opacity: 0.7;
64 pointer-events: none;
65 }
66 .adm-stripe-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
67 .adm-stripe-eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: #818cf8; margin-bottom: 6px; }
68 .adm-stripe-title { font-family: var(--font-display); font-size: clamp(24px,3.5vw,36px); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 4px; color: var(--text-strong); }
69 .adm-stripe-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
70 .adm-stripe-back { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 12.5px; color: var(--text-muted); background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px; text-decoration: none; font-weight: 500; transition: border-color 120ms,color 120ms,background 120ms; }
71 .adm-stripe-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
72
73 .adm-stripe-notice {
74 margin-bottom: var(--space-4);
75 padding: 12px 16px;
76 border-radius: 10px;
77 font-size: 13.5px;
78 border: 1px solid rgba(251,191,36,0.40);
79 background: rgba(251,191,36,0.06);
80 color: #fde68a;
81 line-height: 1.5;
82 }
83 .adm-stripe-notice code { font-family: var(--font-mono); font-size: 12.5px; background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 4px; }
84
85 .adm-stripe-banner { margin-bottom: var(--space-4); padding: 10px 14px; border-radius: 10px; font-size: 13.5px; border: 1px solid var(--border); background: rgba(255,255,255,0.025); color: var(--text); }
86 .adm-stripe-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
87 .adm-stripe-banner.is-err { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
88
89 .adm-stripe-table { width: 100%; border-collapse: collapse; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
90 .adm-stripe-table thead th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); padding: 10px 14px; background: rgba(255,255,255,0.015); border-bottom: 1px solid var(--border); }
91 .adm-stripe-table tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); font-size: 13px; color: var(--text); vertical-align: middle; }
92 .adm-stripe-table tbody tr:last-child td { border-bottom: none; }
93 .adm-stripe-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
94 .adm-stripe-table code { font-family: var(--font-mono); font-size: 11px; color: var(--text-strong); }
95
96 .adm-stripe-pill { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 700; }
97 .adm-stripe-pill-active { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
98 .adm-stripe-pill-warn { background: rgba(251,146,60,0.12); color: #fdba74; box-shadow: inset 0 0 0 1px rgba(251,146,60,0.32); }
99 .adm-stripe-pill-err { background: rgba(248,113,113,0.12); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35); }
100 .adm-stripe-pill-muted { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
101
102 .adm-stripe-mismatch-row td { background: rgba(251,146,60,0.05) !important; }
103
6fd5915Claude104 .adm-stripe-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; transition: background 120ms, border-color 120ms; border: 1px solid rgba(91,110,232,0.45); background: rgba(91,110,232,0.08); color: #c5b3ff; }
105 .adm-stripe-btn:hover { background: rgba(91,110,232,0.18); border-color: rgba(91,110,232,0.70); color: #e0d5ff; }
7293c67Claude106
107 .adm-stripe-empty { padding: var(--space-6); text-align: center; color: var(--text-muted); font-size: 13.5px; background: var(--bg-elevated); border: 1px dashed var(--border); border-radius: 14px; }
108
109 @media (max-width: 720px) {
110 .adm-stripe-wrap { padding: var(--space-4) var(--space-3); }
111 .adm-stripe-hero { padding: var(--space-4); }
112 .adm-stripe-table { display: block; overflow-x: auto; }
113 }
114`;
115
116type StripeRow = {
117 userId: string;
118 username: string;
119 email: string;
120 localPlan: string;
121 stripeSubId: string | null;
122 stripeStatus: string | null;
123 stripePlan: string | null;
124 mismatch: boolean;
125 stripeError: string | null;
126};
127
128function statusPill(status: string | null, stripeKeySet: boolean) {
129 if (!stripeKeySet) {
130 return <span class="adm-stripe-pill adm-stripe-pill-muted">N/A</span>;
131 }
132 if (!status) {
133 return <span class="adm-stripe-pill adm-stripe-pill-muted">no sub</span>;
134 }
135 const lower = status.toLowerCase();
136 if (lower === "active" || lower === "trialing") {
137 return <span class="adm-stripe-pill adm-stripe-pill-active">{status}</span>;
138 }
139 if (lower === "past_due") {
140 return <span class="adm-stripe-pill adm-stripe-pill-warn">{status}</span>;
141 }
142 return <span class="adm-stripe-pill adm-stripe-pill-err">{status}</span>;
143}
144
145adminStripe.get("/admin/stripe", async (c) => {
146 const g = await gate(c);
147 if (g instanceof Response) return g;
148 const { user } = g;
149
150 const stripeKeySet = !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_SECRET_KEY.length >= 10);
151
152 // Fetch all non-free users (join users + user_quotas)
153 const nonFreeRows = await db
154 .select({
155 userId: userQuotas.userId,
156 planSlug: userQuotas.planSlug,
157 stripeCustomerId: userQuotas.stripeCustomerId,
158 stripeSubscriptionId: userQuotas.stripeSubscriptionId,
159 stripeSubscriptionStatus: userQuotas.stripeSubscriptionStatus,
160 username: users.username,
161 email: users.email,
162 })
163 .from(userQuotas)
164 .innerJoin(users, eq(userQuotas.userId, users.id))
165 .where(ne(userQuotas.planSlug, "free"))
166 .orderBy(desc(userQuotas.planSlug), users.username)
167 .limit(500);
168
169 // For each user, fetch live Stripe status (if key is set)
170 const rows: StripeRow[] = [];
171 for (const r of nonFreeRows) {
172 let stripeStatus: string | null = r.stripeSubscriptionStatus;
173 let stripePlan: string | null = null;
174 let stripeError: string | null = null;
175
176 if (stripeKeySet && r.stripeSubscriptionId) {
177 try {
178 const res = await getSubscription(r.stripeSubscriptionId);
179 if (res.ok) {
180 stripeStatus = res.subscription.status;
181 stripePlan = planSlugFromSubscription(res.subscription);
182 } else {
183 stripeError = res.error;
184 // Keep DB-cached status as fallback
185 }
186 } catch (err) {
187 stripeError = err instanceof Error ? err.message : String(err);
188 }
189 }
190
191 // Determine mismatch:
192 // - local plan is non-free but Stripe subscription is cancelled/past_due/missing
193 // - local plan is free but Stripe shows active (shouldn't happen but catch it)
194 const activeOnStripe =
195 stripeStatus === "active" || stripeStatus === "trialing";
196 const cancelledOnStripe =
197 stripeStatus && stripeStatus !== "active" && stripeStatus !== "trialing";
198
199 let mismatch = false;
200 if (stripeKeySet && r.stripeSubscriptionId) {
201 if (cancelledOnStripe && r.planSlug !== "free") mismatch = true;
202 }
203
204 rows.push({
205 userId: r.userId,
206 username: r.username,
207 email: r.email,
208 localPlan: r.planSlug,
209 stripeSubId: r.stripeSubscriptionId,
210 stripeStatus,
211 stripePlan,
212 mismatch,
213 stripeError,
214 });
215 }
216
217 const mismatches = rows.filter((r) => r.mismatch);
218
219 const msg = c.req.query("msg") || "";
220 const errMsg = c.req.query("err") || "";
221
222 return c.html(
223 <Layout title="Admin — Stripe Sync" user={user}>
224 <style dangerouslySetInnerHTML={{ __html: styles }} />
225 <div class="adm-stripe-wrap">
226 <div class="adm-stripe-hero">
227 <div class="adm-stripe-hero-inner">
228 <div>
229 <div class="adm-stripe-eyebrow">Admin / Billing</div>
230 <h1 class="adm-stripe-title">Stripe Sync</h1>
231 <p class="adm-stripe-sub">
232 Non-free users — local plan vs live Stripe subscription status.
233 {mismatches.length > 0 && ` ${mismatches.length} mismatch${mismatches.length > 1 ? "es" : ""} detected.`}
234 </p>
235 </div>
236 <a href="/admin" class="adm-stripe-back">← Admin</a>
237 </div>
238 </div>
239
240 {!stripeKeySet && (
241 <div class="adm-stripe-notice">
242 <strong>STRIPE_SECRET_KEY not configured</strong> — showing local plan data only.
243 Set <code>STRIPE_SECRET_KEY</code> in your environment to enable live Stripe sync.
244 </div>
245 )}
246
247 {msg && <div class="adm-stripe-banner is-ok">{msg}</div>}
248 {errMsg && <div class="adm-stripe-banner is-err">{errMsg}</div>}
249
250 {rows.length === 0 ? (
251 <div class="adm-stripe-empty">No non-free users found.</div>
252 ) : (
253 <table class="adm-stripe-table">
254 <thead>
255 <tr>
256 <th>Username</th>
257 <th>Email</th>
258 <th>Local plan</th>
259 <th>Stripe status</th>
260 <th>Stripe plan</th>
261 <th>Subscription ID</th>
262 <th>Mismatch</th>
263 {stripeKeySet && <th>Action</th>}
264 </tr>
265 </thead>
266 <tbody>
267 {rows.map((r) => (
268 <tr class={r.mismatch ? "adm-stripe-mismatch-row" : ""}>
269 <td>
270 <a href={`/${r.username}`} style="color:var(--accent);text-decoration:none;font-weight:600;">
271 {r.username}
272 </a>
273 </td>
274 <td style="color:var(--text-muted)">{r.email}</td>
275 <td>
276 <span class="adm-stripe-pill adm-stripe-pill-active">{r.localPlan}</span>
277 </td>
278 <td>{statusPill(r.stripeStatus, stripeKeySet)}</td>
279 <td>
280 {r.stripePlan
281 ? <span class="adm-stripe-pill adm-stripe-pill-active">{r.stripePlan}</span>
282 : <span style="color:var(--text-muted)">—</span>
283 }
284 </td>
285 <td>
286 {r.stripeSubId
287 ? <code>{r.stripeSubId.slice(0, 20)}…</code>
288 : <span style="color:var(--text-muted)">—</span>
289 }
290 {r.stripeError && (
291 <span style="color:#fca5a5;font-size:11px;display:block;margin-top:2px" title={r.stripeError}>
292 ⚠ fetch failed
293 </span>
294 )}
295 </td>
296 <td>
297 {r.mismatch
298 ? <span class="adm-stripe-pill adm-stripe-pill-warn">mismatch</span>
299 : <span class="adm-stripe-pill adm-stripe-pill-muted">ok</span>
300 }
301 </td>
302 {stripeKeySet && (
303 <td>
304 {r.mismatch ? (
305 <form method="post" action={`/admin/stripe/${r.userId}/sync`}>
306 <button type="submit" class="adm-stripe-btn"
307 title={`Update local plan to match Stripe (${r.stripeStatus ?? "no sub"})`}>
308 Fix
309 </button>
310 </form>
311 ) : (
312 <span style="color:var(--text-muted);font-size:12px">—</span>
313 )}
314 </td>
315 )}
316 </tr>
317 ))}
318 </tbody>
319 </table>
320 )}
321 </div>
322 </Layout>
323 );
324});
325
326// POST /admin/stripe/:userId/sync — update local plan to match Stripe
327adminStripe.post("/admin/stripe/:userId/sync", async (c) => {
328 const g = await gate(c);
329 if (g instanceof Response) return g;
330 const { user: adminUser } = g;
331
332 const targetUserId = c.req.param("userId");
333
334 // Fetch quota row
335 const quotaRows = await db
336 .select({
337 userId: userQuotas.userId,
338 planSlug: userQuotas.planSlug,
339 stripeSubscriptionId: userQuotas.stripeSubscriptionId,
340 stripeSubscriptionStatus: userQuotas.stripeSubscriptionStatus,
341 })
342 .from(userQuotas)
343 .where(eq(userQuotas.userId, targetUserId))
344 .limit(1);
345
346 const quota = quotaRows[0];
347 if (!quota) {
348 return c.redirect("/admin/stripe?err=" + encodeURIComponent("User quota row not found."));
349 }
350
351 // Determine the correct plan from Stripe
352 let newPlan: string = "free";
353 let stripeStatus: string | null = null;
354
355 if (!quota.stripeSubscriptionId) {
356 newPlan = "free";
357 } else {
358 try {
359 const res = await getSubscription(quota.stripeSubscriptionId);
360 if (res.ok) {
361 stripeStatus = res.subscription.status;
362 const isActive = stripeStatus === "active" || stripeStatus === "trialing";
363 if (isActive) {
364 const slug = planSlugFromSubscription(res.subscription);
365 newPlan = slug ?? "free";
366 } else {
367 newPlan = "free";
368 }
369 } else {
370 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`Stripe API error: ${res.error}`));
371 }
372 } catch (err) {
373 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`Stripe request failed: ${err instanceof Error ? err.message : String(err)}`));
374 }
375 }
376
377 // Update local plan
378 try {
379 await db
380 .update(userQuotas)
381 .set({
382 planSlug: newPlan,
383 stripeSubscriptionStatus: stripeStatus ?? quota.stripeSubscriptionStatus,
384 updatedAt: new Date(),
385 })
386 .where(eq(userQuotas.userId, targetUserId));
387 } catch (err) {
388 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`DB update failed: ${err instanceof Error ? err.message : String(err)}`));
389 }
390
391 await audit({
392 userId: adminUser.id,
393 action: "billing.admin_sync",
394 targetType: "user",
395 targetId: targetUserId,
396 metadata: {
397 oldPlan: quota.planSlug,
398 newPlan,
399 stripeStatus: stripeStatus ?? "unknown",
400 triggeredBy: adminUser.id,
401 },
402 });
403
404 return c.redirect(
405 "/admin/stripe?msg=" +
406 encodeURIComponent(`Plan updated: ${quota.planSlug} → ${newPlan} (Stripe status: ${stripeStatus ?? "unknown"})`)
407 );
408});
409
410export default adminStripe;