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