diff --git a/actions/createCheckoutSession.ts b/actions/createCheckoutSession.ts index 90a1b3b0..8a355e7a 100644 --- a/actions/createCheckoutSession.ts +++ b/actions/createCheckoutSession.ts @@ -50,6 +50,7 @@ export async function createCheckoutSession( ...(customerId ? { customer: customerId } : { customer_email: identity.email || undefined }), + subscription_data: { metadata: { identity_id: identity.id } }, success_url: successUrl.toString(), cancel_url: cancelUrl, }); diff --git a/app/api/checkout/success/route.ts b/app/api/checkout/success/route.ts index be86f7c0..a2c04ef6 100644 --- a/app/api/checkout/success/route.ts +++ b/app/api/checkout/success/route.ts @@ -23,6 +23,7 @@ export async function GET(req: NextRequest) { if (identityId && sub) { const periodEnd = sub.items.data[0]?.current_period_end ?? 0; + const lookupKey = sub.items.data[0]?.price.lookup_key; const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); // Optimistic upsert — idempotent with webhook handler @@ -31,7 +32,7 @@ export async function GET(req: NextRequest) { identity_id: identityId, stripe_customer_id: customerId, stripe_subscription_id: sub.id, - plan: PRODUCT_DEFINITION.name, + plan: lookupKey, status: sub.status, current_period_end: new Date(periodEnd * 1000).toISOString(), updated_at: new Date().toISOString(), diff --git a/app/api/inngest/functions/stripe_handle_checkout_completed.ts b/app/api/inngest/functions/stripe_handle_checkout_completed.ts index f27ff6da..46804dd9 100644 --- a/app/api/inngest/functions/stripe_handle_checkout_completed.ts +++ b/app/api/inngest/functions/stripe_handle_checkout_completed.ts @@ -15,6 +15,7 @@ export const stripe_handle_checkout_completed = inngest.createFunction( 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; return { identityId: s.client_reference_id, @@ -22,6 +23,7 @@ export const stripe_handle_checkout_completed = inngest.createFunction( subId: sub?.id ?? null, subStatus: sub?.status ?? null, periodEnd, + lookupKey, }; }); @@ -37,7 +39,7 @@ export const stripe_handle_checkout_completed = inngest.createFunction( identity_id: session.identityId!, stripe_customer_id: session.customerId, stripe_subscription_id: session.subId!, - plan: PRODUCT_DEFINITION.name, + plan: session.lookupKey, status: session.subStatus, current_period_end: new Date( session.periodEnd * 1000, diff --git a/app/api/inngest/functions/stripe_handle_subscription_updated.ts b/app/api/inngest/functions/stripe_handle_subscription_updated.ts index bcd98bad..74e9b346 100644 --- a/app/api/inngest/functions/stripe_handle_subscription_updated.ts +++ b/app/api/inngest/functions/stripe_handle_subscription_updated.ts @@ -12,12 +12,15 @@ export const stripe_handle_subscription_updated = inngest.createFunction( event.data.subscriptionId, ); const periodEnd = sub.items.data[0]?.current_period_end ?? 0; + const lookupKey = sub.items.data[0]?.price.lookup_key ?? null; return { id: sub.id, customerId: sub.customer as string, status: sub.cancel_at_period_end ? "canceling" : sub.status, periodEnd, + lookupKey, + metadataIdentityId: sub.metadata.identity_id ?? null, }; }); @@ -31,38 +34,45 @@ export const stripe_handle_subscription_updated = inngest.createFunction( .eq("stripe_customer_id", subData.customerId) .single(); - if (!existingSub) { + const identityId = existingSub?.identity_id ?? subData.metadataIdentityId; + + if (!identityId) { console.warn( - `No subscription record for customer ${subData.customerId}`, + `No subscription record for customer ${subData.customerId} and no identity_id in metadata`, ); return; } - // Update subscription record + // Upsert subscription record await supabaseServerClient .from("user_subscriptions") - .update({ - status: subData.status, - plan: PRODUCT_DEFINITION.name, - current_period_end: new Date( - subData.periodEnd * 1000, - ).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("identity_id", existingSub.identity_id); - - // Update entitlement expiry dates for all entitlements from this subscription - for (const key of Object.keys(entitlements)) { - await supabaseServerClient - .from("user_entitlements") - .update({ - expires_at: new Date( + .upsert( + { + identity_id: identityId, + stripe_customer_id: subData.customerId, + stripe_subscription_id: subData.id, + status: subData.status, + plan: subData.lookupKey, + current_period_end: new Date( subData.periodEnd * 1000, ).toISOString(), - }) - .eq("identity_id", existingSub.identity_id) - .eq("entitlement_key", key) - .eq("source", `stripe:${subData.id}`); + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + // Upsert entitlements for all entitlements from this subscription + 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(subData.periodEnd * 1000).toISOString(), + source: `stripe:${subData.id}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); } }); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 985317a7..6ffc1ecf 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -46,11 +46,13 @@ export async function POST(req: NextRequest) { case "invoice.payment_failed": { const invoice = event.data.object; - const subDetails = invoice.parent?.subscription_details; + const sub = invoice.parent?.subscription_details?.subscription; const subId = - typeof subDetails?.subscription === "string" - ? subDetails.subscription - : subDetails?.subscription?.id || ""; + typeof sub === "string" + ? sub + : typeof sub === "object" && sub + ? sub.id + : ""; await inngest.send({ name: "stripe/invoice.payment.failed", data: { diff --git a/specs/2026-02-03-pro-tier.md b/specs/2026-02-03-pro-tier.md index acea7104..b91596de 100644 --- a/specs/2026-02-03-pro-tier.md +++ b/specs/2026-02-03-pro-tier.md @@ -17,7 +17,7 @@ Two new tables separate Stripe subscription state from feature access: - `stripe_customer_id` (text, unique) - `stripe_subscription_id` (text, unique, nullable) - `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd` -- `status` (text) — mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid` +- `status` (text) — mirrors Stripe (`trialing`, `active`, `past_due`, `canceled`, `unpaid`) plus custom `canceling` (set when `cancel_at_period_end` is true, indicates subscription is active but will not renew) - `current_period_end` (timestamp) - `created_at`, `updated_at` @@ -34,7 +34,9 @@ The unique constraint on `(identity_id, entitlement_key)` means writes are upser ### SKU → Entitlements Mapping -Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata: +Entitlements for each product are defined in code (`stripe/products.ts`) alongside the product definitions. The sync script pushes this metadata to Stripe for consistency, but the codebase is the source of truth. + +Example from `stripe/products.ts`: ```json { @@ -42,7 +44,7 @@ Entitlements for each Stripe Product are stored in Stripe's product metadata, no } ``` -This keeps Stripe as the source of truth for what each SKU grants. +The sync script ensures Stripe metadata stays in sync with code definitions. ### Stripe Product Sync