feat: plan limits

This commit is contained in:
Mythie
2023-10-15 20:26:32 +11:00
parent 0d026f3476
commit 093488a67c
31 changed files with 750 additions and 272 deletions

View File

@ -14,6 +14,13 @@
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*"
"@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.4.19",
"next-auth": "4.22.3",
"react": "18.2.0",
"ts-pattern": "^5.0.5",
"zod": "^3.21.4"
}
}

View File

@ -0,0 +1,31 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record<string, string>;
};
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZLimitsResponseSchema.parse(res))
.catch(() => {
return {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
} satisfies TLimitsResponseSchema;
});
};

View File

@ -0,0 +1,11 @@
import { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
};
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
};

View File

@ -0,0 +1,6 @@
export const ERROR_CODES: Record<string, string> = {
UNAUTHORIZED: 'You must be logged in to access this resource',
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
UNKNOWN: 'An unknown error occurred',
};

View File

@ -0,0 +1,54 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
req: NextApiRequest,
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
) => {
try {
const token = await getToken({ req });
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
});
}
if (!token?.email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const limits = await getServerLimits({ email: token.email });
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
} catch (err) {
console.error('error', err);
if (err instanceof Error) {
const status = match(err.message)
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
.otherwise(() => 500);
return res.status(status).json({
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
});
}
res.status(500).json({
error: ERROR_CODES.UNKNOWN,
});
}
};

View File

@ -0,0 +1,53 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
const LimitsContext = createContext<LimitsContextValue | null>(null);
export const useLimits = () => {
const limits = useContext(LimitsContext);
if (!limits) {
throw new Error('useLimits must be used within a LimitsProvider');
}
return limits;
};
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
children?: React.ReactNode;
};
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
const defaultValue: TLimitsResponseSchema = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
};
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
useEffect(() => {
void getLimits().then((limits) => setLimits(limits));
}, []);
useEffect(() => {
const onFocus = () => {
void getLimits().then((limits) => setLimits(limits));
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
};

View File

@ -0,0 +1,18 @@
'use server';
import { headers } from 'next/headers';
import { getLimits } from '../client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
};
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits = await getLimits({ headers: requestHeaders });
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
};

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({
documents: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
recipients: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
});
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema,
remaining: ZLimitsSchema,
});
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
export const ZLimitsErrorResponseSchema = z.object({
error: z.string(),
});
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;

View File

@ -0,0 +1,78 @@
import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string;
};
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
};
}
if (!email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const user = await prisma.user.findFirst({
where: {
email,
},
include: {
Subscription: true,
},
});
if (!user) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
if (user.Subscription?.priceId) {
const { product } = await stripe.prices
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
if (typeof product === 'string') {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
}
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
}
const documents = await prisma.document.count({
where: {
userId: user.id,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
},
});
remaining.documents = Math.max(remaining.documents - documents, 0);
return {
quota,
remaining,
};
};

View File

@ -0,0 +1,17 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetProductByPriceIdOptions = {
priceId: string;
};
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
const { product } = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
if (typeof product === 'string' || 'deleted' in product) {
throw new Error('Product not found');
}
return product;
};

View File

@ -0,0 +1,224 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const stripeWebhookHandler = async (
req: NextApiRequest,
res: NextApiResponse<StripeWebhookResponse>,
) => {
try {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return res.status(500).json({
success: false,
message: 'Billing is disabled',
});
}
const signature =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!signature) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
const body = await buffer(req);
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
);
await match(event.type)
.with('checkout.session.completed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
const userId = Number(session.client_reference_id);
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription?.id;
if (!subscriptionId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== 'subscription_cycle') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await onSubscriptionDeleted({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.otherwise(() => {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
});
} catch (err) {
console.error(err);
res.status(500).json({
success: false,
message: 'Unknown error',
});
}
};

View File

@ -0,0 +1,21 @@
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};

View File

@ -0,0 +1,44 @@
import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.upsert({
where: {
customerId,
},
create: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
},
update: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
};