Skip to content

Commit 06458e3

Browse files
committed
Adds Zod validation for webhook payloads
Signed-off-by: Mihovil Ilakovac <[email protected]>
1 parent fe73757 commit 06458e3

File tree

5 files changed

+286
-76
lines changed

5 files changed

+286
-76
lines changed

template/app/src/payment/lemonSqueezy/webhook.ts

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { type PrismaClient } from '@prisma/client';
44
import express from 'express';
55
import { paymentPlans, PaymentPlanId } from '../plans';
66
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails';
7-
import { type Order, type Subscription, getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
7+
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
88
import crypto from 'crypto';
99
import { requireNodeEnvVar } from '../../server/utils';
10-
10+
import { parseWebhookPayload, type OrderData, type SubscriptionData } from './webhookPayload';
11+
import { assertUnreachable } from '../../shared/utils';
1112

1213
export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => {
1314
try {
@@ -25,27 +26,30 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co
2526
throw new HttpError(400, 'Invalid signature');
2627
}
2728

28-
const event = JSON.parse(rawBody);
29-
const userId = event.meta.custom_data.user_id;
29+
const payload = await parseWebhookPayload(rawBody).catch((e) => {
30+
throw new HttpError(500, e.message);
31+
});
32+
const userId = payload.meta.custom_data.user_id;
3033
const prismaUserDelegate = context.entities.User;
31-
switch (event.meta.event_name) {
34+
35+
switch (payload.eventName) {
3236
case 'order_created':
33-
await handleOrderCreated(event as Order, userId, prismaUserDelegate);
37+
await handleOrderCreated(payload.data, userId, prismaUserDelegate);
3438
break;
3539
case 'subscription_created':
36-
await handleSubscriptionCreated(event as Subscription, userId, prismaUserDelegate);
40+
await handleSubscriptionCreated(payload.data, userId, prismaUserDelegate);
3741
break;
3842
case 'subscription_updated':
39-
await handleSubscriptionUpdated(event as Subscription, userId, prismaUserDelegate);
43+
await handleSubscriptionUpdated(payload.data, userId, prismaUserDelegate);
4044
break;
4145
case 'subscription_cancelled':
42-
await handleSubscriptionCancelled(event as Subscription, userId, prismaUserDelegate);
46+
await handleSubscriptionCancelled(payload.data, userId, prismaUserDelegate);
4347
break;
4448
case 'subscription_expired':
45-
await handleSubscriptionExpired(event as Subscription, userId, prismaUserDelegate);
49+
await handleSubscriptionExpired(payload.data, userId, prismaUserDelegate);
4650
break;
4751
default:
48-
console.error('Unhandled event type: ', event.meta.event_name);
52+
assertUnreachable(payload);
4953
}
5054

5155
response.status(200).json({ received: true });
@@ -70,8 +74,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon
7074
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up
7175
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders,
7276
// as well as to save the customer portal URL and customer id for the user.
73-
async function handleOrderCreated(data: Order, userId: string, prismaUserDelegate: PrismaClient['user']) {
74-
const { customer_id, status, first_order_item, order_number } = data.data.attributes;
77+
async function handleOrderCreated(data: OrderData, userId: string, prismaUserDelegate: PrismaClient['user']) {
78+
const { customer_id, status, first_order_item, order_number } = data.attributes;
7579
const lemonSqueezyId = customer_id.toString();
7680

7781
const planId = getPlanIdByVariantId(first_order_item.variant_id.toString());
@@ -94,8 +98,12 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
9498
console.log(`Order ${order_number} created for user ${lemonSqueezyId}`);
9599
}
96100

97-
async function handleSubscriptionCreated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
98-
const { customer_id, status, variant_id } = data.data.attributes;
101+
async function handleSubscriptionCreated(
102+
data: SubscriptionData,
103+
userId: string,
104+
prismaUserDelegate: PrismaClient['user']
105+
) {
106+
const { customer_id, status, variant_id } = data.attributes;
99107
const lemonSqueezyId = customer_id.toString();
100108

101109
const planId = getPlanIdByVariantId(variant_id.toString());
@@ -118,18 +126,21 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri
118126
console.log(`Subscription created for user ${lemonSqueezyId}`);
119127
}
120128

121-
122129
// NOTE: LemonSqueezy's 'subscription_updated' event is sent as a catch-all and fires even after 'subscription_created' & 'order_created'.
123-
async function handleSubscriptionUpdated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
124-
const { customer_id, status, variant_id } = data.data.attributes;
130+
async function handleSubscriptionUpdated(
131+
data: SubscriptionData,
132+
userId: string,
133+
prismaUserDelegate: PrismaClient['user']
134+
) {
135+
const { customer_id, status, variant_id } = data.attributes;
125136
const lemonSqueezyId = customer_id.toString();
126137

127138
const planId = getPlanIdByVariantId(variant_id.toString());
128139

129140
// We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active.
130141
// Note that a status changes to 'past_due' on a failed payment retry, then after 4 unsuccesful payment retries status
131-
// becomes 'unpaid' and finally 'expired' (i.e. 'deleted').
132-
// NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard.
142+
// becomes 'unpaid' and finally 'expired' (i.e. 'deleted').
143+
// NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard.
133144
// If you do enable these features, make sure to handle these statuses here.
134145
if (status === 'past_due' || status === 'active') {
135146
await updateUserLemonSqueezyPaymentDetails(
@@ -146,8 +157,12 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri
146157
}
147158
}
148159

149-
async function handleSubscriptionCancelled(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
150-
const { customer_id } = data.data.attributes;
160+
async function handleSubscriptionCancelled(
161+
data: SubscriptionData,
162+
userId: string,
163+
prismaUserDelegate: PrismaClient['user']
164+
) {
165+
const { customer_id } = data.attributes;
151166
const lemonSqueezyId = customer_id.toString();
152167

153168
await updateUserLemonSqueezyPaymentDetails(
@@ -162,8 +177,12 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p
162177
console.log(`Subscription cancelled for user ${lemonSqueezyId}`);
163178
}
164179

165-
async function handleSubscriptionExpired(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
166-
const { customer_id } = data.data.attributes;
180+
async function handleSubscriptionExpired(
181+
data: SubscriptionData,
182+
userId: string,
183+
prismaUserDelegate: PrismaClient['user']
184+
) {
185+
const { customer_id } = data.attributes;
167186
const lemonSqueezyId = customer_id.toString();
168187

169188
await updateUserLemonSqueezyPaymentDetails(
@@ -181,7 +200,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri
181200
async function fetchUserCustomerPortalUrl({ lemonSqueezyId }: { lemonSqueezyId: string }): Promise<string> {
182201
const { data: lemonSqueezyCustomer, error } = await getCustomer(lemonSqueezyId);
183202
if (error) {
184-
throw new Error(`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`);
203+
throw new Error(
204+
`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`
205+
);
185206
}
186207
const customerPortalUrl = lemonSqueezyCustomer.data.attributes.urls.customer_portal;
187208
if (!customerPortalUrl) {
@@ -198,4 +219,4 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
198219
throw new Error(`No plan with LemonSqueezy variant id ${variantId}`);
199220
}
200221
return planId;
201-
}
222+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as z from 'zod';
2+
import { assertUnreachable } from '../../shared/utils';
3+
4+
export async function parseWebhookPayload(rawPayload: string) {
5+
const rawEvent = JSON.parse(rawPayload) as unknown;
6+
const event = await genericEventSchema.parseAsync(rawEvent).catch(() => {
7+
throw new Error('Invalid Lemon Squeezy Webhook Event');
8+
});
9+
switch (event.meta.event_name) {
10+
case 'order_created':
11+
const orderData = await orderDataSchema.parseAsync(event.data).catch((e) => {
12+
console.error(e);
13+
throw new Error('Invalid Lemon Squeezy Order Event');
14+
});
15+
return { eventName: event.meta.event_name, meta: event.meta, data: orderData };
16+
case 'subscription_created':
17+
case 'subscription_updated':
18+
case 'subscription_cancelled':
19+
case 'subscription_expired':
20+
const subscriptionData = await subscriptionDataSchema.parseAsync(event.data).catch((e) => {
21+
console.error(e);
22+
throw new Error('Invalid Lemon Squeezy Subscription Event');
23+
});
24+
return { eventName: event.meta.event_name, meta: event.meta, data: subscriptionData };
25+
default:
26+
assertUnreachable(event.meta.event_name, 'Invalid Lemon Squeezy Webhook Event');
27+
}
28+
}
29+
30+
export type SubscriptionData = z.infer<typeof subscriptionDataSchema>;
31+
32+
export type OrderData = z.infer<typeof orderDataSchema>;
33+
34+
const genericEventSchema = z.object({
35+
meta: z.object({
36+
event_name: z
37+
.literal('order_created')
38+
.or(z.literal('subscription_created'))
39+
.or(z.literal('subscription_updated'))
40+
.or(z.literal('subscription_cancelled'))
41+
.or(z.literal('subscription_expired')),
42+
custom_data: z.object({
43+
user_id: z.string(),
44+
}),
45+
}),
46+
data: z.unknown(),
47+
});
48+
49+
const orderDataSchema = z.object({
50+
attributes: z.object({
51+
customer_id: z.number(),
52+
status: z.string(),
53+
first_order_item: z.object({
54+
variant_id: z.string(),
55+
}),
56+
order_number: z.string(),
57+
}),
58+
});
59+
60+
const subscriptionDataSchema = z.object({
61+
attributes: z.object({
62+
customer_id: z.number(),
63+
status: z.string(),
64+
variant_id: z.string(),
65+
}),
66+
});

0 commit comments

Comments
 (0)