diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 6f19044..489d02a 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -35,10 +35,14 @@ interface CreateStripeCheckoutSessionParams { mode: StripeMode; } -export async function createStripeCheckoutSession({ priceId, customerId, mode }: CreateStripeCheckoutSessionParams) { +export async function createStripeCheckoutSession({ + priceId, + customerId, + mode, +}: CreateStripeCheckoutSessionParams) { try { - const metadata = returnMetadataByMode({ mode, priceId }); - + const paymentIntentData = getPaymentIntentData({ mode, priceId }); + return await stripe.checkout.sessions.create({ line_items: [ { @@ -58,7 +62,7 @@ export async function createStripeCheckoutSession({ priceId, customerId, mode }: // 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. - ...metadata + payment_intent_data: paymentIntentData, }); } catch (error) { console.error(error); @@ -66,21 +70,16 @@ export async function createStripeCheckoutSession({ priceId, customerId, mode }: } } -interface ReturnMetadataByModeParams { - mode: StripeMode; - priceId: string; -} - -interface ReturnMetadataByModeResult { - payment_intent_data: Stripe.Checkout.SessionCreateParams.PaymentIntentData; -} - -function returnMetadataByMode({ mode, priceId }: ReturnMetadataByModeParams): ReturnMetadataByModeResult | undefined { +function getPaymentIntentData({ mode, priceId }: { mode: StripeMode; priceId: string }): + | { + metadata: { priceId: string }; + } + | undefined { switch (mode) { case 'subscription': return undefined; case 'payment': - return { payment_intent_data: { metadata: { priceId } } }; + return { metadata: { priceId } }; default: assertUnreachable(mode); } diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 1bfccc7..b0c9972 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -4,7 +4,7 @@ import { type PrismaClient } from '@prisma/client'; import express from 'express'; import { Stripe } from 'stripe'; import { stripe } from './stripeClient'; -import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans'; +import { paymentPlans, PaymentPlanId, SubscriptionStatus, PaymentPlanEffect, PaymentPlan } from '../plans'; import { updateUserStripePaymentDetails } from './paymentDetails'; import { emailSender } from 'wasp/server/email'; import { assertUnreachable } from '../../shared/utils'; @@ -35,7 +35,6 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) break; case 'payment_intent.succeeded': const paymentIntent = event.data.object as Stripe.PaymentIntent; - if (paymentIntent.invoice) return; // We handle invoices in the invoice.paid webhook. await handlePaymentIntentSucceeded(paymentIntent, prismaUserDelegate); break; case 'customer.subscription.updated': @@ -77,7 +76,11 @@ export async function handleCheckoutSessionCompleted( }); const lineItemPriceId = extractPriceId(line_items); const planId = getPlanIdByPriceId(lineItemPriceId); - const { subscriptionPlan } = getPlanEffectDetailsById(planId); + const plan = paymentPlans[planId]; + if (plan.effect.kind === 'credits') { + return; + } + const { subscriptionPlan } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect }); return updateUserStripePaymentDetails( { userStripeId, subscriptionPlan }, @@ -97,6 +100,12 @@ export async function handlePaymentIntentSucceeded( paymentIntent: Stripe.PaymentIntent, 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 = validateUserStripeIdOrThrow(paymentIntent.customer); const datePaid = new Date(paymentIntent.created * 1000); @@ -109,10 +118,15 @@ export async function handlePaymentIntentSucceeded( } const planId = getPlanIdByPriceId(metadata.priceId); - const { subscriptionPlan, numOfCreditsPurchased } = getPlanEffectDetailsById(planId); + const plan = paymentPlans[planId]; + if (plan.effect.kind === 'subscription') { + return; + } + + const { numOfCreditsPurchased } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect }); return updateUserStripePaymentDetails( - { userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid }, + { userStripeId, numOfCreditsPurchased, datePaid }, prismaUserDelegate ); } @@ -195,21 +209,16 @@ function getPlanIdByPriceId(priceId: string): PaymentPlanId { return planId; } -function getPlanEffectDetailsById(planId: PaymentPlanId) { - const plan = paymentPlans[planId]; - let subscriptionPlan: PaymentPlanId | undefined; - let numOfCreditsPurchased: number | undefined; - - switch (plan.effect.kind) { +function getPlanEffectPaymentDetails({ planId, planEffect }: { planId: PaymentPlanId, planEffect: PaymentPlanEffect}): { + subscriptionPlan: PaymentPlanId | undefined; + numOfCreditsPurchased: number | undefined; +} { + switch (planEffect.kind) { case 'subscription': - subscriptionPlan = planId; - break; + return { subscriptionPlan: planId, numOfCreditsPurchased: undefined }; case 'credits': - numOfCreditsPurchased = plan.effect.amount; - break; + return { subscriptionPlan: undefined, numOfCreditsPurchased: planEffect.amount }; default: - assertUnreachable(plan.effect); + assertUnreachable(planEffect); } - - return { subscriptionPlan, numOfCreditsPurchased }; }