t3dotgg stripe-recommendations

repository·main·Indexed 27 days ago

https://github.com/t3dotgg/stripe-recommendations

An architectural pattern for implementing Stripe in SaaS applications to prevent 'split-brain' states between Stripe and local databases. It utilizes a single synchronization function and a KV store (such as Redis or Upstash) to maintain a single source of truth, featuring a recommended integration flow, customer binding strategies, and webhook handler configurations for TypeScript and JavaScript backends.

Tokens
2.7K
Snippets
5
Records
7
Agent score
42%

What's inside stripe-recommendations

  1. Ensure Customer is defined before Checkout

    main

    Always ensure a Stripe customer exists and is bound to your user ID in your KV store before initiating a checkout session. This prevents issues with ephemeral customers.

    export async function GET(req: Request) {
      const user = auth(req);
    
      // Get the stripeCustomerId from your KV store
      let stripeCustomerId = await kv.get(`stripe:user:${user.id}`);
    
      // Create a new Stripe customer if this user doesn't have one
      if (!stripeCustomerId) {
        const newCustomer = await stripe.customers.create({
          email: user.email,
          metadata: {
            userId: user.id, // DO NOT FORGET THIS
          },
        });
    
        // Store the relation between userId and stripeCustomerId in your KV
        await kv.set(`stripe:user:${user.id}`, newCustomer.id);
        stripeCustomerId = newCustomer.id;
      }
    
      // ALWAYS create a checkout with a stripeCustomerId. They should enforce this.
      const checkout = await stripe.checkout.sessions.create({
        customer: stripeCustomerId,
        success_url: "https://t3.chat/success",
        ... 
      });
    }
  2. Recommended Stripe Integration Flow

    main

    To avoid 'split-brain' states where your database and Stripe are out of sync, follow this specific flow:

    1. Frontend: Call a generate-stripe-checkout endpoint when the user clicks 'Subscribe'.
    2. Backend: Create a Stripe customer (if one doesn't exist).
    3. Backend: Store the binding between Stripe's customerId and your app's userId in your KV store.
    4. Backend: Create a Stripe checkout session with a success_url pointing to a dedicated /success route.
    5. User: Completes payment and is redirected to /success.
    6. Frontend: On the /success page load, trigger a backend function (API, Server Action, etc.) to sync data.
    7. Backend: Use the userId to retrieve the customerId from KV, then call syncStripeDataToKV(customerId).
    8. Frontend: Redirect the user to their dashboard/intended destination after sync succeeds.
    9. Backend (Webhook): On all relevant Stripe events, call syncStripeDataToKV(customerId) to keep the KV store updated.
  3. Configure Stripe Webhook Handler

    main

    The webhook handler (/api/stripe) should verify the Stripe signature and then call a processing function. If using Next.js Pages Router, you must disable the body parser so Stripe can verify the raw request body.

    // For Next.js Pages Router
    export const config = {
      api: {
        bodyParser: false,
      },
    };
    
    // Webhook implementation sketch
    export async function POST(req: Request) {
      const body = await req.text();
      const signature = (await headers()).get("Stripe-Signature");
    
      if (!signature) return NextResponse.json({}, { status: 400 });
    
      async function doEventProcessing() {
        if (typeof signature !== "string") {
          throw new Error("[STRIPE HOOK] Header isn't a string???");
        }
    
        const event = stripe.webhooks.constructEvent(
          body,
          signature,
          process.env.STRIPE_WEBHOOK_SECRET!
        );
    
        waitUntil(processEvent(event));
      }
    
      const { error } = await tryCatch(doEventProcessing());
    
      if (error) {
        console.error("[STRIPE HOOK] Error processing event", error);
      }
    
      return NextResponse.json({ received: true });
    }
  4. Implement syncStripeDataToKV

    main

    The syncStripeDataToKV(customerId: string) function is the core of this architecture. It fetches the latest subscription data from Stripe and overwrites the customer's state in your KV store. This should be called from both your /success endpoint and your Stripe webhook handler to prevent race conditions.

    // The contents of this function should probably be wrapped in a try/catch
    export async function syncStripeDataToKV(customerId: string) {
      // Fetch latest subscription data from Stripe
      const subscriptions = await stripe.subscriptions.list({
        customer: customerId,
        limit: 1,
        status: "all",
        expand: ["data.default_payment_method"],
      });
    
      if (subscriptions.data.length === 0) {
        const subData = { status: "none" };
        await kv.set(`stripe:customer:${customerId}`, subData);
        return subData;
      }
    
      // If a user can have multiple subscriptions, that's your problem
      const subscription = subscriptions.data[0];
    
      // Store complete subscription state
      const subData = {
        subscriptionId: subscription.id,
        status: subscription.status,
        priceId: subscription.items.data[0].price.id,
        currentPeriodEnd: subscription.current_period_end,
        currentPeriodStart: subscription.current_period_start,
        cancelAtPeriodEnd: subscription.cancel_at_period_end,
        paymentMethod:
          subscription.default_payment_method &&
          typeof subscription.default_payment_method !== "string"
            ? {
                brand: subscription.default_payment_method.card?.brand ?? null,
                last4: subscription.default_payment_method.card?.last4 ?? null,
              }
            : null,
      };
    
      // Store the data in your KV
      await kv.set(`stripe:customer:${customerId}`, subData);
      return subData;
    }
  5. Track these Stripe Events

    main

    To maintain a consistent subscription state, your webhook handler should only process the following Stripe.Event.Type values. All these events are expected to contain a customer ID in the event object.

    const allowedEvents: Stripe.Event.Type[] = [
      "checkout.session.completed",
      "customer.subscription.created",
      "customer.subscription.updated",
      "customer.subscription.deleted",
      "customer.subscription.paused",
      "customer.subscription.resumed",
      "customer.subscription.pending_update_applied",
      "customer.subscription.pending_update_expired",
      "customer.subscription.trial_will_end",
      "invoice.paid",
      "invoice.payment_failed",
      "invoice.payment_action_required",
      "invoice.upcoming",
      "invoice.marked_uncollectible",
      "invoice.payment_succeeded",
      "payment_intent.succeeded",
      "payment_intent.payment_failed",
      "payment_intent.canceled",
    ];
  6. STRIPE_SUB_CACHE Type Definition

    main

    This type defines the structure of the subscription data stored in your KV store after calling syncStripeDataToKV.

    export type STRIPE_SUB_CACHE =
      | {
          subscriptionId: string | null;
          status: Stripe.Subscription.Status;
          priceId: string | null;
          currentPeriodStart: number | null;
          currentPeriodEnd: number | null;
          cancelAtPeriodEnd: boolean;
          paymentMethod: {
            brand: string | null; // e.g., "visa", "mastercard"
            last4: string | null; // e.g., "4242"
          } | null;
        }
      | {
          status: "none";
        };