Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Package this #18

Open
mvl22 opened this issue Jan 30, 2025 · 1 comment
Open

Package this #18

mvl22 opened this issue Jan 30, 2025 · 1 comment

Comments

@mvl22
Copy link

mvl22 commented Jan 30, 2025

Great video.

You've got a pile of useful code there. 90%+ of it is entirely generic and useful for anyone integrating. The remaining 10% is specific to your infrastructure, e.g. the KV, which could be interfaced via a callback. Most of your implementation is internal (private) code in the class, with only a few real public entry points.

So it would be useful to package this as an npm package, and provide callbacks for writing to the database.

@TheMagoo73
Copy link

TheMagoo73 commented Feb 11, 2025

Fingers crossed @t3dotgg has no issues with this, in with the huge caveat that it's so far 100% untested (because I'm an itinerent tinkerer who's not really allowed to write code these days), here's what I think a starter for ten could look like.

I've abstracted out the kv and auth so you should be able to use what ever you have by building your own adaptors, hopefully the rest is self-explanitory, and actually works!

Anyway, massive thanks to @t3dotgg for puting the original effort in. Stripe's been a PitA for way, way to long. If folks dig this lemme know, and I'll put some work into sorting it properly. If not, if it helps one person, I'm happy with that.

import { NextRequest, NextResponse } from "next/server.js";
import { headers } from "next/headers.js";
import { waitUntil } from "@vercel/functions";
import Stripe from "stripe";

const tryCatch = async <T>(p: Promise<T>) => {
  try {
    const data = await p;
    return { data, error: null };
  } catch (error) {
    return { error, data: null };
  }
};

const allowedEvents: string[] = [
  "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",
];

type StripeWebhookHandler = Record<
  "POST",
  (req: NextRequest) => Promise<Response>
>;

type StripeCheckoutSessionHandler = Record<
  "GET",
  (req: NextRequest) => Promise<Response>
>;

type StripeCheckoutSuccessHandler = Record<
  "GET",
  (req: NextRequest) => Promise<Response>
>;

type StripeSubCache =
  | {
      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";
    };

export interface NextStripeResult {
  stripeWebhookHandler: StripeWebhookHandler;
  stripeCheckoutSessionHandler: StripeCheckoutSessionHandler;
  stripeCheckoutSuccessHandler: StripeCheckoutSuccessHandler;
}

export interface KVProvider {
  setKey(key: string, value: object | string | number): void;
  getKey(key: string): Promise<object | string | number>;
}

export type User = {
  id: string;
  email: string;
};

export interface AuthProvider {
  getUser(request: NextRequest): Promise<User>;
}

export interface NextStripeConfig {
  kvProvider: KVProvider;
  authProvider: AuthProvider;
  redirects: {
    checkoutSuccessUrl: string;
    checkoutFailureUrl: string;
    unauthenticatedUrl: string;
    checkoutCompletedUrl: string;
  };
}

export type StripeSessionOptions = {
  lineItems?: Stripe.Checkout.SessionCreateParams.LineItem[];
  metadata?: Record<string, string>;
  mode?: "payment" | "subscription";
};

export default function NextStripe(config: NextStripeConfig): NextStripeResult {
  // Initialize stripe client
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: "2022-11-15", // Use latest API version
  });

  const kv = config.kvProvider;
  const authProvider = config.authProvider;

  const syncStripeDataToKV = async (
    customerId: string,
  ): Promise<StripeSubCache> => {
    // 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" } as StripeSubCache;
      await kv.setKey(`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.setKey(`stripe:customer:${customerId}`, subData);
    return subData;
  };

  async function processEvent(event: Stripe.Event) {
    // Skip processing if the event isn't one I'm tracking (list of all events below)
    if (!allowedEvents.includes(event.type)) return;

    // All the events I track have a customerId
    const { customer: customerId } = event?.data?.object as {
      customer: string; // Sadly TypeScript does not know this
    };

    // This helps make it typesafe and also lets me know if my assumption is wrong
    if (typeof customerId !== "string") {
      throw new Error(
        `[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}`,
      );
    }

    return await syncStripeDataToKV(customerId);
  }

  return {
    stripeWebhookHandler: {
      POST: async (req: NextRequest) => {
        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 });
      },
    } as const,

    stripeCheckoutSessionHandler: {
      GET: async (req: NextRequest) => {
        const options = (await req.json()) as StripeSessionOptions;

        const user = await authProvider.getUser(req);

        // Get the stripeCustomerId from your KV store
        let stripeCustomerId = await kv.getKey(`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.setKey(`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 as string,
          success_url: config.redirects.checkoutSuccessUrl,
          ...options,
        });

        // Allow the caller to add line items etc.
        return NextResponse.json(checkout);
      },
    } as const,

    stripeCheckoutSuccessHandler: {
      GET: async (req: NextRequest) => {
        const user = await authProvider.getUser(req);
        const stripeCustomerId = await kv.getKey(`stripe:user:${user.id}`);
        if (!stripeCustomerId) {
          return NextResponse.redirect(config.redirects.checkoutFailureUrl);
        }

        await syncStripeDataToKV(stripeCustomerId as string);
        return NextResponse.redirect(config.redirects.checkoutCompletedUrl);
      },
    } as const,
  };
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants