@@ -4,10 +4,12 @@ import { type PrismaClient } from '@prisma/client';
4
4
import express from 'express' ;
5
5
import { paymentPlans , PaymentPlanId } from '../plans' ;
6
6
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails' ;
7
- import { type Order , type Subscription , getCustomer } from '@lemonsqueezy/lemonsqueezy.js' ;
7
+ import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js' ;
8
8
import crypto from 'crypto' ;
9
9
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' ;
11
13
12
14
export const lemonSqueezyWebhook : PaymentsWebhook = async ( request , response , context ) => {
13
15
try {
@@ -25,36 +27,49 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co
25
27
throw new HttpError ( 400 , 'Invalid signature' ) ;
26
28
}
27
29
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 ;
30
39
const prismaUserDelegate = context . entities . User ;
31
- switch ( event . meta . event_name ) {
40
+
41
+ switch ( payload . eventName ) {
32
42
case 'order_created' :
33
- await handleOrderCreated ( event as Order , userId , prismaUserDelegate ) ;
43
+ await handleOrderCreated ( payload . data , userId , prismaUserDelegate ) ;
34
44
break ;
35
45
case 'subscription_created' :
36
- await handleSubscriptionCreated ( event as Subscription , userId , prismaUserDelegate ) ;
46
+ await handleSubscriptionCreated ( payload . data , userId , prismaUserDelegate ) ;
37
47
break ;
38
48
case 'subscription_updated' :
39
- await handleSubscriptionUpdated ( event as Subscription , userId , prismaUserDelegate ) ;
49
+ await handleSubscriptionUpdated ( payload . data , userId , prismaUserDelegate ) ;
40
50
break ;
41
51
case 'subscription_cancelled' :
42
- await handleSubscriptionCancelled ( event as Subscription , userId , prismaUserDelegate ) ;
52
+ await handleSubscriptionCancelled ( payload . data , userId , prismaUserDelegate ) ;
43
53
break ;
44
54
case 'subscription_expired' :
45
- await handleSubscriptionExpired ( event as Subscription , userId , prismaUserDelegate ) ;
55
+ await handleSubscriptionExpired ( payload . data , userId , prismaUserDelegate ) ;
46
56
break ;
47
57
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 ) ;
49
60
}
50
61
51
- response . status ( 200 ) . json ( { received : true } ) ;
62
+ return response . status ( 200 ) . json ( { received : true } ) ;
52
63
} catch ( err ) {
64
+ if ( err instanceof UnhandledWebhookEventError ) {
65
+ return response . status ( 200 ) . json ( { received : true } ) ;
66
+ }
67
+
53
68
console . error ( 'Webhook error:' , err ) ;
54
69
if ( err instanceof HttpError ) {
55
- response . status ( err . statusCode ) . json ( { error : err . message } ) ;
70
+ return response . status ( err . statusCode ) . json ( { error : err . message } ) ;
56
71
} 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' } ) ;
58
73
}
59
74
}
60
75
} ;
@@ -70,8 +85,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon
70
85
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up
71
86
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders,
72
87
// 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 ;
75
90
const lemonSqueezyId = customer_id . toString ( ) ;
76
91
77
92
const planId = getPlanIdByVariantId ( first_order_item . variant_id . toString ( ) ) ;
@@ -94,8 +109,12 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
94
109
console . log ( `Order ${ order_number } created for user ${ lemonSqueezyId } ` ) ;
95
110
}
96
111
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 ;
99
118
const lemonSqueezyId = customer_id . toString ( ) ;
100
119
101
120
const planId = getPlanIdByVariantId ( variant_id . toString ( ) ) ;
@@ -118,18 +137,21 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri
118
137
console . log ( `Subscription created for user ${ lemonSqueezyId } ` ) ;
119
138
}
120
139
121
-
122
140
// 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 ;
125
147
const lemonSqueezyId = customer_id . toString ( ) ;
126
148
127
149
const planId = getPlanIdByVariantId ( variant_id . toString ( ) ) ;
128
150
129
151
// We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active.
130
152
// 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.
133
155
// If you do enable these features, make sure to handle these statuses here.
134
156
if ( status === 'past_due' || status === 'active' ) {
135
157
await updateUserLemonSqueezyPaymentDetails (
@@ -146,8 +168,12 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri
146
168
}
147
169
}
148
170
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 ;
151
177
const lemonSqueezyId = customer_id . toString ( ) ;
152
178
153
179
await updateUserLemonSqueezyPaymentDetails (
@@ -162,8 +188,12 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p
162
188
console . log ( `Subscription cancelled for user ${ lemonSqueezyId } ` ) ;
163
189
}
164
190
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 ;
167
197
const lemonSqueezyId = customer_id . toString ( ) ;
168
198
169
199
await updateUserLemonSqueezyPaymentDetails (
@@ -181,7 +211,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri
181
211
async function fetchUserCustomerPortalUrl ( { lemonSqueezyId } : { lemonSqueezyId : string } ) : Promise < string > {
182
212
const { data : lemonSqueezyCustomer , error } = await getCustomer ( lemonSqueezyId ) ;
183
213
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
+ ) ;
185
217
}
186
218
const customerPortalUrl = lemonSqueezyCustomer . data . attributes . urls . customer_portal ;
187
219
if ( ! customerPortalUrl ) {
@@ -198,4 +230,4 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
198
230
throw new Error ( `No plan with LemonSqueezy variant id ${ variantId } ` ) ;
199
231
}
200
232
return planId ;
201
- }
233
+ }
0 commit comments