diff --git a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx
index 1edff068..1577358e 100644
--- a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx
+++ b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx
@@ -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`
-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:
-
- `account.updated`
-
- `checkout.session.completed`
-
- `customer.subscription.deleted`
-
- `customer.subscription.updated`
-
- `invoice.paid`
-
- `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`:
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.
If you've deployed to Fly.io, you can do that easily with the following command:
diff --git a/template/app/package.json b/template/app/package.json
index f19a9ef8..1f8c377e 100644
--- a/template/app/package.json
+++ b/template/app/package.json
@@ -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",
diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts
index 85d6a1ec..391ea398 100644
--- a/template/app/src/payment/plans.ts
+++ b/template/app/src/payment/plans.ts
@@ -5,6 +5,7 @@ export enum SubscriptionStatus {
CancelAtPeriodEnd = 'cancel_at_period_end',
Active = 'active',
Deleted = 'deleted',
+ Pending = 'pending',
}
export enum PaymentPlanId {
diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts
index 489d02a0..0ab9ad54 100644
--- a/template/app/src/payment/stripe/checkoutUtils.ts
+++ b/template/app/src/payment/stripe/checkoutUtils.ts
@@ -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,
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,
});
} 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);
- }
-}
diff --git a/template/app/src/payment/stripe/stripeClient.ts b/template/app/src/payment/stripe/stripeClient.ts
index da1b7fcb..1c1acaf2 100644
--- a/template/app/src/payment/stripe/stripeClient.ts
+++ b/template/app/src/payment/stripe/stripeClient.ts
@@ -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',
});
diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts
index b6d53849..9828e115 100644
--- a/template/app/src/payment/stripe/webhook.ts
+++ b/template/app/src/payment/stripe/webhook.ts
@@ -13,7 +13,7 @@ import { z } from 'zod';
import {
parseWebhookPayload,
type InvoicePaidData,
- type PaymentIntentSucceededData,
+ SubscriptionCreatedData,
type SessionCompletedData,
type SubscriptionDeletedData,
type SubscriptionUpdatedData,
@@ -32,8 +32,8 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context)
case 'invoice.paid':
await handleInvoicePaid(data, prismaUserDelegate);
break;
- case 'payment_intent.succeeded':
- await handlePaymentIntentSucceeded(data, prismaUserDelegate);
+ case 'customer.subscription.created':
+ await handleCustomerSubscriptionCreated(data, prismaUserDelegate);
break;
case 'customer.subscription.updated':
await handleCustomerSubscriptionUpdated(data, prismaUserDelegate);
@@ -85,67 +85,60 @@ 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.
+// Here we only update the user's payment details, and confirm credits
+// if payment mode === payment (e.g. one-time payment). If payment mode === subscription,
+// we update its status in the customer.subscription.created or customer.subscription.updated webhook.
+// 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.
export async function handleCheckoutSessionCompleted(
session: SessionCompletedData,
prismaUserDelegate: PrismaClient['user']
) {
+ if (session.mode !== 'payment' || session.payment_status !== 'paid') {
+ return;
+ }
const userStripeId = session.customer;
- const lineItems = await getSubscriptionLineItemsBySessionId(session.id);
-
+ 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 });
-
- return updateUserStripePaymentDetails({ userStripeId, subscriptionPlan }, prismaUserDelegate);
+ const { numOfCreditsPurchased } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });
+ return updateUserStripePaymentDetails(
+ { userStripeId, numOfCreditsPurchased, datePaid: new Date() },
+ 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.
+// 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.
export async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) {
const userStripeId = invoice.customer;
const datePaid = new Date(invoice.period_start * 1000);
- return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
+ const lineItems = await invoiceLineItemsSchema.parseAsync(invoice.lines);
+ const priceId = extractPriceId(lineItems);
+ const subscriptionPlan = getPlanIdByPriceId(priceId);
+ return updateUserStripePaymentDetails(
+ { userStripeId, datePaid, subscriptionPlan, subscriptionStatus: SubscriptionStatus.Active },
+ prismaUserDelegate
+ );
}
-export async function handlePaymentIntentSucceeded(
- paymentIntent: PaymentIntentSucceededData,
+export async function handleCustomerSubscriptionCreated(
+ subscription: SubscriptionCreatedData,
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 userStripeId = subscription.customer;
+ const priceId = extractPriceId(subscription.items);
+ const subscriptionPlan = getPlanIdByPriceId(priceId);
+ // We currently use Pending for all other Stripe subscription statuses besides Active that we are not handling.
+ // If you want to handle these other statuses, make sure to update the `SubscriptionStatus` type in `payment/plans.ts`
+ const subscriptionStatus: SubscriptionStatus =
+ subscription.status === SubscriptionStatus.Active
+ ? SubscriptionStatus.Active
+ : SubscriptionStatus.Pending;
return updateUserStripePaymentDetails(
- { userStripeId, numOfCreditsPurchased, datePaid },
+ { userStripeId, subscriptionPlan, subscriptionStatus },
prismaUserDelegate
);
}
@@ -156,12 +149,11 @@ export async function handleCustomerSubscriptionUpdated(
) {
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
@@ -211,17 +203,37 @@ const subscriptionItemsSchema = z.object({
),
});
-function extractPriceId(items: SubscsriptionItems): string {
+type InvoiceLineItems = z.infer;
+
+const invoiceLineItemsSchema = z.object({
+ data: z.array(
+ z.object({
+ pricing: z.object({ price_details: z.object({ price: z.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: SubscsriptionItems | InvoiceLineItems): 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 firstItem = items.data[0];
+
+ if ('price' in firstItem) {
+ return firstItem.price.id;
+ } else if ('pricing' in firstItem) {
+ return firstItem.pricing.price_details.price;
+ } else {
+ throw new HttpError(500, 'Unable to extract price id due to unexpected item structure');
+ }
}
-async function getSubscriptionLineItemsBySessionId(sessionId: string) {
+async function getCheckoutLineItemsBySessionId(sessionId: string) {
try {
const { line_items: lineItemsRaw } = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts
index 2e483f39..f585aaa0 100644
--- a/template/app/src/payment/stripe/webhookPayload.ts
+++ b/template/app/src/payment/stripe/webhookPayload.ts
@@ -13,9 +13,9 @@ 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.created':
+ const createdSubscription = await subscriptionCreatedDataSchema.parseAsync(event.data.object);
+ return { eventName: event.type, data: createdSubscription };
case 'customer.subscription.updated':
const updatedSubscription = await subscriptionUpdatedDataSchema.parseAsync(event.data.object);
return { eventName: event.type, data: updatedSubscription };
@@ -54,6 +54,8 @@ 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']),
});
/**
@@ -61,21 +63,34 @@ const sessionCompletedDataSchema = z.object({
* @type import('stripe').Stripe.Invoice
*/
const invoicePaidDataSchema = z.object({
+ id: z.string(),
customer: z.string(),
period_start: z.number(),
+ lines: z.object({
+ data: z.array(
+ z.object({
+ pricing: z.object({ price_details: z.object({ price: z.string() }) }),
+ })
+ ),
+ }),
});
/**
* This is a subtype of
- * @type import('stripe').Stripe.PaymentIntent
+ * @type import('stripe').Stripe.Subscription
*/
-const paymentIntentSucceededDataSchema = z.object({
- invoice: z.unknown().optional(),
- created: z.number(),
- metadata: z.object({
- priceId: z.string().optional(),
- }),
+const subscriptionCreatedDataSchema = z.object({
customer: z.string(),
+ status: z.string(),
+ items: z.object({
+ data: z.array(
+ z.object({
+ price: z.object({
+ id: z.string(),
+ }),
+ })
+ ),
+ }),
});
/**
@@ -109,7 +124,7 @@ export type SessionCompletedData = z.infer;
export type InvoicePaidData = z.infer;
-export type PaymentIntentSucceededData = z.infer;
+export type SubscriptionCreatedData = z.infer;
export type SubscriptionUpdatedData = z.infer;
diff --git a/template/app/src/user/AccountPage.tsx b/template/app/src/user/AccountPage.tsx
index 207de2a5..2f2599ab 100644
--- a/template/app/src/user/AccountPage.tsx
+++ b/template/app/src/user/AccountPage.tsx
@@ -119,6 +119,7 @@ function prettyPrintStatus(
past_due: `Payment for your ${planName} plan is past due! Please update your subscription payment information.`,
cancel_at_period_end: `Your ${planName} plan subscription has been canceled, but remains active until the end of the current billing period${endOfBillingPeriod}`,
deleted: `Your previous subscription has been canceled and is no longer active.`,
+ pending: `Your subscription is pending. Your payment must be finalized or is still being processed.`,
};
if (Object.keys(statusToMessage).includes(subscriptionStatus)) {
return statusToMessage[subscriptionStatus];