abstract stripe payment stuff

This commit is contained in:
Franjo Mindek
2025-11-14 16:49:15 +01:00
parent adb8bb62e6
commit 05f45c2cf3
7 changed files with 257 additions and 111 deletions

View File

@@ -1,30 +1,28 @@
--- template/app/src/payment/stripe/paymentProcessor.ts --- template/app/src/payment/stripe/paymentProcessor.ts
+++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts
@@ -13,8 +13,8 @@ @@ -8,8 +8,8 @@
} from "./checkoutUtils"; } from "../paymentProcessor";
import { stripeClient } from "./stripeClient"; import type { PaymentPlanEffect } from "../plans";
import { import {
- fetchUserPaymentProcessorUserId, - fetchUserPaymentProcessorUserId,
- updateUserPaymentProcessorUserId, - updateUserPaymentProcessorUserId,
+ fetchUserStripeId, + fetchUserStripeId,
+ updateUserStripeId + updateUserStripeId
} from "./user"; } from "../user";
import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; import {
createStripeCheckoutSession,
@@ -28,10 +28,10 @@ @@ -28,8 +28,8 @@
}: CreateCheckoutSessionArgs) => { }: CreateCheckoutSessionArgs) => {
const customer = await ensureStripeCustomer(userEmail); const customer = await ensureStripeCustomer(userEmail);
- await updateUserPaymentProcessorUserId( - await updateUserPaymentProcessorUserId(
- { userId, paymentProcessorUserId: customer.id },
+ await updateUserStripeId( + await updateUserStripeId(
{ + { userId, stripeId: customer.id },
userId,
- paymentProcessorUserId: customer.id,
+ stripeId: customer.id,
},
prismaUserDelegate, prismaUserDelegate,
); );
@@ -59,18 +59,18 @@
@@ -56,18 +56,18 @@
prismaUserDelegate, prismaUserDelegate,
userId, userId,
}: FetchCustomerPortalUrlArgs) => { }: FetchCustomerPortalUrlArgs) => {

View File

@@ -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,

View File

@@ -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<string | null> {
@@ -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<User["paymentProcessorUserId"]>;
+ stripeId: NonNullable<User["stripeId"]>;
}
-export function updateUserPaymentProcessorUserId(
- { userId, paymentProcessorUserId }: UpdateUserPaymentProcessorUserIdArgs,
+export function updateUserStripeId(
+ { userId, stripeId }: UpdateUserStripeIdArgs,
prismaUserDelegate: PrismaClient["user"],
): Promise<User> {
return prismaUserDelegate.update({
@@ -32,13 +32,13 @@
id: userId,
},
data: {
- paymentProcessorUserId,
+ stripeId,
},
});
}
interface UpdateUserSubscriptionArgs {
- paymentProcessorUserId: NonNullable<User["paymentProcessorUserId"]>;
+ stripeId: NonNullable<User["stripeId"]>;
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<User["paymentProcessorUserId"]>;
+ stripeId: NonNullable<User["stripeId"]>;
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 },

View File

@@ -1,3 +1,4 @@
import { User } from "wasp/entities";
import { requireNodeEnvVar } from "../server/utils"; import { requireNodeEnvVar } from "../server/utils";
export enum SubscriptionStatus { export enum SubscriptionStatus {
@@ -67,3 +68,15 @@ export function getSubscriptionPaymentPlanIds(): PaymentPlanId[] {
(planId) => paymentPlans[planId].effect.kind === "subscription", (planId) => paymentPlans[planId].effect.kind === "subscription",
); );
} }
export function getPaymentPlanIdByPaymentProcessorPlanId(
paymentProcessorUserId: NonNullable<User["paymentProcessorUserId"]>,
): 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}`);
}

View File

@@ -7,15 +7,15 @@ import type {
PaymentProcessor, PaymentProcessor,
} from "../paymentProcessor"; } from "../paymentProcessor";
import type { PaymentPlanEffect } from "../plans"; import type { PaymentPlanEffect } from "../plans";
import {
fetchUserPaymentProcessorUserId,
updateUserPaymentProcessorUserId,
} from "../user";
import { import {
createStripeCheckoutSession, createStripeCheckoutSession,
ensureStripeCustomer, ensureStripeCustomer,
} from "./checkoutUtils"; } from "./checkoutUtils";
import { stripeClient } from "./stripeClient"; import { stripeClient } from "./stripeClient";
import {
fetchUserPaymentProcessorUserId,
updateUserPaymentProcessorUserId,
} from "./user";
import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook";
export const stripePaymentProcessor: PaymentProcessor = { export const stripePaymentProcessor: PaymentProcessor = {
@@ -29,20 +29,17 @@ export const stripePaymentProcessor: PaymentProcessor = {
const customer = await ensureStripeCustomer(userEmail); const customer = await ensureStripeCustomer(userEmail);
await updateUserPaymentProcessorUserId( await updateUserPaymentProcessorUserId(
{ { userId, paymentProcessorUserId: customer.id },
userId,
paymentProcessorUserId: customer.id,
},
prismaUserDelegate, prismaUserDelegate,
); );
const stripeSession = await createStripeCheckoutSession({ const checkoutSession = await createStripeCheckoutSession({
customerId: customer.id, customerId: customer.id,
priceId: paymentPlan.getPaymentProcessorPlanId(), priceId: paymentPlan.getPaymentProcessorPlanId(),
mode: paymentPlanEffectToStripeCheckoutSessionMode(paymentPlan.effect), mode: paymentPlanEffectToStripeCheckoutSessionMode(paymentPlan.effect),
}); });
if (!stripeSession.url) { if (!checkoutSession.url) {
throw new Error( throw new Error(
"Stripe checkout session URL is missing. Checkout session might not be active.", "Stripe checkout session URL is missing. Checkout session might not be active.",
); );
@@ -50,8 +47,8 @@ export const stripePaymentProcessor: PaymentProcessor = {
return { return {
session: { session: {
url: stripeSession.url, url: checkoutSession.url,
id: stripeSession.id, id: checkoutSession.id,
}, },
}; };
}, },

View File

@@ -7,12 +7,14 @@ import { emailSender } from "wasp/server/email";
import { requireNodeEnvVar } from "../../server/utils"; import { requireNodeEnvVar } from "../../server/utils";
import { assertUnreachable } from "../../shared/utils"; import { assertUnreachable } from "../../shared/utils";
import { UnhandledWebhookEventError } from "../errors"; import { UnhandledWebhookEventError } from "../errors";
import { PaymentPlanId, paymentPlans, SubscriptionStatus } from "../plans";
import { stripeClient } from "./stripeClient";
import { import {
updateUserOneTimePaymentDetails, getPaymentPlanIdByPaymentProcessorPlanId,
updateUserSubscriptionDetails, PaymentPlanId,
} from "./user"; paymentPlans,
SubscriptionStatus,
} from "../plans";
import { updateUserCredits, updateUserSubscription } from "../user";
import { stripeClient } from "./stripeClient";
/** /**
* Stripe requires a raw request to construct events successfully. * Stripe requires a raw request to construct events successfully.
@@ -35,38 +37,30 @@ export const stripeWebhook: PaymentsWebhook = async (
) => { ) => {
const prismaUserDelegate = context.entities.User; const prismaUserDelegate = context.entities.User;
try { try {
const stripeEvent = constructStripeEvent(request); const event = constructStripeEvent(request);
// If you'd like to handle more events, you can add more cases below. // 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 // When deploying your app, you configure your webhook in the Stripe dashboard
// to only send the events that you're handling above. // to only send the events that you're handling above.
// See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook // See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook
switch (stripeEvent.type) { switch (event.type) {
case "invoice.paid": case "invoice.paid":
await handleInvoicePaid(stripeEvent, prismaUserDelegate); await handleInvoicePaid(event, prismaUserDelegate);
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":
await handleCustomerSubscriptionUpdated( await handleCustomerSubscriptionUpdated(event, prismaUserDelegate);
stripeEvent,
prismaUserDelegate,
);
break; break;
case "customer.subscription.deleted": case "customer.subscription.deleted":
await handleCustomerSubscriptionDeleted( await handleCustomerSubscriptionDeleted(event, prismaUserDelegate);
stripeEvent,
prismaUserDelegate,
);
break; break;
default: default:
throw new UnhandledWebhookEventError(stripeEvent.type); throw new UnhandledWebhookEventError(event.type);
} }
return response.status(204).send(); return response.status(204).send();
} catch (error) { } catch (error) {
if (error instanceof UnhandledWebhookEventError) { if (error instanceof UnhandledWebhookEventError) {
// In development, it is likely that we will receive events that we are not handling. // In development, it is likely that we will receive events that we are not handling.
// E.g. via the `stripe trigger` command. // 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") { if (process.env.NODE_ENV === "development") {
console.info("Unhandled Stripe webhook event in development: ", error); console.info("Unhandled Stripe webhook event in development: ", error);
} else if (process.env.NODE_ENV === "production") { } else if (process.env.NODE_ENV === "production") {
@@ -82,7 +76,7 @@ export const stripeWebhook: PaymentsWebhook = async (
return response.status(400).json({ error: error.message }); return response.status(400).json({ error: error.message });
} else { } else {
return response return response
.status(400) .status(500)
.json({ error: "Error processing Stripe webhook event" }); .json({ error: "Error processing Stripe webhook event" });
} }
} }
@@ -109,13 +103,15 @@ async function handleInvoicePaid(
const invoice = event.data.object; const invoice = event.data.object;
const customerId = getCustomerId(invoice.customer); const customerId = getCustomerId(invoice.customer);
const invoicePaidAtDate = getInvoicePaidAtDate(invoice); const invoicePaidAtDate = getInvoicePaidAtDate(invoice);
const paymentPlanId = getPaymentPlanIdByPriceId(getInvoicePriceId(invoice)); const paymentPlanId = getPaymentPlanIdByPaymentProcessorPlanId(
getInvoicePriceId(invoice),
);
switch (paymentPlanId) { switch (paymentPlanId) {
case PaymentPlanId.Credits10: case PaymentPlanId.Credits10:
await updateUserOneTimePaymentDetails( await updateUserCredits(
{ {
customerId, paymentProcessorUserId: customerId,
datePaid: invoicePaidAtDate, datePaid: invoicePaidAtDate,
numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount, numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount,
}, },
@@ -124,9 +120,9 @@ async function handleInvoicePaid(
break; break;
case PaymentPlanId.Pro: case PaymentPlanId.Pro:
case PaymentPlanId.Hobby: case PaymentPlanId.Hobby:
await updateUserSubscriptionDetails( await updateUserSubscription(
{ {
customerId, paymentProcessorUserId: customerId,
datePaid: invoicePaidAtDate, datePaid: invoicePaidAtDate,
paymentPlanId, paymentPlanId,
subscriptionStatus: SubscriptionStatus.Active, subscriptionStatus: SubscriptionStatus.Active,
@@ -168,12 +164,12 @@ async function handleCustomerSubscriptionUpdated(
} }
const customerId = getCustomerId(subscription.customer); const customerId = getCustomerId(subscription.customer);
const paymentPlanId = getPaymentPlanIdByPriceId( const paymentPlanId = getPaymentPlanIdByPaymentProcessorPlanId(
getSubscriptionPriceId(subscription), getSubscriptionPriceId(subscription),
); );
const user = await updateUserSubscriptionDetails( const user = await updateUserSubscription(
{ customerId, paymentPlanId, subscriptionStatus }, { paymentProcessorUserId: customerId, paymentPlanId, subscriptionStatus },
prismaUserDelegate, prismaUserDelegate,
); );
@@ -190,14 +186,31 @@ async function handleCustomerSubscriptionUpdated(
function getOpenSaasSubscriptionStatus( function getOpenSaasSubscriptionStatus(
subscription: Stripe.Subscription, subscription: Stripe.Subscription,
): SubscriptionStatus | undefined { ): SubscriptionStatus | undefined {
if (subscription.status === SubscriptionStatus.Active) { const stripeToOpenSaasSubscriptionStatusMap: Record<
if (subscription.cancel_at_period_end) { Stripe.Subscription.Status,
return SubscriptionStatus.CancelAtPeriodEnd; SubscriptionStatus | undefined
} > = {
return SubscriptionStatus.Active; trialing: SubscriptionStatus.Active,
} else if (subscription.status === SubscriptionStatus.PastDue) { active: SubscriptionStatus.Active,
return SubscriptionStatus.PastDue; 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( function getSubscriptionPriceId(
@@ -222,8 +235,11 @@ async function handleCustomerSubscriptionDeleted(
const subscription = event.data.object; const subscription = event.data.object;
const customerId = getCustomerId(subscription.customer); const customerId = getCustomerId(subscription.customer);
await updateUserSubscriptionDetails( await updateUserSubscription(
{ customerId, subscriptionStatus: SubscriptionStatus.Deleted }, {
paymentProcessorUserId: customerId,
subscriptionStatus: SubscriptionStatus.Deleted,
},
prismaUserDelegate, prismaUserDelegate,
); );
} }
@@ -249,15 +265,3 @@ function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date {
// so we multiply by 1000 to convert to milliseconds. // so we multiply by 1000 to convert to milliseconds.
return new Date(invoice.status_transitions.paid_at * 1000); 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;
}

View File

@@ -1,8 +1,6 @@
import { PrismaClient } from "@prisma/client";
import Stripe from "stripe";
import { User } from "wasp/entities"; import { User } from "wasp/entities";
import type { SubscriptionStatus } from "../plans"; import { PrismaClient } from "wasp/server";
import { PaymentPlanId } from "../plans"; import { PaymentPlanId, SubscriptionStatus } from "./plans";
export async function fetchUserPaymentProcessorUserId( export async function fetchUserPaymentProcessorUserId(
userId: User["id"], userId: User["id"],
@@ -39,50 +37,25 @@ export function updateUserPaymentProcessorUserId(
}); });
} }
interface UpdateUserOneTimePaymentDetailsArgs { interface UpdateUserSubscriptionArgs {
customerId: Stripe.Customer["id"]; paymentProcessorUserId: NonNullable<User["paymentProcessorUserId"]>;
datePaid: Date;
numOfCreditsPurchased: number;
}
export function updateUserOneTimePaymentDetails(
{
customerId,
datePaid,
numOfCreditsPurchased,
}: UpdateUserOneTimePaymentDetailsArgs,
userDelegate: PrismaClient["user"],
): Promise<User> {
return userDelegate.update({
where: {
paymentProcessorUserId: customerId,
},
data: {
datePaid,
credits: { increment: numOfCreditsPurchased },
},
});
}
interface UpdateUserSubscriptionDetailsArgs {
customerId: Stripe.Customer["id"];
subscriptionStatus: SubscriptionStatus; subscriptionStatus: SubscriptionStatus;
datePaid?: Date;
paymentPlanId?: PaymentPlanId; paymentPlanId?: PaymentPlanId;
datePaid?: Date;
} }
export function updateUserSubscriptionDetails( export function updateUserSubscription(
{ {
customerId, paymentProcessorUserId,
paymentPlanId, paymentPlanId,
subscriptionStatus, subscriptionStatus,
datePaid, datePaid,
}: UpdateUserSubscriptionDetailsArgs, }: UpdateUserSubscriptionArgs,
userDelegate: PrismaClient["user"], userDelegate: PrismaClient["user"],
): Promise<User> { ) {
return userDelegate.update({ return userDelegate.update({
where: { where: {
paymentProcessorUserId: customerId, paymentProcessorUserId,
}, },
data: { data: {
subscriptionPlan: paymentPlanId, subscriptionPlan: paymentPlanId,
@@ -91,3 +64,28 @@ export function updateUserSubscriptionDetails(
}, },
}); });
} }
interface UpdateUserCreditsArgs {
paymentProcessorUserId: NonNullable<User["paymentProcessorUserId"]>;
numOfCreditsPurchased: number;
datePaid: Date;
}
export function updateUserCredits(
{
paymentProcessorUserId,
numOfCreditsPurchased,
datePaid,
}: UpdateUserCreditsArgs,
userDelegate: PrismaClient["user"],
) {
return userDelegate.update({
where: {
paymentProcessorUserId,
},
data: {
credits: { increment: numOfCreditsPurchased },
datePaid,
},
});
}