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