-
Notifications
You must be signed in to change notification settings - Fork 198
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
Comments
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 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,
};
}
|
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.
The text was updated successfully, but these errors were encountered: