a tool for shared writing and social publishing

fix some edge cases

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