From 93976e3d1e683592aeb351977897661dd9137aaa Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 11 Mar 2026 00:58:39 -0400 Subject: [PATCH] add stripe webhook handlers --- .../stripe/handle_checkout_completed.ts | 48 ++++++++++++++++ .../stripe/handle_invoice_payment_failed.ts | 15 +++++ .../stripe/handle_subscription_deleted.ts | 13 +++++ .../stripe/handle_subscription_updated.ts | 56 +++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 app/api/webhooks/stripe/handle_checkout_completed.ts create mode 100644 app/api/webhooks/stripe/handle_invoice_payment_failed.ts create mode 100644 app/api/webhooks/stripe/handle_subscription_deleted.ts create mode 100644 app/api/webhooks/stripe/handle_subscription_updated.ts diff --git a/app/api/webhooks/stripe/handle_checkout_completed.ts b/app/api/webhooks/stripe/handle_checkout_completed.ts new file mode 100644 index 00000000..2d39c365 --- /dev/null +++ b/app/api/webhooks/stripe/handle_checkout_completed.ts @@ -0,0 +1,48 @@ +import { getStripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; + +export async function handleCheckoutCompleted(sessionId: string) { + const s = await getStripe().checkout.sessions.retrieve(sessionId, { + expand: ["subscription"], + }); + const sub = typeof s.subscription === "object" ? s.subscription : null; + const periodEnd = sub?.items.data[0]?.current_period_end ?? 0; + const lookupKey = sub?.items.data[0]?.price.lookup_key ?? null; + const identityId = s.client_reference_id; + const customerId = s.customer as string; + const subId = sub?.id ?? null; + const subStatus = sub?.status ?? null; + + if (!identityId || !subId) { + throw new Error("Missing client_reference_id or subscription"); + } + + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); + + await supabaseServerClient.from("user_subscriptions").upsert( + { + identity_id: identityId, + stripe_customer_id: customerId, + stripe_subscription_id: subId, + plan: lookupKey, + status: subStatus, + current_period_end: new Date(periodEnd * 1000).toISOString(), + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + for (const key of Object.keys(entitlements)) { + await supabaseServerClient.from("user_entitlements").upsert( + { + identity_id: identityId, + entitlement_key: key, + granted_at: new Date().toISOString(), + expires_at: new Date(periodEnd * 1000).toISOString(), + source: `stripe:${subId}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); + } +} diff --git a/app/api/webhooks/stripe/handle_invoice_payment_failed.ts b/app/api/webhooks/stripe/handle_invoice_payment_failed.ts new file mode 100644 index 00000000..d38c2084 --- /dev/null +++ b/app/api/webhooks/stripe/handle_invoice_payment_failed.ts @@ -0,0 +1,15 @@ +import { supabaseServerClient } from "supabase/serverClient"; + +export async function handleInvoicePaymentFailed(subscriptionId: string) { + if (subscriptionId) { + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "past_due", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", subscriptionId); + } + + // Entitlements remain valid until expires_at +} diff --git a/app/api/webhooks/stripe/handle_subscription_deleted.ts b/app/api/webhooks/stripe/handle_subscription_deleted.ts new file mode 100644 index 00000000..3a468acb --- /dev/null +++ b/app/api/webhooks/stripe/handle_subscription_deleted.ts @@ -0,0 +1,13 @@ +import { supabaseServerClient } from "supabase/serverClient"; + +export async function handleSubscriptionDeleted(subscriptionId: string) { + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "canceled", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", subscriptionId); + + // Entitlements expire naturally via expires_at — no need to delete them +} diff --git a/app/api/webhooks/stripe/handle_subscription_updated.ts b/app/api/webhooks/stripe/handle_subscription_updated.ts new file mode 100644 index 00000000..179e958c --- /dev/null +++ b/app/api/webhooks/stripe/handle_subscription_updated.ts @@ -0,0 +1,56 @@ +import { getStripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; + +export async function handleSubscriptionUpdated(subscriptionId: string) { + const sub = await getStripe().subscriptions.retrieve(subscriptionId); + const periodEnd = sub.items.data[0]?.current_period_end ?? 0; + const lookupKey = sub.items.data[0]?.price.lookup_key ?? null; + const customerId = sub.customer as string; + const status = sub.cancel_at_period_end ? "canceling" : sub.status; + const metadataIdentityId = sub.metadata.identity_id ?? null; + + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); + + // Find the identity by stripe_customer_id + const { data: existingSub } = await supabaseServerClient + .from("user_subscriptions") + .select("identity_id") + .eq("stripe_customer_id", customerId) + .single(); + + const identityId = existingSub?.identity_id ?? metadataIdentityId; + + if (!identityId) { + console.warn( + `No subscription record for customer ${customerId} and no identity_id in metadata`, + ); + return; + } + + await supabaseServerClient.from("user_subscriptions").upsert( + { + identity_id: identityId, + stripe_customer_id: customerId, + stripe_subscription_id: sub.id, + status, + plan: lookupKey, + current_period_end: new Date(periodEnd * 1000).toISOString(), + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + for (const key of Object.keys(entitlements)) { + await supabaseServerClient.from("user_entitlements").upsert( + { + identity_id: identityId, + entitlement_key: key, + granted_at: new Date().toISOString(), + expires_at: new Date(periodEnd * 1000).toISOString(), + source: `stripe:${sub.id}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); + } +} -- 2.51.2