Commit619109aunknown_key
feat(billing): full Stripe checkout + subscription lifecycle
feat(billing): full Stripe checkout + subscription lifecycle
Builds on the Stripe bootstrap workflow by wiring actual upgrade + auto-
plan-assignment end-to-end. No SDK dependency — pure fetch against
Stripe's REST API. All functions return discriminated ok/error results;
Stripe outage never breaks the primary request path.
drizzle/0038_stripe_billing.sql + schema.ts
- user_quotas gets stripe_customer_id, stripe_subscription_id,
stripe_subscription_status, current_period_end
- Unique partial index on customer_id (fast webhook lookup)
src/lib/stripe.ts (new, 180 LOC)
- findOrCreateCustomer() — reuses stored id, falls back to create
- createCheckoutSession() — resolves Stripe price by lookup_key
(gluecron_<slug>_monthly), sets client_reference_id + subscription
metadata so the webhook can link back to the user
- createBillingPortalSession() — delegated management (change card,
download invoices, cancel) to Stripe's hosted portal
- getSubscription() + planSlugFromSubscription() — maps
lookup_key or metadata back to a gluecron plan slug
src/routes/billing.tsx
- Self-serve upgrade buttons on /settings/billing for each paid plan
- POST /billing/upgrade/:plan → creates a customer, creates a
checkout session, redirects to Stripe (303)
- GET /billing/success + /billing/cancel landing pages
- POST /billing/manage → billing portal for existing subscribers
- Unconfigured-Stripe banner when STRIPE_SECRET_KEY unset
src/routes/stripe-webhook.ts
- Actual lifecycle handling replaces the stub:
- checkout.session.completed → link customer to user_quotas row
- customer.subscription.created/updated → reconcile plan from
the subscription's price.lookup_key
- customer.subscription.deleted → downgrade to free
- invoice.payment_failed → flag status=past_due (grace period)
- All handlers swallow errors and return 200 so Stripe doesn't
retry-storm on transient DB issues
src/lib/billing.ts
- QuotaView extended with stripeCustomerId, stripeSubscriptionId,
stripeSubscriptionStatus, currentPeriodEnd
Test state: 230 pass / 58 fail / 52 errors (baseline unchanged).
All new code is non-test surface; existing Drizzle type quirks
pre-date this change.
https://claude.ai/code/session_017Do52tMX1P9jPTWXhEohXH6 files changed+552−5619109ae7b2ea003af89f68a2c181ee3866c46d5
6 changed files+552−5
Addeddrizzle/0038_stripe_billing.sql+18−0View fileUnifiedSplit
@@ -0,0 +1,18 @@
1-- Stripe billing columns on user_quotas. Adds subscription linkage + lifecycle
2-- state so the webhook handler can auto-assign plans and enforce grace periods.
3
4ALTER TABLE user_quotas
5 ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT,
6 ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT,
7 ADD COLUMN IF NOT EXISTS stripe_subscription_status TEXT,
8 ADD COLUMN IF NOT EXISTS current_period_end TIMESTAMPTZ;
9--> statement-breakpoint
10
11CREATE UNIQUE INDEX IF NOT EXISTS user_quotas_stripe_customer_id_idx
12 ON user_quotas (stripe_customer_id)
13 WHERE stripe_customer_id IS NOT NULL;
14--> statement-breakpoint
15
16CREATE INDEX IF NOT EXISTS user_quotas_stripe_subscription_status_idx
17 ON user_quotas (stripe_subscription_status)
18 WHERE stripe_subscription_status IS NOT NULL;
Modifiedsrc/db/schema.ts+5−0View fileUnifiedSplit
@@ -1801,6 +1801,11 @@ export const userQuotas = pgTable("user_quotas", {
18011801 .default(0),
18021802 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
18031803 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1804 // Stripe linkage (migration 0038). Null until the user first upgrades.
1805 stripeCustomerId: text("stripe_customer_id"),
1806 stripeSubscriptionId: text("stripe_subscription_id"),
1807 stripeSubscriptionStatus: text("stripe_subscription_status"),
1808 currentPeriodEnd: timestamp("current_period_end"),
18041809});
18051810
18061811export type UserQuota = typeof userQuotas.$inferSelect;
Modifiedsrc/lib/billing.ts+10−0View fileUnifiedSplit
@@ -116,6 +116,11 @@ export interface QuotaView {
116116 bandwidthGbUsedThisMonth: number;
117117 };
118118 cycleStart: Date | null;
119 /** Stripe linkage — populated once the user completes a checkout session. */
120 stripeCustomerId: string | null;
121 stripeSubscriptionId: string | null;
122 stripeSubscriptionStatus: string | null;
123 currentPeriodEnd: Date | null;
119124 percent: {
120125 storage: number;
121126 aiTokens: number;
@@ -164,6 +169,11 @@ export async function getUserQuota(userId: string): Promise<QuotaView> {
164169 plan,
165170 usage,
166171 cycleStart: (row?.cycleStart as Date | null) || null,
172 stripeCustomerId: (row?.stripeCustomerId as string | null) || null,
173 stripeSubscriptionId: (row?.stripeSubscriptionId as string | null) || null,
174 stripeSubscriptionStatus:
175 (row?.stripeSubscriptionStatus as string | null) || null,
176 currentPeriodEnd: (row?.currentPeriodEnd as Date | null) || null,
167177 percent: {
168178 storage: pct(usage.storageMbUsed, plan.storageMbLimit),
169179 aiTokens: pct(usage.aiTokensUsedThisMonth, plan.aiTokensMonthly),
Addedsrc/lib/stripe.ts+189−0View fileUnifiedSplit
@@ -0,0 +1,189 @@
1/**
2 * Stripe REST helpers — fetch-based, no SDK dependency. Used by the billing
3 * upgrade flow and the webhook handler.
4 *
5 * All fns return discriminated `{ ok: true, ... }` / `{ ok: false, error }`
6 * results. Never throws — a Stripe outage must not break the primary
7 * request path.
8 *
9 * `STRIPE_SECRET_KEY` is read at call time (not module init) so tests can
10 * mutate env without reloading the module.
11 */
12
13const STRIPE_API = "https://api.stripe.com/v1";
14
15export type StripeFail = { ok: false; error: string };
16
17function getKey(): string | null {
18 const k = process.env.STRIPE_SECRET_KEY;
19 if (!k || k.length < 10) return null;
20 return k;
21}
22
23function encodeForm(body: Record<string, string | string[]>): string {
24 const params = new URLSearchParams();
25 for (const [k, v] of Object.entries(body)) {
26 if (Array.isArray(v)) for (const item of v) params.append(`${k}[]`, item);
27 else params.append(k, v);
28 }
29 return params.toString();
30}
31
32async function stripeRequest<T>(
33 path: string,
34 body?: Record<string, string | string[]>,
35 method: "GET" | "POST" | "DELETE" = body ? "POST" : "GET"
36): Promise<{ ok: true; data: T } | StripeFail> {
37 const key = getKey();
38 if (!key) return { ok: false, error: "STRIPE_SECRET_KEY not configured" };
39 try {
40 const res = await fetch(`${STRIPE_API}${path}`, {
41 method,
42 headers: {
43 Authorization: `Bearer ${key}`,
44 "Content-Type": "application/x-www-form-urlencoded",
45 },
46 body: body ? encodeForm(body) : undefined,
47 });
48 const json = await res.json();
49 if (!res.ok) {
50 return {
51 ok: false,
52 error: `stripe ${method} ${path} ${res.status}: ${json.error?.message ?? "unknown"}`,
53 };
54 }
55 return { ok: true, data: json as T };
56 } catch (err) {
57 return {
58 ok: false,
59 error: `stripe ${method} ${path} network: ${err instanceof Error ? err.message : String(err)}`,
60 };
61 }
62}
63
64// ---------------------------------------------------------------------------
65// Customer
66// ---------------------------------------------------------------------------
67
68export async function findOrCreateCustomer(args: {
69 userId: string;
70 email: string;
71 existingCustomerId?: string | null;
72}): Promise<{ ok: true; customerId: string } | StripeFail> {
73 if (args.existingCustomerId) {
74 // Trust the caller's stored id; Stripe will 404 if it's stale, we fall
75 // back to create.
76 const check = await stripeRequest<{ id: string; deleted?: boolean }>(
77 `/customers/${encodeURIComponent(args.existingCustomerId)}`
78 );
79 if (check.ok && !check.data.deleted) {
80 return { ok: true, customerId: check.data.id };
81 }
82 }
83 const res = await stripeRequest<{ id: string }>(`/customers`, {
84 email: args.email,
85 "metadata[gluecron_user_id]": args.userId,
86 });
87 if (!res.ok) return res;
88 return { ok: true, customerId: res.data.id };
89}
90
91// ---------------------------------------------------------------------------
92// Checkout Session
93// ---------------------------------------------------------------------------
94
95export async function createCheckoutSession(args: {
96 customerId: string;
97 planSlug: "pro" | "team" | "enterprise";
98 successUrl: string;
99 cancelUrl: string;
100 userId: string;
101}): Promise<{ ok: true; url: string; sessionId: string } | StripeFail> {
102 const lookupKey = `gluecron_${args.planSlug}_monthly`;
103 // Resolve price by lookup_key — matches what stripe-bootstrap seeds.
104 const priceRes = await stripeRequest<{ data: Array<{ id: string }> }>(
105 `/prices?lookup_keys[]=${encodeURIComponent(lookupKey)}&limit=1`
106 );
107 if (!priceRes.ok) return priceRes;
108 const priceId = priceRes.data.data[0]?.id;
109 if (!priceId) {
110 return {
111 ok: false,
112 error: `no Stripe price found for lookup_key=${lookupKey} — run the Stripe Bootstrap workflow first`,
113 };
114 }
115 const res = await stripeRequest<{ id: string; url: string }>(
116 `/checkout/sessions`,
117 {
118 mode: "subscription",
119 customer: args.customerId,
120 "line_items[0][price]": priceId,
121 "line_items[0][quantity]": "1",
122 success_url: args.successUrl,
123 cancel_url: args.cancelUrl,
124 client_reference_id: args.userId,
125 "metadata[gluecron_user_id]": args.userId,
126 "metadata[gluecron_plan_slug]": args.planSlug,
127 "subscription_data[metadata][gluecron_user_id]": args.userId,
128 "subscription_data[metadata][gluecron_plan_slug]": args.planSlug,
129 }
130 );
131 if (!res.ok) return res;
132 return { ok: true, url: res.data.url, sessionId: res.data.id };
133}
134
135// ---------------------------------------------------------------------------
136// Customer Portal (for existing subscribers to manage their plan)
137// ---------------------------------------------------------------------------
138
139export async function createBillingPortalSession(args: {
140 customerId: string;
141 returnUrl: string;
142}): Promise<{ ok: true; url: string } | StripeFail> {
143 const res = await stripeRequest<{ url: string }>(`/billing_portal/sessions`, {
144 customer: args.customerId,
145 return_url: args.returnUrl,
146 });
147 if (!res.ok) return res;
148 return { ok: true, url: res.data.url };
149}
150
151// ---------------------------------------------------------------------------
152// Subscription fetch (used by webhook to enrich on events)
153// ---------------------------------------------------------------------------
154
155export type StripeSubscription = {
156 id: string;
157 status: string;
158 customer: string;
159 current_period_end: number;
160 items: { data: Array<{ price: { id: string; lookup_key?: string } }> };
161 metadata?: Record<string, string>;
162};
163
164export async function getSubscription(
165 subscriptionId: string
166): Promise<{ ok: true; subscription: StripeSubscription } | StripeFail> {
167 const res = await stripeRequest<StripeSubscription>(
168 `/subscriptions/${encodeURIComponent(subscriptionId)}`
169 );
170 if (!res.ok) return res;
171 return { ok: true, subscription: res.data };
172}
173
174/** Given a Stripe subscription, derive the gluecron plan slug. Prefers
175 * the lookup_key on the price item; falls back to subscription metadata. */
176export function planSlugFromSubscription(
177 sub: StripeSubscription
178): "pro" | "team" | "enterprise" | null {
179 const lk = sub.items?.data?.[0]?.price?.lookup_key;
180 if (lk?.startsWith("gluecron_") && lk.endsWith("_monthly")) {
181 const slug = lk.slice("gluecron_".length, -"_monthly".length);
182 if (slug === "pro" || slug === "team" || slug === "enterprise") return slug;
183 }
184 const metaSlug = sub.metadata?.gluecron_plan_slug;
185 if (metaSlug === "pro" || metaSlug === "team" || metaSlug === "enterprise") {
186 return metaSlug;
187 }
188 return null;
189}
Modifiedsrc/routes/billing.tsx+123−3View fileUnifiedSplit
@@ -26,6 +26,12 @@ import {
2626 listPlans,
2727 setUserPlan,
2828} from "../lib/billing";
29import {
30 createBillingPortalSession,
31 createCheckoutSession,
32 findOrCreateCustomer,
33} from "../lib/stripe";
34import { config } from "../lib/config";
2935
3036const billing = new Hono<AuthEnv>();
3137billing.use("*", softAuth);
@@ -144,17 +150,131 @@ billing.get("/settings/billing", requireAuth, async (c) => {
144150 CURRENT PLAN
145151 </div>
146152 )}
153 {!isCurrent && p.slug !== "free" && p.priceCents > 0 && (
154 <form
155 method="post"
156 action={`/billing/upgrade/${p.slug}`}
157 style="margin-top:10px"
158 >
159 <button
160 type="submit"
161 class="btn btn-primary"
162 style="width:100%;font-size:13px"
163 >
164 {quota.planSlug === "free" ? "Upgrade" : "Switch"} to {p.name}
165 </button>
166 </form>
167 )}
147168 </div>
148169 );
149170 })}
150171 </div>
151 <p style="font-size:12px;color:var(--text-muted);margin-top:12px">
152 To change plans, contact a site administrator.
153 </p>
172 {quota.planSlug !== "free" && (
173 <div style="margin-top:16px">
174 <form method="post" action="/billing/manage" style="display:inline">
175 <button
176 type="submit"
177 class="btn"
178 style="font-size:13px"
179 >
180 Manage subscription (Stripe Customer Portal)
181 </button>
182 </form>
183 <span
184 style="font-size:12px;color:var(--text-muted);margin-left:12px"
185 >
186 — change card, download invoices, cancel anytime.
187 </span>
188 </div>
189 )}
190 {!process.env.STRIPE_SECRET_KEY && (
191 <p
192 style="font-size:12px;color:var(--text-muted);margin-top:12px;font-style:italic"
193 >
194 (Stripe not yet configured on this instance — upgrade buttons will
195 return a setup error. Run the Stripe Bootstrap workflow to enable.)
196 </p>
197 )}
154198 </Layout>
155199 );
156200});
157201
202// ----- Upgrade flow (Stripe Checkout) -----
203
204billing.post("/billing/upgrade/:plan", requireAuth, async (c) => {
205 const user = c.get("user")!;
206 const planSlug = c.req.param("plan");
207 if (planSlug !== "pro" && planSlug !== "team" && planSlug !== "enterprise") {
208 return c.redirect("/settings/billing?error=invalid-plan");
209 }
210
211 const quota = await getUserQuota(user.id);
212 const customer = await findOrCreateCustomer({
213 userId: user.id,
214 email: user.email ?? `${user.username}@gluecron.local`,
215 existingCustomerId: quota.stripeCustomerId ?? null,
216 });
217 if (!customer.ok) {
218 console.error(`[billing/upgrade] customer: ${customer.error}`);
219 return c.redirect(
220 `/settings/billing?error=${encodeURIComponent(customer.error)}`
221 );
222 }
223
224 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
225 const session = await createCheckoutSession({
226 customerId: customer.customerId,
227 planSlug,
228 successUrl: `${base}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
229 cancelUrl: `${base}/billing/cancel`,
230 userId: user.id,
231 });
232 if (!session.ok) {
233 console.error(`[billing/upgrade] checkout: ${session.error}`);
234 return c.redirect(
235 `/settings/billing?error=${encodeURIComponent(session.error)}`
236 );
237 }
238
239 // Stash the customerId onto the quota row now (doesn't wait for webhook)
240 // so subsequent upgrades don't re-create a customer.
241 await db
242 .update(userQuotas)
243 .set({ stripeCustomerId: customer.customerId, updatedAt: new Date() })
244 .where(eq(userQuotas.userId, user.id));
245
246 return c.redirect(session.url, 303);
247});
248
249billing.get("/billing/success", requireAuth, async (c) => {
250 // The webhook does the actual plan assignment; this is just a landing page.
251 return c.redirect("/settings/billing?upgraded=1");
252});
253
254billing.get("/billing/cancel", requireAuth, async (c) => {
255 return c.redirect("/settings/billing?canceled=1");
256});
257
258billing.post("/billing/manage", requireAuth, async (c) => {
259 const user = c.get("user")!;
260 const quota = await getUserQuota(user.id);
261 if (!quota.stripeCustomerId) {
262 return c.redirect("/settings/billing?error=no-subscription");
263 }
264 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
265 const session = await createBillingPortalSession({
266 customerId: quota.stripeCustomerId,
267 returnUrl: `${base}/settings/billing`,
268 });
269 if (!session.ok) {
270 console.error(`[billing/manage] portal: ${session.error}`);
271 return c.redirect(
272 `/settings/billing?error=${encodeURIComponent(session.error)}`
273 );
274 }
275 return c.redirect(session.url, 303);
276});
277
158278// ----- Admin billing panel -----
159279
160280billing.get("/admin/billing", async (c) => {
Modifiedsrc/routes/stripe-webhook.ts+207−2View fileUnifiedSplit
@@ -18,7 +18,11 @@
1818 */
1919
2020import { Hono } from "hono";
21import { eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import { userQuotas } from "../db/schema";
2124import { reportError } from "../lib/observability";
25import { getSubscription, planSlugFromSubscription } from "../lib/stripe";
2226
2327const stripeWebhook = new Hono();
2428
@@ -96,8 +100,19 @@ stripeWebhook.post("/api/webhooks/stripe", async (c) => {
96100 `[stripe-webhook] authentic event id=${event.id} type=${event.type}`
97101 );
98102
99 // v1: accept-and-log only. Full handling ships with the billing integration
100 // sprint. Stripe retries non-200s — returning 200 here prevents that.
103 // Handle subscription lifecycle. Every handler swallows errors and returns
104 // 200 so Stripe doesn't retry-storm on transient DB issues — we log and
105 // rely on the next event (Stripe fires subscription.updated periodically).
106 try {
107 await handleStripeEvent(event as StripeEvent);
108 } catch (err) {
109 reportError(err as Error, {
110 path: "/api/webhooks/stripe",
111 stripeEventType: (event as StripeEvent).type,
112 stripeEventId: (event as StripeEvent).id,
113 });
114 }
115
101116 return c.json({ received: true });
102117});
103118
@@ -109,4 +124,194 @@ stripeWebhook.onError((err, c) => {
109124 return c.json({ error: "internal error" }, 500);
110125});
111126
127// ---------------------------------------------------------------------------
128// Event handlers — each is defensive (never throws). Called from the main
129// route handler after signature verification.
130// ---------------------------------------------------------------------------
131
132type StripeEvent = {
133 id: string;
134 type: string;
135 data?: { object?: Record<string, unknown> };
136};
137
138async function handleStripeEvent(event: StripeEvent): Promise<void> {
139 const obj = event.data?.object ?? {};
140 switch (event.type) {
141 case "checkout.session.completed": {
142 const userId = String(obj.client_reference_id ?? "");
143 const customerId = String(obj.customer ?? "");
144 const subscriptionId = obj.subscription ? String(obj.subscription) : null;
145 if (!userId || !customerId) {
146 console.warn(
147 `[stripe-webhook] checkout.session.completed missing userId/customerId — skipping`
148 );
149 return;
150 }
151 await upsertQuotaRow(userId, {
152 stripeCustomerId: customerId,
153 stripeSubscriptionId: subscriptionId,
154 });
155 // Enrich from the subscription object to set the actual plan slug
156 if (subscriptionId) await reconcileSubscription(subscriptionId, userId);
157 return;
158 }
159 case "customer.subscription.created":
160 case "customer.subscription.updated": {
161 const subId = String(obj.id ?? "");
162 if (!subId) return;
163 const userId = String(
164 (obj.metadata as Record<string, string> | undefined)?.gluecron_user_id ?? ""
165 );
166 await reconcileSubscription(subId, userId || null);
167 return;
168 }
169 case "customer.subscription.deleted": {
170 const customerId = String(obj.customer ?? "");
171 if (!customerId) return;
172 await db
173 .update(userQuotas)
174 .set({
175 planSlug: "free",
176 stripeSubscriptionId: null,
177 stripeSubscriptionStatus: "canceled",
178 currentPeriodEnd: null,
179 updatedAt: new Date(),
180 })
181 .where(eq(userQuotas.stripeCustomerId, customerId));
182 console.log(`[stripe-webhook] downgraded customer=${customerId} to free`);
183 return;
184 }
185 case "invoice.payment_failed": {
186 const customerId = String(obj.customer ?? "");
187 if (!customerId) return;
188 // Mark status — don't downgrade immediately (Stripe retries billing
189 // over a grace window; a future subscription.updated with status=
190 // past_due/unpaid/canceled will do the actual plan move).
191 await db
192 .update(userQuotas)
193 .set({
194 stripeSubscriptionStatus: "past_due",
195 updatedAt: new Date(),
196 })
197 .where(eq(userQuotas.stripeCustomerId, customerId));
198 return;
199 }
200 default:
201 // All other events (invoice.payment_succeeded etc.) — accept silently.
202 return;
203 }
204}
205
206async function reconcileSubscription(
207 subscriptionId: string,
208 fallbackUserId: string | null
209): Promise<void> {
210 const res = await getSubscription(subscriptionId);
211 if (!res.ok) {
212 console.warn(
213 `[stripe-webhook] getSubscription(${subscriptionId}) failed: ${res.error}`
214 );
215 return;
216 }
217 const sub = res.subscription;
218 const slug = planSlugFromSubscription(sub);
219 const customerId = sub.customer;
220 const status = sub.status;
221 const periodEnd = sub.current_period_end
222 ? new Date(sub.current_period_end * 1000)
223 : null;
224
225 // Prefer locating by customer id (set during checkout.session.completed).
226 // Fall back to metadata gluecron_user_id if present.
227 const userId =
228 (sub.metadata?.gluecron_user_id as string | undefined) ?? fallbackUserId ?? null;
229
230 if (status !== "active" && status !== "trialing") {
231 // Non-active subscriptions don't grant a paid plan. We still record
232 // their state so the user can see "past_due" in /settings/billing.
233 if (customerId) {
234 await db
235 .update(userQuotas)
236 .set({
237 stripeSubscriptionId: sub.id,
238 stripeSubscriptionStatus: status,
239 currentPeriodEnd: periodEnd,
240 updatedAt: new Date(),
241 })
242 .where(eq(userQuotas.stripeCustomerId, customerId));
243 }
244 return;
245 }
246
247 if (!slug) {
248 console.warn(
249 `[stripe-webhook] subscription ${sub.id} active but no plan slug resolvable — leaving plan unchanged`
250 );
251 return;
252 }
253
254 if (userId) {
255 await upsertQuotaRow(userId, {
256 planSlug: slug,
257 stripeCustomerId: customerId,
258 stripeSubscriptionId: sub.id,
259 stripeSubscriptionStatus: status,
260 currentPeriodEnd: periodEnd,
261 });
262 console.log(
263 `[stripe-webhook] user=${userId} → plan=${slug} (status=${status})`
264 );
265 return;
266 }
267
268 // Last resort: locate by customer id and update in place
269 if (customerId) {
270 await db
271 .update(userQuotas)
272 .set({
273 planSlug: slug,
274 stripeSubscriptionId: sub.id,
275 stripeSubscriptionStatus: status,
276 currentPeriodEnd: periodEnd,
277 updatedAt: new Date(),
278 })
279 .where(eq(userQuotas.stripeCustomerId, customerId));
280 }
281}
282
283async function upsertQuotaRow(
284 userId: string,
285 patch: {
286 planSlug?: string;
287 stripeCustomerId?: string | null;
288 stripeSubscriptionId?: string | null;
289 stripeSubscriptionStatus?: string | null;
290 currentPeriodEnd?: Date | null;
291 }
292): Promise<void> {
293 // Try update first; if no row exists, insert.
294 const res = await db
295 .update(userQuotas)
296 .set({ ...patch, updatedAt: new Date() })
297 .where(eq(userQuotas.userId, userId))
298 .returning({ userId: userQuotas.userId });
299 if (res.length === 0) {
300 await db
301 .insert(userQuotas)
302 .values({
303 userId,
304 planSlug: patch.planSlug ?? "free",
305 stripeCustomerId: patch.stripeCustomerId ?? null,
306 stripeSubscriptionId: patch.stripeSubscriptionId ?? null,
307 stripeSubscriptionStatus: patch.stripeSubscriptionStatus ?? null,
308 currentPeriodEnd: patch.currentPeriodEnd ?? null,
309 })
310 .onConflictDoUpdate({
311 target: userQuotas.userId,
312 set: { ...patch, updatedAt: new Date() },
313 });
314 }
315}
316
112317export default stripeWebhook;
113318