mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-11-23 19:36:50 +01:00
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:
@@ -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',
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
11
template/app/src/server/stripe/stripeClient.ts
Normal file
11
template/app/src/server/stripe/stripeClient.ts
Normal 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',
|
||||
});
|
||||
8
template/app/src/server/utils.ts
Normal file
8
template/app/src/server/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
28
template/app/src/server/webhooks/stripePaymentDetails.ts
Normal file
28
template/app/src/server/webhooks/stripePaymentDetails.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user