@@ -4,10 +4,11 @@ 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' ;
11
12
12
13
export const lemonSqueezyWebhook : PaymentsWebhook = async ( request , response , context ) => {
13
14
try {
@@ -25,27 +26,30 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co
25
26
throw new HttpError ( 400 , 'Invalid signature' ) ;
26
27
}
27
28
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 ;
30
33
const prismaUserDelegate = context . entities . User ;
31
- switch ( event . meta . event_name ) {
34
+
35
+ switch ( payload . eventName ) {
32
36
case 'order_created' :
33
- await handleOrderCreated ( event as Order , userId , prismaUserDelegate ) ;
37
+ await handleOrderCreated ( payload . data , userId , prismaUserDelegate ) ;
34
38
break ;
35
39
case 'subscription_created' :
36
- await handleSubscriptionCreated ( event as Subscription , userId , prismaUserDelegate ) ;
40
+ await handleSubscriptionCreated ( payload . data , userId , prismaUserDelegate ) ;
37
41
break ;
38
42
case 'subscription_updated' :
39
- await handleSubscriptionUpdated ( event as Subscription , userId , prismaUserDelegate ) ;
43
+ await handleSubscriptionUpdated ( payload . data , userId , prismaUserDelegate ) ;
40
44
break ;
41
45
case 'subscription_cancelled' :
42
- await handleSubscriptionCancelled ( event as Subscription , userId , prismaUserDelegate ) ;
46
+ await handleSubscriptionCancelled ( payload . data , userId , prismaUserDelegate ) ;
43
47
break ;
44
48
case 'subscription_expired' :
45
- await handleSubscriptionExpired ( event as Subscription , userId , prismaUserDelegate ) ;
49
+ await handleSubscriptionExpired ( payload . data , userId , prismaUserDelegate ) ;
46
50
break ;
47
51
default :
48
- console . error ( 'Unhandled event type: ' , event . meta . event_name ) ;
52
+ assertUnreachable ( payload ) ;
49
53
}
50
54
51
55
response . status ( 200 ) . json ( { received : true } ) ;
@@ -70,8 +74,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon
70
74
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up
71
75
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders,
72
76
// 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 ;
75
79
const lemonSqueezyId = customer_id . toString ( ) ;
76
80
77
81
const planId = getPlanIdByVariantId ( first_order_item . variant_id . toString ( ) ) ;
@@ -94,8 +98,12 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
94
98
console . log ( `Order ${ order_number } created for user ${ lemonSqueezyId } ` ) ;
95
99
}
96
100
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 ;
99
107
const lemonSqueezyId = customer_id . toString ( ) ;
100
108
101
109
const planId = getPlanIdByVariantId ( variant_id . toString ( ) ) ;
@@ -118,18 +126,21 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri
118
126
console . log ( `Subscription created for user ${ lemonSqueezyId } ` ) ;
119
127
}
120
128
121
-
122
129
// 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 ;
125
136
const lemonSqueezyId = customer_id . toString ( ) ;
126
137
127
138
const planId = getPlanIdByVariantId ( variant_id . toString ( ) ) ;
128
139
129
140
// We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active.
130
141
// 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.
133
144
// If you do enable these features, make sure to handle these statuses here.
134
145
if ( status === 'past_due' || status === 'active' ) {
135
146
await updateUserLemonSqueezyPaymentDetails (
@@ -146,8 +157,12 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri
146
157
}
147
158
}
148
159
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 ;
151
166
const lemonSqueezyId = customer_id . toString ( ) ;
152
167
153
168
await updateUserLemonSqueezyPaymentDetails (
@@ -162,8 +177,12 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p
162
177
console . log ( `Subscription cancelled for user ${ lemonSqueezyId } ` ) ;
163
178
}
164
179
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 ;
167
186
const lemonSqueezyId = customer_id . toString ( ) ;
168
187
169
188
await updateUserLemonSqueezyPaymentDetails (
@@ -181,7 +200,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri
181
200
async function fetchUserCustomerPortalUrl ( { lemonSqueezyId } : { lemonSqueezyId : string } ) : Promise < string > {
182
201
const { data : lemonSqueezyCustomer , error } = await getCustomer ( lemonSqueezyId ) ;
183
202
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
+ ) ;
185
206
}
186
207
const customerPortalUrl = lemonSqueezyCustomer . data . attributes . urls . customer_portal ;
187
208
if ( ! customerPortalUrl ) {
@@ -198,4 +219,4 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
198
219
throw new Error ( `No plan with LemonSqueezy variant id ${ variantId } ` ) ;
199
220
}
200
221
return planId ;
201
- }
222
+ }
0 commit comments