From a8f52f23f3912e2ede2e50dfc5cbdcb402029827 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 19 Feb 2025 14:57:38 +0100 Subject: [PATCH 01/13] Adds Zod validation for webhook payloads Signed-off-by: Mihovil Ilakovac --- template/app/src/payment/errors.ts | 6 + .../app/src/payment/lemonSqueezy/webhook.ts | 90 ++++++---- .../payment/lemonSqueezy/webhookPayload.ts | 63 +++++++ template/app/src/payment/stripe/webhook.ts | 154 +++++++++++------- .../app/src/payment/stripe/webhookPayload.ts | 87 ++++++++++ template/app/src/shared/utils.ts | 4 +- 6 files changed, 317 insertions(+), 87 deletions(-) create mode 100644 template/app/src/payment/errors.ts create mode 100644 template/app/src/payment/lemonSqueezy/webhookPayload.ts create mode 100644 template/app/src/payment/stripe/webhookPayload.ts diff --git a/template/app/src/payment/errors.ts b/template/app/src/payment/errors.ts new file mode 100644 index 00000000..59d7bafe --- /dev/null +++ b/template/app/src/payment/errors.ts @@ -0,0 +1,6 @@ +export class UnhandledWebhookEventError extends Error { + constructor(eventType: string) { + super(`Unhandled event type: ${eventType}`); + this.name = 'UnhandledWebhookEventError'; + } +} diff --git a/template/app/src/payment/lemonSqueezy/webhook.ts b/template/app/src/payment/lemonSqueezy/webhook.ts index 91914156..67bafeb3 100644 --- a/template/app/src/payment/lemonSqueezy/webhook.ts +++ b/template/app/src/payment/lemonSqueezy/webhook.ts @@ -4,10 +4,12 @@ import { type PrismaClient } from '@prisma/client'; import express from 'express'; import { paymentPlans, PaymentPlanId } 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 { @@ -25,36 +27,49 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co throw new HttpError(400, 'Invalid signature'); } - const event = JSON.parse(rawBody); - const userId = event.meta.custom_data.user_id; + const payload = await parseWebhookPayload(rawBody).catch((e) => { + if (e instanceof UnhandledWebhookEventError) { + throw e; + } else { + console.error('Error parsing webhook payload', e); + throw new HttpError(400, e.message); + } + }); + const userId = payload.meta.custom_data.user_id; const prismaUserDelegate = context.entities.User; - switch (event.meta.event_name) { + + switch (payload.eventName) { case 'order_created': - await handleOrderCreated(event as Order, userId, prismaUserDelegate); + await handleOrderCreated(payload.data, userId, prismaUserDelegate); break; case 'subscription_created': - await handleSubscriptionCreated(event as Subscription, userId, prismaUserDelegate); + await handleSubscriptionCreated(payload.data, userId, prismaUserDelegate); break; case 'subscription_updated': - await handleSubscriptionUpdated(event as Subscription, userId, prismaUserDelegate); + await handleSubscriptionUpdated(payload.data, userId, prismaUserDelegate); break; case 'subscription_cancelled': - await handleSubscriptionCancelled(event as Subscription, userId, prismaUserDelegate); + await handleSubscriptionCancelled(payload.data, userId, prismaUserDelegate); break; case 'subscription_expired': - await handleSubscriptionExpired(event as Subscription, userId, prismaUserDelegate); + await handleSubscriptionExpired(payload.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(payload); } - response.status(200).json({ received: true }); + return response.status(200).json({ received: true }); } catch (err) { + if (err instanceof UnhandledWebhookEventError) { + 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' }); } } }; @@ -70,8 +85,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()); @@ -94,8 +109,12 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat console.log(`Order ${order_number} created for user ${lemonSqueezyId}`); } -async function handleSubscriptionCreated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { - const { customer_id, status, variant_id } = data.data.attributes; +async function handleSubscriptionCreated( + data: SubscriptionData, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { + const { customer_id, status, variant_id } = data.attributes; const lemonSqueezyId = customer_id.toString(); const planId = getPlanIdByVariantId(variant_id.toString()); @@ -118,18 +137,21 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri console.log(`Subscription created for user ${lemonSqueezyId}`); } - // 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, userId: string, prismaUserDelegate: PrismaClient['user']) { - const { customer_id, status, variant_id } = data.data.attributes; +async function handleSubscriptionUpdated( + data: SubscriptionData, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { + const { customer_id, status, variant_id } = data.attributes; const lemonSqueezyId = customer_id.toString(); const planId = getPlanIdByVariantId(variant_id.toString()); // We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active. // Note that a status changes to 'past_due' on a failed payment retry, then after 4 unsuccesful payment retries status - // becomes 'unpaid' and finally 'expired' (i.e. 'deleted'). - // NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard. + // becomes 'unpaid' and finally 'expired' (i.e. 'deleted'). + // NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard. // If you do enable these features, make sure to handle these statuses here. if (status === 'past_due' || status === 'active') { await updateUserLemonSqueezyPaymentDetails( @@ -146,8 +168,12 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri } } -async function handleSubscriptionCancelled(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { - const { customer_id } = data.data.attributes; +async function handleSubscriptionCancelled( + data: SubscriptionData, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { + const { customer_id } = data.attributes; const lemonSqueezyId = customer_id.toString(); await updateUserLemonSqueezyPaymentDetails( @@ -162,8 +188,12 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p console.log(`Subscription cancelled for user ${lemonSqueezyId}`); } -async function handleSubscriptionExpired(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { - const { customer_id } = data.data.attributes; +async function handleSubscriptionExpired( + data: SubscriptionData, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { + const { customer_id } = data.attributes; const lemonSqueezyId = customer_id.toString(); await updateUserLemonSqueezyPaymentDetails( @@ -181,7 +211,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri async function fetchUserCustomerPortalUrl({ lemonSqueezyId }: { lemonSqueezyId: string }): Promise { const { data: lemonSqueezyCustomer, error } = await getCustomer(lemonSqueezyId); if (error) { - throw new Error(`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`); + throw new Error( + `Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}` + ); } const customerPortalUrl = lemonSqueezyCustomer.data.attributes.urls.customer_portal; if (!customerPortalUrl) { @@ -198,4 +230,4 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId { throw new Error(`No plan with LemonSqueezy variant id ${variantId}`); } return planId; -} \ No newline at end of file +} diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts new file mode 100644 index 00000000..722ca938 --- /dev/null +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -0,0 +1,63 @@ +import * as z from 'zod'; +import { UnhandledWebhookEventError } from '../errors'; + +export async function parseWebhookPayload(rawPayload: string) { + const rawEvent = JSON.parse(rawPayload) as unknown; + const event = await genericEventSchema.parseAsync(rawEvent).catch((e) => { + console.error(e); + throw new Error('Invalid Lemon Squeezy Webhook Event'); + }); + switch (event.meta.event_name) { + case 'order_created': + const orderData = await orderDataSchema.parseAsync(event.data).catch((e) => { + console.error(e); + throw new Error('Invalid Lemon Squeezy Order Event'); + }); + 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).catch((e) => { + console.error(e); + throw new Error('Invalid Lemon Squeezy Subscription Event'); + }); + 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); + } +} + +export type SubscriptionData = z.infer; + +export type OrderData = z.infer; + +const genericEventSchema = z.object({ + meta: z.object({ + event_name: z.string(), + custom_data: z.object({ + user_id: z.string(), + }), + }), + data: z.unknown(), +}); + +const orderDataSchema = z.object({ + attributes: z.object({ + customer_id: z.number(), + status: z.string(), + first_order_item: z.object({ + variant_id: z.string(), + }), + order_number: z.string(), + }), +}); + +const subscriptionDataSchema = z.object({ + attributes: z.object({ + customer_id: z.number(), + status: z.string(), + variant_id: z.string(), + }), +}); diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index f018bf9f..7a880f6b 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -10,47 +10,75 @@ import { emailSender } from 'wasp/server/email'; import { assertUnreachable } from '../../shared/utils'; import { requireNodeEnvVar } from '../../server/utils'; import { z } from 'zod'; +import { + InvoicePaidData, + parseWebhookPayload, + SessionCompletedData, + SubscriptionDeletedData, + SubscriptionUpdatedData, +} from './webhookPayload'; +import { UnhandledWebhookEventError } from '../errors'; export const stripeWebhook: PaymentsWebhook = async (request, response, context) => { - const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET'); - const sig = request.headers['stripe-signature']; - if (!sig) { - throw new HttpError(400, 'Stripe Webhook Signature Not Provided'); - } - let event: Stripe.Event; try { - event = stripe.webhooks.constructEvent(request.body, sig, secret); + const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET'); + const sig = request.headers['stripe-signature']; + if (!sig) { + throw new HttpError(400, 'Stripe Webhook Signature Not Provided'); + } + const rawStripeEvent = ensureStripeEvent(request, sig, secret); + const payload = await parseWebhookPayload(rawStripeEvent).catch((e) => { + if (e instanceof UnhandledWebhookEventError) { + throw e; + } else { + console.error('Error parsing webhook payload', e); + throw new HttpError(400, e.message); + } + }); + const prismaUserDelegate = context.entities.User; + switch (payload.eventName) { + case 'checkout.session.completed': + await handleCheckoutSessionCompleted(payload.data, prismaUserDelegate); + break; + case 'invoice.paid': + await handleInvoicePaid(payload.data, prismaUserDelegate); + break; + case 'customer.subscription.updated': + await handleCustomerSubscriptionUpdated(payload.data, prismaUserDelegate); + break; + case 'customer.subscription.deleted': + await handleCustomerSubscriptionDeleted(payload.data, prismaUserDelegate); + break; + default: + // If you'd like to handle more events, you can add more cases above. + // When deploying your app, you configure your webhook in the Stripe dashboard to only send the events that you're + // handling above and that are necessary for the functioning of your app. See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook + // In development, it is likely that you will receive other events that you are not handling, and that's fine. These can be ignored without any issues. + assertUnreachable(payload); + } + return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook } catch (err) { - throw new HttpError(400, 'Error Constructing Stripe Webhook Event'); - } - const prismaUserDelegate = context.entities.User; - switch (event.type) { - case 'checkout.session.completed': - const session = event.data.object as Stripe.Checkout.Session; - await handleCheckoutSessionCompleted(session, prismaUserDelegate); - break; - case 'invoice.paid': - const invoice = event.data.object as Stripe.Invoice; - await handleInvoicePaid(invoice, prismaUserDelegate); - break; - case 'customer.subscription.updated': - const updatedSubscription = event.data.object as Stripe.Subscription; - await handleCustomerSubscriptionUpdated(updatedSubscription, prismaUserDelegate); - break; - case 'customer.subscription.deleted': - const deletedSubscription = event.data.object as Stripe.Subscription; - await handleCustomerSubscriptionDeleted(deletedSubscription, prismaUserDelegate); - break; - default: - // If you'd like to handle more events, you can add more cases above. - // When deploying your app, you configure your webhook in the Stripe dashboard to only send the events that you're - // handling above and that are necessary for the functioning of your app. See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook - // In development, it is likely that you will receive other events that you are not handling, and that's fine. These can be ignored without any issues. - console.error('Unhandled event type: ', event.type); + if (err instanceof UnhandledWebhookEventError) { + return response.status(200).json({ received: true }); + } + + console.error('Webhook error:', err); + if (err instanceof HttpError) { + return response.status(err.statusCode).json({ error: err.message }); + } else { + return response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' }); + } } - response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook }; +function ensureStripeEvent(request: express.Request, sig: string | string[], secret: string): Stripe.Event { + try { + return stripe.webhooks.constructEvent(request.body, sig, secret); + } catch (err) { + throw new HttpError(500, 'Error Constructing Stripe Webhook Event'); + } +} + export const stripeMiddlewareConfigFn: 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. @@ -60,15 +88,15 @@ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) = }; export async function handleCheckoutSessionCompleted( - session: Stripe.Checkout.Session, - prismaUserDelegate: PrismaClient["user"] + session: SessionCompletedData, + prismaUserDelegate: PrismaClient['user'] ) { - const userStripeId = validateUserStripeIdOrThrow(session.customer); - const { line_items } = await stripe.checkout.sessions.retrieve(session.id, { - expand: ['line_items'], + const userStripeId = session.customer; + const lineItems = await getSubscriptionLineItemsBySessionId(session.id).catch((e) => { + throw new HttpError(500, e.message); }); - const lineItemPriceId = extractPriceId(line_items); + const lineItemPriceId = extractPriceId(lineItems); const planId = getPlanIdByPriceId(lineItemPriceId); const plan = paymentPlans[planId]; @@ -92,15 +120,15 @@ export async function handleCheckoutSessionCompleted( ); } -export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaClient["user"]) { +export async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) { const userStripeId = validateUserStripeIdOrThrow(invoice.customer); const datePaid = new Date(invoice.period_start * 1000); return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate); } export async function handleCustomerSubscriptionUpdated( - subscription: Stripe.Subscription, - prismaUserDelegate: PrismaClient["user"] + subscription: SubscriptionUpdatedData, + prismaUserDelegate: PrismaClient['user'] ) { const userStripeId = validateUserStripeIdOrThrow(subscription.customer); let subscriptionStatus: SubscriptionStatus | undefined; @@ -114,9 +142,12 @@ export async function handleCustomerSubscriptionUpdated( subscriptionStatus = subscription.cancel_at_period_end ? 'cancel_at_period_end' : 'active'; } else if (subscription.status === 'past_due') { subscriptionStatus = 'past_due'; - } + } if (subscriptionStatus) { - const user = await updateUserStripePaymentDetails({ userStripeId, subscriptionPlan, subscriptionStatus }, prismaUserDelegate); + const user = await updateUserStripePaymentDetails( + { userStripeId, subscriptionPlan, subscriptionStatus }, + prismaUserDelegate + ); if (subscription.cancel_at_period_end) { if (user.email) { await emailSender.send({ @@ -132,8 +163,8 @@ export async function handleCustomerSubscriptionUpdated( } export async function handleCustomerSubscriptionDeleted( - subscription: Stripe.Subscription, - prismaUserDelegate: PrismaClient["user"] + subscription: SubscriptionDeletedData, + prismaUserDelegate: PrismaClient['user'] ) { const userStripeId = validateUserStripeIdOrThrow(subscription.customer); return updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate); @@ -145,7 +176,9 @@ function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['cust return userStripeId; } -const LineItemsPriceSchema = z.object({ +type SubscsriptionItems = z.infer; + +const subscriptionItemsSchema = z.object({ data: z.array( z.object({ price: z.object({ @@ -155,15 +188,24 @@ const LineItemsPriceSchema = z.object({ ), }); -function extractPriceId(items: Stripe.Checkout.Session['line_items'] | Stripe.Subscription['items']) { - const result = LineItemsPriceSchema.safeParse(items); - if (!result.success) { - throw new HttpError(400, 'No price id in stripe event object'); - } - if (result.data.data.length > 1) { +function extractPriceId(items: SubscsriptionItems): string { + if (items.data.length > 1) { throw new HttpError(400, 'More than one item in stripe event object'); } - return result.data.data[0].price.id; + return items.data[0].price.id; +} + +async function getSubscriptionLineItemsBySessionId(sessionId: string) { + const { line_items: lineItemsRaw } = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['line_items'], + }); + + const lineItems = await subscriptionItemsSchema.parseAsync(lineItemsRaw).catch((e) => { + console.error(e); + throw new Error('Error parsing Stripe line items'); + }); + + return lineItems; } function getPlanIdByPriceId(priceId: string): PaymentPlanId { @@ -174,4 +216,4 @@ function getPlanIdByPriceId(priceId: string): PaymentPlanId { throw new Error(`No plan with Stripe price id ${priceId}`); } return planId; -} \ No newline at end of file +} diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts new file mode 100644 index 00000000..4c5e6462 --- /dev/null +++ b/template/app/src/payment/stripe/webhookPayload.ts @@ -0,0 +1,87 @@ +import * as z from 'zod'; +import { Stripe } from 'stripe'; +import { UnhandledWebhookEventError } from '../errors'; + +export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) { + const event = await genericStripeEventSchema.parseAsync(rawStripeEvent).catch((e) => { + console.error(e); + throw new Error('Invalid Stripe Event'); + }); + switch (event.type) { + case 'checkout.session.completed': + const session = await sessionCompletedDataSchema.parseAsync(event.data.object).catch((e) => { + console.error(e); + throw new Error('Error parsing Stripe event object'); + }); + return { eventName: event.type, data: session }; + case 'invoice.paid': + const invoice = await invoicePaidDataSchema.parseAsync(event.data.object).catch((e) => { + console.error(e); + throw new Error('Error parsing Stripe event object'); + }); + return { eventName: event.type, data: invoice }; + case 'customer.subscription.updated': + const updatedSubscription = await subscriptionUpdatedDataSchema + .parseAsync(event.data.object) + .catch((e) => { + console.error(e); + throw new Error('Error parsing Stripe event object'); + }); + return { eventName: event.type, data: updatedSubscription }; + case 'customer.subscription.deleted': + const deletedSubscription = await subscriptionDeletedDataSchema + .parseAsync(event.data.object) + .catch((e) => { + console.error(e); + throw new Error('Error parsing Stripe event object'); + }); + return { eventName: event.type, data: deletedSubscription }; + default: + // If you'd like to handle more events, you can add more cases above. + throw new UnhandledWebhookEventError(event.type); + } +} + +const genericStripeEventSchema = z.object({ + type: z.string(), + data: z.object({ + object: z.unknown(), + }), +}); + +const sessionCompletedDataSchema = z.object({ + id: z.string(), + customer: z.string(), +}); + +const invoicePaidDataSchema = z.object({ + customer: z.string(), + period_start: z.number(), +}); + +const subscriptionUpdatedDataSchema = z.object({ + customer: z.string(), + status: z.string(), + cancel_at_period_end: z.boolean(), + items: z.object({ + data: z.array( + z.object({ + price: z.object({ + id: z.string(), + }), + }) + ), + }), +}); + +const subscriptionDeletedDataSchema = z.object({ + customer: z.string(), +}); + +export type SessionCompletedData = z.infer; + +export type InvoicePaidData = z.infer; + +export type SubscriptionUpdatedData = z.infer; + +export type SubscriptionDeletedData = z.infer; diff --git a/template/app/src/shared/utils.ts b/template/app/src/shared/utils.ts index 0fb2711d..bc5738d3 100644 --- a/template/app/src/shared/utils.ts +++ b/template/app/src/shared/utils.ts @@ -3,6 +3,6 @@ * will never execute. See https://stackoverflow.com/a/39419171. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function assertUnreachable(x: never): never { - throw Error('This code should be unreachable'); +export function assertUnreachable(x: never, message: string = 'Unexpected value encountered'): never { + throw Error(message); } From 20ee20a6376eea6b3085395bfa1a58bc7b3fccb9 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 20 Feb 2025 10:20:06 +0100 Subject: [PATCH 02/13] Cleanup --- template/app/src/payment/stripe/webhook.ts | 2 +- template/app/src/shared/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 7a880f6b..d3f625c7 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -66,7 +66,7 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) if (err instanceof HttpError) { return response.status(err.statusCode).json({ error: err.message }); } else { - return response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' }); + return response.status(400).json({ error: 'Error Processing Stripe Webhook Event' }); } } }; diff --git a/template/app/src/shared/utils.ts b/template/app/src/shared/utils.ts index bc5738d3..0fb2711d 100644 --- a/template/app/src/shared/utils.ts +++ b/template/app/src/shared/utils.ts @@ -3,6 +3,6 @@ * will never execute. See https://stackoverflow.com/a/39419171. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function assertUnreachable(x: never, message: string = 'Unexpected value encountered'): never { - throw Error(message); +export function assertUnreachable(x: never): never { + throw Error('This code should be unreachable'); } From ad41d609e86755ab2db428fb7d8ecaa43dafe9c8 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 20 Feb 2025 10:38:03 +0100 Subject: [PATCH 03/13] Update Lemon Squeezy validation --- template/app/src/payment/lemonSqueezy/webhookPayload.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts index 722ca938..84db416b 100644 --- a/template/app/src/payment/lemonSqueezy/webhookPayload.ts +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -48,7 +48,7 @@ const orderDataSchema = z.object({ customer_id: z.number(), status: z.string(), first_order_item: z.object({ - variant_id: z.string(), + variant_id: z.number(), }), order_number: z.string(), }), @@ -58,6 +58,6 @@ const subscriptionDataSchema = z.object({ attributes: z.object({ customer_id: z.number(), status: z.string(), - variant_id: z.string(), + variant_id: z.number(), }), }); From b7de40616a323a5735942b54427d60ce9c319984 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 20 Feb 2025 10:38:31 +0100 Subject: [PATCH 04/13] Update Lemon Squeezy validation #2 --- template/app/src/payment/lemonSqueezy/webhookPayload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts index 84db416b..76cf8b90 100644 --- a/template/app/src/payment/lemonSqueezy/webhookPayload.ts +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -50,7 +50,7 @@ const orderDataSchema = z.object({ first_order_item: z.object({ variant_id: z.number(), }), - order_number: z.string(), + order_number: z.number(), }), }); From ef496af27e7a2a247bb6ce0f03b0cbd360f77d8f Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 20 Feb 2025 10:46:35 +0100 Subject: [PATCH 05/13] Add extra comments above the validation schemas --- template/app/src/payment/lemonSqueezy/webhookPayload.ts | 4 ++++ template/app/src/payment/stripe/webhookPayload.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts index 76cf8b90..a3feb961 100644 --- a/template/app/src/payment/lemonSqueezy/webhookPayload.ts +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -43,6 +43,8 @@ const genericEventSchema = z.object({ 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(), @@ -54,6 +56,8 @@ const orderDataSchema = z.object({ }), }); +// 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(), diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts index 4c5e6462..06022037 100644 --- a/template/app/src/payment/stripe/webhookPayload.ts +++ b/template/app/src/payment/stripe/webhookPayload.ts @@ -49,16 +49,19 @@ const genericStripeEventSchema = z.object({ }), }); +// This is a subtype of Stripe.Checkout.Session from "stripe" const sessionCompletedDataSchema = z.object({ id: z.string(), customer: z.string(), }); +// This is a subtype of Stripe.Invoice from "stripe" const invoicePaidDataSchema = z.object({ customer: z.string(), period_start: z.number(), }); +// This is a subtype of Stripe.Subscription from "stripe" const subscriptionUpdatedDataSchema = z.object({ customer: z.string(), status: z.string(), @@ -74,6 +77,7 @@ const subscriptionUpdatedDataSchema = z.object({ }), }); +// This is a subtype of Stripe.Subscription from "stripe" const subscriptionDeletedDataSchema = z.object({ customer: z.string(), }); From 77ad7a4969b5a1292d4e0180e21970ce78099c23 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 20 Feb 2025 11:25:32 +0100 Subject: [PATCH 06/13] Drop redundant validation --- template/app/src/payment/stripe/webhook.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 12859c16..a0ed9fc5 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -118,7 +118,7 @@ export async function handleCheckoutSessionCompleted( // This is called when a subscription is purchased or renewed and payment succeeds. // Invoices are not created for one-time payments, so we handle them in the payment_intent.succeeded webhook. export async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) { - const userStripeId = validateUserStripeIdOrThrow(invoice.customer); + const userStripeId = invoice.customer; const datePaid = new Date(invoice.period_start * 1000); return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate); } @@ -133,7 +133,7 @@ export async function handlePaymentIntentSucceeded( return; } - const userStripeId = validateUserStripeIdOrThrow(paymentIntent.customer); + const userStripeId = paymentIntent.customer; const datePaid = new Date(paymentIntent.created * 1000); // We capture the price id from the payment intent metadata @@ -162,7 +162,7 @@ export async function handleCustomerSubscriptionUpdated( subscription: SubscriptionUpdatedData, prismaUserDelegate: PrismaClient['user'] ) { - const userStripeId = validateUserStripeIdOrThrow(subscription.customer); + const userStripeId = subscription.customer; let subscriptionStatus: SubscriptionStatus | undefined; const priceId = extractPriceId(subscription.items); @@ -198,16 +198,10 @@ export async function handleCustomerSubscriptionDeleted( subscription: SubscriptionDeletedData, prismaUserDelegate: PrismaClient['user'] ) { - const userStripeId = validateUserStripeIdOrThrow(subscription.customer); + const userStripeId = subscription.customer; return updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate); } -function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']): string { - if (!userStripeId) throw new HttpError(400, 'No customer id'); - if (typeof userStripeId !== 'string') throw new HttpError(400, 'Customer id is not a string'); - return userStripeId; -} - type SubscsriptionItems = z.infer; const subscriptionItemsSchema = z.object({ From e40c1e4cd3a9619e198aedb5a10a069b64180d5b Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 20 Feb 2025 18:11:25 +0100 Subject: [PATCH 07/13] Adds type imports --- template/app/src/payment/stripe/webhook.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index a0ed9fc5..140009ee 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -2,21 +2,21 @@ import { type MiddlewareConfigFn, HttpError } from 'wasp/server'; import { type PaymentsWebhook } from 'wasp/server/api'; import { type PrismaClient } from '@prisma/client'; import express from 'express'; -import { Stripe } from 'stripe'; +import type { Stripe } from 'stripe'; import { stripe } from './stripeClient'; -import { paymentPlans, PaymentPlanId, SubscriptionStatus, PaymentPlanEffect, PaymentPlan } from '../plans'; +import { paymentPlans, PaymentPlanId, type SubscriptionStatus, type PaymentPlanEffect } from '../plans'; import { updateUserStripePaymentDetails } from './paymentDetails'; import { emailSender } from 'wasp/server/email'; import { assertUnreachable } from '../../shared/utils'; import { requireNodeEnvVar } from '../../server/utils'; import { z } from 'zod'; import { - InvoicePaidData, parseWebhookPayload, - PaymentIntentSucceededData, - SessionCompletedData, - SubscriptionDeletedData, - SubscriptionUpdatedData, + type InvoicePaidData, + type PaymentIntentSucceededData, + type SessionCompletedData, + type SubscriptionDeletedData, + type SubscriptionUpdatedData, } from './webhookPayload'; import { UnhandledWebhookEventError } from '../errors'; From 98047b7ca3710a1db72f091073193e5cbb09f1ea Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 28 Feb 2025 17:32:02 +0100 Subject: [PATCH 08/13] PR comments --- .../app/src/payment/lemonSqueezy/webhook.ts | 57 +++++----- .../payment/lemonSqueezy/webhookPayload.ts | 49 +++++---- template/app/src/payment/stripe/webhook.ts | 62 +++++------ .../app/src/payment/stripe/webhookPayload.ts | 103 +++++++++--------- 4 files changed, 134 insertions(+), 137 deletions(-) diff --git a/template/app/src/payment/lemonSqueezy/webhook.ts b/template/app/src/payment/lemonSqueezy/webhook.ts index 298f0527..99e4d0a8 100644 --- a/template/app/src/payment/lemonSqueezy/webhook.ts +++ b/template/app/src/payment/lemonSqueezy/webhook.ts @@ -13,50 +13,31 @@ 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 payload = await parseWebhookPayload(rawBody).catch((e) => { - if (e instanceof UnhandledWebhookEventError) { - throw e; - } else { - console.error('Error parsing webhook payload', e); - throw new HttpError(400, e.message); - } - }); - const userId = payload.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 (payload.eventName) { + switch (eventName) { case 'order_created': - await handleOrderCreated(payload.data, userId, prismaUserDelegate); + await handleOrderCreated(data, userId, prismaUserDelegate); break; case 'subscription_created': - await handleSubscriptionCreated(payload.data, userId, prismaUserDelegate); + await handleSubscriptionCreated(data, userId, prismaUserDelegate); break; case 'subscription_updated': - await handleSubscriptionUpdated(payload.data, userId, prismaUserDelegate); + await handleSubscriptionUpdated(data, userId, prismaUserDelegate); break; case 'subscription_cancelled': - await handleSubscriptionCancelled(payload.data, userId, prismaUserDelegate); + await handleSubscriptionCancelled(data, userId, prismaUserDelegate); break; case 'subscription_expired': - await handleSubscriptionExpired(payload.data, userId, prismaUserDelegate); + await handleSubscriptionExpired(data, userId, prismaUserDelegate); break; default: // If you'd like to handle more events, you can add more cases above. - assertUnreachable(payload); + assertUnreachable(eventName); } return response.status(200).json({ received: true }); @@ -74,6 +55,24 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co } }; +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. diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts index a3feb961..a3d79151 100644 --- a/template/app/src/payment/lemonSqueezy/webhookPayload.ts +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -1,31 +1,32 @@ import * as z from 'zod'; import { UnhandledWebhookEventError } from '../errors'; +import { HttpError } from 'wasp/server'; export async function parseWebhookPayload(rawPayload: string) { - const rawEvent = JSON.parse(rawPayload) as unknown; - const event = await genericEventSchema.parseAsync(rawEvent).catch((e) => { - console.error(e); - throw new Error('Invalid Lemon Squeezy Webhook Event'); - }); - switch (event.meta.event_name) { - case 'order_created': - const orderData = await orderDataSchema.parseAsync(event.data).catch((e) => { - console.error(e); - throw new Error('Invalid Lemon Squeezy Order Event'); - }); - 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).catch((e) => { - console.error(e); - throw new Error('Invalid Lemon Squeezy Subscription Event'); - }); - 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); + 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'); + } } } diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index c5032bed..6769d52c 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -22,43 +22,31 @@ import { UnhandledWebhookEventError } from '../errors'; export const stripeWebhook: PaymentsWebhook = async (request, response, context) => { try { - const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET'); - const sig = request.headers['stripe-signature']; - if (!sig) { - throw new HttpError(400, 'Stripe Webhook Signature Not Provided'); - } - const rawStripeEvent = ensureStripeEvent(request, sig, secret); - const payload = await parseWebhookPayload(rawStripeEvent).catch((e) => { - if (e instanceof UnhandledWebhookEventError) { - throw e; - } else { - console.error('Error parsing webhook payload', e); - throw new HttpError(400, e.message); - } - }); + const rawStripeEvent = constructStripeEvent(request); + const { eventName, data } = await parseWebhookPayload(rawStripeEvent); const prismaUserDelegate = context.entities.User; - switch (payload.eventName) { + switch (eventName) { case 'checkout.session.completed': - await handleCheckoutSessionCompleted(payload.data, prismaUserDelegate); + await handleCheckoutSessionCompleted(data, prismaUserDelegate); break; case 'invoice.paid': - await handleInvoicePaid(payload.data, prismaUserDelegate); + await handleInvoicePaid(data, prismaUserDelegate); break; case 'payment_intent.succeeded': - await handlePaymentIntentSucceeded(payload.data, prismaUserDelegate); + await handlePaymentIntentSucceeded(data, prismaUserDelegate); break; case 'customer.subscription.updated': - await handleCustomerSubscriptionUpdated(payload.data, prismaUserDelegate); + await handleCustomerSubscriptionUpdated(data, prismaUserDelegate); break; case 'customer.subscription.deleted': - await handleCustomerSubscriptionDeleted(payload.data, prismaUserDelegate); + await handleCustomerSubscriptionDeleted(data, prismaUserDelegate); break; default: // If you'd like to handle more events, you can add more cases above. // When deploying your app, you configure your webhook in the Stripe dashboard to only send the events that you're // handling above and that are necessary for the functioning of your app. See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook // In development, it is likely that you will receive other events that you are not handling, and that's fine. These can be ignored without any issues. - assertUnreachable(payload); + assertUnreachable(eventName); } return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook } catch (err) { @@ -70,16 +58,21 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) if (err instanceof HttpError) { return response.status(err.statusCode).json({ error: err.message }); } else { - return response.status(400).json({ error: 'Error Processing Stripe Webhook Event' }); + return response.status(400).json({ error: 'Error processing Stripe webhook event' }); } } }; -function ensureStripeEvent(request: express.Request, sig: string | string[], secret: string): Stripe.Event { +function constructStripeEvent(request: express.Request): Stripe.Event { try { + const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET'); + const sig = request.headers['stripe-signature']; + if (!sig) { + throw new HttpError(400, 'Stripe webhook signature not provided'); + } return stripe.webhooks.constructEvent(request.body, sig, secret); } catch (err) { - throw new HttpError(500, 'Error Constructing Stripe Webhook Event'); + throw new HttpError(500, 'Error constructing Stripe webhook event'); } } @@ -99,9 +92,7 @@ export async function handleCheckoutSessionCompleted( prismaUserDelegate: PrismaClient['user'] ) { const userStripeId = session.customer; - const lineItems = await getSubscriptionLineItemsBySessionId(session.id).catch((e) => { - throw new HttpError(500, e.message); - }); + const lineItems = await getSubscriptionLineItemsBySessionId(session.id); const lineItemPriceId = extractPriceId(lineItems); @@ -227,16 +218,17 @@ function extractPriceId(items: SubscsriptionItems): string { } async function getSubscriptionLineItemsBySessionId(sessionId: string) { - const { line_items: lineItemsRaw } = await stripe.checkout.sessions.retrieve(sessionId, { - expand: ['line_items'], - }); + try { + const { line_items: lineItemsRaw } = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['line_items'], + }); - const lineItems = await subscriptionItemsSchema.parseAsync(lineItemsRaw).catch((e) => { - console.error(e); - throw new Error('Error parsing Stripe line items'); - }); + const lineItems = await subscriptionItemsSchema.parseAsync(lineItemsRaw); - return lineItems; + return lineItems; + } catch (e: unknown) { + throw new HttpError(500, 'Error parsing Stripe line items'); + } } function getPlanIdByPriceId(priceId: string): PaymentPlanId { diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts index 45e8acc4..a669d5bd 100644 --- a/template/app/src/payment/stripe/webhookPayload.ts +++ b/template/app/src/payment/stripe/webhookPayload.ts @@ -1,55 +1,45 @@ import * as z from 'zod'; import { Stripe } from 'stripe'; import { UnhandledWebhookEventError } from '../errors'; +import { HttpError } from 'wasp/server'; export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) { - const event = await genericStripeEventSchema.parseAsync(rawStripeEvent).catch((e) => { - console.error(e); - throw new Error('Invalid Stripe Event'); - }); - switch (event.type) { - case 'checkout.session.completed': - const session = await sessionCompletedDataSchema.parseAsync(event.data.object).catch((e) => { - console.error(e); - throw new Error('Error parsing Stripe event object'); - }); - return { eventName: event.type, data: session }; - case 'invoice.paid': - const invoice = await invoicePaidDataSchema.parseAsync(event.data.object).catch((e) => { - console.error(e); - throw new Error('Error parsing Stripe event object'); - }); - return { eventName: event.type, data: invoice }; - case 'payment_intent.succeeded': - const paymentIntent = await paymentIntentSucceededDataSchema - .parseAsync(event.data.object) - .catch((e) => { - console.error(e); - throw new Error('Error parsing Stripe event object'); - }); - return { eventName: event.type, data: paymentIntent }; - case 'customer.subscription.updated': - const updatedSubscription = await subscriptionUpdatedDataSchema - .parseAsync(event.data.object) - .catch((e) => { - console.error(e); - throw new Error('Error parsing Stripe event object'); - }); - return { eventName: event.type, data: updatedSubscription }; - case 'customer.subscription.deleted': - const deletedSubscription = await subscriptionDeletedDataSchema - .parseAsync(event.data.object) - .catch((e) => { - console.error(e); - throw new Error('Error parsing Stripe event object'); - }); - return { eventName: event.type, data: deletedSubscription }; - default: - // If you'd like to handle more events, you can add more cases above. - throw new UnhandledWebhookEventError(event.type); + try { + const event = await genericStripeEventSchema.parseAsync(rawStripeEvent); + switch (event.type) { + case 'checkout.session.completed': + const session = await sessionCompletedDataSchema.parseAsync(event.data.object); + return { eventName: event.type, data: session }; + case 'invoice.paid': + const invoice = await invoicePaidDataSchema.parseAsync(event.data.object); + return { eventName: event.type, data: invoice }; + case 'payment_intent.succeeded': + const paymentIntent = await paymentIntentSucceededDataSchema.parseAsync(event.data.object); + return { eventName: event.type, data: paymentIntent }; + case 'customer.subscription.updated': + const updatedSubscription = await subscriptionUpdatedDataSchema.parseAsync(event.data.object); + return { eventName: event.type, data: updatedSubscription }; + case 'customer.subscription.deleted': + const deletedSubscription = await subscriptionDeletedDataSchema.parseAsync(event.data.object); + return { eventName: event.type, data: deletedSubscription }; + default: + // If you'd like to handle more events, you can add more cases above. + throw new UnhandledWebhookEventError(event.type); + } + } catch (e: unknown) { + if (e instanceof UnhandledWebhookEventError) { + throw e; + } else { + console.error(e); + throw new HttpError(400, 'Error parsing Stripe event object'); + } } } +/** + * This is a subtype of + * @type import('stripe').Stripe.Event + */ const genericStripeEventSchema = z.object({ type: z.string(), data: z.object({ @@ -57,19 +47,28 @@ const genericStripeEventSchema = z.object({ }), }); -// This is a subtype of Stripe.Checkout.Session from "stripe" +/** + * This is a subtype of + * @type import('stripe').Stripe.Checkout.Session + */ const sessionCompletedDataSchema = z.object({ id: z.string(), customer: z.string(), }); -// This is a subtype of Stripe.Invoice from "stripe" +/** + * This is a subtype of + * @type import('stripe').Stripe.Invoice + */ const invoicePaidDataSchema = z.object({ customer: z.string(), period_start: z.number(), }); -// This is a subtype of Stripe.PaymentIntent from "stripe" +/** + * This is a subtype of + * @type import('stripe').Stripe.PaymentIntent + */ const paymentIntentSucceededDataSchema = z.object({ invoice: z.unknown().optional(), created: z.number(), @@ -79,7 +78,10 @@ const paymentIntentSucceededDataSchema = z.object({ customer: z.string(), }); -// This is a subtype of Stripe.Subscription from "stripe" +/** + * This is a subtype of + * @type import('stripe').Stripe.Subscription + */ const subscriptionUpdatedDataSchema = z.object({ customer: z.string(), status: z.string(), @@ -95,7 +97,10 @@ const subscriptionUpdatedDataSchema = z.object({ }), }); -// This is a subtype of Stripe.Subscription from "stripe" +/** + * This is a subtype of + * @type import('stripe').Stripe.Subscription + */ const subscriptionDeletedDataSchema = z.object({ customer: z.string(), }); From 85ff1af4e09f743c1b68e7d506ba541495fedaa6 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 16 Apr 2025 12:42:17 +0200 Subject: [PATCH 09/13] PR comments --- .../app/src/payment/lemonSqueezy/webhook.ts | 2 +- .../src/payment/lemonSqueezy/webhookPayload.ts | 17 +++++++++++++---- template/app/src/payment/stripe/webhook.ts | 5 ++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/template/app/src/payment/lemonSqueezy/webhook.ts b/template/app/src/payment/lemonSqueezy/webhook.ts index 99e4d0a8..53779d46 100644 --- a/template/app/src/payment/lemonSqueezy/webhook.ts +++ b/template/app/src/payment/lemonSqueezy/webhook.ts @@ -43,7 +43,7 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co return response.status(200).json({ received: true }); } catch (err) { if (err instanceof UnhandledWebhookEventError) { - return response.status(200).json({ received: true }); + return response.status(422).json({ error: err.message }); } console.error('Webhook error:', err); diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts index a3d79151..1b2cf24d 100644 --- a/template/app/src/payment/lemonSqueezy/webhookPayload.ts +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -34,6 +34,9 @@ export type SubscriptionData = z.infer; export type OrderData = z.infer; +/** + * This schema is based on LemonSqueezyResponse type + */ const genericEventSchema = z.object({ meta: z.object({ event_name: z.string(), @@ -44,8 +47,11 @@ const genericEventSchema = z.object({ data: z.unknown(), }); -// This is a subtype of Order type from "@lemonsqueezy/lemonsqueezy.js" -// specifically Order['data'] +/** + * This schema is based on + * @type import('@lemonsqueezy/lemonsqueezy.js').Order + * specifically Order['data']. + */ const orderDataSchema = z.object({ attributes: z.object({ customer_id: z.number(), @@ -57,8 +63,11 @@ const orderDataSchema = z.object({ }), }); -// This is a subtype of Subscription type from "@lemonsqueezy/lemonsqueezy.js" -// specifically Subscription['data'] +/** + * This schema is based on + * @type import('@lemonsqueezy/lemonsqueezy.js').Subscription + * specifically Subscription['data']. + */ const subscriptionDataSchema = z.object({ attributes: z.object({ customer_id: z.number(), diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 6769d52c..798bbaa1 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -51,7 +51,7 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook } catch (err) { if (err instanceof UnhandledWebhookEventError) { - return response.status(200).json({ received: true }); + return response.status(422).json({ error: err.message }); } console.error('Webhook error:', err); @@ -211,6 +211,9 @@ const subscriptionItemsSchema = z.object({ }); function extractPriceId(items: SubscsriptionItems): string { + if (items.data.length === 0) { + throw new HttpError(400, 'No items in stripe event object'); + } if (items.data.length > 1) { throw new HttpError(400, 'More than one item in stripe event object'); } From 8d016dd778ff2bb5c34d1122577de7e0ead30ec1 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 16 Apr 2025 12:45:46 +0200 Subject: [PATCH 10/13] Destructure event payload right away --- .../app/src/payment/lemonSqueezy/webhookPayload.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/template/app/src/payment/lemonSqueezy/webhookPayload.ts b/template/app/src/payment/lemonSqueezy/webhookPayload.ts index 1b2cf24d..9a178403 100644 --- a/template/app/src/payment/lemonSqueezy/webhookPayload.ts +++ b/template/app/src/payment/lemonSqueezy/webhookPayload.ts @@ -5,20 +5,20 @@ import { HttpError } from 'wasp/server'; export async function parseWebhookPayload(rawPayload: string) { try { const rawEvent: unknown = JSON.parse(rawPayload); - const event = await genericEventSchema.parseAsync(rawEvent); - switch (event.meta.event_name) { + const { meta, data } = await genericEventSchema.parseAsync(rawEvent); + switch (meta.event_name) { case 'order_created': - const orderData = await orderDataSchema.parseAsync(event.data); - return { eventName: event.meta.event_name, meta: event.meta, data: orderData }; + const orderData = await orderDataSchema.parseAsync(data); + return { eventName: meta.event_name, 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 }; + const subscriptionData = await subscriptionDataSchema.parseAsync(data); + return { eventName: meta.event_name, 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); + throw new UnhandledWebhookEventError(meta.event_name); } } catch (e: unknown) { if (e instanceof UnhandledWebhookEventError) { From 8ac1924eb7c4199bfa34ae4cd62f795466cdbb9d Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 16 Apr 2025 13:47:41 +0200 Subject: [PATCH 11/13] Adds debug log --- template/app/src/payment/stripe/webhookPayload.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts index a669d5bd..6670d3e6 100644 --- a/template/app/src/payment/stripe/webhookPayload.ts +++ b/template/app/src/payment/stripe/webhookPayload.ts @@ -6,6 +6,7 @@ import { HttpError } from 'wasp/server'; export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) { try { const event = await genericStripeEventSchema.parseAsync(rawStripeEvent); + console.log('event', event); switch (event.type) { case 'checkout.session.completed': const session = await sessionCompletedDataSchema.parseAsync(event.data.object); From 8b0eae5cdaf14caa329ba747775d59d3842b9083 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 16 Apr 2025 14:09:33 +0200 Subject: [PATCH 12/13] Adjust `payment_intent.succeeded` data schema to match the expected value --- template/app/src/payment/stripe/webhookPayload.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts index 6670d3e6..2e483f39 100644 --- a/template/app/src/payment/stripe/webhookPayload.ts +++ b/template/app/src/payment/stripe/webhookPayload.ts @@ -6,7 +6,6 @@ import { HttpError } from 'wasp/server'; export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) { try { const event = await genericStripeEventSchema.parseAsync(rawStripeEvent); - console.log('event', event); switch (event.type) { case 'checkout.session.completed': const session = await sessionCompletedDataSchema.parseAsync(event.data.object); @@ -74,7 +73,7 @@ const paymentIntentSucceededDataSchema = z.object({ invoice: z.unknown().optional(), created: z.number(), metadata: z.object({ - priceId: z.string(), + priceId: z.string().optional(), }), customer: z.string(), }); From 4575008eb4dd92e750bddaec1b53d217ee558d6d Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 17 Apr 2025 13:46:59 +0200 Subject: [PATCH 13/13] Adds extra logging for unhandled webhook events --- template/app/src/payment/lemonSqueezy/webhook.ts | 1 + template/app/src/payment/stripe/webhook.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/template/app/src/payment/lemonSqueezy/webhook.ts b/template/app/src/payment/lemonSqueezy/webhook.ts index 53779d46..6454282a 100644 --- a/template/app/src/payment/lemonSqueezy/webhook.ts +++ b/template/app/src/payment/lemonSqueezy/webhook.ts @@ -43,6 +43,7 @@ export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, co return response.status(200).json({ received: true }); } catch (err) { if (err instanceof UnhandledWebhookEventError) { + console.error(err.message); return response.status(422).json({ error: err.message }); } diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 798bbaa1..b6d53849 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -51,6 +51,7 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook } catch (err) { if (err instanceof UnhandledWebhookEventError) { + console.error(err.message); return response.status(422).json({ error: err.message }); }