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

Adds Zod validation for webhook payloads #377

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions template/app/src/payment/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class UnhandledWebhookEventError extends Error {
constructor(eventType: string) {
super(`Unhandled event type: ${eventType}`);
this.name = 'UnhandledWebhookEventError';
}
}
88 changes: 51 additions & 37 deletions template/app/src/payment/lemonSqueezy/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,75 @@ import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails';
import { type Order, type Subscription, getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import crypto from 'crypto';
import { requireNodeEnvVar } from '../../server/utils';
import { parseWebhookPayload, type OrderData, type SubscriptionData } from './webhookPayload';
import { assertUnreachable } from '../../shared/utils';
import { UnhandledWebhookEventError } from '../errors';

export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => {
try {
const rawBody = request.body.toString('utf8');
const signature = request.get('X-Signature');
if (!signature) {
throw new HttpError(400, 'Lemon Squeezy Webhook Signature Not Provided');
}

const secret = requireNodeEnvVar('LEMONSQUEEZY_WEBHOOK_SECRET');
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');

if (!crypto.timingSafeEqual(Buffer.from(signature, 'utf8'), digest)) {
throw new HttpError(400, 'Invalid signature');
}
const rawRequestBody = parseRequestBody(request);

const event = JSON.parse(rawBody);
const userId = event.meta.custom_data.user_id;
const { eventName, meta, data } = await parseWebhookPayload(rawRequestBody);
const userId = meta.custom_data.user_id;
const prismaUserDelegate = context.entities.User;
switch (event.meta.event_name) {

switch (eventName) {
case 'order_created':
await handleOrderCreated(event as Order, userId, prismaUserDelegate);
await handleOrderCreated(data, userId, prismaUserDelegate);
break;
case 'subscription_created':
await handleSubscriptionCreated(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionCreated(data, userId, prismaUserDelegate);
break;
case 'subscription_updated':
await handleSubscriptionUpdated(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionUpdated(data, userId, prismaUserDelegate);
break;
case 'subscription_cancelled':
await handleSubscriptionCancelled(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionCancelled(data, userId, prismaUserDelegate);
break;
case 'subscription_expired':
await handleSubscriptionExpired(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionExpired(data, userId, prismaUserDelegate);
break;
default:
console.error('Unhandled event type: ', event.meta.event_name);
// If you'd like to handle more events, you can add more cases above.
assertUnreachable(eventName);
}

response.status(200).json({ received: true });
return response.status(200).json({ received: true });
} catch (err) {
if (err instanceof UnhandledWebhookEventError) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if we wanted to return something other than 200 if we receive a request for a webhook event we don't handle.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we could, but we shouldn't be receiving any webhooks we don't explicitly request from the Stripe dashboard settings. Maybe the console.error is enough?

Copy link
Collaborator Author

@infomiho infomiho Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't be receiving any webhooks we don't explicitly request from the Stripe

Yes, I understand but I kept seeing errors for some of the hooks in the e2e tests so I implemented this bit - this way we are just "ignoring" the extra webhook calls.

return response.status(200).json({ received: true });
}

console.error('Webhook error:', err);
if (err instanceof HttpError) {
response.status(err.statusCode).json({ error: err.message });
return response.status(err.statusCode).json({ error: err.message });
} else {
response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' });
return response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did this work without return before?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Express sends a response as soon as you call json, but I've added return here so the other code doesn't execute needlessly. It just makes the code more precise.

}
}
};

function parseRequestBody(request: express.Request): string {
const requestBody = request.body.toString('utf8');
const signature = request.get('X-Signature');
if (!signature) {
throw new HttpError(400, 'Lemon Squeezy webhook signature not provided');
}

const secret = requireNodeEnvVar('LEMONSQUEEZY_WEBHOOK_SECRET');
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(requestBody).digest('hex'), 'utf8');

if (!crypto.timingSafeEqual(Buffer.from(signature, 'utf8'), digest)) {
throw new HttpError(400, 'Invalid signature');
}

return requestBody;
}

export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => {
// We need to delete the default 'express.json' middleware and replace it with 'express.raw' middleware
// because webhook data in the body of the request as raw JSON, not as JSON in the body of the request.
Expand All @@ -69,8 +84,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders,
// as well as to save the customer portal URL and customer id for the user.
async function handleOrderCreated(data: Order, userId: string, prismaUserDelegate: PrismaClient['user']) {
const { customer_id, status, first_order_item, order_number } = data.data.attributes;
async function handleOrderCreated(data: OrderData, userId: string, prismaUserDelegate: PrismaClient['user']) {
const { customer_id, status, first_order_item, order_number } = data.attributes;
const lemonSqueezyId = customer_id.toString();

const planId = getPlanIdByVariantId(first_order_item.variant_id.toString());
Expand All @@ -94,11 +109,11 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
}

async function handleSubscriptionCreated(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id, status, variant_id } = data.data.attributes;
const { customer_id, status, variant_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();

const planId = getPlanIdByVariantId(variant_id.toString());
Expand All @@ -123,11 +138,11 @@ async function handleSubscriptionCreated(

// NOTE: LemonSqueezy's 'subscription_updated' event is sent as a catch-all and fires even after 'subscription_created' & 'order_created'.
async function handleSubscriptionUpdated(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id, status, variant_id } = data.data.attributes;
const { customer_id, status, variant_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();

const planId = getPlanIdByVariantId(variant_id.toString());
Expand All @@ -153,11 +168,11 @@ async function handleSubscriptionUpdated(
}

async function handleSubscriptionCancelled(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id } = data.data.attributes;
const { customer_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();

await updateUserLemonSqueezyPaymentDetails(
Expand All @@ -174,11 +189,11 @@ async function handleSubscriptionCancelled(
}

async function handleSubscriptionExpired(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id } = data.data.attributes;
const { customer_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();

await updateUserLemonSqueezyPaymentDetails(
Expand Down Expand Up @@ -217,4 +232,3 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
}
return planId;
}

68 changes: 68 additions & 0 deletions template/app/src/payment/lemonSqueezy/webhookPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as z from 'zod';
import { UnhandledWebhookEventError } from '../errors';
import { HttpError } from 'wasp/server';

export async function parseWebhookPayload(rawPayload: string) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's missing a return type and is a module boundary.

Btw, it's a complicated type. Should we maybe name it?

Copy link
Collaborator Author

@infomiho infomiho Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-02-28 at 13 33 27

Given that this is a template, I wouldn't type the return type. It feels complex and users will need to update multiple places just to satisfy the Typescript compiler.

try {
const rawEvent: unknown = JSON.parse(rawPayload);
const event = await genericEventSchema.parseAsync(rawEvent);
switch (event.meta.event_name) {
case 'order_created':
const orderData = await orderDataSchema.parseAsync(event.data);
return { eventName: event.meta.event_name, meta: event.meta, data: orderData };
case 'subscription_created':
case 'subscription_updated':
case 'subscription_cancelled':
case 'subscription_expired':
const subscriptionData = await subscriptionDataSchema.parseAsync(event.data);
return { eventName: event.meta.event_name, meta: event.meta, data: subscriptionData };
default:
// If you'd like to handle more events, you can add more cases above.
throw new UnhandledWebhookEventError(event.meta.event_name);
}
} catch (e: unknown) {
if (e instanceof UnhandledWebhookEventError) {
throw e;
} else {
console.error(e);
throw new HttpError(400, 'Error parsing Lemon Squeezy webhook payload');
}
}
}

export type SubscriptionData = z.infer<typeof subscriptionDataSchema>;

export type OrderData = z.infer<typeof orderDataSchema>;

const genericEventSchema = z.object({
meta: z.object({
event_name: z.string(),
custom_data: z.object({
user_id: z.string(),
}),
}),
data: z.unknown(),
});

// This is a subtype of Order type from "@lemonsqueezy/lemonsqueezy.js"
// specifically Order['data']
const orderDataSchema = z.object({
attributes: z.object({
customer_id: z.number(),
status: z.string(),
first_order_item: z.object({
variant_id: z.number(),
}),
order_number: z.number(),
}),
});

// This is a subtype of Subscription type from "@lemonsqueezy/lemonsqueezy.js"
// specifically Subscription['data']
const subscriptionDataSchema = z.object({
attributes: z.object({
customer_id: z.number(),
status: z.string(),
variant_id: z.number(),
}),
});
Loading