diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 790177c8a..980414882 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -4,8 +4,10 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; +import { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; +import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; import { @@ -19,7 +21,9 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types'; +export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); + +export type TUserFormSchema = z.infer; export default function UserPage({ params }: { params: { id: number } }) { const { toast } = useToast(); diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 42d767c40..644c9017a 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -2,12 +2,15 @@ import { useState } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; @@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { const { toast } = useToast(); + const { quota, remaining } = useLimits(); + const [isLoading, setIsLoading] = useState(false); const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); @@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { } catch (error) { console.error(error); - toast({ - title: 'Error', - description: 'An error occurred while uploading your document.', - variant: 'destructive', - }); + if (error instanceof TRPCClientError) { + toast({ + title: 'Error', + description: error.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'Error', + description: 'An error occurred while uploading your document.', + variant: 'destructive', + }); + } } finally { setIsLoading(false); } @@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { return (
- + + +
+ {remaining.documents > 0 && Number.isFinite(remaining.documents) && ( +

+ {remaining.documents} of {quota.documents} documents remaining this month. +

+ )} +
{isLoading && ( -
+
)} + + {remaining.documents === 0 && ( +
+
+

+ You have reached your document limit. +

+ +

+ You can upload up to {quota.documents} documents per month on your current plan. +

+ + + Upgrade your account to upload more documents. + +
+
+ )}
); }; diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 19f27b788..efd3aa2ea 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'; import { getServerSession } from 'next-auth'; +import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; @@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({ return ( -
+ +
-
{children}
+
{children}
- + + ); } diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index ce41f4f6d..9f7e44e25 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -3,9 +3,10 @@ import { redirect } from 'next/navigation'; import { match } from 'ts-pattern'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; -import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; +import { Stripe } from '@documenso/lib/server-only/stripe'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -30,12 +31,10 @@ export default async function BillingSettingsPage() { let subscriptionProduct: Stripe.Product | null = null; - if (subscription?.planId) { - const foundSubscriptionProduct = (await stripe.products.list()).data.find( - (item) => item.default_price === subscription.planId, + if (subscription?.priceId) { + subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( + () => null, ); - - subscriptionProduct = foundSubscriptionProduct ?? null; } const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; diff --git a/apps/web/src/pages/api/limits/index.ts b/apps/web/src/pages/api/limits/index.ts new file mode 100644 index 000000000..e788086d9 --- /dev/null +++ b/apps/web/src/pages/api/limits/index.ts @@ -0,0 +1,3 @@ +import { limitsHandler } from '@documenso/ee/server-only/limits/handler'; + +export default limitsHandler; diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 9f1a6937a..65385a6bc 100644 --- a/apps/web/src/pages/api/stripe/webhook/index.ts +++ b/apps/web/src/pages/api/stripe/webhook/index.ts @@ -1,237 +1,7 @@ -import { NextApiRequest, NextApiResponse } from 'next'; - -import { randomBytes } from 'crypto'; -import { readFileSync } from 'fs'; -import { buffer } from 'micro'; -import { match } from 'ts-pattern'; - -import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; -import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; -import { redis } from '@documenso/lib/server-only/redis'; -import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; -import { prisma } from '@documenso/prisma'; -import { - DocumentDataType, - DocumentStatus, - FieldType, - ReadStatus, - SendStatus, - SigningStatus, - SubscriptionStatus, -} from '@documenso/prisma/client'; - -const log = (...args: unknown[]) => console.log('[stripe]', ...args); +import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler'; export const config = { api: { bodyParser: false }, }; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) { - // return res.status(500).json({ - // success: false, - // message: 'Subscriptions are not enabled', - // }); - // } - - const sig = - typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : ''; - - if (!sig) { - return res.status(400).json({ - success: false, - message: 'No signature found in request', - }); - } - - log('constructing body...'); - const body = await buffer(req); - log('constructed body'); - - const event = stripe.webhooks.constructEvent( - body, - sig, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars - process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!, - ); - log('event-type:', event.type); - - if (event.type === 'customer.subscription.updated') { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const subscription = event.data.object as Stripe.Subscription; - - await handleCustomerSubscriptionUpdated(subscription); - - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); - } - - if (event.type === 'checkout.session.completed') { - // This is required since we don't want to create a guard for every event type - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const session = event.data.object as Stripe.Checkout.Session; - - if (session.metadata?.source === 'landing') { - const user = await prisma.user.findFirst({ - where: { - id: Number(session.client_reference_id), - }, - }); - - if (!user) { - return res.status(500).json({ - success: false, - message: 'User not found', - }); - } - - const signatureText = session.metadata?.signatureText || user.name; - let signatureDataUrl = ''; - - if (session.metadata?.signatureDataUrl) { - const result = await redis.get(`signature:${session.metadata.signatureDataUrl}`); - - if (result) { - signatureDataUrl = result; - } - } - - const now = new Date(); - - const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'); - - const { id: documentDataId } = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: bytes64, - initialData: bytes64, - }, - }); - - const document = await prisma.document.create({ - data: { - title: 'Documenso Supporter Pledge.pdf', - status: DocumentStatus.COMPLETED, - userId: user.id, - documentDataId, - }, - include: { - documentData: true, - }, - }); - - const { documentData } = document; - - if (!documentData) { - throw new Error(`Document ${document.id} has no document data`); - } - - const recipient = await prisma.recipient.create({ - data: { - name: user.name ?? '', - email: user.email, - token: randomBytes(16).toString('hex'), - signedAt: now, - readStatus: ReadStatus.OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.SIGNED, - documentId: document.id, - }, - }); - - const field = await prisma.field.create({ - data: { - documentId: document.id, - recipientId: recipient.id, - type: FieldType.SIGNATURE, - page: 0, - positionX: 77, - positionY: 638, - inserted: false, - customText: '', - }, - }); - - if (signatureDataUrl) { - documentData.data = await insertImageInPDF( - documentData.data, - signatureDataUrl, - field.positionX.toNumber(), - field.positionY.toNumber(), - field.page, - ); - } else { - documentData.data = await insertTextInPDF( - documentData.data, - signatureText ?? '', - field.positionX.toNumber(), - field.positionY.toNumber(), - field.page, - ); - } - - await Promise.all([ - prisma.signature.create({ - data: { - fieldId: field.id, - recipientId: recipient.id, - signatureImageAsBase64: signatureDataUrl || undefined, - typedSignature: signatureDataUrl ? '' : signatureText, - }, - }), - prisma.document.update({ - where: { - id: document.id, - }, - data: { - documentData: { - update: { - data: documentData.data, - }, - }, - }, - }), - ]); - } - - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); - } - - log('Unhandled webhook event', event.type); - - return res.status(400).json({ - success: false, - message: 'Unhandled webhook event', - }); -} - -const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const { plan } = subscription as unknown as Stripe.SubscriptionItem; - - 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.update({ - where: { - customerId: customerId, - }, - data: { - planId: plan.id, - status, - cancelAtPeriodEnd: subscription.cancel_at_period_end, - periodEnd: new Date(subscription.current_period_end * 1000), - updatedAt: new Date(), - }, - }); -}; +export default stripeWebhookHandler; diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index bae4acc4c..a42844904 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -6,7 +6,3 @@ export default trpcNext.createNextApiHandler({ router: appRouter, createContext: async ({ req, res }) => createTrpcContext({ req, res }), }); - -// export default async function handler(_req: NextApiRequest, res: NextApiResponse) { -// res.json({ hello: 'world' }); -// } diff --git a/apps/web/src/providers/admin-user-profile-update.types.ts b/apps/web/src/providers/admin-user-profile-update.types.ts deleted file mode 100644 index 49bda22fc..000000000 --- a/apps/web/src/providers/admin-user-profile-update.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; - -export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); -export type TUserFormSchema = z.infer; diff --git a/package-lock.json b/package-lock.json index 82044e919..24ff6e16b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20360,9 +20360,21 @@ "license": "COMMERCIAL", "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" } }, + "packages/ee/node_modules/ts-pattern": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz", + "integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA==" + }, "packages/email": { "name": "@documenso/email", "version": "1.0.0", @@ -20422,7 +20434,8 @@ "pdf-lib": "^1.17.1", "react": "18.2.0", "stripe": "^12.7.0", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.21.4" }, "devDependencies": { "@types/bcrypt": "^5.0.0", diff --git a/packages/ee/package.json b/packages/ee/package.json index b6d237fd3..40999d31c 100644 --- a/packages/ee/package.json +++ b/packages/ee/package.json @@ -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" } } diff --git a/packages/ee/server-only/limits/client.ts b/packages/ee/server-only/limits/client.ts new file mode 100644 index 000000000..2fab2530e --- /dev/null +++ b/packages/ee/server-only/limits/client.ts @@ -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; +}; + +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; + }); +}; diff --git a/packages/ee/server-only/limits/constants.ts b/packages/ee/server-only/limits/constants.ts new file mode 100644 index 000000000..71ff29d9d --- /dev/null +++ b/packages/ee/server-only/limits/constants.ts @@ -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, +}; diff --git a/packages/ee/server-only/limits/errors.ts b/packages/ee/server-only/limits/errors.ts new file mode 100644 index 000000000..948a9ba5c --- /dev/null +++ b/packages/ee/server-only/limits/errors.ts @@ -0,0 +1,6 @@ +export const ERROR_CODES: Record = { + 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', +}; diff --git a/packages/ee/server-only/limits/handler.ts b/packages/ee/server-only/limits/handler.ts new file mode 100644 index 000000000..25cc23da9 --- /dev/null +++ b/packages/ee/server-only/limits/handler.ts @@ -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, +) => { + try { + const token = await getToken({ req }); + + const isBillingEnabled = await getFlag('app_billing'); + + if (!isBillingEnabled) { + return withStaleWhileRevalidate(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(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, + }); + } +}; diff --git a/packages/ee/server-only/limits/provider/client.tsx b/packages/ee/server-only/limits/provider/client.tsx new file mode 100644 index 000000000..11b6bcae0 --- /dev/null +++ b/packages/ee/server-only/limits/provider/client.tsx @@ -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(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 {children}; +}; diff --git a/packages/ee/server-only/limits/provider/server.tsx b/packages/ee/server-only/limits/provider/server.tsx new file mode 100644 index 000000000..c9295483a --- /dev/null +++ b/packages/ee/server-only/limits/provider/server.tsx @@ -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 {children}; +}; diff --git a/packages/ee/server-only/limits/schema.ts b/packages/ee/server-only/limits/schema.ts new file mode 100644 index 000000000..e3394995d --- /dev/null +++ b/packages/ee/server-only/limits/schema.ts @@ -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; + +export const ZLimitsResponseSchema = z.object({ + quota: ZLimitsSchema, + remaining: ZLimitsSchema, +}); + +export type TLimitsResponseSchema = z.infer; + +export const ZLimitsErrorResponseSchema = z.object({ + error: z.string(), +}); + +export type TLimitsErrorResponseSchema = z.infer; diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts new file mode 100644 index 000000000..78fd8b87a --- /dev/null +++ b/packages/ee/server-only/limits/server.ts @@ -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, + }; +}; diff --git a/packages/ee/server-only/stripe/get-product-by-price-id.ts b/packages/ee/server-only/stripe/get-product-by-price-id.ts new file mode 100644 index 000000000..621c918f2 --- /dev/null +++ b/packages/ee/server-only/stripe/get-product-by-price-id.ts @@ -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; +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts new file mode 100644 index 000000000..825c266cf --- /dev/null +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -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, +) => { + 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', + }); + } +}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts new file mode 100644 index 000000000..27ff0cf4d --- /dev/null +++ b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts @@ -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, + }, + }); +}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts new file mode 100644 index 000000000..dfa22d128 --- /dev/null +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -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), + }, + }); +}; diff --git a/packages/lib/next-auth/guards/is-admin.ts b/packages/lib/next-auth/guards/is-admin.ts index 2801305dd..6df56b722 100644 --- a/packages/lib/next-auth/guards/is-admin.ts +++ b/packages/lib/next-auth/guards/is-admin.ts @@ -1,5 +1,3 @@ import { Role, User } from '@documenso/prisma/client'; -const isAdmin = (user: User) => user.roles.includes(Role.ADMIN); - -export { isAdmin }; +export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN); diff --git a/packages/lib/package.json b/packages/lib/package.json index 381dcc1c6..9626484be 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -33,7 +33,8 @@ "pdf-lib": "^1.17.1", "react": "18.2.0", "stripe": "^12.7.0", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.21.4" }, "devDependencies": { "@types/bcrypt": "^5.0.0", diff --git a/packages/lib/server-only/http/to-next-request.ts b/packages/lib/server-only/http/to-next-request.ts new file mode 100644 index 000000000..59b6b70c6 --- /dev/null +++ b/packages/lib/server-only/http/to-next-request.ts @@ -0,0 +1,9 @@ +import { NextRequest } from 'next/server'; + +export const toNextRequest = (req: Request) => { + const headers = Object.fromEntries(req.headers.entries()); + + return new NextRequest(req, { + headers: headers, + }); +}; diff --git a/packages/lib/server-only/http/with-swr.ts b/packages/lib/server-only/http/with-swr.ts new file mode 100644 index 000000000..029f2bb6d --- /dev/null +++ b/packages/lib/server-only/http/with-swr.ts @@ -0,0 +1,28 @@ +import { NextApiResponse } from 'next'; +import { NextResponse } from 'next/server'; + +type NarrowedResponse = T extends NextResponse + ? NextResponse + : T extends NextApiResponse + ? NextApiResponse + : never; + +export const withStaleWhileRevalidate = ( + res: NarrowedResponse, + cacheInSeconds = 60, + staleCacheInSeconds = 300, +) => { + if ('headers' in res) { + res.headers.set( + 'Cache-Control', + `public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`, + ); + } else { + res.setHeader( + 'Cache-Control', + `public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`, + ); + } + + return res; +}; diff --git a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts b/packages/lib/server-only/subscription/get-subscription-by-user-id.ts index 7dafd2a04..772134f7c 100644 --- a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts +++ b/packages/lib/server-only/subscription/get-subscription-by-user-id.ts @@ -7,7 +7,7 @@ export type GetSubscriptionByUserIdOptions = { }; export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => { - return prisma.subscription.findFirst({ + return await prisma.subscription.findFirst({ where: { userId, }, diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 76c1ab552..53e7c121b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -31,7 +31,7 @@ model User { accounts Account[] sessions Session[] Document Document[] - Subscription Subscription[] + Subscription Subscription? PasswordResetToken PasswordResetToken[] } diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 6d19afc0b..d8e165594 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; @@ -63,13 +64,25 @@ export const documentRouter = router({ try { const { title, documentDataId } = input; + const { remaining } = await getServerLimits({ email: ctx.user.email }); + + if (remaining.documents <= 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'You have reached your document limit for this month. Please upgrade your plan.', + }); + } + return await createDocument({ userId: ctx.user.id, title, documentDataId, }); } catch (err) { - console.error(err); + if (err instanceof TRPCError) { + throw err; + } throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 834d32545..cd2c6e3b4 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -74,16 +74,23 @@ const DocumentDropzoneCardCenterVariants: Variants = { export type DocumentDropzoneProps = { className?: string; + disabled?: boolean; onDrop?: (_file: File) => void | Promise; [key: string]: unknown; }; -export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzoneProps) => { +export const DocumentDropzone = ({ + className, + onDrop, + disabled, + ...props +}: DocumentDropzoneProps) => { const { getRootProps, getInputProps } = useDropzone({ accept: { 'application/pdf': ['.pdf'], }, multiple: false, + disabled, onDrop: ([acceptedFile]) => { if (acceptedFile && onDrop) { void onDrop(acceptedFile); @@ -102,11 +109,12 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo diff --git a/turbo.json b/turbo.json index ba52731e9..d2c763619 100644 --- a/turbo.json +++ b/turbo.json @@ -73,6 +73,7 @@ "NEXT_PRIVATE_SMTP_FROM_NAME", "NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_STRIPE_API_KEY", + "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET", "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -85,4 +86,4 @@ "E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_PASSWORD" ] -} \ No newline at end of file +}