a tool for shared writing and social publishing

fix some edge cases

+50 -32
+1
actions/createCheckoutSession.ts
··· 50 50 ...(customerId 51 51 ? { customer: customerId } 52 52 : { customer_email: identity.email || undefined }), 53 + subscription_data: { metadata: { identity_id: identity.id } }, 53 54 success_url: successUrl.toString(), 54 55 cancel_url: cancelUrl, 55 56 });
+2 -1
app/api/checkout/success/route.ts
··· 23 23 24 24 if (identityId && sub) { 25 25 const periodEnd = sub.items.data[0]?.current_period_end ?? 0; 26 + const lookupKey = sub.items.data[0]?.price.lookup_key; 26 27 const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); 27 28 28 29 // Optimistic upsert — idempotent with webhook handler ··· 31 32 identity_id: identityId, 32 33 stripe_customer_id: customerId, 33 34 stripe_subscription_id: sub.id, 34 - plan: PRODUCT_DEFINITION.name, 35 + plan: lookupKey, 35 36 status: sub.status, 36 37 current_period_end: new Date(periodEnd * 1000).toISOString(), 37 38 updated_at: new Date().toISOString(),
+3 -1
app/api/inngest/functions/stripe_handle_checkout_completed.ts
··· 15 15 const sub = 16 16 typeof s.subscription === "object" ? s.subscription : null; 17 17 const periodEnd = sub?.items.data[0]?.current_period_end ?? 0; 18 + const lookupKey = sub?.items.data[0]?.price.lookup_key ?? null; 18 19 19 20 return { 20 21 identityId: s.client_reference_id, ··· 22 23 subId: sub?.id ?? null, 23 24 subStatus: sub?.status ?? null, 24 25 periodEnd, 26 + lookupKey, 25 27 }; 26 28 }); 27 29 ··· 37 39 identity_id: session.identityId!, 38 40 stripe_customer_id: session.customerId, 39 41 stripe_subscription_id: session.subId!, 40 - plan: PRODUCT_DEFINITION.name, 42 + plan: session.lookupKey, 41 43 status: session.subStatus, 42 44 current_period_end: new Date( 43 45 session.periodEnd * 1000,
+33 -23
app/api/inngest/functions/stripe_handle_subscription_updated.ts
··· 12 12 event.data.subscriptionId, 13 13 ); 14 14 const periodEnd = sub.items.data[0]?.current_period_end ?? 0; 15 + const lookupKey = sub.items.data[0]?.price.lookup_key ?? null; 15 16 16 17 return { 17 18 id: sub.id, 18 19 customerId: sub.customer as string, 19 20 status: sub.cancel_at_period_end ? "canceling" : sub.status, 20 21 periodEnd, 22 + lookupKey, 23 + metadataIdentityId: sub.metadata.identity_id ?? null, 21 24 }; 22 25 }); 23 26 ··· 31 34 .eq("stripe_customer_id", subData.customerId) 32 35 .single(); 33 36 34 - if (!existingSub) { 37 + const identityId = existingSub?.identity_id ?? subData.metadataIdentityId; 38 + 39 + if (!identityId) { 35 40 console.warn( 36 - `No subscription record for customer ${subData.customerId}`, 41 + `No subscription record for customer ${subData.customerId} and no identity_id in metadata`, 37 42 ); 38 43 return; 39 44 } 40 45 41 - // Update subscription record 46 + // Upsert subscription record 42 47 await supabaseServerClient 43 48 .from("user_subscriptions") 44 - .update({ 45 - status: subData.status, 46 - plan: PRODUCT_DEFINITION.name, 47 - current_period_end: new Date( 48 - subData.periodEnd * 1000, 49 - ).toISOString(), 50 - updated_at: new Date().toISOString(), 51 - }) 52 - .eq("identity_id", existingSub.identity_id); 53 - 54 - // Update entitlement expiry dates for all entitlements from this subscription 55 - for (const key of Object.keys(entitlements)) { 56 - await supabaseServerClient 57 - .from("user_entitlements") 58 - .update({ 59 - expires_at: new Date( 49 + .upsert( 50 + { 51 + identity_id: identityId, 52 + stripe_customer_id: subData.customerId, 53 + stripe_subscription_id: subData.id, 54 + status: subData.status, 55 + plan: subData.lookupKey, 56 + current_period_end: new Date( 60 57 subData.periodEnd * 1000, 61 58 ).toISOString(), 62 - }) 63 - .eq("identity_id", existingSub.identity_id) 64 - .eq("entitlement_key", key) 65 - .eq("source", `stripe:${subData.id}`); 59 + updated_at: new Date().toISOString(), 60 + }, 61 + { onConflict: "identity_id" }, 62 + ); 63 + 64 + // Upsert entitlements for all entitlements from this subscription 65 + for (const key of Object.keys(entitlements)) { 66 + await supabaseServerClient.from("user_entitlements").upsert( 67 + { 68 + identity_id: identityId, 69 + entitlement_key: key, 70 + granted_at: new Date().toISOString(), 71 + expires_at: new Date(subData.periodEnd * 1000).toISOString(), 72 + source: `stripe:${subData.id}`, 73 + }, 74 + { onConflict: "identity_id,entitlement_key" }, 75 + ); 66 76 } 67 77 }); 68 78
+6 -4
app/api/webhooks/stripe/route.ts
··· 46 46 47 47 case "invoice.payment_failed": { 48 48 const invoice = event.data.object; 49 - const subDetails = invoice.parent?.subscription_details; 49 + const sub = invoice.parent?.subscription_details?.subscription; 50 50 const subId = 51 - typeof subDetails?.subscription === "string" 52 - ? subDetails.subscription 53 - : subDetails?.subscription?.id || ""; 51 + typeof sub === "string" 52 + ? sub 53 + : typeof sub === "object" && sub 54 + ? sub.id 55 + : ""; 54 56 await inngest.send({ 55 57 name: "stripe/invoice.payment.failed", 56 58 data: {
+5 -3
specs/2026-02-03-pro-tier.md
··· 17 17 - `stripe_customer_id` (text, unique) 18 18 - `stripe_subscription_id` (text, unique, nullable) 19 19 - `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd` 20 - - `status` (text) — mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid` 20 + - `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) 21 21 - `current_period_end` (timestamp) 22 22 - `created_at`, `updated_at` 23 23 ··· 34 34 35 35 ### SKU → Entitlements Mapping 36 36 37 - Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata: 37 + 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. 38 + 39 + Example from `stripe/products.ts`: 38 40 39 41 ```json 40 42 { ··· 42 44 } 43 45 ``` 44 46 45 - This keeps Stripe as the source of truth for what each SKU grants. 47 + The sync script ensures Stripe metadata stays in sync with code definitions. 46 48 47 49 ### Stripe Product Sync 48 50