Merge pull request #145 from documenso/feat/add-subscriptions

Add subscriptions
This commit is contained in:
Timur Ercan
2023-05-22 16:43:10 +02:00
committed by GitHub
46 changed files with 2968 additions and 66 deletions

View File

@@ -37,6 +37,13 @@ SMTP_MAIL_PASSWORD=''
# Sender for signing requests and completion mails.
MAIL_FROM='documenso@localhost.com'
# STRIPE
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
#FEATURE FLAGS
# Allow users to register via the /signup page. Otherwise they will be redirect to the home page.
ALLOW_SIGNUP=true
NEXT_PUBLIC_ALLOW_SIGNUP=true
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS=true

View File

@@ -13,7 +13,7 @@
"editor.codeActionsOnSave": {
"source.removeUnusedImports": false
},
"typescript.tsdk": "node_modules\\typescript\\lib",
"typescript.tsdk": "node_modules/typescript/lib",
"spellright.language": ["de"],
"spellright.documentTypes": ["markdown", "latex", "plaintext"]
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { classNames } from "@documenso/lib";
import { STRIPE_PLANS, fetchCheckoutSession, useSubscription } from "@documenso/lib/stripe";
import { Button } from "@documenso/ui";
import { Switch } from "@headlessui/react";
export const BillingPlans = () => {
const { subscription, isLoading } = useSubscription();
const [isAnnual, setIsAnnual] = useState(false);
return (
<div>
{!subscription &&
STRIPE_PLANS.map((plan) => (
<div key={plan.name} className="rounded-lg border py-4 px-6">
<h3 className="text-center text-lg font-medium leading-6 text-gray-900">{plan.name}</h3>
<div className="my-4 flex justify-center">
<Switch.Group as="div" className="flex items-center">
<Switch
checked={isAnnual}
onChange={setIsAnnual}
className={classNames(
isAnnual ? "bg-neon-600" : "bg-gray-200",
"focus:ring-neon-600 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={classNames(
isAnnual ? "translate-x-5" : "translate-x-0",
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3 text-sm">
<span className="font-medium text-gray-900">Annual billing</span>{" "}
<span className="text-gray-500">(Save $60)</span>
</Switch.Label>
</Switch.Group>
</div>
<p className="mt-2 text-center text-gray-500">
${(isAnnual ? plan.prices.yearly.price : plan.prices.monthly.price).toFixed(2)}{" "}
<span className="text-sm text-gray-400">{isAnnual ? "/yr" : "/mo"}</span>
</p>
<p className="mt-4 text-center text-sm text-gray-500">
All you need for easy signing. <br></br>Includes everthing we build this year.
</p>
<div className="mt-4">
<Button
className="w-full"
disabled={isLoading}
onClick={() =>
fetchCheckoutSession({
priceId: isAnnual ? plan.prices.yearly.priceId : plan.prices.monthly.priceId,
}).then((res) => {
if (res.success) {
window.location.href = res.url;
}
})
}>
Subscribe
</Button>
</div>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { useSubscription } from "@documenso/lib/stripe"
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from '@prisma/client'
import Link from "next/link";
export const BillingWarning = () => {
const { subscription } = useSubscription();
return (
<>
{subscription?.status === SubscriptionStatus.PAST_DUE && (
<div className="bg-yellow-50 p-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-3xl items-start justify-center">
<div className="flex-shrink-0">
<PaperAirplaneIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Your subscription is past due.{" "}
<Link href="/account/billing" className="text-yellow-700 underline">
Please update your payment information to avoid any service interruptions.
</Link>
</p>
</div>
</div>
</div>
)}
{subscription?.status === SubscriptionStatus.INACTIVE && (
<div className="bg-red-50 p-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-3xl items-center justify-center">
<div className="flex-shrink-0">
<PaperAirplaneIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
Your subscription is inactive. You can continue to view and edit your documents,
but you will not be able to send them or create new ones.{" "}
<Link href="/account/billing" className="text-red-700 underline">
You can update your payment information here
</Link>
</p>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -1,8 +1,13 @@
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { useSubscription } from "@documenso/lib/stripe";
import Navigation from "./navigation";
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
import { BillingWarning } from "./billing-warning";
function useRedirectToLoginIfUnauthenticated() {
const { data: session, status } = useSession();
@@ -30,11 +35,16 @@ function useRedirectToLoginIfUnauthenticated() {
export default function Layout({ children }: any) {
useRedirectToLoginIfUnauthenticated();
const { subscription } = useSubscription();
return (
<>
<div className="min-h-full">
<Navigation></Navigation>
<Navigation />
<main>
<BillingWarning />
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
</main>
</div>

View File

@@ -4,8 +4,11 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { updateUser } from "@documenso/features";
import { getUser } from "@documenso/lib/api";
import { fetchPortalSession, isSubscriptionsEnabled, useSubscription } from "@documenso/lib/stripe";
import { Button } from "@documenso/ui";
import { KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { BillingPlans } from "./billing-plans";
import { CreditCardIcon, KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
const subNavigation = [
@@ -20,20 +23,29 @@ const subNavigation = [
href: "/settings/password",
icon: KeyIcon,
current: false,
},
}
];
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true") {
subNavigation.push({
name: "Billing",
href: "/settings/billing",
icon: CreditCardIcon,
current: false,
});
}
function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
export default function Setttings() {
const session = useSession();
const { subscription, hasSubscription } = useSubscription();
const [user, setUser] = useState({
email: "",
name: "",
});
useEffect(() => {
getUser().then((res: any) => {
res.json().then((j: any) => {
@@ -158,6 +170,7 @@ export default function Setttings() {
<Button onClick={() => updateUser(user)}>Save</Button>
</div>
</form>
<div
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[1].name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
@@ -171,9 +184,72 @@ export default function Setttings() {
</div>
</div>
</div>
<div
hidden={!subNavigation.at(2) || subNavigation.find((e) => e.current)?.name !== subNavigation.at(2)?.name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
{/* Billing section */}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">Billing</h2>
{!isSubscriptionsEnabled() && (
<p className="mt-2 text-sm text-gray-500">
Subscriptions are not enabled on this instance, you have nothing to do here.
</p>
)}
{isSubscriptionsEnabled() && (
<>
<p className="mt-1 text-sm text-gray-500">
Your subscription is currently{" "}
<strong>
{subscription?.status &&
subscription?.status !== SubscriptionStatus.INACTIVE
? "Active"
: "Inactive"}
</strong>
.
</p>
{subscription?.status === SubscriptionStatus.PAST_DUE && (
<p className="mt-1 text-sm text-red-500">
Your subscription is past due. Please update your payment details to
continue using the service without interruption.
</p>
)}
<div className="mt-8">
<div className="grid grid-cols-1 lg:grid-cols-2">
<BillingPlans />
</div>
{subscription && (
<Button
onClick={() => {
if (isSubscriptionsEnabled() && subscription?.customerId) {
fetchPortalSession({
id: subscription.customerId,
}).then((res) => {
if (res.success) {
window.location.href = res.url;
}
});
}
}}>
Manage my subscription
</Button>
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-10 max-w-[1100px]" hidden={!!user.email}>
<div className="ph-item">
<div className="ph-col-12">

View File

@@ -1,12 +1,12 @@
/** @type {import('next').NextConfig} */
require("dotenv").config({ path: "../../.env" });
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: false,
};
const withTM = require("next-transpile-modules")([
const transpileModules = require("next-transpile-modules")([
"@documenso/prisma",
"@documenso/lib",
"@documenso/ui",
@@ -15,8 +15,10 @@ const withTM = require("next-transpile-modules")([
"@documenso/signing",
"react-signature-canvas",
]);
const plugins = [];
plugins.push(withTM);
const plugins = [
transpileModules
];
const moduleExports = () => plugins.reduce((acc, next) => next(acc), nextConfig);

View File

@@ -7,9 +7,11 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"db-studio": "prisma db studio"
"db-studio": "prisma db studio",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/pdf": "*",
"@documenso/prisma": "*",
"@documenso/ui": "*",
@@ -42,11 +44,11 @@
"@tailwindcss/forms": "^0.5.3",
"@types/bcryptjs": "^2.4.2",
"@types/filesystem": "^0.0.32",
"@types/react-dom": "18.0.9",
"@types/formidable": "^2.0.5",
"@types/node": "^18.11.18",
"@types/nodemailer": "^6.4.7",
"@types/nodemailer-sendgrid": "^1.0.0",
"@types/react-dom": "18.0.9",
"@types/react-pdf": "^6.2.0",
"@types/react-resizable": "^3.0.3",
"autoprefixer": "^10.4.13",
@@ -57,6 +59,7 @@
"next-transpile-modules": "^10.0.0",
"postcss": "^8.4.19",
"sass": "^1.57.1",
"stripe-cli": "^0.1.0",
"tailwindcss": "^3.2.4",
"typescript": "4.8.4"
}

View File

@@ -1,6 +1,7 @@
import { ReactElement, ReactNode } from "react";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { SubscriptionProvider } from "@documenso/lib/stripe/providers/subscription-provider";
import "../../../node_modules/placeholder-loading/src/scss/placeholder-loading.scss";
import "../../../node_modules/react-resizable/css/styles.css";
import "../styles/tailwind.css";
@@ -20,13 +21,15 @@ type AppPropsWithLayout = AppProps & {
export default function App({
Component,
pageProps: { session, ...pageProps },
pageProps: { session, initialSubscription, ...pageProps },
}: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page: any) => page);
return (
<SessionProvider session={session}>
<Toaster position="top-center"></Toaster>
{getLayout(<Component {...pageProps} />)}
<SubscriptionProvider initialSubscription={initialSubscription}>
<Toaster position="top-center" />
{getLayout(<Component {...pageProps} />)}
</SubscriptionProvider>
</SessionProvider>
);
}

View File

@@ -1,9 +1,6 @@
import { Head, Html, Main, NextScript } from "next/document";
import Script from "next/script";
export default function Document(props) {
let pageProps = props.__NEXT_DATA__?.props?.pageProps;
export default function Document() {
return (
<Html className="h-full scroll-smooth bg-gray-100 font-normal antialiased" lang="en">
<Head>

View File

@@ -4,6 +4,7 @@ import { defaultHandler, defaultResponder } from "@documenso/lib/server";
import { getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import formidable from "formidable";
import { isSubscribedServer } from "@documenso/lib/stripe";
export const config = {
api: {
@@ -15,7 +16,17 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const form = formidable();
const user = await getUserFromToken(req, res);
if (!user) return;
if (!user) {
return res.status(401).end();
};
const isSubscribed = await isSubscribedServer(req);
if (!isSubscribed) {
throw new Error("User is not subscribed.");
}
form.parse(req, async (err, fields, files) => {
if (err) throw err;

View File

@@ -0,0 +1 @@
export { checkoutSessionHandler as default } from '@documenso/lib/stripe/handlers/checkout-session'

View File

@@ -0,0 +1 @@
export { portalSessionHandler as default } from "@documenso/lib/stripe/handlers/portal-session";

View File

@@ -0,0 +1 @@
export { getSubscriptionHandler as default } from '@documenso/lib/stripe/handlers/get-subscription'

View File

@@ -0,0 +1,5 @@
export const config = {
api: { bodyParser: false },
};
export { webhookHandler as default } from "@documenso/lib/stripe/handlers/webhook";

View File

@@ -20,12 +20,15 @@ import {
} from "@prisma/client";
import { truncate } from "fs";
import { Tooltip as ReactTooltip } from "react-tooltip";
import { useSubscription } from "@documenso/lib/stripe";
type FormValues = {
document: File;
};
const DashboardPage: NextPageWithLayout = (props: any) => {
const { hasSubscription } = useSubscription();
const stats = [
{
name: "Draft",
@@ -90,9 +93,12 @@ const DashboardPage: NextPageWithLayout = (props: any) => {
</div>
<div
onClick={() => {
document?.getElementById("fileUploadHelper")?.click();
if (hasSubscription) {
document?.getElementById("fileUploadHelper")?.click();
}
}}
className="group hover:border-neon-600 duration-200 relative block w-full cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
aria-disabled={!hasSubscription}
className="group hover:border-neon-600 duration-200 relative block w-full cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 aria-disabled:opacity-50 aria-disabled:pointer-events-none">
<svg
className="mx-auto h-12 w-12 text-gray-400 group-hover:text-gray-700 duration-200"

View File

@@ -20,9 +20,11 @@ import {
} from "@heroicons/react/24/outline";
import { DocumentStatus } from "@prisma/client";
import { Tooltip as ReactTooltip } from "react-tooltip";
import { useSubscription } from "@documenso/lib/stripe";
const DocumentsPage: NextPageWithLayout = (props: any) => {
const router = useRouter();
const { hasSubscription } = useSubscription();
const [documents, setDocuments]: any[] = useState([]);
const [filteredDocuments, setFilteredDocuments] = useState([]);
@@ -135,6 +137,7 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<Button
icon={DocumentPlusIcon}
disabled={!hasSubscription}
onClick={() => {
document?.getElementById("fileUploadHelper")?.click();
}}>

View File

@@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib";
import { getDocument } from "@documenso/lib/query";
import { getUserFromToken } from "@documenso/lib/server";
import { useSubscription } from "@documenso/lib/stripe";
import { Breadcrumb, Button } from "@documenso/ui";
import PDFEditor from "../../../components/editor/pdf-editor";
import Layout from "../../../components/layout";
@@ -14,6 +15,7 @@ import { Document as PrismaDocument } from "@prisma/client";
const DocumentsDetailPage: NextPageWithLayout = (props: any) => {
const router = useRouter();
const { hasSubscription } = useSubscription();
return (
<div className="mt-4">

View File

@@ -21,6 +21,7 @@ import {
import { DocumentStatus, Document as PrismaDocument, Recipient } from "@prisma/client";
import { FormProvider, useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useSubscription } from "@documenso/lib/stripe";
export type FormValues = {
signers: Array<Pick<Recipient, 'id' | 'email' | 'name' | 'sendStatus' | 'readStatus' | 'signingStatus'>>;
@@ -29,6 +30,7 @@ export type FormValues = {
type FormSigner = FormValues["signers"][number];
const RecipientsPage: NextPageWithLayout = (props: any) => {
const { hasSubscription } = useSubscription();
const title: string = `"` + props?.document?.title + `"` + "Recipients | Documenso";
const breadcrumbItems = [
{
@@ -116,6 +118,7 @@ const RecipientsPage: NextPageWithLayout = (props: any) => {
: setOpen(true);
}}
disabled={
!hasSubscription ||
(formValues.length || 0) === 0 ||
!formValues.some(
(r) => r.email && !hasEmailError(r) && r.sendStatus === "NOT_SENT"

View File

@@ -24,11 +24,11 @@ export async function getServerSideProps(context: any) {
},
};
const ALLOW_SIGNUP = process.env.ALLOW_SIGNUP === "true";
const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP === "true";
return {
props: {
ALLOW_SIGNUP: ALLOW_SIGNUP,
ALLOW_SIGNUP,
},
};
}

View File

@@ -0,0 +1 @@
export { default } from ".";

View File

@@ -15,7 +15,7 @@ export default function SignupPage(props: { source: string }) {
}
export async function getServerSideProps(context: any) {
if (process.env.ALLOW_SIGNUP !== "true")
if (process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "true")
return {
redirect: {
destination: "/login",

24
apps/web/process-env.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URL: string;
NEXT_PUBLIC_WEBAPP_URL: string;
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
SENDGRID_API_KEY?: string;
SMTP_MAIL_HOST?: string;
SMTP_MAIL_PORT?: string;
SMTP_MAIL_USER?: string;
SMTP_MAIL_PASSWORD?: string;
MAIL_FROM: string;
STRIPE_API_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID?: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID?: string;
NEXT_PUBLIC_ALLOW_SIGNUP?: string;
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS?: string;
}
}

View File

@@ -21,6 +21,6 @@
"../../packages/types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx"
],
, "../../packages/lib/process-env.d.ts" ],
"exclude": ["node_modules"]
}

View File

@@ -33,7 +33,7 @@ services:
- SMTP_MAIL_USER=username
- SMTP_MAIL_PASSWORD=password
- MAIL_FROM=admin@example.com
- ALLOW_SIGNUP=true
- NEXT_PUBLIC_ALLOW_SIGNUP=true
ports:
- 3000:3000
volumes:

1994
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"docker:compose": "docker-compose -f ./docker/compose-without-app.yml",
"docker:compose-up": "npm run docker:compose -- up -d",
"docker:compose-down": "npm run docker:compose -- down",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook",
"dx": "npm install && run-s docker:compose-up db-migrate:dev",
"d": "npm install && run-s docker:compose-up db-migrate:dev && npm run db-seed && npm run dev"
},

View File

@@ -1,7 +1,7 @@
import { ChangeEvent } from "react";
import router from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "../lib/constants";
import toast from "react-hot-toast";
import { ChangeEvent } from "react";
export const uploadDocument = async (event: ChangeEvent) => {
if (event.target instanceof HTMLInputElement && event.target?.files && event.target.files[0]) {
@@ -16,24 +16,28 @@ export const uploadDocument = async (event: ChangeEvent) => {
body.append("document", document || "");
await toast
.promise(
fetch("/api/documents", {
method: "POST",
body,
}),
{
loading: "Uploading document...",
success: `${fileName} uploaded successfully.`,
error: "Could not upload document :/",
await toast.promise(
fetch("/api/documents", {
method: "POST",
body,
}).then((response: Response) => {
if (!response.ok) {
throw new Error("Could not upload document");
}
)
.then((response: Response) => {
response.json().then((createdDocumentIdFromBody) => {
router.push(
`${NEXT_PUBLIC_WEBAPP_URL}/documents/${createdDocumentIdFromBody}/recipients`
);
});
});
}),
{
loading: "Uploading document...",
success: `${fileName} uploaded successfully.`,
error: "Could not upload document :/",
}
).catch((_err) => {
// Do nothing
});
}
};

View File

@@ -4,6 +4,10 @@
"private": true,
"main": "index.ts",
"dependencies": {
"bcryptjs": "^2.4.3"
"@documenso/prisma": "*",
"@prisma/client": "^4.8.1",
"bcryptjs": "^2.4.3",
"micro": "^10.0.1",
"stripe": "^12.4.0"
}
}
}

24
packages/lib/process-env.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URL: string;
NEXT_PUBLIC_WEBAPP_URL: string;
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
SENDGRID_API_KEY?: string;
SMTP_MAIL_HOST?: string;
SMTP_MAIL_PORT?: string;
SMTP_MAIL_USER?: string;
SMTP_MAIL_PASSWORD?: string;
MAIL_FROM: string;
STRIPE_API_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID?: string;
STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID?: string;
NEXT_PUBLIC_ALLOW_SIGNUP?: string;
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS?: string;
}
}

View File

@@ -0,0 +1,7 @@
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
apiVersion: "2022-11-15",
typescript: true,
});

View File

@@ -0,0 +1,15 @@
export const STRIPE_PLANS = [
{
name: "Community Plan",
prices: {
monthly: {
price: 30,
priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ?? "",
},
yearly: {
price: 300,
priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ?? "",
},
},
},
];

View File

@@ -0,0 +1,23 @@
import { CheckoutSessionRequest, CheckoutSessionResponse } from "../handlers/checkout-session"
export type FetchCheckoutSessionOptions = CheckoutSessionRequest['body']
export const fetchCheckoutSession = async ({
id,
priceId
}: FetchCheckoutSessionOptions) => {
const response = await fetch('/api/stripe/checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id,
priceId
})
});
const json: CheckoutSessionResponse = await response.json();
return json;
}

View File

@@ -0,0 +1,14 @@
import { GetSubscriptionResponse } from "../handlers/get-subscription";
export const fetchSubscription = async () => {
const response = await fetch("/api/stripe/subscription", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const json: GetSubscriptionResponse = await response.json();
return json;
};

View File

@@ -0,0 +1,19 @@
import { PortalSessionRequest, PortalSessionResponse } from "../handlers/portal-session";
export type FetchPortalSessionOptions = PortalSessionRequest["body"];
export const fetchPortalSession = async ({ id }: FetchPortalSessionOptions) => {
const response = await fetch("/api/stripe/portal-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
}),
});
const json: PortalSessionResponse = await response.json();
return json;
};

View File

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

View File

@@ -0,0 +1,92 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { stripe } from "../client";
import { getToken } from "next-auth/jwt";
export type CheckoutSessionRequest = {
body: {
id?: string;
priceId: string;
};
};
export type CheckoutSessionResponse =
| {
success: false;
message: string;
}
| {
success: true;
url: string;
};
export const checkoutSessionHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "POST") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
const user = await prisma.user.findFirst({
where: {
email: token.email,
},
});
if (!user) {
return res.status(404).json({
success: false,
message: "No user found",
});
}
const { id, priceId } = req.body;
if (typeof priceId !== "string") {
return res.status(400).json({
success: false,
message: "No id or priceId found in request",
});
}
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: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
allow_promotion_codes: 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({
success: true,
url: session.url,
});
};

View File

@@ -0,0 +1,63 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { Subscription } from "@prisma/client";
import { getToken } from "next-auth/jwt";
export type GetSubscriptionRequest = never;
export type GetSubscriptionResponse =
| {
success: false;
message: string;
}
| {
success: true;
subscription: Subscription;
};
export const getSubscriptionHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "GET") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
const subscription = await prisma.subscription.findFirst({
where: {
User: {
email: token.email,
},
},
});
if (!subscription) {
return res.status(404).json({
success: false,
message: "No subscription found",
});
}
return res.status(200).json({
success: true,
subscription,
});
};

View File

@@ -0,0 +1,54 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripe } from "../client";
export type PortalSessionRequest = {
body: {
id: string;
};
};
export type PortalSessionResponse =
| {
success: false;
message: string;
}
| {
success: true;
url: string;
};
export const portalSessionHandler = async (req: NextApiRequest, res: NextApiResponse<PortalSessionResponse>) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "POST") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const { id } = req.body;
if (typeof id !== "string") {
return res.status(400).json({
success: false,
message: "No id found in request",
});
}
const session = await stripe.billingPortal.sessions.create({
customer: id,
return_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
return res.status(200).json({
success: true,
url: session.url,
});
};

View File

@@ -0,0 +1,164 @@
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";
const log = (...args: any[]) => console.log("[stripe]", ...args);
export const webhookHandler = async (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, 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;
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
await prisma.subscription.upsert({
where: {
customerId,
},
create: {
customerId,
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId: Number(session.client_reference_id as string),
},
update: {
customerId,
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string);
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "invoice.payment_failed") {
const failedInvoice = event.data.object as Stripe.Invoice;
const customerId = failedInvoice.customer as string;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.PAST_DUE,
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "customer.subscription.updated") {
const updatedSubscription = event.data.object as Stripe.Subscription;
const customerId = updatedSubscription.customer as string;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.ACTIVE,
planId: updatedSubscription.id,
priceId: updatedSubscription.items.data[0].price.id,
periodEnd: new Date(updatedSubscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "customer.subscription.deleted") {
const deletedSubscription = event.data.object as Stripe.Subscription;
const customerId = deletedSubscription.customer as string;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
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",
});
};

View File

@@ -0,0 +1,6 @@
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'

View File

@@ -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<SubscriptionContextValue>({
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<Subscription | null>(
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 (
<SubscriptionContext.Provider
value={{
subscription,
hasSubscription,
isLoading,
}}>
{children}
</SubscriptionContext.Provider>
);
};

View File

@@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateTable
CREATE TABLE "Subscription" (
"id" SERIAL NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'INACTIVE',
"planId" TEXT,
"priceId" TEXT,
"customerId" TEXT,
"periodEnd" TIMESTAMP(3),
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Subscription_userId_idx" ON "Subscription"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_planId_customerId_key" ON "Subscription"("planId", "customerId");
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[customerId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "Subscription_planId_customerId_key";
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_customerId_key" ON "Subscription"("customerId");

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "SubscriptionStatus" ADD VALUE 'PAST_DUE';

View File

@@ -23,6 +23,30 @@ model User {
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription[]
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
INACTIVE
}
model Subscription {
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String?
periodEnd DateTime?
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([customerId])
@@index([userId])
}
model Account {