Skip to content

update stripe sdk and webhooks to match #427

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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions opensaas-sh/blog/src/content/docs/guides/deploying.mdx
Original file line number Diff line number Diff line change
@@ -175,13 +175,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, {
2. click on `+ add endpoint`
3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`
<Image src={stripeListenEvents} alt="listen events" loading="lazy" />
4. select the events you want to listen to. These should be the same events you're consuming in your webhook. For example, if you haven't added any additional events to the webhook and are using the defaults that came with this template, then you'll need to add:
<br/>- `account.updated`
<br/>- `checkout.session.completed`
<br/>- `customer.subscription.deleted`
<br/>- `customer.subscription.updated`
<br/>- `invoice.paid`
<br/>- `payment_intent.succeeded`
4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts):
<Image src={stripeSigningSecret} alt="signing secret" loading="lazy" />
5. after that, go to the webhook you just created and `reveal` the new signing secret.
6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable. <br/>If you've deployed to Fly.io, you can do that easily with the following command:
2 changes: 1 addition & 1 deletion template/app/package.json
Original file line number Diff line number Diff line change
@@ -24,7 +24,7 @@
"react-hot-toast": "^2.4.1",
"react-icons": "4.11.0",
"react-router-dom": "^6.26.2",
"stripe": "11.15.0",
"stripe": "18.1.0",
"tailwind-merge": "^2.2.1",
"tailwindcss": "^3.2.7",
"vanilla-cookieconsent": "^3.0.1",
24 changes: 1 addition & 23 deletions template/app/src/payment/stripe/checkoutUtils.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@ import type { StripeMode } from './paymentProcessor';

import Stripe from 'stripe';
import { stripe } from './stripeClient';
import { assertUnreachable } from '../../shared/utils';

// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
@@ -41,8 +40,6 @@ export async function createStripeCheckoutSession({
mode,
}: CreateStripeCheckoutSessionParams) {
try {
const paymentIntentData = getPaymentIntentData({ mode, priceId });

return await stripe.checkout.sessions.create({
line_items: [
{
@@ -54,33 +51,14 @@ export async function createStripeCheckoutSession({
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fixes #412

customer_update: {
address: 'auto',
},
customer: customerId,
// Stripe only allows us to pass payment intent metadata for one-time payments, not subscriptions.
// We do this so that we can capture priceId in the payment_intent.succeeded webhook
// and easily confirm the user's payment based on the price id. For subscriptions, we can get the price id
// in the customer.subscription.updated webhook via the line_items field.
payment_intent_data: paymentIntentData,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we're no longer dealing with payment intents to handle one-time payment products. these are now just getting handled directly in checkout.session.completed.

});
} catch (error) {
console.error(error);
throw error;
}
}

function getPaymentIntentData({ mode, priceId }: { mode: StripeMode; priceId: string }):
| {
metadata: { priceId: string };
}
| undefined {
switch (mode) {
case 'subscription':
return undefined;
case 'payment':
return { metadata: { priceId } };
default:
assertUnreachable(mode);
}
}
2 changes: 1 addition & 1 deletion template/app/src/payment/stripe/paymentDetails.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import type { SubscriptionStatus } from '../plans';
import { PaymentPlanId } from '../plans';
import { PrismaClient } from '@prisma/client';

export const updateUserStripePaymentDetails = (
export const updateUserStripePaymentDetails = async (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why the async? Seems unnecessary.

{ userStripeId, subscriptionPlan, subscriptionStatus, datePaid, numOfCreditsPurchased }: {
userStripeId: string;
subscriptionPlan?: PaymentPlanId;
2 changes: 1 addition & 1 deletion template/app/src/payment/stripe/stripeClient.ts
Original file line number Diff line number Diff line change
@@ -8,5 +8,5 @@ export const stripe = new Stripe(requireNodeEnvVar('STRIPE_API_KEY'), {
// npm package to the API version that matches your Stripe dashboard's one.
// For more details and alternative setups check
// https://docs.stripe.com/api/versioning .
apiVersion: '2022-11-15',
apiVersion: '2025-04-30.basil',
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fixes issue #229

});
139 changes: 58 additions & 81 deletions template/app/src/payment/stripe/webhook.ts
Original file line number Diff line number Diff line change
@@ -9,11 +9,9 @@ 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 {
parseWebhookPayload,
type InvoicePaidData,
type PaymentIntentSucceededData,
type SessionCompletedData,
type SubscriptionDeletedData,
type SubscriptionUpdatedData,
@@ -32,9 +30,6 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context)
case 'invoice.paid':
await handleInvoicePaid(data, prismaUserDelegate);
break;
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(data, prismaUserDelegate);
break;
case 'customer.subscription.updated':
await handleCustomerSubscriptionUpdated(data, prismaUserDelegate);
break;
@@ -85,83 +80,64 @@ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) =
return middlewareConfig;
};

// Because a checkout session completed could potentially result in a failed payment,
// we can update the user's payment details here, but confirm credits or a subscription
// if the payment succeeds in other, more specific, webhooks.
export async function handleCheckoutSessionCompleted(
// Here we only update the user's payment details, and confirm credits because Stripe does not send invoices for one-time payments.
// NOTE: If you're accepting async payment methods like bank transfers or SEPA and not just card payments
// which are synchronous, checkout session completed could potentially result in a pending payment.
// If so, use the checkout.session.async_payment_succeeded event to confirm the payment.
Comment on lines +85 to +86
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// which are synchronous, checkout session completed could potentially result in a pending payment.
// If so, use the checkout.session.async_payment_succeeded event to confirm the payment.
// which are synchronous, the checkout.session.completed could event potentially result in a pending payment.
// If so, use the checkout.session.async_payment_succeeded event to confirm the payment.

Makes it clearer what we're talking about.

async function handleCheckoutSessionCompleted(
session: SessionCompletedData,
prismaUserDelegate: PrismaClient['user']
) {
const userStripeId = session.customer;
const lineItems = await getSubscriptionLineItemsBySessionId(session.id);
const isSuccessfulOneTimePayment = session.mode === 'payment' && session.payment_status === 'paid';
if (isSuccessfulOneTimePayment) {
await saveSuccessfulOneTimePayment(session, prismaUserDelegate);
}
}

async function saveSuccessfulOneTimePayment(
session: SessionCompletedData,
prismaUserDelegate: PrismaClient['user']
) {
const userStripeId = session.customer;
const lineItems = await getCheckoutLineItemsBySessionId(session.id);
const lineItemPriceId = extractPriceId(lineItems);

const planId = getPlanIdByPriceId(lineItemPriceId);
const plan = paymentPlans[planId];
if (plan.effect.kind === 'credits') {
return;
}
const { subscriptionPlan } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });
const { numOfCreditsPurchased } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });
return updateUserStripePaymentDetails(
{ userStripeId, numOfCreditsPurchased, datePaid: new Date() },
prismaUserDelegate
);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

due to updates to the webhook events on Stripe's side, we can now process credits-based payments and subscriptions within the same endpoint. If it's a subscription payment, numOfCreditsPurchased will be undefined and thus won't be updated in the DB. We

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think your sentence got cut off in editing 😄

}

return updateUserStripePaymentDetails({ userStripeId, subscriptionPlan }, prismaUserDelegate);
// This is called when a subscription is successfully purchased or renewed and payment succeeds.
// Invoices are not created for one-time payments, so we handle them above.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Instead of "above," let's say where we handle them.

async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) {
await saveActiveSubscription(invoice, 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']) {
async function saveActiveSubscription(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) {
const userStripeId = invoice.customer;
const datePaid = new Date(invoice.period_start * 1000);
return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
}

export async function handlePaymentIntentSucceeded(
paymentIntent: PaymentIntentSucceededData,
prismaUserDelegate: PrismaClient['user']
) {
// 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;
}

const userStripeId = paymentIntent.customer;
const datePaid = new Date(paymentIntent.created * 1000);

// We capture the price id from the payment intent metadata
// that we passed in when creating the checkout session in checkoutUtils.ts.
const { metadata } = paymentIntent;

if (!metadata.priceId) {
throw new HttpError(400, 'No price id found in payment intent');
}

const planId = getPlanIdByPriceId(metadata.priceId);
const plan = paymentPlans[planId];
if (plan.effect.kind === 'subscription') {
return;
}

const { numOfCreditsPurchased } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });

const priceId = extractPriceId(invoice.lines);
const subscriptionPlan = getPlanIdByPriceId(priceId);
return updateUserStripePaymentDetails(
{ userStripeId, numOfCreditsPurchased, datePaid },
{ userStripeId, datePaid, subscriptionPlan, subscriptionStatus: SubscriptionStatus.Active },
prismaUserDelegate
);
}

export async function handleCustomerSubscriptionUpdated(
async function handleCustomerSubscriptionUpdated(
subscription: SubscriptionUpdatedData,
prismaUserDelegate: PrismaClient['user']
) {
const userStripeId = subscription.customer;
let subscriptionStatus: SubscriptionStatus | undefined;

const priceId = extractPriceId(subscription.items);
const subscriptionPlan = getPlanIdByPriceId(priceId);

// There are other subscription statuses, such as `trialing` that we are not handling and simply ignore
// If you'd like to handle more statuses, you can add more cases above. Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well
// If you'd like to handle more statuses, you can add more cases above. Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well.
if (subscription.status === SubscriptionStatus.Active) {
subscriptionStatus = subscription.cancel_at_period_end
? SubscriptionStatus.CancelAtPeriodEnd
@@ -188,7 +164,7 @@ export async function handleCustomerSubscriptionUpdated(
}
}

export async function handleCustomerSubscriptionDeleted(
async function handleCustomerSubscriptionDeleted(
subscription: SubscriptionDeletedData,
prismaUserDelegate: PrismaClient['user']
) {
@@ -199,40 +175,41 @@ export async function handleCustomerSubscriptionDeleted(
);
}

type SubscsriptionItems = z.infer<typeof subscriptionItemsSchema>;

const subscriptionItemsSchema = z.object({
data: z.array(
z.object({
price: z.object({
id: z.string(),
}),
})
),
});

function extractPriceId(items: SubscsriptionItems): string {
// We only expect one line item, but if you set up a product with multiple prices, you should change this function to handle them.
function extractPriceId(
items: Stripe.ApiList<Stripe.LineItem> | SubscriptionUpdatedData['items'] | InvoicePaidData['lines']
): 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');
}
return items.data[0].price.id;
}
const item = items.data[0];

async function getSubscriptionLineItemsBySessionId(sessionId: string) {
try {
const { line_items: lineItemsRaw } = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
// The 'price' property is found on SubscriptionItem and LineItem.
if ('price' in item && item.price?.id) {
return item.price.id;
}

const lineItems = await subscriptionItemsSchema.parseAsync(lineItemsRaw);
// The 'pricing' property is found on InvoiceLineItem.
if ('pricing' in item) {
const priceId = item.pricing?.price_details?.price;
if (priceId) {
return priceId;
}
}
throw new HttpError(400, 'Unable to extract price id from item');
}

return lineItems;
} catch (e: unknown) {
throw new HttpError(500, 'Error parsing Stripe line items');
async function getCheckoutLineItemsBySessionId(sessionId: string) {
const { line_items } = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
if (!line_items) {
throw new HttpError(400, 'No line items found in checkout session');
}
return line_items;
}

function getPlanIdByPriceId(priceId: string): PaymentPlanId {
26 changes: 9 additions & 17 deletions template/app/src/payment/stripe/webhookPayload.ts
Original file line number Diff line number Diff line change
@@ -13,9 +13,6 @@ export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) {
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 };
@@ -54,28 +51,25 @@ const genericStripeEventSchema = z.object({
const sessionCompletedDataSchema = z.object({
id: z.string(),
customer: z.string(),
payment_status: z.enum(['paid', 'unpaid', 'no_payment_required']),
mode: z.enum(['payment', 'subscription']),
});

/**
* This is a subtype of
* @type import('stripe').Stripe.Invoice
*/
const invoicePaidDataSchema = z.object({
id: z.string(),
customer: z.string(),
period_start: z.number(),
});

/**
* This is a subtype of
* @type import('stripe').Stripe.PaymentIntent
*/
const paymentIntentSucceededDataSchema = z.object({
invoice: z.unknown().optional(),
created: z.number(),
metadata: z.object({
priceId: z.string().optional(),
lines: z.object({
data: z.array(
z.object({
pricing: z.object({ price_details: z.object({ price: z.string() }) }),
})
),
}),
customer: z.string(),
});

/**
@@ -109,8 +103,6 @@ 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>;