a tool for shared writing and social publishing

get stripe async

+52 -79
+2 -2
actions/cancelSubscription.ts
··· 1 1 "use server"; 2 2 3 3 import { getIdentityData } from "./getIdentityData"; 4 - import { stripe } from "stripe/client"; 4 + import { getStripe } from "stripe/client"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 6 import { Ok, Err, type Result } from "src/result"; 7 7 ··· 23 23 return Err("No active subscription found"); 24 24 } 25 25 26 - await stripe.subscriptions.update(sub.stripe_subscription_id, { 26 + await getStripe().subscriptions.update(sub.stripe_subscription_id, { 27 27 cancel_at_period_end: true, 28 28 }); 29 29
+5 -5
actions/createCheckoutSession.ts
··· 1 1 "use server"; 2 2 3 3 import { getIdentityData } from "./getIdentityData"; 4 - import { stripe } from "stripe/client"; 4 + import { getStripe } from "stripe/client"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 - import { PRICE_IDS } from "stripe/products"; 6 + import { getPriceId } from "stripe/products"; 7 7 import { Ok, Err, type Result } from "src/result"; 8 8 9 9 export async function createCheckoutSession( ··· 15 15 return Err("Not authenticated"); 16 16 } 17 17 18 - const priceId = PRICE_IDS[cadence]; 18 + const priceId = await getPriceId(cadence); 19 19 if (!priceId) { 20 - return Err("Price not configured. Set STRIPE_PRICE_MONTHLY_ID and STRIPE_PRICE_YEARLY_ID env vars."); 20 + return Err("No Stripe price found. Run the sync script first."); 21 21 } 22 22 23 23 // Check for existing Stripe customer ··· 43 43 44 44 const cancelUrl = returnUrl || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; 45 45 46 - const session = await stripe.checkout.sessions.create({ 46 + const session = await getStripe().checkout.sessions.create({ 47 47 mode: "subscription", 48 48 line_items: [{ price: priceId, quantity: 1 }], 49 49 client_reference_id: identity.id,
+7 -17
app/api/checkout/success/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 - import { stripe } from "stripe/client"; 2 + import { getStripe } from "stripe/client"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 - import { parseEntitlements } from "stripe/products"; 4 + import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; 5 5 6 6 export async function GET(req: NextRequest) { 7 7 const sessionId = req.nextUrl.searchParams.get("session_id"); ··· 12 12 } 13 13 14 14 try { 15 - const session = await stripe.checkout.sessions.retrieve(sessionId, { 16 - expand: ["subscription", "subscription.items.data.price.product"], 15 + const session = await getStripe().checkout.sessions.retrieve(sessionId, { 16 + expand: ["subscription"], 17 17 }); 18 18 19 19 const identityId = session.client_reference_id; ··· 22 22 typeof session.subscription === "object" ? session.subscription : null; 23 23 24 24 if (identityId && sub) { 25 - const priceItem = sub.items.data[0]; 26 - const product = 27 - priceItem?.price.product && 28 - typeof priceItem.price.product === "object" && 29 - !("deleted" in priceItem.price.product) 30 - ? priceItem.price.product 31 - : null; 32 - const periodEnd = priceItem?.current_period_end ?? 0; 25 + const periodEnd = sub.items.data[0]?.current_period_end ?? 0; 26 + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); 33 27 34 28 // Optimistic upsert — idempotent with webhook handler 35 29 await supabaseServerClient.from("user_subscriptions").upsert( ··· 37 31 identity_id: identityId, 38 32 stripe_customer_id: customerId, 39 33 stripe_subscription_id: sub.id, 40 - plan: product?.name || "Leaflet Pro", 34 + plan: PRODUCT_DEFINITION.name, 41 35 status: sub.status, 42 36 current_period_end: new Date(periodEnd * 1000).toISOString(), 43 37 updated_at: new Date().toISOString(), 44 38 }, 45 39 { onConflict: "identity_id" }, 46 40 ); 47 - 48 - const entitlements = product 49 - ? parseEntitlements(product.metadata) 50 - : { publication_analytics: true }; 51 41 52 42 for (const key of Object.keys(entitlements)) { 53 43 await supabaseServerClient.from("user_entitlements").upsert(
+10 -24
app/api/inngest/functions/stripe_handle_checkout_completed.ts
··· 1 1 import { inngest } from "../client"; 2 - import { stripe } from "stripe/client"; 2 + import { getStripe } from "stripe/client"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 - import { parseEntitlements } from "stripe/products"; 4 + import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; 5 5 6 6 export const stripe_handle_checkout_completed = inngest.createFunction( 7 7 { id: "stripe-handle-checkout-completed" }, 8 8 { event: "stripe/checkout.session.completed" }, 9 9 async ({ event, step }) => { 10 10 const session = await step.run("fetch-checkout-session", async () => { 11 - const s = await stripe.checkout.sessions.retrieve(event.data.sessionId, { 12 - expand: ["subscription", "subscription.items.data.price.product"], 13 - }); 11 + const s = await getStripe().checkout.sessions.retrieve( 12 + event.data.sessionId, 13 + { expand: ["subscription"] }, 14 + ); 14 15 const sub = 15 16 typeof s.subscription === "object" ? s.subscription : null; 16 - const priceItem = sub?.items.data[0]; 17 - const product = 18 - priceItem?.price.product && 19 - typeof priceItem.price.product === "object" && 20 - !("deleted" in priceItem.price.product) 21 - ? priceItem.price.product 22 - : null; 23 - const periodEnd = priceItem?.current_period_end ?? 0; 17 + const periodEnd = sub?.items.data[0]?.current_period_end ?? 0; 24 18 25 19 return { 26 20 identityId: s.client_reference_id, ··· 28 22 subId: sub?.id ?? null, 29 23 subStatus: sub?.status ?? null, 30 24 periodEnd, 31 - productName: product?.name || "Leaflet Pro", 32 - productMetadata: product?.metadata ?? null, 33 25 }; 34 26 }); 35 27 ··· 38 30 } 39 31 40 32 await step.run("upsert-subscription-and-entitlements", async () => { 41 - // Upsert user_subscriptions 33 + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); 34 + 42 35 await supabaseServerClient.from("user_subscriptions").upsert( 43 36 { 44 37 identity_id: session.identityId!, 45 38 stripe_customer_id: session.customerId, 46 39 stripe_subscription_id: session.subId!, 47 - plan: session.productName, 40 + plan: PRODUCT_DEFINITION.name, 48 41 status: session.subStatus, 49 42 current_period_end: new Date( 50 43 session.periodEnd * 1000, ··· 53 46 }, 54 47 { onConflict: "identity_id" }, 55 48 ); 56 - 57 - // Parse entitlements from product metadata and upsert 58 - const entitlements = session.productMetadata 59 - ? parseEntitlements( 60 - session.productMetadata as Record<string, string>, 61 - ) 62 - : { publication_analytics: true }; 63 49 64 50 for (const key of Object.keys(entitlements)) { 65 51 await supabaseServerClient.from("user_entitlements").upsert(
+8 -21
app/api/inngest/functions/stripe_handle_subscription_updated.ts
··· 1 1 import { inngest } from "../client"; 2 - import { stripe } from "stripe/client"; 2 + import { getStripe } from "stripe/client"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 - import { parseEntitlements } from "stripe/products"; 4 + import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; 5 5 6 6 export const stripe_handle_subscription_updated = inngest.createFunction( 7 7 { id: "stripe-handle-subscription-updated" }, 8 8 { event: "stripe/customer.subscription.updated" }, 9 9 async ({ event, step }) => { 10 10 const subData = await step.run("fetch-subscription", async () => { 11 - const sub = await stripe.subscriptions.retrieve( 11 + const sub = await getStripe().subscriptions.retrieve( 12 12 event.data.subscriptionId, 13 - { expand: ["items.data.price.product"] }, 14 13 ); 15 - const priceItem = sub.items.data[0]; 16 - const product = 17 - priceItem?.price.product && 18 - typeof priceItem.price.product === "object" && 19 - !("deleted" in priceItem.price.product) 20 - ? priceItem.price.product 21 - : null; 22 - const periodEnd = priceItem?.current_period_end ?? 0; 14 + const periodEnd = sub.items.data[0]?.current_period_end ?? 0; 23 15 24 16 return { 25 17 id: sub.id, 26 18 customerId: sub.customer as string, 27 - status: sub.status, 19 + status: sub.cancel_at_period_end ? "canceling" : sub.status, 28 20 periodEnd, 29 - productName: product?.name || "Leaflet Pro", 30 - productMetadata: product?.metadata ?? null, 31 21 }; 32 22 }); 33 23 34 24 await step.run("update-subscription-and-entitlements", async () => { 25 + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); 26 + 35 27 // Find the identity by stripe_customer_id 36 28 const { data: existingSub } = await supabaseServerClient 37 29 .from("user_subscriptions") ··· 51 43 .from("user_subscriptions") 52 44 .update({ 53 45 status: subData.status, 54 - plan: subData.productName, 46 + plan: PRODUCT_DEFINITION.name, 55 47 current_period_end: new Date( 56 48 subData.periodEnd * 1000, 57 49 ).toISOString(), ··· 60 52 .eq("identity_id", existingSub.identity_id); 61 53 62 54 // Update entitlement expiry dates for all entitlements from this subscription 63 - const entitlements = subData.productMetadata 64 - ? parseEntitlements( 65 - subData.productMetadata as Record<string, string>, 66 - ) 67 - : {}; 68 55 for (const key of Object.keys(entitlements)) { 69 56 await supabaseServerClient 70 57 .from("user_entitlements")
+2 -2
app/api/webhooks/stripe/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 - import { stripe } from "stripe/client"; 2 + import { getStripe } from "stripe/client"; 3 3 import { inngest } from "app/api/inngest/client"; 4 4 5 5 export async function POST(req: NextRequest) { ··· 11 11 12 12 let event; 13 13 try { 14 - event = stripe.webhooks.constructEvent( 14 + event = getStripe().webhooks.constructEvent( 15 15 body, 16 16 signature, 17 17 process.env.STRIPE_WEBHOOK_SECRET as string,
+10 -3
stripe/client.ts
··· 1 1 import Stripe from "stripe"; 2 2 3 - export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { 4 - apiVersion: "2026-02-25.clover", 5 - }); 3 + let _stripe: Stripe | null = null; 4 + 5 + export function getStripe(): Stripe { 6 + if (!_stripe) { 7 + _stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { 8 + apiVersion: "2026-02-25.clover", 9 + }); 10 + } 11 + return _stripe; 12 + }
+8 -5
stripe/products.ts
··· 23 23 }, 24 24 }; 25 25 26 - // Populated at runtime by sync script or looked up dynamically 27 - export const PRICE_IDS: Record<"month" | "year", string> = { 28 - month: process.env.STRIPE_PRICE_MONTHLY_ID || "", 29 - year: process.env.STRIPE_PRICE_YEARLY_ID || "", 30 - }; 26 + export async function getPriceId( 27 + cadence: "month" | "year", 28 + ): Promise<string | null> { 29 + const { getStripe } = await import("./client"); 30 + const key = PRICE_DEFINITIONS[cadence].lookup_key; 31 + const prices = await getStripe().prices.list({ lookup_keys: [key] }); 32 + return prices.data[0]?.id ?? null; 33 + } 31 34 32 35 export function parseEntitlements( 33 36 metadata: Record<string, string> | null,