mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-10 12:59:05 +02:00
Define all the subscription statuses in a single place
This commit is contained in:
parent
0543c9f3cc
commit
86a8ecd88c
@ -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'
|
||||
>
|
||||
<option value=''>Select filters</option>
|
||||
{['past_due', 'cancel_at_period_end', 'active', 'deleted', null].map((status) => {
|
||||
if (!subscriptionStatusFilter.includes(status as SubscriptionStatus)) {
|
||||
<option key='has-not-subscribed' value=''>
|
||||
has not subscribed
|
||||
</option>
|
||||
{[...Object.values(SubscriptionStatus)].map((status) => {
|
||||
if (!subscriptionStatusFilter.includes(status)) {
|
||||
return (
|
||||
<option key={status} value={status || ''}>
|
||||
{status ? status : 'has not subscribed'}
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
|
@ -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<never, void> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<GptPayload, GeneratedSched
|
||||
const hasCredits = context.user.credits > 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) {
|
||||
|
@ -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<boolean>(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,
|
||||
|
@ -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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
} {
|
||||
|
@ -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<User, 'id'>;
|
||||
|
||||
@ -11,9 +11,7 @@ type MockUserData = Omit<User, 'id'>;
|
||||
* 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<SubscriptionStatus | null>(['active', 'cancel_at_period_end', 'past_due', 'deleted', null]);
|
||||
const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatus | null>([
|
||||
...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 }),
|
||||
|
@ -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 }) {
|
||||
<div className='mt-10 px-6'>
|
||||
<div className='overflow-hidden border border-gray-900/10 shadow-lg sm:rounded-lg mb-4 lg:m-8 dark:border-gray-100/10'>
|
||||
<div className='px-4 py-5 sm:px-6 lg:px-8'>
|
||||
<h3 className='text-base font-semibold leading-6 text-gray-900 dark:text-white'>Account Information</h3>
|
||||
<h3 className='text-base font-semibold leading-6 text-gray-900 dark:text-white'>
|
||||
Account Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className='border-t border-gray-900/10 dark:border-gray-100/10 px-4 py-5 sm:p-0'>
|
||||
<dl className='sm:divide-y sm:divide-gray-900/10 sm:dark:divide-gray-100/10'>
|
||||
{!!user.email && (
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500 dark:text-white'>Email address</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>{user.email}</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>
|
||||
{user.email}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{!!user.username && (
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500 dark:text-white'>Username</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>{user.username}</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>
|
||||
{user.username}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
@ -36,7 +42,9 @@ export default function AccountPage({ user }: { user: User }) {
|
||||
</div>
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500 dark:text-white'>About</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>I'm a cool customer.</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>
|
||||
I'm a cool customer.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@ -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 (
|
||||
<>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}</dd>
|
||||
{subscriptionStatus !== 'deleted' ? <CustomerPortalButton /> : <BuyMoreButton />}
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
|
||||
{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}
|
||||
</dd>
|
||||
{subscriptionStatus !== SubscriptionStatus.Deleted ? <CustomerPortalButton /> : <BuyMoreButton />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>Credits remaining: {credits}</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
|
||||
Credits remaining: {credits}
|
||||
</dd>
|
||||
<BuyMoreButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<SubscriptionStatus, string> = {
|
||||
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 (
|
||||
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
|
||||
<WaspRouterLink to={routes.PricingPageRoute.to} className='font-medium text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-500'>
|
||||
<WaspRouterLink
|
||||
to={routes.PricingPageRoute.to}
|
||||
className='font-medium text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-500'
|
||||
>
|
||||
Buy More/Upgrade
|
||||
</WaspRouterLink>
|
||||
</div>
|
||||
@ -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 (
|
||||
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
|
||||
<button onClick={handleClick} disabled={isCustomerPortalUrlLoading} className='font-medium text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300'>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isCustomerPortalUrlLoading}
|
||||
className='font-medium text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300'
|
||||
>
|
||||
Manage Subscription
|
||||
</button>
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user