{feature}
diff --git a/template/app/src/server/stripe/paymentPlans.ts b/template/app/src/server/stripe/paymentPlans.ts
index fd9a2481..4366ffc9 100644
--- a/template/app/src/server/stripe/paymentPlans.ts
+++ b/template/app/src/server/stripe/paymentPlans.ts
@@ -1,28 +1,35 @@
import { PaymentPlanId, SubscriptionPlanId } from "../../shared/constants";
-
interface PaymentPlan {
+ id: PaymentPlanId;
stripePriceID: string | undefined;
subscriptionPlan?: SubscriptionPlanId;
+ pricePerMonth?: string;
+ price?: string;
credits: number;
mode: 'subscription' | 'payment'
+ description?: string;
+ features?: string[];
}
export const paymentPlans: Record = {
// TODO: check if ENV VARS are set in the .env file
hobby: {
+ id: PaymentPlanId.SubscriptionHobby,
stripePriceID: process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID,
subscriptionPlan: SubscriptionPlanId.Hobby,
credits: 0,
mode: 'subscription',
},
pro: {
+ id: PaymentPlanId.SubscriptionPro,
stripePriceID: process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID,
subscriptionPlan: SubscriptionPlanId.Hobby,
credits: 0,
mode: 'subscription',
},
credits10: {
+ id: PaymentPlanId.Credits10,
stripePriceID: process.env.STRIPE_CREDITS_PRICE_ID,
credits: 10,
mode: 'payment',
diff --git a/template/app/src/server/webhooks/stripe.ts b/template/app/src/server/webhooks/stripe.ts
index 08f50a60..2b7b6226 100644
--- a/template/app/src/server/webhooks/stripe.ts
+++ b/template/app/src/server/webhooks/stripe.ts
@@ -1,20 +1,14 @@
+import type { PrismaUserDelegate, SubscriptionStatusOptions } from '../../shared/types';
import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
import { type StripeWebhook } from 'wasp/server/api';
import express from 'express';
import { Stripe } from 'stripe';
import { stripe } from '../stripe/stripeClient';
-import {
- handleCheckoutSessionCompleted,
- handleInvoicePaid,
- handleCustomerSubscriptionDeleted,
- handleCustomerSubscriptionUpdated,
-} from './stripeEventHandlers';
-
-type StripeEventTypes =
- | 'checkout.session.completed'
- | 'invoice.paid'
- | 'customer.subscription.updated'
- | 'customer.subscription.deleted';
+import { paymentPlans } from '../stripe/paymentPlans';
+import { SubscriptionPlanId } from '../../shared/constants';
+import { updateUserStripePaymentDetails } from './stripePaymentDetails';
+import { emailSender } from 'wasp/server/email';
+import { z } from 'zod';
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
@@ -32,7 +26,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
throw new HttpError(400, 'Error Constructing Stripe Webhook Event');
}
const prismaUserDelegate = context.entities.User;
- switch (event.type as StripeEventTypes) {
+ switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutSessionCompleted(session, prismaUserDelegate);
@@ -41,7 +35,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice, prismaUserDelegate);
break;
- case 'customer.subscription.updated':
+ case 'customer.subscription.updated':
const updatedSubscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionUpdated(updatedSubscription, prismaUserDelegate);
break;
@@ -50,7 +44,11 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
await handleCustomerSubscriptionDeleted(deletedSubscription, prismaUserDelegate);
break;
default:
- console.log('Unhandled event type: ', event.type);
+ // 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
};
@@ -61,3 +59,109 @@ export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
return middlewareConfig;
};
+
+
+export async function handleCheckoutSessionCompleted(
+ session: Stripe.Checkout.Session,
+ prismaUserDelegate: PrismaUserDelegate
+) {
+ const userStripeId = validateUserStripeIdOrThrow(session.customer);
+ const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
+ expand: ['line_items'],
+ });
+ console.log('line_items: ', line_items);
+ const lineItemPriceId = validateAndUseLineItemData(line_items);
+
+ let subscriptionPlan: SubscriptionPlanId | undefined;
+ let numOfCreditsPurchased: number | undefined;
+ for (const paymentPlan of Object.values(paymentPlans)) {
+ if (paymentPlan.stripePriceID === lineItemPriceId) {
+ subscriptionPlan = paymentPlan.subscriptionPlan;
+ numOfCreditsPurchased = paymentPlan.credits;
+ break;
+ }
+ }
+
+ return await updateUserStripePaymentDetails(
+ { userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
+ prismaUserDelegate
+ );
+}
+
+export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaUserDelegate) {
+ const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
+ const datePaid = new Date(invoice.period_start * 1000);
+ return await updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
+}
+
+export async function handleCustomerSubscriptionUpdated(
+ subscription: Stripe.Subscription,
+ prismaUserDelegate: PrismaUserDelegate
+) {
+ const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
+ let subscriptionStatus: SubscriptionStatusOptions | undefined;
+
+ switch (subscription.status as Stripe.Subscription.Status) {
+ case 'active':
+ subscriptionStatus = 'active';
+ break;
+ case 'past_due':
+ subscriptionStatus = 'past_due';
+ break;
+ }
+ if (subscription.cancel_at_period_end) {
+ subscriptionStatus = 'cancel_at_period_end';
+ }
+ if (!subscriptionStatus) throw new HttpError(400, 'Subscription status not handled');
+
+ 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: PrismaUserDelegate
+) {
+ const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
+ return await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
+}
+
+const LineItemsPriceSchema = z.object({
+ data: z.array(
+ z.object({
+ price: z.object({
+ id: z.string(),
+ }),
+ })
+ ),
+});
+
+function validateAndUseLineItemData(line_items: Stripe.ApiList | undefined) {
+ 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');
+ }
+ return result.data.data[0].price.id;
+}
+
+function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']) {
+ 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;
+}
\ No newline at end of file
diff --git a/template/app/src/server/webhooks/stripeEventHandlers.ts b/template/app/src/server/webhooks/stripeEventHandlers.ts
deleted file mode 100644
index e294bb4d..00000000
--- a/template/app/src/server/webhooks/stripeEventHandlers.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { PrismaUserDelegate, SubscriptionStatusOptions } from '../../shared/types';
-import { Stripe } from 'stripe';
-import { stripe } from '../stripe/stripeClient';
-import { paymentPlans } from '../stripe/paymentPlans';
-import { SubscriptionPlanId } from '../../shared/constants';
-import { updateUserStripePaymentDetails } from './stripePaymentDetails';
-import { HttpError } from 'wasp/server';
-import { emailSender } from 'wasp/server/email';
-
-const validateUserStripeIdOrThrow = (userStripeId: Stripe.Checkout.Session['customer']) => {
- 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;
-}
-
-export const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session, prismaUserDelegate: PrismaUserDelegate) => {
- const userStripeId = validateUserStripeIdOrThrow(session.customer);
- const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
- expand: ['line_items'],
- });
- if (!line_items?.data?.length) throw new HttpError(400, 'No line items');
- if (line_items.data.length > 1) throw new HttpError(400, 'More than one line item in session');
- const lineItemPriceId = line_items?.data[0]?.price?.id;
- if (!lineItemPriceId) throw new HttpError(400, 'No price id in line item');
-
- let subscriptionPlan: SubscriptionPlanId | undefined;
- let numOfCreditsPurchased: number | undefined;
- for (const paymentPlan of Object.values(paymentPlans)) {
- if (paymentPlan.stripePriceID === lineItemPriceId) {
- subscriptionPlan = paymentPlan.subscriptionPlan;
- numOfCreditsPurchased = paymentPlan.credits;
- break;
- }
- }
-
- return await updateUserStripePaymentDetails(
- { userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
- prismaUserDelegate
- );
-};
-
-export const handleInvoicePaid = async (invoice: Stripe.Invoice, prismaUserDelegate: PrismaUserDelegate) => {
- const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
- const datePaid = new Date(invoice.period_start * 1000);
- return await updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
-};
-
-export const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription, prismaUserDelegate: PrismaUserDelegate) => {
- const userStripeId = validateUserStripeIdOrThrow(subscription.customer)
-
- const statusMapping: Record = {
- active: 'active',
- past_due: 'past_due',
- cancel_at_period_end: 'cancel_at_period_end',
- };
- let subscriptionStatus = statusMapping[subscription.status];
- if (subscription.cancel_at_period_end) {
- subscriptionStatus = 'cancel_at_period_end';
- }
-
- 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 const handleCustomerSubscriptionDeleted = async (subscription: Stripe.Subscription, prismaUserDelegate: PrismaUserDelegate) => {
- const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
- return await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
-};
From 1b8dae1bb0728a12ca0fa381c18921da480edb1f Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Fri, 5 Jul 2024 13:30:39 +0200
Subject: [PATCH 13/26] small changes
---
template/app/src/client/app/AccountPage.tsx | 21 ++++++++-----------
template/app/src/payment/plans.ts | 2 +-
.../server/webhooks/stripePaymentDetails.ts | 4 ++--
3 files changed, 12 insertions(+), 15 deletions(-)
diff --git a/template/app/src/client/app/AccountPage.tsx b/template/app/src/client/app/AccountPage.tsx
index f8b77580..1c926675 100644
--- a/template/app/src/client/app/AccountPage.tsx
+++ b/template/app/src/client/app/AccountPage.tsx
@@ -69,17 +69,14 @@ function UserCurrentSubscriptionStatus(user: User) {
const planName = prettyPaymentPlanName(parsePaymentPlanId(user.subscriptionPlan));
const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(user.datePaid);
- // TODO: refactor this as a Record instead of a switch statement?
- switch (subscriptionStatus) {
- case 'active' satisfies SubscriptionStatusOptions:
- return `${planName}`;
- case 'past_due' satisfies SubscriptionStatusOptions:
- return `Payment for your ${planName} plan is past due! Please update your subscription payment information.`;
- case 'cancel_at_period_end' satisfies SubscriptionStatusOptions:
- return `Your ${planName} plan subscription has been canceled, but remains active until the end of the current billing period${endOfBillingPeriod}`;
- case 'deleted' satisfies SubscriptionStatusOptions:
- return `Your previous subscription has been canceled and is no longer active.`;
- }
+ const statusToMessage: Record = {
+ active: `${planName}`,
+ past_due: `Payment for your ${planName} plan is past due! Please update your subscription payment information.`,
+ cancel_at_period_end: `Your ${planName} plan subscription has been canceled, but remains active until the end of the current billing period${endOfBillingPeriod}`,
+ deleted: `Your previous subscription has been canceled and is no longer active.`
+ };
+
+ return statusToMessage[subscriptionStatus as SubscriptionStatusOptions];
}
if (user.subscriptionStatus) {
@@ -88,7 +85,7 @@ function UserCurrentSubscriptionStatus(user: User) {
{getSubscriptionMessage(user.subscriptionStatus)}
- {user.subscriptionStatus !== ('deleted' satisfies SubscriptionStatusOptions) ? : }
+ {user.subscriptionStatus as SubscriptionStatusOptions !== 'deleted' ? : }
>
);
}
diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts
index 4ccce9de..293c0c0b 100644
--- a/template/app/src/payment/plans.ts
+++ b/template/app/src/payment/plans.ts
@@ -10,7 +10,7 @@ export interface PaymentPlan {
effect: PaymentPlanEffect
}
-export type PaymentPlanEffect = { kind: 'subscription' } | { kind: 'credits', amount: number }
+export type PaymentPlanEffect = { kind: 'subscription' } | { kind: 'credits', amount: number };
export type PaymentPlanEffectKinds = PaymentPlanEffect extends { kind: infer K } ? K : never;
export const paymentPlans: Record = {
diff --git a/template/app/src/server/webhooks/stripePaymentDetails.ts b/template/app/src/server/webhooks/stripePaymentDetails.ts
index e93529f6..6e1d62e8 100644
--- a/template/app/src/server/webhooks/stripePaymentDetails.ts
+++ b/template/app/src/server/webhooks/stripePaymentDetails.ts
@@ -1,9 +1,9 @@
import type { SubscriptionStatusOptions, PrismaUserDelegate } from '../../shared/types';
-import { SubscriptionPlanId } from '../../shared/constants';
+import { PaymentPlanId } from '../../payment/plans'
type UserStripePaymentDetails = {
userStripeId: string;
- subscriptionPlan?: SubscriptionPlanId
+ subscriptionPlan?: PaymentPlanId
subscriptionStatus?: SubscriptionStatusOptions;
numOfCreditsPurchased?: number;
datePaid?: Date;
From 3d4041657a8637526db4dc12bc30e94b0dce7d91 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Sat, 6 Jul 2024 07:26:59 +0200
Subject: [PATCH 14/26] put stripe event handlers back for marty merge
---
template/app/src/server/webhooks/stripe.ts | 113 +----------------
.../server/webhooks/stripeEventHandlers.ts | 114 ++++++++++++++++++
2 files changed, 115 insertions(+), 112 deletions(-)
create mode 100644 template/app/src/server/webhooks/stripeEventHandlers.ts
diff --git a/template/app/src/server/webhooks/stripe.ts b/template/app/src/server/webhooks/stripe.ts
index 2b7b6226..ee8c529b 100644
--- a/template/app/src/server/webhooks/stripe.ts
+++ b/template/app/src/server/webhooks/stripe.ts
@@ -1,14 +1,9 @@
-import type { PrismaUserDelegate, SubscriptionStatusOptions } from '../../shared/types';
import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
import { type StripeWebhook } from 'wasp/server/api';
import express from 'express';
import { Stripe } from 'stripe';
import { stripe } from '../stripe/stripeClient';
-import { paymentPlans } from '../stripe/paymentPlans';
-import { SubscriptionPlanId } from '../../shared/constants';
-import { updateUserStripePaymentDetails } from './stripePaymentDetails';
-import { emailSender } from 'wasp/server/email';
-import { z } from 'zod';
+import { handleCheckoutSessionCompleted, handleCustomerSubscriptionDeleted, handleCustomerSubscriptionUpdated, handleInvoicePaid } from './stripeEventHandlers';
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
@@ -59,109 +54,3 @@ export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
return middlewareConfig;
};
-
-
-export async function handleCheckoutSessionCompleted(
- session: Stripe.Checkout.Session,
- prismaUserDelegate: PrismaUserDelegate
-) {
- const userStripeId = validateUserStripeIdOrThrow(session.customer);
- const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
- expand: ['line_items'],
- });
- console.log('line_items: ', line_items);
- const lineItemPriceId = validateAndUseLineItemData(line_items);
-
- let subscriptionPlan: SubscriptionPlanId | undefined;
- let numOfCreditsPurchased: number | undefined;
- for (const paymentPlan of Object.values(paymentPlans)) {
- if (paymentPlan.stripePriceID === lineItemPriceId) {
- subscriptionPlan = paymentPlan.subscriptionPlan;
- numOfCreditsPurchased = paymentPlan.credits;
- break;
- }
- }
-
- return await updateUserStripePaymentDetails(
- { userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
- prismaUserDelegate
- );
-}
-
-export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaUserDelegate) {
- const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
- const datePaid = new Date(invoice.period_start * 1000);
- return await updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
-}
-
-export async function handleCustomerSubscriptionUpdated(
- subscription: Stripe.Subscription,
- prismaUserDelegate: PrismaUserDelegate
-) {
- const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
- let subscriptionStatus: SubscriptionStatusOptions | undefined;
-
- switch (subscription.status as Stripe.Subscription.Status) {
- case 'active':
- subscriptionStatus = 'active';
- break;
- case 'past_due':
- subscriptionStatus = 'past_due';
- break;
- }
- if (subscription.cancel_at_period_end) {
- subscriptionStatus = 'cancel_at_period_end';
- }
- if (!subscriptionStatus) throw new HttpError(400, 'Subscription status not handled');
-
- 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: PrismaUserDelegate
-) {
- const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
- return await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
-}
-
-const LineItemsPriceSchema = z.object({
- data: z.array(
- z.object({
- price: z.object({
- id: z.string(),
- }),
- })
- ),
-});
-
-function validateAndUseLineItemData(line_items: Stripe.ApiList | undefined) {
- 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');
- }
- return result.data.data[0].price.id;
-}
-
-function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']) {
- 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;
-}
\ No newline at end of file
diff --git a/template/app/src/server/webhooks/stripeEventHandlers.ts b/template/app/src/server/webhooks/stripeEventHandlers.ts
new file mode 100644
index 00000000..280cda9e
--- /dev/null
+++ b/template/app/src/server/webhooks/stripeEventHandlers.ts
@@ -0,0 +1,114 @@
+import type { PrismaUserDelegate, SubscriptionStatusOptions } from '../../shared/types';
+import { paymentPlans } from '../stripe/paymentPlans';
+import { SubscriptionPlanId } from '../../shared/constants';
+import { updateUserStripePaymentDetails } from './stripePaymentDetails';
+import { emailSender } from 'wasp/server/email';
+import { Stripe } from 'stripe';
+import { stripe } from '../stripe/stripeClient';
+import { HttpError } from 'wasp/server';
+import { z } from 'zod';
+
+export async function handleCheckoutSessionCompleted(
+ session: Stripe.Checkout.Session,
+ prismaUserDelegate: PrismaUserDelegate
+) {
+ const userStripeId = validateUserStripeIdOrThrow(session.customer);
+ const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
+ expand: ['line_items'],
+ });
+ console.log('line_items: ', line_items);
+ const lineItemPriceId = validateAndUseLineItemData(line_items);
+
+ let subscriptionPlan: SubscriptionPlanId | undefined;
+ let numOfCreditsPurchased: number | undefined;
+ for (const paymentPlan of Object.values(paymentPlans)) {
+ if (paymentPlan.stripePriceID === lineItemPriceId) {
+ subscriptionPlan = paymentPlan.subscriptionPlan;
+ numOfCreditsPurchased = paymentPlan.credits;
+ break;
+ }
+ }
+
+ return await updateUserStripePaymentDetails(
+ { userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
+ prismaUserDelegate
+ );
+}
+
+export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaUserDelegate) {
+ const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
+ const datePaid = new Date(invoice.period_start * 1000);
+ return await updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
+}
+
+export async function handleCustomerSubscriptionUpdated(
+ subscription: Stripe.Subscription,
+ prismaUserDelegate: PrismaUserDelegate
+) {
+ const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
+ let subscriptionStatus: SubscriptionStatusOptions | undefined;
+
+ switch (subscription.status as Stripe.Subscription.Status) {
+ case 'active':
+ subscriptionStatus = 'active';
+ break;
+ case 'past_due':
+ subscriptionStatus = 'past_due';
+ break;
+ }
+ if (subscription.cancel_at_period_end) {
+ subscriptionStatus = 'cancel_at_period_end';
+ }
+ if (!subscriptionStatus) throw new HttpError(400, 'Subscription status not handled');
+
+ 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: PrismaUserDelegate
+) {
+ const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
+ return await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
+}
+
+const LineItemsPriceSchema = z.object({
+ data: z.array(
+ z.object({
+ price: z.object({
+ id: z.string(),
+ }),
+ })
+ ),
+});
+
+function validateAndUseLineItemData(line_items: Stripe.ApiList | undefined) {
+ 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');
+ }
+ return result.data.data[0].price.id;
+}
+
+function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']) {
+ 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;
+}
From 73089dcb41c54b4bc652986d38221c0426ad5a6b Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Sat, 6 Jul 2024 07:40:38 +0200
Subject: [PATCH 15/26] merge consilidated types from martin
---
template/app/src/server/actions.ts | 18 +++++++++++++++++-
template/app/src/shared/types.ts | 25 -------------------------
2 files changed, 17 insertions(+), 26 deletions(-)
diff --git a/template/app/src/server/actions.ts b/template/app/src/server/actions.ts
index 2cf7dc6a..dba81105 100644
--- a/template/app/src/server/actions.ts
+++ b/template/app/src/server/actions.ts
@@ -10,7 +10,6 @@ import {
type UpdateTask,
} from 'wasp/server/operations';
import { PaymentPlanId, paymentPlans, type PaymentPlanEffect, type PaymentPlanEffectKinds } from '../payment/plans';
-import type { GeneratedSchedule, StripePaymentResult } from '../shared/types';
import { fetchStripeCustomer, createStripeCheckoutSession, type StripeMode } from './stripe/checkoutUtils.js';
import OpenAI from 'openai';
@@ -22,6 +21,11 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
+export type StripePaymentResult = {
+ sessionUrl: string | null;
+ sessionId: string;
+};
+
export const stripePayment: StripePayment = async (paymentPlanId, context) => {
if (!context.user) {
throw new HttpError(401);
@@ -77,6 +81,18 @@ type GptPayload = {
hours: string;
};
+type GeneratedSchedule = {
+ mainTasks: {
+ name: string;
+ priority: 'low' | 'medium' | 'high';
+ }[]; // Main tasks provided by user, ordered by priority
+ subtasks: {
+ description: string;
+ time: number; // total time it takes to complete given main task in hours, e.g. 2.75
+ mainTaskName: string; // name of main task related to subtask
+ }[];
+};
+
export const generateGptResponse: GenerateGptResponse = async ({ hours }, context) => {
if (!context.user) {
throw new HttpError(401);
diff --git a/template/app/src/shared/types.ts b/template/app/src/shared/types.ts
index 5a0bd521..4c6a39a7 100644
--- a/template/app/src/shared/types.ts
+++ b/template/app/src/shared/types.ts
@@ -2,29 +2,4 @@ import { PrismaClient } from '@prisma/client';
export type PrismaUserDelegate = PrismaClient['user']
-export type StripePaymentResult = {
- sessionUrl: string | null;
- sessionId: string;
-};
-
export type SubscriptionStatusOptions = 'past_due' | 'cancel_at_period_end' | 'active' | 'deleted';
-
-export type Subtask = {
- description: string; // detailed breakdown and description of sub-task
- time: number; // total time it takes to complete given main task in hours, e.g. 2.75
- mainTaskName: string; // name of main task related to subtask
-};
-
-export type MainTask = {
- name: string;
- priority: 'low' | 'medium' | 'high';
-};
-
-export type GeneratedSchedule = {
- mainTasks: MainTask[]; // Main tasks provided by user, ordered by priority
- subtasks: Subtask[]; // Array of subtasks
-};
-
-export type FunctionCallResponse = {
- schedule: GeneratedSchedule[];
-};
From 744c7db5f96556d9adafccb68f3480c6d13f8f1a Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Sat, 6 Jul 2024 08:36:49 +0200
Subject: [PATCH 16/26] move some types around
---
.../client/admin/components/CheckboxOne.tsx | 2 +-
.../client/admin/components/CheckboxTwo.tsx | 2 +-
.../admin/components/DarkModeSwitcher.tsx | 2 +-
.../admin/components/DropdownEditDelete.tsx | 2 +-
.../src/client/admin/components/Header.tsx | 2 +-
.../src/client/admin/components/Sidebar.tsx | 2 +-
.../client/admin/components/SwitcherOne.tsx | 2 +-
.../client/admin/components/SwitcherTwo.tsx | 2 +-
.../admin/components/TotalPaidViewsCard.tsx | 2 +-
.../admin/components/TotalPayingUsersCard.tsx | 2 +-
.../admin/components/TotalRevenueCard.tsx | 2 +-
.../admin/components/TotalSignupsCard.tsx | 2 +-
.../client/admin/components/UsersTable.tsx | 2 +-
template/app/src/client/app/AccountPage.tsx | 75 ++++++-----
template/app/src/client/app/DemoAppPage.tsx | 7 +-
template/app/src/client/app/PricingPage.tsx | 2 +-
.../app/src/{shared/utils.ts => client/cn.ts} | 0
.../app/src/client/components/AppNavBar.tsx | 6 +-
.../src/client/components/DropdownUser.tsx | 2 +-
.../src/client/components/UserMenuItems.tsx | 2 +-
.../src/client/landing-page/LandingPage.tsx | 4 +-
.../client/landing-page/contentSections.ts | 18 +--
template/app/src/client/landing-page/urls.ts | 2 +
.../app/src/file-upload/FileUploadPage.tsx | 2 +-
template/app/src/gpt/schedule.ts | 15 +++
template/app/src/payment/plan.ts | 0
template/app/src/payment/plans.ts | 21 +--
template/app/src/server/actions.ts | 20 +--
template/app/src/server/queries.ts | 2 +-
template/app/src/server/scripts/dbSeeds.ts | 3 +-
.../app/src/server/stripe/checkoutUtils.ts | 8 +-
.../app/src/server/stripe/paymentPlans.ts | 45 -------
template/app/src/server/webhooks/stripe.ts | 123 +++++++++++++++++-
.../server/webhooks/stripeEventHandlers.ts | 123 ------------------
.../server/webhooks/stripePaymentDetails.ts | 16 ++-
template/app/src/shared/constants.ts | 2 -
template/app/src/shared/types.ts | 5 -
37 files changed, 243 insertions(+), 286 deletions(-)
rename template/app/src/{shared/utils.ts => client/cn.ts} (100%)
create mode 100644 template/app/src/client/landing-page/urls.ts
create mode 100644 template/app/src/gpt/schedule.ts
delete mode 100644 template/app/src/payment/plan.ts
delete mode 100644 template/app/src/server/stripe/paymentPlans.ts
delete mode 100644 template/app/src/server/webhooks/stripeEventHandlers.ts
delete mode 100644 template/app/src/shared/constants.ts
delete mode 100644 template/app/src/shared/types.ts
diff --git a/template/app/src/client/admin/components/CheckboxOne.tsx b/template/app/src/client/admin/components/CheckboxOne.tsx
index a0b32b1f..5b9da038 100644
--- a/template/app/src/client/admin/components/CheckboxOne.tsx
+++ b/template/app/src/client/admin/components/CheckboxOne.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const CheckboxOne = () => {
const [isChecked, setIsChecked] = useState(false);
diff --git a/template/app/src/client/admin/components/CheckboxTwo.tsx b/template/app/src/client/admin/components/CheckboxTwo.tsx
index e7c89074..a5e24ac3 100644
--- a/template/app/src/client/admin/components/CheckboxTwo.tsx
+++ b/template/app/src/client/admin/components/CheckboxTwo.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const CheckboxTwo = () => {
const [enabled, setEnabled] = useState(false);
diff --git a/template/app/src/client/admin/components/DarkModeSwitcher.tsx b/template/app/src/client/admin/components/DarkModeSwitcher.tsx
index 62cc6bdb..c95d51c1 100644
--- a/template/app/src/client/admin/components/DarkModeSwitcher.tsx
+++ b/template/app/src/client/admin/components/DarkModeSwitcher.tsx
@@ -1,4 +1,4 @@
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
import useColorMode from '../../hooks/useColorMode';
const DarkModeSwitcher = () => {
diff --git a/template/app/src/client/admin/components/DropdownEditDelete.tsx b/template/app/src/client/admin/components/DropdownEditDelete.tsx
index cd2f9e41..0f9a0d6b 100644
--- a/template/app/src/client/admin/components/DropdownEditDelete.tsx
+++ b/template/app/src/client/admin/components/DropdownEditDelete.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const DropdownDefault = () => {
const [dropdownOpen, setDropdownOpen] = useState(false);
diff --git a/template/app/src/client/admin/components/Header.tsx b/template/app/src/client/admin/components/Header.tsx
index e31ad493..a0bb19e0 100644
--- a/template/app/src/client/admin/components/Header.tsx
+++ b/template/app/src/client/admin/components/Header.tsx
@@ -2,7 +2,7 @@ import { type AuthUser } from 'wasp/auth/types';
import DarkModeSwitcher from './DarkModeSwitcher';
import MessageButton from './MessageButton';
import DropdownUser from '../../components/DropdownUser';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const Header = (props: {
sidebarOpen: string | boolean | undefined;
diff --git a/template/app/src/client/admin/components/Sidebar.tsx b/template/app/src/client/admin/components/Sidebar.tsx
index 1e23a73e..e55d537f 100644
--- a/template/app/src/client/admin/components/Sidebar.tsx
+++ b/template/app/src/client/admin/components/Sidebar.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import Logo from '../../static/logo.png';
import SidebarLinkGroup from './SidebarLinkGroup';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
interface SidebarProps {
sidebarOpen: boolean;
diff --git a/template/app/src/client/admin/components/SwitcherOne.tsx b/template/app/src/client/admin/components/SwitcherOne.tsx
index 73f2eaa5..c4451d9c 100644
--- a/template/app/src/client/admin/components/SwitcherOne.tsx
+++ b/template/app/src/client/admin/components/SwitcherOne.tsx
@@ -1,6 +1,6 @@
import { type User } from 'wasp/entities';
import { useState } from 'react';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const SwitcherOne = ({ user, updateUserById }: { user?: Partial; updateUserById?: any }) => {
const [enabled, setEnabled] = useState(user?.isAdmin || false);
diff --git a/template/app/src/client/admin/components/SwitcherTwo.tsx b/template/app/src/client/admin/components/SwitcherTwo.tsx
index 780a726c..5456fe52 100644
--- a/template/app/src/client/admin/components/SwitcherTwo.tsx
+++ b/template/app/src/client/admin/components/SwitcherTwo.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const SwitcherTwo = () => {
const [enabled, setEnabled] = useState(false);
diff --git a/template/app/src/client/admin/components/TotalPaidViewsCard.tsx b/template/app/src/client/admin/components/TotalPaidViewsCard.tsx
index f729c553..e35d98c6 100644
--- a/template/app/src/client/admin/components/TotalPaidViewsCard.tsx
+++ b/template/app/src/client/admin/components/TotalPaidViewsCard.tsx
@@ -1,4 +1,4 @@
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
import { UpArrow, DownArrow } from '../images/icon/icons-arrows';
type PageViewsStats = {
diff --git a/template/app/src/client/admin/components/TotalPayingUsersCard.tsx b/template/app/src/client/admin/components/TotalPayingUsersCard.tsx
index 03ffb9d7..edd1d35a 100644
--- a/template/app/src/client/admin/components/TotalPayingUsersCard.tsx
+++ b/template/app/src/client/admin/components/TotalPayingUsersCard.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { UpArrow, DownArrow } from '../images/icon/icons-arrows';
import type { DailyStatsProps } from '../common/types';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
diff --git a/template/app/src/client/admin/components/TotalRevenueCard.tsx b/template/app/src/client/admin/components/TotalRevenueCard.tsx
index af31b9ac..91981901 100644
--- a/template/app/src/client/admin/components/TotalRevenueCard.tsx
+++ b/template/app/src/client/admin/components/TotalRevenueCard.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useEffect } from 'react';
+import { useMemo } from 'react';
import { UpArrow, DownArrow } from '../images/icon/icons-arrows';
import type { DailyStatsProps } from '../common/types';
diff --git a/template/app/src/client/admin/components/TotalSignupsCard.tsx b/template/app/src/client/admin/components/TotalSignupsCard.tsx
index cd7c2539..db4b4bb2 100644
--- a/template/app/src/client/admin/components/TotalSignupsCard.tsx
+++ b/template/app/src/client/admin/components/TotalSignupsCard.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { UpArrow } from '../images/icon/icons-arrows';
import type { DailyStatsProps } from '../common/types';
-import { cn } from '../../../shared/utils';
+import { cn } from '../../cn';
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
diff --git a/template/app/src/client/admin/components/UsersTable.tsx b/template/app/src/client/admin/components/UsersTable.tsx
index bb7a9372..b908d809 100644
--- a/template/app/src/client/admin/components/UsersTable.tsx
+++ b/template/app/src/client/admin/components/UsersTable.tsx
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
import SwitcherOne from './SwitcherOne';
import Loader from '../common/Loader';
import DropdownEditDelete from './DropdownEditDelete';
-import { type SubscriptionStatusOptions } from '../../../shared/types';
+import { type SubscriptionStatusOptions } from '../../../payment/plans';
const UsersTable = () => {
const [skip, setskip] = useState(0);
diff --git a/template/app/src/client/app/AccountPage.tsx b/template/app/src/client/app/AccountPage.tsx
index 1c926675..1bbe643b 100644
--- a/template/app/src/client/app/AccountPage.tsx
+++ b/template/app/src/client/app/AccountPage.tsx
@@ -1,5 +1,5 @@
import type { User } from 'wasp/entities';
-import type { SubscriptionStatusOptions } from '../../shared/types';
+import type { SubscriptionStatusOptions } from '../../payment/plans';
import { PaymentPlanId, parsePaymentPlanId } from '../../payment/plans';
import { Link } from 'wasp/client/router';
import { logout } from 'wasp/client/auth';
@@ -28,7 +28,7 @@ export default function AccountPage({ user }: { user: User }) {
)}
Your Plan
-
+
About
@@ -51,41 +51,14 @@ export default function AccountPage({ user }: { user: User }) {
);
}
-function UserCurrentSubscriptionStatus(user: User) {
- const prettyPrintEndOfBillingPeriod = (date: Date | null) => {
- if (!date) {
- console.error('User date paid is missing');
- return '.';
- }
- const oneMonthFromNow = new Date(date);
- oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1);
- return ': ' + oneMonthFromNow.toLocaleDateString();
- };
-
- function getSubscriptionMessage(subscriptionStatus: string) {
- if (!user.subscriptionPlan) {
- throw new Error('User is missing a subscriptionPlan');
- }
- const planName = prettyPaymentPlanName(parsePaymentPlanId(user.subscriptionPlan));
- const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(user.datePaid);
-
- const statusToMessage: Record = {
- active: `${planName}`,
- past_due: `Payment for your ${planName} plan is past due! Please update your subscription payment information.`,
- cancel_at_period_end: `Your ${planName} plan subscription has been canceled, but remains active until the end of the current billing period${endOfBillingPeriod}`,
- deleted: `Your previous subscription has been canceled and is no longer active.`
- };
-
- return statusToMessage[subscriptionStatus as SubscriptionStatusOptions];
- }
-
- if (user.subscriptionStatus) {
+function UserCurrentPaymentPlan({ subscriptionPlan, subscriptionStatus, datePaid, credits }: Pick) {
+ if (subscriptionStatus && subscriptionPlan) {
return (
<>