Define all the subscription statuses in a single place

This commit is contained in:
Filip Sodić 2025-02-21 14:36:52 +01:00
parent 0543c9f3cc
commit 86a8ecd88c
9 changed files with 150 additions and 68 deletions

View File

@ -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>
);
}

View File

@ -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;
}
}
}

View File

@ -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) {

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
} {

View File

@ -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 }),

View File

@ -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>