Skip to content

Commit a8f52f2

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

File tree

6 files changed

+317
-87
lines changed

6 files changed

+317
-87
lines changed

template/app/src/payment/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class UnhandledWebhookEventError extends Error {
2+
constructor(eventType: string) {
3+
super(`Unhandled event type: ${eventType}`);
4+
this.name = 'UnhandledWebhookEventError';
5+
}
6+
}

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

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ 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';
12+
import { UnhandledWebhookEventError } from '../errors';
1113

1214
export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => {
1315
try {
@@ -25,36 +27,49 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co
2527
throw new HttpError(400, 'Invalid signature');
2628
}
2729

28-
const event = JSON.parse(rawBody);
29-
const userId = event.meta.custom_data.user_id;
30+
const payload = await parseWebhookPayload(rawBody).catch((e) => {
31+
if (e instanceof UnhandledWebhookEventError) {
32+
throw e;
33+
} else {
34+
console.error('Error parsing webhook payload', e);
35+
throw new HttpError(400, e.message);
36+
}
37+
});
38+
const userId = payload.meta.custom_data.user_id;
3039
const prismaUserDelegate = context.entities.User;
31-
switch (event.meta.event_name) {
40+
41+
switch (payload.eventName) {
3242
case 'order_created':
33-
await handleOrderCreated(event as Order, userId, prismaUserDelegate);
43+
await handleOrderCreated(payload.data, userId, prismaUserDelegate);
3444
break;
3545
case 'subscription_created':
36-
await handleSubscriptionCreated(event as Subscription, userId, prismaUserDelegate);
46+
await handleSubscriptionCreated(payload.data, userId, prismaUserDelegate);
3747
break;
3848
case 'subscription_updated':
39-
await handleSubscriptionUpdated(event as Subscription, userId, prismaUserDelegate);
49+
await handleSubscriptionUpdated(payload.data, userId, prismaUserDelegate);
4050
break;
4151
case 'subscription_cancelled':
42-
await handleSubscriptionCancelled(event as Subscription, userId, prismaUserDelegate);
52+
await handleSubscriptionCancelled(payload.data, userId, prismaUserDelegate);
4353
break;
4454
case 'subscription_expired':
45-
await handleSubscriptionExpired(event as Subscription, userId, prismaUserDelegate);
55+
await handleSubscriptionExpired(payload.data, userId, prismaUserDelegate);
4656
break;
4757
default:
48-
console.error('Unhandled event type: ', event.meta.event_name);
58+
// If you'd like to handle more events, you can add more cases above.
59+
assertUnreachable(payload);
4960
}
5061

51-
response.status(200).json({ received: true });
62+
return response.status(200).json({ received: true });
5263
} catch (err) {
64+
if (err instanceof UnhandledWebhookEventError) {
65+
return response.status(200).json({ received: true });
66+
}
67+
5368
console.error('Webhook error:', err);
5469
if (err instanceof HttpError) {
55-
response.status(err.statusCode).json({ error: err.message });
70+
return response.status(err.statusCode).json({ error: err.message });
5671
} else {
57-
response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' });
72+
return response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' });
5873
}
5974
}
6075
};
@@ -70,8 +85,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon
7085
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up
7186
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders,
7287
// 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;
88+
async function handleOrderCreated(data: OrderData, userId: string, prismaUserDelegate: PrismaClient['user']) {
89+
const { customer_id, status, first_order_item, order_number } = data.attributes;
7590
const lemonSqueezyId = customer_id.toString();
7691

7792
const planId = getPlanIdByVariantId(first_order_item.variant_id.toString());
@@ -94,8 +109,12 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
94109
console.log(`Order ${order_number} created for user ${lemonSqueezyId}`);
95110
}
96111

97-
async function handleSubscriptionCreated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
98-
const { customer_id, status, variant_id } = data.data.attributes;
112+
async function handleSubscriptionCreated(
113+
data: SubscriptionData,
114+
userId: string,
115+
prismaUserDelegate: PrismaClient['user']
116+
) {
117+
const { customer_id, status, variant_id } = data.attributes;
99118
const lemonSqueezyId = customer_id.toString();
100119

101120
const planId = getPlanIdByVariantId(variant_id.toString());
@@ -118,18 +137,21 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri
118137
console.log(`Subscription created for user ${lemonSqueezyId}`);
119138
}
120139

121-
122140
// 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;
141+
async function handleSubscriptionUpdated(
142+
data: SubscriptionData,
143+
userId: string,
144+
prismaUserDelegate: PrismaClient['user']
145+
) {
146+
const { customer_id, status, variant_id } = data.attributes;
125147
const lemonSqueezyId = customer_id.toString();
126148

127149
const planId = getPlanIdByVariantId(variant_id.toString());
128150

129151
// We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active.
130152
// 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.
153+
// becomes 'unpaid' and finally 'expired' (i.e. 'deleted').
154+
// NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard.
133155
// If you do enable these features, make sure to handle these statuses here.
134156
if (status === 'past_due' || status === 'active') {
135157
await updateUserLemonSqueezyPaymentDetails(
@@ -146,8 +168,12 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri
146168
}
147169
}
148170

149-
async function handleSubscriptionCancelled(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
150-
const { customer_id } = data.data.attributes;
171+
async function handleSubscriptionCancelled(
172+
data: SubscriptionData,
173+
userId: string,
174+
prismaUserDelegate: PrismaClient['user']
175+
) {
176+
const { customer_id } = data.attributes;
151177
const lemonSqueezyId = customer_id.toString();
152178

153179
await updateUserLemonSqueezyPaymentDetails(
@@ -162,8 +188,12 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p
162188
console.log(`Subscription cancelled for user ${lemonSqueezyId}`);
163189
}
164190

165-
async function handleSubscriptionExpired(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
166-
const { customer_id } = data.data.attributes;
191+
async function handleSubscriptionExpired(
192+
data: SubscriptionData,
193+
userId: string,
194+
prismaUserDelegate: PrismaClient['user']
195+
) {
196+
const { customer_id } = data.attributes;
167197
const lemonSqueezyId = customer_id.toString();
168198

169199
await updateUserLemonSqueezyPaymentDetails(
@@ -181,7 +211,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri
181211
async function fetchUserCustomerPortalUrl({ lemonSqueezyId }: { lemonSqueezyId: string }): Promise<string> {
182212
const { data: lemonSqueezyCustomer, error } = await getCustomer(lemonSqueezyId);
183213
if (error) {
184-
throw new Error(`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`);
214+
throw new Error(
215+
`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`
216+
);
185217
}
186218
const customerPortalUrl = lemonSqueezyCustomer.data.attributes.urls.customer_portal;
187219
if (!customerPortalUrl) {
@@ -198,4 +230,4 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
198230
throw new Error(`No plan with LemonSqueezy variant id ${variantId}`);
199231
}
200232
return planId;
201-
}
233+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as z from 'zod';
2+
import { UnhandledWebhookEventError } from '../errors';
3+
4+
export async function parseWebhookPayload(rawPayload: string) {
5+
const rawEvent = JSON.parse(rawPayload) as unknown;
6+
const event = await genericEventSchema.parseAsync(rawEvent).catch((e) => {
7+
console.error(e);
8+
throw new Error('Invalid Lemon Squeezy Webhook Event');
9+
});
10+
switch (event.meta.event_name) {
11+
case 'order_created':
12+
const orderData = await orderDataSchema.parseAsync(event.data).catch((e) => {
13+
console.error(e);
14+
throw new Error('Invalid Lemon Squeezy Order Event');
15+
});
16+
return { eventName: event.meta.event_name, meta: event.meta, data: orderData };
17+
case 'subscription_created':
18+
case 'subscription_updated':
19+
case 'subscription_cancelled':
20+
case 'subscription_expired':
21+
const subscriptionData = await subscriptionDataSchema.parseAsync(event.data).catch((e) => {
22+
console.error(e);
23+
throw new Error('Invalid Lemon Squeezy Subscription Event');
24+
});
25+
return { eventName: event.meta.event_name, meta: event.meta, data: subscriptionData };
26+
default:
27+
// If you'd like to handle more events, you can add more cases above.
28+
throw new UnhandledWebhookEventError(event.meta.event_name);
29+
}
30+
}
31+
32+
export type SubscriptionData = z.infer<typeof subscriptionDataSchema>;
33+
34+
export type OrderData = z.infer<typeof orderDataSchema>;
35+
36+
const genericEventSchema = z.object({
37+
meta: z.object({
38+
event_name: z.string(),
39+
custom_data: z.object({
40+
user_id: z.string(),
41+
}),
42+
}),
43+
data: z.unknown(),
44+
});
45+
46+
const orderDataSchema = z.object({
47+
attributes: z.object({
48+
customer_id: z.number(),
49+
status: z.string(),
50+
first_order_item: z.object({
51+
variant_id: z.string(),
52+
}),
53+
order_number: z.string(),
54+
}),
55+
});
56+
57+
const subscriptionDataSchema = z.object({
58+
attributes: z.object({
59+
customer_id: z.number(),
60+
status: z.string(),
61+
variant_id: z.string(),
62+
}),
63+
});

0 commit comments

Comments
 (0)