diff --git a/packages/lib/package.json b/packages/lib/package.json index 61b0226e1..2c6b26121 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -4,7 +4,10 @@ "private": true, "main": "index.ts", "dependencies": { + "@documenso/prisma": "*", + "@prisma/client": "^4.8.1", "bcryptjs": "^2.4.3", + "micro": "^10.0.1", "stripe": "^12.4.0" } } \ No newline at end of file diff --git a/packages/lib/stripe/client.ts b/packages/lib/stripe/client.ts new file mode 100644 index 000000000..d78571d59 --- /dev/null +++ b/packages/lib/stripe/client.ts @@ -0,0 +1,7 @@ +import Stripe from 'stripe'; + + +export const stripe = new Stripe(process.env.STRIPE_API_KEY!, { + apiVersion: "2022-11-15", + typescript: true, +}); \ No newline at end of file diff --git a/packages/lib/stripe/data/plans.ts b/packages/lib/stripe/data/plans.ts index 4d39b1091..2d8ea302b 100644 --- a/packages/lib/stripe/data/plans.ts +++ b/packages/lib/stripe/data/plans.ts @@ -1,12 +1,14 @@ export const STRIPE_PLANS = [ { - name: "Community Plan", + name: "Community Plan (Monthly)", period: "monthly", - priceId: process.env.STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ?? "", + price: 30, + priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ?? "", }, { - name: "Community Plan", + name: "Community Plan (Yearly)", period: "yearly", - priceId: process.env.STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ?? "", + price: 300, + priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ?? "", }, ]; diff --git a/packages/lib/stripe/guards/subscriptions.ts b/packages/lib/stripe/guards/subscriptions.ts new file mode 100644 index 000000000..c801549b0 --- /dev/null +++ b/packages/lib/stripe/guards/subscriptions.ts @@ -0,0 +1,35 @@ +import { GetServerSideProps, GetServerSidePropsContext, NextApiRequest } from "next"; +import { SubscriptionStatus } from "@prisma/client"; +import { getToken } from "next-auth/jwt"; + +export const isSubscriptionsEnabled = () => { + return process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true"; +}; + +export const isSubscribedServer = async ( + req: NextApiRequest | GetServerSidePropsContext["req"] +) => { + const { default: prisma } = await import("@documenso/prisma"); + + if (!isSubscriptionsEnabled()) { + return true; + } + + const token = await getToken({ + req, + }); + + if (!token || !token.email) { + return false; + } + + const subscription = await prisma.subscription.findFirst({ + where: { + User: { + email: token.email, + }, + }, + }); + + return subscription !== null && subscription.status !== SubscriptionStatus.INACTIVE; +}; diff --git a/packages/lib/stripe/handlers/checkout-session.ts b/packages/lib/stripe/handlers/checkout-session.ts index b814f0e1e..2654d03f6 100644 --- a/packages/lib/stripe/handlers/checkout-session.ts +++ b/packages/lib/stripe/handlers/checkout-session.ts @@ -1,11 +1,11 @@ import { NextApiRequest, NextApiResponse } from "next"; import prisma from "@documenso/prisma"; -import { stripe } from "../index"; +import { stripe } from "../client"; import { getToken } from "next-auth/jwt"; export type CheckoutSessionRequest = { body: { - id: string; + id?: string; priceId: string; }; }; @@ -61,7 +61,7 @@ export const checkoutSessionHandler = async (req: NextApiRequest, res: NextApiRe const { id, priceId } = req.body; - if (typeof id !== "string" || typeof priceId !== "string") { + if (typeof priceId !== "string") { return res.status(400).json({ success: false, message: "No id or priceId found in request", @@ -70,6 +70,7 @@ export const checkoutSessionHandler = async (req: NextApiRequest, res: NextApiRe const session = await stripe.checkout.sessions.create({ customer: id, + customer_email: user.email, client_reference_id: String(user.id), payment_method_types: ["card"], line_items: [ @@ -80,8 +81,8 @@ export const checkoutSessionHandler = async (req: NextApiRequest, res: NextApiRe ], mode: "subscription", allow_promotion_codes: true, - success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/settings/billing?success=true`, - cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/settings/billing?canceled=true`, + success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing?success=true`, + cancel_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing?canceled=true`, }); return res.status(200).json({ diff --git a/packages/lib/stripe/handlers/portal-session.ts b/packages/lib/stripe/handlers/portal-session.ts index 0fff5625d..21f724b63 100644 --- a/packages/lib/stripe/handlers/portal-session.ts +++ b/packages/lib/stripe/handlers/portal-session.ts @@ -1,5 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { stripe } from "../index"; +import { stripe } from "../client"; + export type PortalSessionRequest = { body: { @@ -43,11 +44,11 @@ export const portalSessionHandler = async (req: NextApiRequest, res: NextApiResp const session = await stripe.billingPortal.sessions.create({ customer: id, - return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/settings/billing`, + return_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, }); return res.status(200).json({ success: true, url: session.url, }); -}; +}; \ No newline at end of file diff --git a/packages/lib/stripe/handlers/webhook.ts b/packages/lib/stripe/handlers/webhook.ts index 27be10b5d..390fc21de 100644 --- a/packages/lib/stripe/handlers/webhook.ts +++ b/packages/lib/stripe/handlers/webhook.ts @@ -1,8 +1,11 @@ -import prisma from "@documenso/prisma"; -import { SubscriptionStatus } from "@prisma/client"; import { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@documenso/prisma"; +import { stripe } from "../client"; +import { SubscriptionStatus } from "@prisma/client"; +import { buffer } from "micro"; import Stripe from "stripe"; -import { stripe } from "../index"; + +const log = (...args: any[]) => console.log("[stripe]", ...args); export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) { @@ -22,7 +25,12 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) }); } - const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!); + log("constructing body...") + const body = await buffer(req); + log("constructed body") + + const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); + log("event-type:", event.type); if (event.type === "checkout.session.completed") { const session = event.data.object as Stripe.Checkout.Session; @@ -120,7 +128,7 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) priceId: updatedSubscription.items.data[0].price.id, periodEnd: new Date(updatedSubscription.current_period_end * 1000), }, - }) + }); return res.status(200).json({ success: true, @@ -148,6 +156,7 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) }); } + log("Unhandled webhook event", event.type); return res.status(400).json({ success: false, message: "Unhandled webhook event", diff --git a/packages/lib/stripe/index.ts b/packages/lib/stripe/index.ts index 4026b6d5f..b24d812db 100644 --- a/packages/lib/stripe/index.ts +++ b/packages/lib/stripe/index.ts @@ -1,6 +1,6 @@ -import Stripe from 'stripe'; - -export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2022-11-15", - typescript: true, -}); \ No newline at end of file +export * from './data/plans' +export * from './fetchers/checkout-session' +export * from './fetchers/get-subscription' +export * from './fetchers/portal-session' +export * from './guards/subscriptions' +export * from './providers/subscription-provider' diff --git a/packages/lib/stripe/providers/subscription-provider.tsx b/packages/lib/stripe/providers/subscription-provider.tsx new file mode 100644 index 000000000..78291fcc8 --- /dev/null +++ b/packages/lib/stripe/providers/subscription-provider.tsx @@ -0,0 +1,89 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { fetchSubscription } from "../fetchers/get-subscription"; +import { Subscription, SubscriptionStatus } from "@prisma/client"; +import { useSession } from "next-auth/react"; + + +export type SubscriptionContextValue = { + subscription: Subscription | null; + hasSubscription: boolean; + isLoading: boolean; +}; + +const SubscriptionContext = createContext({ + subscription: null, + hasSubscription: false, + isLoading: false, +}); + +export const useSubscription = () => { + const context = useContext(SubscriptionContext); + + if (!context) { + throw new Error(`useSubscription must be used within a SubscriptionProvider`); + } + + return context; +}; + +export interface SubscriptionProviderProps { + children: React.ReactNode; + initialSubscription?: Subscription; +} + +export const SubscriptionProvider = ({ + children, + initialSubscription, +}: SubscriptionProviderProps) => { + const session = useSession(); + + const [isLoading, setIsLoading] = useState(false); + const [subscription, setSubscription] = useState( + initialSubscription || null + ); + + const hasSubscription = useMemo(() => { + console.log({ + "process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS": process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS, + enabled: process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true", + "subscription.status": subscription?.status, + "subscription.periodEnd": subscription?.periodEnd, + }); + + if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true") { + return ( + subscription?.status === SubscriptionStatus.ACTIVE && + !!subscription?.periodEnd && + new Date(subscription.periodEnd) > new Date() + ); + } + + return true; + }, [subscription]); + + useEffect(() => { + if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true" && session.data) { + setIsLoading(true); + fetchSubscription().then((res) => { + if (res.success) { + setSubscription(res.subscription); + } else { + setSubscription(null); + } + + setIsLoading(false); + }); + } + }, [session.data]); + + return ( + + {children} + + ); +}; \ No newline at end of file