Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Zod validation for webhook payloads #377

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Prev Previous commit
Next Next commit
Merge branch 'main' into miho-webhooks-runtime-validations
  • Loading branch information
infomiho committed Feb 20, 2025
commit a9695447933c0f5f324c75fafc9be2c498cc3a89
35 changes: 29 additions & 6 deletions template/app/src/payment/stripe/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { z } from 'zod';
import {
InvoicePaidData,
parseWebhookPayload,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we separate and import types at the type of the file, as we've been doing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean import the types at the top of the file? I don't see that we did that in this file e.g.

import { type MiddlewareConfigFn, HttpError } from 'wasp/server';

is on top.

I've added the import type bit for the types.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah true, this won't apply for zod types as they're runtime specific, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't apply for zod types as they're runtime specific, right?

I'm not following sorry :) What do mean exactly that won't apply to Zod types?

PaymentIntentSucceededData,
SessionCompletedData,
SubscriptionDeletedData,
SubscriptionUpdatedData,
Expand Down Expand Up @@ -43,6 +44,9 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context)
case 'invoice.paid':
await handleInvoicePaid(payload.data, prismaUserDelegate);
break;
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(payload.data, prismaUserDelegate);
break;
case 'customer.subscription.updated':
await handleCustomerSubscriptionUpdated(payload.data, prismaUserDelegate);
break;
Expand Down Expand Up @@ -108,23 +112,22 @@ export async function handleCheckoutSessionCompleted(
}
const { subscriptionPlan } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });

return updateUserStripePaymentDetails(
{ userStripeId, subscriptionPlan },
prismaUserDelegate
);
return updateUserStripePaymentDetails({ userStripeId, subscriptionPlan }, prismaUserDelegate);
}

// 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 datePaid = new Date(invoice.period_start * 1000);
return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
}

export async function handlePaymentIntentSucceeded(
paymentIntent: Stripe.PaymentIntent,
paymentIntent: PaymentIntentSucceededData,
prismaUserDelegate: PrismaClient['user']
) {
// We handle invoices in the invoice.paid webhook. Invoices exist for subscription payments,
// We handle invoices in the invoice.paid webhook. Invoices exist for subscription payments,
// but not for one-time payment/credits products which use the Stripe `payment` mode on checkout sessions.
if (paymentIntent.invoice) {
return;
Expand Down Expand Up @@ -246,3 +249,23 @@ function getPlanIdByPriceId(priceId: string): PaymentPlanId {
}
return planId;
}

function getPlanEffectPaymentDetails({
planId,
planEffect,
}: {
planId: PaymentPlanId;
planEffect: PaymentPlanEffect;
}): {
subscriptionPlan: PaymentPlanId | undefined;
numOfCreditsPurchased: number | undefined;
} {
switch (planEffect.kind) {
case 'subscription':
return { subscriptionPlan: planId, numOfCreditsPurchased: undefined };
case 'credits':
return { subscriptionPlan: undefined, numOfCreditsPurchased: planEffect.amount };
default:
assertUnreachable(planEffect);
}
}
20 changes: 20 additions & 0 deletions template/app/src/payment/stripe/webhookPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) {
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)
Expand Down Expand Up @@ -61,6 +69,16 @@ const invoicePaidDataSchema = z.object({
period_start: z.number(),
});

// This is a subtype of Stripe.PaymentIntent from "stripe"
const paymentIntentSucceededDataSchema = z.object({
invoice: z.unknown().optional(),
created: z.number(),
metadata: z.object({
priceId: z.string(),
}),
customer: z.string(),
});

// This is a subtype of Stripe.Subscription from "stripe"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applies to all these comments.

I think there's some special syntax you can use when referencing other symbols to enable users to Ctrl+click and go to their definition.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen.Recording.2025-02-28.at.13.16.56.mov

Great idea!

const subscriptionUpdatedDataSchema = z.object({
customer: z.string(),
Expand All @@ -86,6 +104,8 @@ export type SessionCompletedData = z.infer<typeof sessionCompletedDataSchema>;

export type InvoicePaidData = z.infer<typeof invoicePaidDataSchema>;

export type PaymentIntentSucceededData = z.infer<typeof paymentIntentSucceededDataSchema>;

export type SubscriptionUpdatedData = z.infer<typeof subscriptionUpdatedDataSchema>;

export type SubscriptionDeletedData = z.infer<typeof subscriptionDeletedDataSchema>;
You are viewing a condensed version of this merge commit. You can view the full changes here.