diff --git a/template/app/src/admin/dashboards/users/UsersTable.tsx b/template/app/src/admin/dashboards/users/UsersTable.tsx index 4d1f4a8..e698488 100644 --- a/template/app/src/admin/dashboards/users/UsersTable.tsx +++ b/template/app/src/admin/dashboards/users/UsersTable.tsx @@ -1,4 +1,4 @@ -import { type SubscriptionStatus } from '../../../payment/plans'; +import { SubscriptionStatus } from '../../../payment/plans'; import { useQuery, getPaginatedUsers } from 'wasp/client/operations'; import { useState, useEffect } from 'react'; import SwitcherOne from '../../elements/forms/SwitcherOne'; @@ -103,11 +103,14 @@ const UsersTable = () => { className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0' > - {['past_due', 'cancel_at_period_end', 'active', 'deleted', null].map((status) => { - if (!subscriptionStatusFilter.includes(status as SubscriptionStatus)) { + + {[...Object.values(SubscriptionStatus)].map((status) => { + if (!subscriptionStatusFilter.includes(status)) { return ( - ); } diff --git a/template/app/src/analytics/stats.ts b/template/app/src/analytics/stats.ts index 32d1275..e34826a 100644 --- a/template/app/src/analytics/stats.ts +++ b/template/app/src/analytics/stats.ts @@ -1,11 +1,12 @@ import { type DailyStats } from 'wasp/entities'; import { type DailyStatsJob } from 'wasp/server/jobs'; import Stripe from 'stripe'; -import { stripe } from '../payment/stripe/stripeClient' +import { stripe } from '../payment/stripe/stripeClient'; import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils'; // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; import { paymentProcessor } from '../payment/paymentProcessor'; +import { SubscriptionStatus } from '../payment/plans'; export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean }; @@ -30,7 +31,7 @@ export const calculateDailyStats: DailyStatsJob = async (_args, con // we don't want to count those users as current paying users const paidUserCount = await context.entities.User.count({ where: { - subscriptionStatus: 'active', + subscriptionStatus: SubscriptionStatus.Active, }, }); @@ -196,4 +197,5 @@ async function fetchTotalLemonSqueezyRevenue() { console.error('Error fetching Lemon Squeezy revenue:', error); throw error; } -} \ No newline at end of file +} + diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index ca58426..8045b5a 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -10,6 +10,7 @@ import type { import { HttpError } from 'wasp/server'; import { GeneratedSchedule } from './schedule'; import OpenAI from 'openai'; +import { SubscriptionStatus } from '../payment/plans'; const openai = setupOpenAI(); function setupOpenAI() { @@ -51,8 +52,8 @@ export const generateGptResponse: GenerateGptResponse 0; const hasValidSubscription = !!context.user.subscriptionStatus && - context.user.subscriptionStatus !== 'deleted' && - context.user.subscriptionStatus !== 'past_due'; + context.user.subscriptionStatus !== SubscriptionStatus.Deleted && + context.user.subscriptionStatus !== SubscriptionStatus.PastDue; const canUserContinue = hasCredits || hasValidSubscription; if (!canUserContinue) { diff --git a/template/app/src/payment/PricingPage.tsx b/template/app/src/payment/PricingPage.tsx index 8afc4f8..41297c4 100644 --- a/template/app/src/payment/PricingPage.tsx +++ b/template/app/src/payment/PricingPage.tsx @@ -1,6 +1,6 @@ import { useAuth } from 'wasp/client/auth'; import { generateCheckoutSession, getCustomerPortalUrl, useQuery } from 'wasp/client/operations'; -import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } from './plans'; +import { PaymentPlanId, paymentPlans, prettyPaymentPlanName, SubscriptionStatus } from './plans'; import { AiFillCheckCircle } from 'react-icons/ai'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -40,7 +40,7 @@ const PricingPage = () => { const [isPaymentLoading, setIsPaymentLoading] = useState(false); const { data: user } = useAuth(); - const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== 'deleted'; + const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== SubscriptionStatus.Deleted; const { data: customerPortalUrl, diff --git a/template/app/src/payment/lemonSqueezy/webhook.ts b/template/app/src/payment/lemonSqueezy/webhook.ts index 9191415..6c04753 100644 --- a/template/app/src/payment/lemonSqueezy/webhook.ts +++ b/template/app/src/payment/lemonSqueezy/webhook.ts @@ -2,13 +2,12 @@ import { type MiddlewareConfigFn, HttpError } from 'wasp/server'; import { type PaymentsWebhook } from 'wasp/server/api'; import { type PrismaClient } from '@prisma/client'; import express from 'express'; -import { paymentPlans, PaymentPlanId } from '../plans'; +import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans'; import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails'; import { type Order, type Subscription, getCustomer } from '@lemonsqueezy/lemonsqueezy.js'; import crypto from 'crypto'; import { requireNodeEnvVar } from '../../server/utils'; - export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => { try { const rawBody = request.body.toString('utf8'); @@ -94,7 +93,11 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat console.log(`Order ${order_number} created for user ${lemonSqueezyId}`); } -async function handleSubscriptionCreated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { +async function handleSubscriptionCreated( + data: Subscription, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { const { customer_id, status, variant_id } = data.data.attributes; const lemonSqueezyId = customer_id.toString(); @@ -106,7 +109,7 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri lemonSqueezyId, userId, subscriptionPlan: planId, - subscriptionStatus: status, + subscriptionStatus: status as SubscriptionStatus, datePaid: new Date(), }, prismaUserDelegate @@ -118,9 +121,12 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri console.log(`Subscription created for user ${lemonSqueezyId}`); } - // NOTE: LemonSqueezy's 'subscription_updated' event is sent as a catch-all and fires even after 'subscription_created' & 'order_created'. -async function handleSubscriptionUpdated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { +async function handleSubscriptionUpdated( + data: Subscription, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { const { customer_id, status, variant_id } = data.data.attributes; const lemonSqueezyId = customer_id.toString(); @@ -128,8 +134,8 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri // We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active. // Note that a status changes to 'past_due' on a failed payment retry, then after 4 unsuccesful payment retries status - // becomes 'unpaid' and finally 'expired' (i.e. 'deleted'). - // NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard. + // becomes 'unpaid' and finally 'expired' (i.e. 'deleted'). + // NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard. // If you do enable these features, make sure to handle these statuses here. if (status === 'past_due' || status === 'active') { await updateUserLemonSqueezyPaymentDetails( @@ -137,7 +143,7 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri lemonSqueezyId, userId, subscriptionPlan: planId, - subscriptionStatus: status, + subscriptionStatus: status as SubscriptionStatus, ...(status === 'active' && { datePaid: new Date() }), }, prismaUserDelegate @@ -146,7 +152,11 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri } } -async function handleSubscriptionCancelled(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { +async function handleSubscriptionCancelled( + data: Subscription, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { const { customer_id } = data.data.attributes; const lemonSqueezyId = customer_id.toString(); @@ -154,7 +164,8 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p { lemonSqueezyId, userId, - subscriptionStatus: 'cancel_at_period_end', // cancel_at_period_end is the Stripe equivalent of LemonSqueezy's cancelled + // cancel_at_period_end is the Stripe equivalent of LemonSqueezy's cancelled + subscriptionStatus: 'cancel_at_period_end' as SubscriptionStatus, }, prismaUserDelegate ); @@ -162,7 +173,11 @@ async function handleSubscriptionCancelled(data: Subscription, userId: string, p console.log(`Subscription cancelled for user ${lemonSqueezyId}`); } -async function handleSubscriptionExpired(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) { +async function handleSubscriptionExpired( + data: Subscription, + userId: string, + prismaUserDelegate: PrismaClient['user'] +) { const { customer_id } = data.data.attributes; const lemonSqueezyId = customer_id.toString(); @@ -170,7 +185,8 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri { lemonSqueezyId, userId, - subscriptionStatus: 'deleted', // deleted is the Stripe equivalent of LemonSqueezy's expired + // deleted is the Stripe equivalent of LemonSqueezy's expired + subscriptionStatus: SubscriptionStatus.Deleted, }, prismaUserDelegate ); @@ -181,7 +197,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri async function fetchUserCustomerPortalUrl({ lemonSqueezyId }: { lemonSqueezyId: string }): Promise { const { data: lemonSqueezyCustomer, error } = await getCustomer(lemonSqueezyId); if (error) { - throw new Error(`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`); + throw new Error( + `Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}` + ); } const customerPortalUrl = lemonSqueezyCustomer.data.attributes.urls.customer_portal; if (!customerPortalUrl) { @@ -198,4 +216,5 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId { throw new Error(`No plan with LemonSqueezy variant id ${variantId}`); } return planId; -} \ No newline at end of file +} + diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts index df45fb3..85d6a1e 100644 --- a/template/app/src/payment/plans.ts +++ b/template/app/src/payment/plans.ts @@ -1,6 +1,11 @@ import { requireNodeEnvVar } from '../server/utils'; -export type SubscriptionStatus = 'past_due' | 'cancel_at_period_end' | 'active' | 'deleted'; +export enum SubscriptionStatus { + PastDue = 'past_due', + CancelAtPeriodEnd = 'cancel_at_period_end', + Active = 'active', + Deleted = 'deleted', +} export enum PaymentPlanId { Hobby = 'hobby', @@ -9,7 +14,7 @@ export enum PaymentPlanId { } export interface PaymentPlan { - // Returns the id under which this payment plan is identified on your payment processor. + // Returns the id under which this payment plan is identified on your payment processor. // E.g. this might be price id on Stripe, or variant id on LemonSqueezy. getPaymentProcessorPlanId: () => string; effect: PaymentPlanEffect; diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index b0c9972..1fd6ff3 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -48,7 +48,7 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) 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 + // 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); } @@ -68,7 +68,7 @@ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) = // if the payment succeeds in other, more specific, webhooks. export async function handleCheckoutSessionCompleted( session: Stripe.Checkout.Session, - prismaUserDelegate: PrismaClient["user"] + prismaUserDelegate: PrismaClient['user'] ) { const userStripeId = validateUserStripeIdOrThrow(session.customer); const { line_items } = await stripe.checkout.sessions.retrieve(session.id, { @@ -82,15 +82,12 @@ export async function handleCheckoutSessionCompleted( } const { subscriptionPlan } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect }); - return updateUserStripePaymentDetails( - { userStripeId, subscriptionPlan }, - prismaUserDelegate - ); + return updateUserStripePaymentDetails({ userStripeId, subscriptionPlan }, prismaUserDelegate); } -// This is called when a subscription is purchased or renewed and payment succeeds. +// This is called when a subscription is purchased or renewed and payment succeeds. // Invoices are not created for one-time payments, so we handle them in the payment_intent.succeeded webhook. -export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaClient["user"]) { +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); @@ -100,7 +97,7 @@ export async function handlePaymentIntentSucceeded( paymentIntent: Stripe.PaymentIntent, prismaUserDelegate: PrismaClient['user'] ) { - // We handle invoices in the invoice.paid webhook. Invoices exist for subscription payments, + // We handle invoices in the invoice.paid webhook. Invoices exist for subscription payments, // but not for one-time payment/credits products which use the Stripe `payment` mode on checkout sessions. if (paymentIntent.invoice) { return; @@ -133,7 +130,7 @@ export async function handlePaymentIntentSucceeded( export async function handleCustomerSubscriptionUpdated( subscription: Stripe.Subscription, - prismaUserDelegate: PrismaClient["user"] + prismaUserDelegate: PrismaClient['user'] ) { const userStripeId = validateUserStripeIdOrThrow(subscription.customer); let subscriptionStatus: SubscriptionStatus | undefined; @@ -143,13 +140,18 @@ export async function handleCustomerSubscriptionUpdated( // 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 (subscription.status === SubscriptionStatus.Active) { + subscriptionStatus = subscription.cancel_at_period_end + ? SubscriptionStatus.CancelAtPeriodEnd + : SubscriptionStatus.Active; + } else if (subscription.status === SubscriptionStatus.PastDue) { + subscriptionStatus = SubscriptionStatus.PastDue; + } if (subscriptionStatus) { - const user = await updateUserStripePaymentDetails({ userStripeId, subscriptionPlan, subscriptionStatus }, prismaUserDelegate); + const user = await updateUserStripePaymentDetails( + { userStripeId, subscriptionPlan, subscriptionStatus }, + prismaUserDelegate + ); if (subscription.cancel_at_period_end) { if (user.email) { await emailSender.send({ @@ -166,10 +168,13 @@ export async function handleCustomerSubscriptionUpdated( export async function handleCustomerSubscriptionDeleted( subscription: Stripe.Subscription, - prismaUserDelegate: PrismaClient["user"] + prismaUserDelegate: PrismaClient['user'] ) { const userStripeId = validateUserStripeIdOrThrow(subscription.customer); - return updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate); + return updateUserStripePaymentDetails( + { userStripeId, subscriptionStatus: SubscriptionStatus.Deleted }, + prismaUserDelegate + ); } function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']): string { @@ -209,7 +214,13 @@ function getPlanIdByPriceId(priceId: string): PaymentPlanId { return planId; } -function getPlanEffectPaymentDetails({ planId, planEffect }: { planId: PaymentPlanId, planEffect: PaymentPlanEffect}): { +function getPlanEffectPaymentDetails({ + planId, + planEffect, +}: { + planId: PaymentPlanId; + planEffect: PaymentPlanEffect; +}): { subscriptionPlan: PaymentPlanId | undefined; numOfCreditsPurchased: number | undefined; } { diff --git a/template/app/src/server/scripts/dbSeeds.ts b/template/app/src/server/scripts/dbSeeds.ts index fb356c8..1287adf 100644 --- a/template/app/src/server/scripts/dbSeeds.ts +++ b/template/app/src/server/scripts/dbSeeds.ts @@ -1,7 +1,7 @@ import { type User } from 'wasp/entities'; import { faker } from '@faker-js/faker'; import type { PrismaClient } from '@prisma/client'; -import { getSubscriptionPaymentPlanIds, type SubscriptionStatus } from '../../payment/plans'; +import { getSubscriptionPaymentPlanIds, SubscriptionStatus } from '../../payment/plans'; type MockUserData = Omit; @@ -11,9 +11,7 @@ type MockUserData = Omit; * For more info see: https://wasp.sh/docs/data-model/backends#seeding-the-database */ export async function seedMockUsers(prismaClient: PrismaClient) { - await Promise.all(generateMockUsersData(50).map((data) => - prismaClient.user.create({ data })) - ); + await Promise.all(generateMockUsersData(50).map((data) => prismaClient.user.create({ data }))); } function generateMockUsersData(numOfUsers: number): MockUserData[] { @@ -23,12 +21,15 @@ function generateMockUsersData(numOfUsers: number): MockUserData[] { function generateMockUserData(): MockUserData { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); - const subscriptionStatus = faker.helpers.arrayElement(['active', 'cancel_at_period_end', 'past_due', 'deleted', null]); + const subscriptionStatus = faker.helpers.arrayElement([ + ...Object.values(SubscriptionStatus), + null, + ]); const now = new Date(); const createdAt = faker.date.past({ refDate: now }); const timePaid = faker.date.between({ from: createdAt, to: now }); const credits = subscriptionStatus ? 0 : faker.number.int({ min: 0, max: 10 }); - const hasUserPaidOnStripe = !!subscriptionStatus || credits > 3 + const hasUserPaidOnStripe = !!subscriptionStatus || credits > 3; return { email: faker.internet.email({ firstName, lastName }), username: faker.internet.userName({ firstName, lastName }), diff --git a/template/app/src/user/AccountPage.tsx b/template/app/src/user/AccountPage.tsx index 71b9a8e..207de2a 100644 --- a/template/app/src/user/AccountPage.tsx +++ b/template/app/src/user/AccountPage.tsx @@ -1,5 +1,5 @@ import type { User } from 'wasp/entities'; -import { type SubscriptionStatus, prettyPaymentPlanName, parsePaymentPlanId } from '../payment/plans'; +import { SubscriptionStatus, prettyPaymentPlanName, parsePaymentPlanId } from '../payment/plans'; import { getCustomerPortalUrl, useQuery } from 'wasp/client/operations'; import { Link as WaspRouterLink, routes } from 'wasp/client/router'; import { logout } from 'wasp/client/auth'; @@ -9,20 +9,26 @@ export default function AccountPage({ user }: { user: User }) {
-

Account Information

+

+ Account Information +

{!!user.email && (
Email address
-
{user.email}
+
+ {user.email} +
)} {!!user.username && (
Username
-
{user.username}
+
+ {user.username} +
)}
@@ -36,7 +42,9 @@ export default function AccountPage({ user }: { user: User }) {
About
-
I'm a cool customer.
+
+ I'm a cool customer. +
@@ -60,31 +68,52 @@ type UserCurrentPaymentPlanProps = { credits: number; }; -function UserCurrentPaymentPlan({ subscriptionPlan, subscriptionStatus, datePaid, credits }: UserCurrentPaymentPlanProps) { +function UserCurrentPaymentPlan({ + subscriptionPlan, + subscriptionStatus, + datePaid, + credits, +}: UserCurrentPaymentPlanProps) { if (subscriptionStatus && subscriptionPlan && datePaid) { return ( <> -
{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}
- {subscriptionStatus !== 'deleted' ? : } +
+ {getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })} +
+ {subscriptionStatus !== SubscriptionStatus.Deleted ? : } ); } return ( <> -
Credits remaining: {credits}
+
+ Credits remaining: {credits} +
); } -function getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid }: { subscriptionPlan: string; subscriptionStatus: SubscriptionStatus; datePaid: Date }) { +function getUserSubscriptionStatusDescription({ + subscriptionPlan, + subscriptionStatus, + datePaid, +}: { + subscriptionPlan: string; + subscriptionStatus: SubscriptionStatus; + datePaid: Date; +}) { const planName = prettyPaymentPlanName(parsePaymentPlanId(subscriptionPlan)); const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(datePaid); return prettyPrintStatus(planName, subscriptionStatus, endOfBillingPeriod); } -function prettyPrintStatus(planName: string, subscriptionStatus: SubscriptionStatus, endOfBillingPeriod: string): string { +function prettyPrintStatus( + planName: string, + subscriptionStatus: SubscriptionStatus, + endOfBillingPeriod: string +): string { const statusToMessage: Record = { active: `${planName}`, past_due: `Payment for your ${planName} plan is past due! Please update your subscription payment information.`, @@ -107,7 +136,10 @@ function prettyPrintEndOfBillingPeriod(date: Date) { function BuyMoreButton() { return (
- + Buy More/Upgrade
@@ -115,7 +147,11 @@ function BuyMoreButton() { } function CustomerPortalButton() { - const { data: customerPortalUrl, isLoading: isCustomerPortalUrlLoading, error: customerPortalUrlError } = useQuery(getCustomerPortalUrl); + const { + data: customerPortalUrl, + isLoading: isCustomerPortalUrlLoading, + error: customerPortalUrlError, + } = useQuery(getCustomerPortalUrl); const handleClick = () => { if (customerPortalUrlError) { @@ -131,7 +167,11 @@ function CustomerPortalButton() { return (
-