From 05f45c2cf30397a32ea4708d703b7734f91728c0 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 14 Nov 2025 16:49:15 +0100 Subject: [PATCH] abstract stripe payment stuff --- .../payment/stripe/paymentProcessor.ts.diff | 24 ++--- .../src/payment/stripe/webhook.ts.diff | 38 +++++++ opensaas-sh/app_diff/src/payment/user.ts.diff | 98 +++++++++++++++++ template/app/src/payment/plans.ts | 13 +++ .../src/payment/stripe/paymentProcessor.ts | 21 ++-- template/app/src/payment/stripe/webhook.ts | 102 +++++++++--------- template/app/src/payment/{stripe => }/user.ts | 72 ++++++------- 7 files changed, 257 insertions(+), 111 deletions(-) create mode 100644 opensaas-sh/app_diff/src/payment/stripe/webhook.ts.diff create mode 100644 opensaas-sh/app_diff/src/payment/user.ts.diff rename template/app/src/payment/{stripe => }/user.ts (69%) diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index 12510001..dcf4ccea 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -1,30 +1,28 @@ --- template/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts -@@ -13,8 +13,8 @@ - } from "./checkoutUtils"; - import { stripeClient } from "./stripeClient"; +@@ -8,8 +8,8 @@ + } from "../paymentProcessor"; + import type { PaymentPlanEffect } from "../plans"; import { - fetchUserPaymentProcessorUserId, - updateUserPaymentProcessorUserId, + fetchUserStripeId, + updateUserStripeId - } from "./user"; - import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; - -@@ -28,10 +28,10 @@ + } from "../user"; + import { + createStripeCheckoutSession, +@@ -28,8 +28,8 @@ }: CreateCheckoutSessionArgs) => { const customer = await ensureStripeCustomer(userEmail); - await updateUserPaymentProcessorUserId( +- { userId, paymentProcessorUserId: customer.id }, + await updateUserStripeId( - { - userId, -- paymentProcessorUserId: customer.id, -+ stripeId: customer.id, - }, ++ { userId, stripeId: customer.id }, prismaUserDelegate, ); -@@ -59,18 +59,18 @@ + +@@ -56,18 +56,18 @@ prismaUserDelegate, userId, }: FetchCustomerPortalUrlArgs) => { diff --git a/opensaas-sh/app_diff/src/payment/stripe/webhook.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/webhook.ts.diff new file mode 100644 index 00000000..3ef27955 --- /dev/null +++ b/opensaas-sh/app_diff/src/payment/stripe/webhook.ts.diff @@ -0,0 +1,38 @@ +--- template/app/src/payment/stripe/webhook.ts ++++ opensaas-sh/app/src/payment/stripe/webhook.ts +@@ -111,7 +111,7 @@ + case PaymentPlanId.Credits10: + await updateUserCredits( + { +- paymentProcessorUserId: customerId, ++ stripeId: customerId, + datePaid: invoicePaidAtDate, + numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount, + }, +@@ -122,7 +122,7 @@ + case PaymentPlanId.Hobby: + await updateUserSubscription( + { +- paymentProcessorUserId: customerId, ++ stripeId: customerId, + datePaid: invoicePaidAtDate, + paymentPlanId, + subscriptionStatus: SubscriptionStatus.Active, +@@ -169,7 +169,7 @@ + ); + + const user = await updateUserSubscription( +- { paymentProcessorUserId: customerId, paymentPlanId, subscriptionStatus }, ++ { stripeId: customerId, paymentPlanId, subscriptionStatus }, + prismaUserDelegate, + ); + +@@ -237,7 +237,7 @@ + + await updateUserSubscription( + { +- paymentProcessorUserId: customerId, ++ stripeId: customerId, + subscriptionStatus: SubscriptionStatus.Deleted, + }, + prismaUserDelegate, diff --git a/opensaas-sh/app_diff/src/payment/user.ts.diff b/opensaas-sh/app_diff/src/payment/user.ts.diff new file mode 100644 index 00000000..eeb3fa80 --- /dev/null +++ b/opensaas-sh/app_diff/src/payment/user.ts.diff @@ -0,0 +1,98 @@ +--- template/app/src/payment/user.ts ++++ opensaas-sh/app/src/payment/user.ts +@@ -2,7 +2,7 @@ + import { PrismaClient } from "wasp/server"; + import { PaymentPlanId, SubscriptionStatus } from "./plans"; + +-export async function fetchUserPaymentProcessorUserId( ++export async function fetchUserStripeId( + userId: User["id"], + prismaUserDelegate: PrismaClient["user"], + ): Promise { +@@ -11,20 +11,20 @@ + id: userId, + }, + select: { +- paymentProcessorUserId: true, ++ stripeId: true, + }, + }); + +- return user.paymentProcessorUserId; ++ return user.stripeId; + } + +-interface UpdateUserPaymentProcessorUserIdArgs { ++interface UpdateUserStripeIdArgs { + userId: User["id"]; +- paymentProcessorUserId: NonNullable; ++ stripeId: NonNullable; + } + +-export function updateUserPaymentProcessorUserId( +- { userId, paymentProcessorUserId }: UpdateUserPaymentProcessorUserIdArgs, ++export function updateUserStripeId( ++ { userId, stripeId }: UpdateUserStripeIdArgs, + prismaUserDelegate: PrismaClient["user"], + ): Promise { + return prismaUserDelegate.update({ +@@ -32,13 +32,13 @@ + id: userId, + }, + data: { +- paymentProcessorUserId, ++ stripeId, + }, + }); + } + + interface UpdateUserSubscriptionArgs { +- paymentProcessorUserId: NonNullable; ++ stripeId: NonNullable; + subscriptionStatus: SubscriptionStatus; + paymentPlanId?: PaymentPlanId; + datePaid?: Date; +@@ -46,7 +46,7 @@ + + export function updateUserSubscription( + { +- paymentProcessorUserId, ++ stripeId, + paymentPlanId, + subscriptionStatus, + datePaid, +@@ -55,7 +55,7 @@ + ) { + return userDelegate.update({ + where: { +- paymentProcessorUserId, ++ stripeId, + }, + data: { + subscriptionPlan: paymentPlanId, +@@ -66,14 +66,14 @@ + } + + interface UpdateUserCreditsArgs { +- paymentProcessorUserId: NonNullable; ++ stripeId: NonNullable; + numOfCreditsPurchased: number; + datePaid: Date; + } + + export function updateUserCredits( + { +- paymentProcessorUserId, ++ stripeId, + numOfCreditsPurchased, + datePaid, + }: UpdateUserCreditsArgs, +@@ -81,7 +81,7 @@ + ) { + return userDelegate.update({ + where: { +- paymentProcessorUserId, ++ stripeId, + }, + data: { + credits: { increment: numOfCreditsPurchased }, diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts index d2c3935d..e5ca0cb6 100644 --- a/template/app/src/payment/plans.ts +++ b/template/app/src/payment/plans.ts @@ -1,3 +1,4 @@ +import { User } from "wasp/entities"; import { requireNodeEnvVar } from "../server/utils"; export enum SubscriptionStatus { @@ -67,3 +68,15 @@ export function getSubscriptionPaymentPlanIds(): PaymentPlanId[] { (planId) => paymentPlans[planId].effect.kind === "subscription", ); } + +export function getPaymentPlanIdByPaymentProcessorPlanId( + paymentProcessorUserId: NonNullable, +): PaymentPlanId { + for (const [planId, plan] of Object.entries(paymentPlans)) { + if (plan.getPaymentProcessorPlanId() === paymentProcessorUserId) { + return planId as PaymentPlanId; + } + } + + throw new Error(`Unknown payment processor ID: ${paymentProcessorUserId}`); +} diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 681a2c03..b2941d36 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -7,15 +7,15 @@ import type { PaymentProcessor, } from "../paymentProcessor"; import type { PaymentPlanEffect } from "../plans"; +import { + fetchUserPaymentProcessorUserId, + updateUserPaymentProcessorUserId, +} from "../user"; import { createStripeCheckoutSession, ensureStripeCustomer, } from "./checkoutUtils"; import { stripeClient } from "./stripeClient"; -import { - fetchUserPaymentProcessorUserId, - updateUserPaymentProcessorUserId, -} from "./user"; import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; export const stripePaymentProcessor: PaymentProcessor = { @@ -29,20 +29,17 @@ export const stripePaymentProcessor: PaymentProcessor = { const customer = await ensureStripeCustomer(userEmail); await updateUserPaymentProcessorUserId( - { - userId, - paymentProcessorUserId: customer.id, - }, + { userId, paymentProcessorUserId: customer.id }, prismaUserDelegate, ); - const stripeSession = await createStripeCheckoutSession({ + const checkoutSession = await createStripeCheckoutSession({ customerId: customer.id, priceId: paymentPlan.getPaymentProcessorPlanId(), mode: paymentPlanEffectToStripeCheckoutSessionMode(paymentPlan.effect), }); - if (!stripeSession.url) { + if (!checkoutSession.url) { throw new Error( "Stripe checkout session URL is missing. Checkout session might not be active.", ); @@ -50,8 +47,8 @@ export const stripePaymentProcessor: PaymentProcessor = { return { session: { - url: stripeSession.url, - id: stripeSession.id, + url: checkoutSession.url, + id: checkoutSession.id, }, }; }, diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index d0a7cd40..69a5cc2a 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -7,12 +7,14 @@ import { emailSender } from "wasp/server/email"; import { requireNodeEnvVar } from "../../server/utils"; import { assertUnreachable } from "../../shared/utils"; import { UnhandledWebhookEventError } from "../errors"; -import { PaymentPlanId, paymentPlans, SubscriptionStatus } from "../plans"; -import { stripeClient } from "./stripeClient"; import { - updateUserOneTimePaymentDetails, - updateUserSubscriptionDetails, -} from "./user"; + getPaymentPlanIdByPaymentProcessorPlanId, + PaymentPlanId, + paymentPlans, + SubscriptionStatus, +} from "../plans"; +import { updateUserCredits, updateUserSubscription } from "../user"; +import { stripeClient } from "./stripeClient"; /** * Stripe requires a raw request to construct events successfully. @@ -35,38 +37,30 @@ export const stripeWebhook: PaymentsWebhook = async ( ) => { const prismaUserDelegate = context.entities.User; try { - const stripeEvent = constructStripeEvent(request); + const event = constructStripeEvent(request); // If you'd like to handle more events, you can add more cases below. // When deploying your app, you configure your webhook in the Stripe dashboard // to only send the events that you're handling above. // See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook - switch (stripeEvent.type) { + switch (event.type) { case "invoice.paid": - await handleInvoicePaid(stripeEvent, prismaUserDelegate); + await handleInvoicePaid(event, prismaUserDelegate); break; case "customer.subscription.updated": - await handleCustomerSubscriptionUpdated( - stripeEvent, - prismaUserDelegate, - ); + await handleCustomerSubscriptionUpdated(event, prismaUserDelegate); break; case "customer.subscription.deleted": - await handleCustomerSubscriptionDeleted( - stripeEvent, - prismaUserDelegate, - ); + await handleCustomerSubscriptionDeleted(event, prismaUserDelegate); break; default: - throw new UnhandledWebhookEventError(stripeEvent.type); + throw new UnhandledWebhookEventError(event.type); } return response.status(204).send(); } catch (error) { if (error instanceof UnhandledWebhookEventError) { // In development, it is likely that we will receive events that we are not handling. // E.g. via the `stripe trigger` command. - // While these can be ignored safely in development, it's good to be aware of them. - // For production we shouldn't have any extra webhook events. if (process.env.NODE_ENV === "development") { console.info("Unhandled Stripe webhook event in development: ", error); } else if (process.env.NODE_ENV === "production") { @@ -82,7 +76,7 @@ export const stripeWebhook: PaymentsWebhook = async ( return response.status(400).json({ error: error.message }); } else { return response - .status(400) + .status(500) .json({ error: "Error processing Stripe webhook event" }); } } @@ -109,13 +103,15 @@ async function handleInvoicePaid( const invoice = event.data.object; const customerId = getCustomerId(invoice.customer); const invoicePaidAtDate = getInvoicePaidAtDate(invoice); - const paymentPlanId = getPaymentPlanIdByPriceId(getInvoicePriceId(invoice)); + const paymentPlanId = getPaymentPlanIdByPaymentProcessorPlanId( + getInvoicePriceId(invoice), + ); switch (paymentPlanId) { case PaymentPlanId.Credits10: - await updateUserOneTimePaymentDetails( + await updateUserCredits( { - customerId, + paymentProcessorUserId: customerId, datePaid: invoicePaidAtDate, numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount, }, @@ -124,9 +120,9 @@ async function handleInvoicePaid( break; case PaymentPlanId.Pro: case PaymentPlanId.Hobby: - await updateUserSubscriptionDetails( + await updateUserSubscription( { - customerId, + paymentProcessorUserId: customerId, datePaid: invoicePaidAtDate, paymentPlanId, subscriptionStatus: SubscriptionStatus.Active, @@ -168,12 +164,12 @@ async function handleCustomerSubscriptionUpdated( } const customerId = getCustomerId(subscription.customer); - const paymentPlanId = getPaymentPlanIdByPriceId( + const paymentPlanId = getPaymentPlanIdByPaymentProcessorPlanId( getSubscriptionPriceId(subscription), ); - const user = await updateUserSubscriptionDetails( - { customerId, paymentPlanId, subscriptionStatus }, + const user = await updateUserSubscription( + { paymentProcessorUserId: customerId, paymentPlanId, subscriptionStatus }, prismaUserDelegate, ); @@ -190,14 +186,31 @@ async function handleCustomerSubscriptionUpdated( function getOpenSaasSubscriptionStatus( subscription: Stripe.Subscription, ): SubscriptionStatus | undefined { - if (subscription.status === SubscriptionStatus.Active) { - if (subscription.cancel_at_period_end) { - return SubscriptionStatus.CancelAtPeriodEnd; - } - return SubscriptionStatus.Active; - } else if (subscription.status === SubscriptionStatus.PastDue) { - return SubscriptionStatus.PastDue; + const stripeToOpenSaasSubscriptionStatusMap: Record< + Stripe.Subscription.Status, + SubscriptionStatus | undefined + > = { + trialing: SubscriptionStatus.Active, + active: SubscriptionStatus.Active, + past_due: SubscriptionStatus.PastDue, + canceled: SubscriptionStatus.Deleted, + unpaid: SubscriptionStatus.Deleted, + incomplete_expired: SubscriptionStatus.Deleted, + paused: undefined, + incomplete: undefined, + }; + + const subscriptionStauts = + stripeToOpenSaasSubscriptionStatusMap[subscription.status]; + + if ( + subscriptionStauts === SubscriptionStatus.Active && + subscription.cancel_at_period_end + ) { + return SubscriptionStatus.CancelAtPeriodEnd; } + + return subscriptionStauts; } function getSubscriptionPriceId( @@ -222,8 +235,11 @@ async function handleCustomerSubscriptionDeleted( const subscription = event.data.object; const customerId = getCustomerId(subscription.customer); - await updateUserSubscriptionDetails( - { customerId, subscriptionStatus: SubscriptionStatus.Deleted }, + await updateUserSubscription( + { + paymentProcessorUserId: customerId, + subscriptionStatus: SubscriptionStatus.Deleted, + }, prismaUserDelegate, ); } @@ -249,15 +265,3 @@ function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { // so we multiply by 1000 to convert to milliseconds. return new Date(invoice.status_transitions.paid_at * 1000); } - -function getPaymentPlanIdByPriceId(priceId: string): PaymentPlanId { - const paymentPlanId = Object.values(PaymentPlanId).find( - (paymentPlanId) => - paymentPlans[paymentPlanId].getPaymentProcessorPlanId() === priceId, - ); - if (!paymentPlanId) { - throw new Error(`No payment plan with Stripe price id ${priceId}`); - } - - return paymentPlanId; -} diff --git a/template/app/src/payment/stripe/user.ts b/template/app/src/payment/user.ts similarity index 69% rename from template/app/src/payment/stripe/user.ts rename to template/app/src/payment/user.ts index e232c6b3..bb75921b 100644 --- a/template/app/src/payment/stripe/user.ts +++ b/template/app/src/payment/user.ts @@ -1,8 +1,6 @@ -import { PrismaClient } from "@prisma/client"; -import Stripe from "stripe"; import { User } from "wasp/entities"; -import type { SubscriptionStatus } from "../plans"; -import { PaymentPlanId } from "../plans"; +import { PrismaClient } from "wasp/server"; +import { PaymentPlanId, SubscriptionStatus } from "./plans"; export async function fetchUserPaymentProcessorUserId( userId: User["id"], @@ -39,50 +37,25 @@ export function updateUserPaymentProcessorUserId( }); } -interface UpdateUserOneTimePaymentDetailsArgs { - customerId: Stripe.Customer["id"]; - datePaid: Date; - numOfCreditsPurchased: number; -} - -export function updateUserOneTimePaymentDetails( - { - customerId, - datePaid, - numOfCreditsPurchased, - }: UpdateUserOneTimePaymentDetailsArgs, - userDelegate: PrismaClient["user"], -): Promise { - return userDelegate.update({ - where: { - paymentProcessorUserId: customerId, - }, - data: { - datePaid, - credits: { increment: numOfCreditsPurchased }, - }, - }); -} - -interface UpdateUserSubscriptionDetailsArgs { - customerId: Stripe.Customer["id"]; +interface UpdateUserSubscriptionArgs { + paymentProcessorUserId: NonNullable; subscriptionStatus: SubscriptionStatus; - datePaid?: Date; paymentPlanId?: PaymentPlanId; + datePaid?: Date; } -export function updateUserSubscriptionDetails( +export function updateUserSubscription( { - customerId, + paymentProcessorUserId, paymentPlanId, subscriptionStatus, datePaid, - }: UpdateUserSubscriptionDetailsArgs, + }: UpdateUserSubscriptionArgs, userDelegate: PrismaClient["user"], -): Promise { +) { return userDelegate.update({ where: { - paymentProcessorUserId: customerId, + paymentProcessorUserId, }, data: { subscriptionPlan: paymentPlanId, @@ -91,3 +64,28 @@ export function updateUserSubscriptionDetails( }, }); } + +interface UpdateUserCreditsArgs { + paymentProcessorUserId: NonNullable; + numOfCreditsPurchased: number; + datePaid: Date; +} + +export function updateUserCredits( + { + paymentProcessorUserId, + numOfCreditsPurchased, + datePaid, + }: UpdateUserCreditsArgs, + userDelegate: PrismaClient["user"], +) { + return userDelegate.update({ + where: { + paymentProcessorUserId, + }, + data: { + credits: { increment: numOfCreditsPurchased }, + datePaid, + }, + }); +}