a tool for shared writing and social publishing

move stripe logic out of inngest

+20 -209
-70
app/api/inngest/functions/stripe_handle_checkout_completed.ts
··· 1 - import { inngest } from "../client"; 2 - import { getStripe } from "stripe/client"; 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 - import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; 5 - 6 - export const stripe_handle_checkout_completed = inngest.createFunction( 7 - { id: "stripe-handle-checkout-completed" }, 8 - { event: "stripe/checkout.session.completed" }, 9 - async ({ event, step }) => { 10 - const session = await step.run("fetch-checkout-session", async () => { 11 - const s = await getStripe().checkout.sessions.retrieve( 12 - event.data.sessionId, 13 - { expand: ["subscription"] }, 14 - ); 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, 22 - customerId: s.customer as string, 23 - subId: sub?.id ?? null, 24 - subStatus: sub?.status ?? null, 25 - periodEnd, 26 - lookupKey, 27 - }; 28 - }); 29 - 30 - if (!session.identityId || !session.subId) { 31 - throw new Error("Missing client_reference_id or subscription"); 32 - } 33 - 34 - await step.run("upsert-subscription-and-entitlements", async () => { 35 - const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); 36 - 37 - await supabaseServerClient.from("user_subscriptions").upsert( 38 - { 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, 46 - ).toISOString(), 47 - updated_at: new Date().toISOString(), 48 - }, 49 - { onConflict: "identity_id" }, 50 - ); 51 - 52 - for (const key of Object.keys(entitlements)) { 53 - await supabaseServerClient.from("user_entitlements").upsert( 54 - { 55 - identity_id: session.identityId!, 56 - entitlement_key: key, 57 - granted_at: new Date().toISOString(), 58 - expires_at: new Date( 59 - session.periodEnd * 1000, 60 - ).toISOString(), 61 - source: `stripe:${session.subId}`, 62 - }, 63 - { onConflict: "identity_id,entitlement_key" }, 64 - ); 65 - } 66 - }); 67 - 68 - return { success: true }; 69 - }, 70 - );
-23
app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts
··· 1 - import { inngest } from "../client"; 2 - import { supabaseServerClient } from "supabase/serverClient"; 3 - 4 - export const stripe_handle_invoice_payment_failed = inngest.createFunction( 5 - { id: "stripe-handle-invoice-payment-failed" }, 6 - { event: "stripe/invoice.payment.failed" }, 7 - async ({ event, step }) => { 8 - await step.run("mark-subscription-past-due", async () => { 9 - if (event.data.subscriptionId) { 10 - await supabaseServerClient 11 - .from("user_subscriptions") 12 - .update({ 13 - status: "past_due", 14 - updated_at: new Date().toISOString(), 15 - }) 16 - .eq("stripe_subscription_id", event.data.subscriptionId); 17 - } 18 - }); 19 - 20 - // Entitlements remain valid until expires_at 21 - return { success: true }; 22 - }, 23 - );
-21
app/api/inngest/functions/stripe_handle_subscription_deleted.ts
··· 1 - import { inngest } from "../client"; 2 - import { supabaseServerClient } from "supabase/serverClient"; 3 - 4 - export const stripe_handle_subscription_deleted = inngest.createFunction( 5 - { id: "stripe-handle-subscription-deleted" }, 6 - { event: "stripe/customer.subscription.deleted" }, 7 - async ({ event, step }) => { 8 - await step.run("mark-subscription-canceled", async () => { 9 - await supabaseServerClient 10 - .from("user_subscriptions") 11 - .update({ 12 - status: "canceled", 13 - updated_at: new Date().toISOString(), 14 - }) 15 - .eq("stripe_subscription_id", event.data.subscriptionId); 16 - }); 17 - 18 - // Entitlements expire naturally via expires_at — no need to delete them 19 - return { success: true }; 20 - }, 21 - );
-81
app/api/inngest/functions/stripe_handle_subscription_updated.ts
··· 1 - import { inngest } from "../client"; 2 - import { getStripe } from "stripe/client"; 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 - import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; 5 - 6 - export const stripe_handle_subscription_updated = inngest.createFunction( 7 - { id: "stripe-handle-subscription-updated" }, 8 - { event: "stripe/customer.subscription.updated" }, 9 - async ({ event, step }) => { 10 - const subData = await step.run("fetch-subscription", async () => { 11 - const sub = await getStripe().subscriptions.retrieve( 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 - 27 - await step.run("update-subscription-and-entitlements", async () => { 28 - const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); 29 - 30 - // Find the identity by stripe_customer_id 31 - const { data: existingSub } = await supabaseServerClient 32 - .from("user_subscriptions") 33 - .select("identity_id") 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 - 79 - return { success: true }; 80 - }, 81 - );
-8
app/api/inngest/route.tsx
··· 13 13 check_oauth_session, 14 14 } from "./functions/cleanup_expired_oauth_sessions"; 15 15 import { write_records_to_pds } from "./functions/write_records_to_pds"; 16 - import { stripe_handle_checkout_completed } from "./functions/stripe_handle_checkout_completed"; 17 - import { stripe_handle_subscription_updated } from "./functions/stripe_handle_subscription_updated"; 18 - import { stripe_handle_subscription_deleted } from "./functions/stripe_handle_subscription_deleted"; 19 - import { stripe_handle_invoice_payment_failed } from "./functions/stripe_handle_invoice_payment_failed"; 20 16 import { sync_document_metadata } from "./functions/sync_document_metadata"; 21 17 22 18 export const { GET, POST, PUT } = serve({ ··· 33 29 cleanup_expired_oauth_sessions, 34 30 check_oauth_session, 35 31 write_records_to_pds, 36 - stripe_handle_checkout_completed, 37 - stripe_handle_subscription_updated, 38 - stripe_handle_subscription_deleted, 39 - stripe_handle_invoice_payment_failed, 40 32 sync_document_metadata, 41 33 ], 42 34 });
+20 -6
app/api/webhooks/stripe/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 2 import { getStripe } from "stripe/client"; 3 3 import { inngest } from "app/api/inngest/client"; 4 + import { handleCheckoutCompleted } from "./handle_checkout_completed"; 5 + import { handleSubscriptionUpdated } from "./handle_subscription_updated"; 6 + import { handleSubscriptionDeleted } from "./handle_subscription_deleted"; 7 + import { handleInvoicePaymentFailed } from "./handle_invoice_payment_failed"; 4 8 5 9 export async function POST(req: NextRequest) { 6 10 const body = await req.text(); ··· 22 26 } 23 27 24 28 switch (event.type) { 25 - case "checkout.session.completed": 29 + case "checkout.session.completed": { 30 + const sessionId = event.data.object.id; 26 31 await inngest.send({ 27 32 name: "stripe/checkout.session.completed", 28 - data: { sessionId: event.data.object.id }, 33 + data: { sessionId }, 29 34 }); 35 + await handleCheckoutCompleted(sessionId); 30 36 break; 37 + } 31 38 32 39 case "customer.subscription.created": 33 - case "customer.subscription.updated": 40 + case "customer.subscription.updated": { 41 + const subscriptionId = event.data.object.id; 34 42 await inngest.send({ 35 43 name: "stripe/customer.subscription.updated", 36 - data: { subscriptionId: event.data.object.id }, 44 + data: { subscriptionId }, 37 45 }); 46 + await handleSubscriptionUpdated(subscriptionId); 38 47 break; 48 + } 39 49 40 - case "customer.subscription.deleted": 50 + case "customer.subscription.deleted": { 51 + const subscriptionId = event.data.object.id; 41 52 await inngest.send({ 42 53 name: "stripe/customer.subscription.deleted", 43 - data: { subscriptionId: event.data.object.id }, 54 + data: { subscriptionId }, 44 55 }); 56 + await handleSubscriptionDeleted(subscriptionId); 45 57 break; 58 + } 46 59 47 60 case "invoice.payment_failed": { 48 61 const invoice = event.data.object; ··· 61 74 customerId: invoice.customer as string, 62 75 }, 63 76 }); 77 + await handleInvoicePaymentFailed(subId); 64 78 break; 65 79 } 66 80 }