refactor Stripe webhook (#200)

* rename TierIds to PaymentPlanIds

* refactor webhook and util functions

* pass userDelegate to function

* Update dbSeeds.ts

* update app diff

* Update template/app/src/server/stripe/stripeClient.ts

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* extract event handlers and more

* Update AccountPage.tsx

* address filips pro effective typescripting and stuff

* Martin's attempt at consolidating types.

* fix

* fix webhook events and validation

* small changes

* put stripe event handlers back for marty merge

* merge consilidated types from martin

* move some types around

* add docs for stripe api version

* Update AccountPage.tsx

* Update stripe.ts

* update SubscriptionStatus type

* Update actions.ts

* add assertUnreachable util

* more small changes

* Update deploying.md

* update accountPage and docs

* update app_diff

---------

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>
Co-authored-by: Martin Sosic <sosic.martin@gmail.com>
This commit is contained in:
vincanger
2024-07-10 16:08:20 +02:00
committed by GitHub
parent 138552d541
commit 78a9189e32
50 changed files with 582 additions and 456 deletions

View File

@@ -2,17 +2,16 @@ import { type User, type Task } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import {
type GenerateGptResponse,
type StripePayment,
type GenerateStripeCheckoutSession,
type UpdateCurrentUser,
type UpdateUserById,
type CreateTask,
type DeleteTask,
type UpdateTask,
} from 'wasp/server/operations';
import Stripe from 'stripe';
import type { GeneratedSchedule, StripePaymentResult } from '../shared/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './payments/stripeUtils.js';
import { TierIds } from '../shared/constants.js';
import { GeneratedSchedule } from '../gpt/schedule';
import { PaymentPlanId, paymentPlans, type PaymentPlanEffect } from '../payment/plans';
import { fetchStripeCustomer, createStripeCheckoutSession, type StripeMode } from './stripe/checkoutUtils.js';
import OpenAI from 'openai';
const openai = setupOpenAI();
@@ -23,7 +22,15 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
export const stripePayment: StripePayment<string, StripePaymentResult> = async (tier, context) => {
export type StripeCheckoutSession = {
sessionUrl: string | null;
sessionId: string;
};
export const generateStripeCheckoutSession: GenerateStripeCheckoutSession<PaymentPlanId, StripeCheckoutSession> = async (
paymentPlanId,
context
) => {
if (!context.user) {
throw new HttpError(401);
}
@@ -35,39 +42,15 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
);
}
let priceId;
if (tier === TierIds.HOBBY) {
priceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.PRO) {
priceId = process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.CREDITS) {
priceId = process.env.STRIPE_CREDITS_PRICE_ID!;
} else {
throw new HttpError(404, 'Invalid tier');
}
const paymentPlan = paymentPlans[paymentPlanId];
const customer = await fetchStripeCustomer(userEmail);
const session = await createStripeCheckoutSession({
priceId: paymentPlan.getStripePriceId(),
customerId: customer.id,
mode: paymentPlanEffectToStripeMode(paymentPlan.effect),
});
let customer: Stripe.Customer | undefined;
let session: Stripe.Checkout.Session | undefined;
try {
customer = await fetchStripeCustomer(userEmail);
if (!customer) {
throw new HttpError(500, 'Error fetching customer');
}
session = await createStripeCheckoutSession({
priceId,
customerId: customer.id,
mode: tier === TierIds.CREDITS ? 'payment' : 'subscription',
});
if (!session) {
throw new HttpError(500, 'Error creating session');
}
} catch (error: any) {
const statusCode = error.statusCode || 500;
const errorMessage = error.message || 'Internal server error';
throw new HttpError(statusCode, errorMessage);
}
const updatedUser = await context.entities.User.update({
await context.entities.User.update({
where: {
id: context.user.id,
},
@@ -83,6 +66,14 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
};
};
function paymentPlanEffectToStripeMode (planEffect: PaymentPlanEffect): StripeMode {
const effectToMode: Record<PaymentPlanEffect['kind'], StripeMode> = {
'subscription': 'subscription',
'credits': 'payment'
};
return effectToMode[planEffect.kind];
}
type GptPayload = {
hours: string;
};
@@ -126,7 +117,7 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
}
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
messages: [
{
role: 'system',

View File

@@ -6,7 +6,7 @@ import {
type GetPaginatedUsers,
type GetAllTasksByUser,
} from 'wasp/server/operations';
import { type SubscriptionStatusOptions } from '../shared/types.js';
import { type SubscriptionStatus } from '../payment/plans';
type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[];
@@ -77,7 +77,7 @@ type GetPaginatedUsersInput = {
cursor?: number | undefined;
emailContains?: string;
isAdmin?: boolean;
subscriptionStatus?: SubscriptionStatusOptions[];
subscriptionStatus?: SubscriptionStatus[];
};
type GetPaginatedUsersOutput = {
users: Pick<

View File

@@ -1,8 +1,7 @@
import { type User } from 'wasp/entities';
import { faker } from '@faker-js/faker';
import type { PrismaClient } from '@prisma/client';
import { TierIds } from '../../shared/constants.js';
import { type SubscriptionStatusOptions } from '../../shared/types.js';
import { getSubscriptionPaymentPlanIds, type SubscriptionStatus } from '../../payment/plans';
type MockUserData = Omit<User, 'id'>;
@@ -24,7 +23,7 @@ function generateMockUsersData(numOfUsers: number): MockUserData[] {
function generateMockUserData(): MockUserData {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatusOptions>(['active', 'canceled', 'past_due', 'deleted', null]);
const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatus | null>(['active', 'cancel_at_period_end', 'past_due', 'deleted', null]);
const now = new Date();
const createdAt = faker.date.past({ refDate: now });
const lastActiveTimestamp = faker.date.between({ from: createdAt, to: now });
@@ -42,6 +41,6 @@ function generateMockUserData(): MockUserData {
stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null,
subscriptionTier: subscriptionStatus ? faker.helpers.arrayElement([TierIds.HOBBY, TierIds.PRO]) : null,
subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds()) : null,
};
}

View File

@@ -1,9 +1,5 @@
import Stripe from 'stripe';
import { HttpError } from 'wasp/server';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
import { stripe } from './stripeClient';
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
@@ -24,12 +20,14 @@ export async function fetchStripeCustomer(customerEmail: string) {
customer = stripeCustomers.data[0];
}
return customer;
} catch (error: any) {
console.error(error.message);
} catch (error) {
console.error(error);
throw error;
}
}
export type StripeMode = 'subscription' | 'payment';
export async function createStripeCheckoutSession({
priceId,
customerId,
@@ -37,7 +35,7 @@ export async function createStripeCheckoutSession({
}: {
priceId: string;
customerId: string;
mode: 'subscription' | 'payment';
mode: StripeMode;
}) {
try {
return await stripe.checkout.sessions.create({
@@ -56,8 +54,8 @@ export async function createStripeCheckoutSession({
},
customer: customerId,
});
} catch (error: any) {
console.error(error.message);
} catch (error) {
console.error(error);
throw error;
}
}

View File

@@ -0,0 +1,11 @@
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_KEY!, {
// NOTE:
// API version below should ideally match the API version in your Stripe dashboard.
// If that is not the case, you will most likely want to (up/down)grade the `stripe`
// 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',
});

View File

@@ -0,0 +1,8 @@
export function requireNodeEnvVar(name: string): string {
const value = process.env[name];
if (value === undefined) {
throw new Error(`Env var ${name} is undefined`);
} else {
return value;
}
}

View File

@@ -1,189 +1,54 @@
import { emailSender } from 'wasp/server/email';
import { type MiddlewareConfigFn } from 'wasp/server';
import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
import { type StripeWebhook } from 'wasp/server/api';
import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { TierIds } from '../../shared/constants.js';
import Stripe from 'stripe';
// make sure the api version matches the version in the Stripe dashboard
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
import { Stripe } from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../../payment/plans';
import { updateUserStripePaymentDetails } from './stripePaymentDetails';
import { emailSender } from 'wasp/server/email';
import { assertUnreachable } from '../../utils';
import { requireNodeEnvVar } from '../utils';
import { z } from 'zod';
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
const sig = request.headers['stripe-signature'] as string;
const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET');
const sig = request.headers['stripe-signature'];
if (!sig) {
throw new HttpError(400, 'Stripe Webhook Signature Not Provided');
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(request.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
// console.table({sig: 'stripe webhook signature verified', type: event.type})
} catch (err: any) {
console.log(err.message);
return response.status(400).send(`Webhook Error: ${err.message}`);
event = stripe.webhooks.constructEvent(request.body, sig, secret);
} catch (err) {
throw new HttpError(400, 'Error Constructing Stripe Webhook Event');
}
try {
if (event.type === 'checkout.session.completed') {
console.log('Checkout session completed');
const prismaUserDelegate = context.entities.User;
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
const userStripeId = session.customer as string;
if (!userStripeId) {
console.log('No userStripeId in session');
return response.status(400).send(`Webhook Error: No userStripeId in session`);
}
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
/**
* here are your products, both subscriptions and one-time payments.
* make sure to configure them in the Stripe dashboard first!
* see: https://docs.opensaas.sh/guides/stripe-integration/
*/
if (line_items?.data[0]?.price?.id === process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID) {
console.log('Hobby subscription purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: new Date(),
subscriptionTier: TierIds.HOBBY,
},
});
} else if (line_items?.data[0]?.price?.id === process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID) {
console.log('Pro subscription purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: new Date(),
subscriptionTier: TierIds.PRO,
},
});
} else if (line_items?.data[0]?.price?.id === process.env.STRIPE_CREDITS_PRICE_ID) {
console.log('Credits purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
credits: {
increment: 10,
},
datePaid: new Date(),
},
});
} else {
response.status(404).send('Invalid product');
}
} else if (event.type === 'invoice.paid') {
await handleCheckoutSessionCompleted(session, prismaUserDelegate);
break;
case 'invoice.paid':
const invoice = event.data.object as Stripe.Invoice;
const userStripeId = invoice.customer as string;
const periodStart = new Date(invoice.period_start * 1000);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: periodStart,
},
});
} else if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
const userStripeId = subscription.customer as string;
if (subscription.status === 'active') {
console.log('Subscription active ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'active',
},
});
}
/**
* you'll want to make a check on the front end to see if the subscription is past due
* and then prompt the user to update their payment method
* this is useful if the user's card expires or is canceled and automatic subscription renewal fails
*/
if (subscription.status === 'past_due') {
console.log('Subscription past due for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'past_due',
},
});
}
/**
* Stripe will send a subscription.updated event when a subscription is canceled
* but the subscription is still active until the end of the period.
* So we check if cancel_at_period_end is true and send an email to the customer.
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end for user: ', userStripeId);
let customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
},
select: {
id: true,
email: true,
},
});
if (customer) {
await context.entities.User.update({
where: {
id: customer.id,
},
data: {
subscriptionStatus: 'canceled',
},
});
if (customer.email) {
await emailSender.send({
to: customer.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
}
} else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
const userStripeId = subscription.customer as string;
/**
* Stripe will send then finally send a subscription.deleted event when subscription period ends
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
console.log('Subscription deleted/ended for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'deleted',
},
});
} else {
console.log(`Unhandled event type ${event.type}`);
}
response.json({ received: true });
} catch (err: any) {
response.status(400).send(`Webhook Error: ${err?.message}`);
await handleInvoicePaid(invoice, prismaUserDelegate);
break;
case 'customer.subscription.updated':
const updatedSubscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionUpdated(updatedSubscription, prismaUserDelegate);
break;
case 'customer.subscription.deleted':
const deletedSubscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionDeleted(deletedSubscription, prismaUserDelegate);
break;
default:
// If you'd like to handle more events, you can add more cases above.
// When deploying your app, you configure your webhook in the Stripe dashboard to only send the events that you're
// handling above and that are necessary for the functioning of your app. See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook
// In development, it is likely that you will receive other events that you are not handling, and that's fine. These can be ignored without any issues.
console.error('Unhandled event type: ', event.type);
}
response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook
};
// This allows us to override Wasp's defaults and parse the raw body of the request from Stripe to verify the signature
@@ -192,3 +57,107 @@ export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
return middlewareConfig;
};
const LineItemsPriceSchema = z.object({
data: z.array(
z.object({
price: z.object({
id: z.string(),
}),
})
),
});
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
prismaUserDelegate: PrismaClient["user"]
) {
const userStripeId = validateUserStripeIdOrThrow(session.customer);
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
const result = LineItemsPriceSchema.safeParse(line_items);
if (!result.success) {
throw new HttpError(400, 'No price id in line item');
}
if (result.data.data.length > 1) {
throw new HttpError(400, 'More than one line item in session');
}
const lineItemPriceId = result.data.data[0].price.id;
const planId = Object.values(PaymentPlanId).find(
(planId) => paymentPlans[planId].getStripePriceId() === lineItemPriceId
);
if (!planId) {
throw new Error(`No plan with stripe price id ${lineItemPriceId}`);
}
const plan = paymentPlans[planId];
let subscriptionPlan: PaymentPlanId | undefined;
let numOfCreditsPurchased: number | undefined;
switch (plan.effect.kind) {
case 'subscription':
subscriptionPlan = planId;
break;
case 'credits':
numOfCreditsPurchased = plan.effect.amount;
break;
default:
assertUnreachable(plan.effect);
}
return updateUserStripePaymentDetails(
{ userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
prismaUserDelegate
);
}
export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaClient["user"]) {
const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
const datePaid = new Date(invoice.period_start * 1000);
return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
}
export async function handleCustomerSubscriptionUpdated(
subscription: Stripe.Subscription,
prismaUserDelegate: PrismaClient["user"]
) {
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
let subscriptionStatus: SubscriptionStatus | undefined;
// 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 (subscription.status === 'active') {
subscriptionStatus = subscription.cancel_at_period_end ? 'cancel_at_period_end' : 'active';
} else if (subscription.status === 'past_due') {
subscriptionStatus = 'past_due';
}
if (subscriptionStatus) {
const user = await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus }, prismaUserDelegate);
if (subscription.cancel_at_period_end) {
if (user.email) {
await emailSender.send({
to: user.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
return user;
}
}
export async function handleCustomerSubscriptionDeleted(
subscription: Stripe.Subscription,
prismaUserDelegate: PrismaClient["user"]
) {
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
return updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
}
function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']): string {
if (!userStripeId) throw new HttpError(400, 'No customer id');
if (typeof userStripeId !== 'string') throw new HttpError(400, 'Customer id is not a string');
return userStripeId;
}

View File

@@ -0,0 +1,28 @@
import type { SubscriptionStatus } from '../../payment/plans';
import { PaymentPlanId } from '../../payment/plans';
import { PrismaClient } from '@prisma/client';
type UserStripePaymentDetails = {
userStripeId: string;
subscriptionPlan?: PaymentPlanId;
subscriptionStatus?: SubscriptionStatus;
numOfCreditsPurchased?: number;
datePaid?: Date;
};
export const updateUserStripePaymentDetails = (
{ userStripeId, subscriptionPlan, subscriptionStatus, datePaid, numOfCreditsPurchased }: UserStripePaymentDetails,
userDelegate: PrismaClient['user']
) => {
return userDelegate.update({
where: {
stripeId: userStripeId,
},
data: {
subscriptionPlan,
subscriptionStatus,
datePaid,
credits: numOfCreditsPurchased !== undefined ? { increment: numOfCreditsPurchased } : undefined,
},
});
};

View File

@@ -1,12 +1,9 @@
import { type DailyStatsJob } from 'wasp/server/jobs';
import Stripe from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js';
// import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
const nowUTC = new Date(Date.now());
nowUTC.setUTCHours(0, 0, 0, 0);