feat: scaffhold subscription table and ui

This commit is contained in:
Mythie
2023-05-05 19:29:42 +10:00
parent bf84ec8962
commit ed3e4d22ef
19 changed files with 2128 additions and 32 deletions

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { updateUser } from "@documenso/features"; import { updateUser } from "@documenso/features";
import { getUser } from "@documenso/lib/api"; import { getUser } from "@documenso/lib/api";
import { Button } from "@documenso/ui"; import { Button } from "@documenso/ui";
import { KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon, KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
const subNavigation = [ const subNavigation = [
@ -21,6 +21,12 @@ const subNavigation = [
icon: KeyIcon, icon: KeyIcon,
current: false, current: false,
}, },
{
name: "Billing",
href: "/settings/billing",
icon: CreditCardIcon,
current: false,
},
]; ];
function classNames(...classes: any) { function classNames(...classes: any) {
@ -33,7 +39,6 @@ export default function Setttings() {
email: "", email: "",
name: "", name: "",
}); });
useEffect(() => { useEffect(() => {
getUser().then((res: any) => { getUser().then((res: any) => {
res.json().then((j: any) => { res.json().then((j: any) => {
@ -158,6 +163,7 @@ export default function Setttings() {
<Button onClick={() => updateUser(user)}>Save</Button> <Button onClick={() => updateUser(user)}>Save</Button>
</div> </div>
</form> </form>
<div <div
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[1].name} hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[1].name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9"> className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
@ -171,9 +177,33 @@ export default function Setttings() {
</div> </div>
</div> </div>
</div> </div>
<div
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation?.[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>
<p className="mt-1 text-sm text-gray-500">
Your subscription is currently <strong>{user?.subscription ? 'Active' : 'Inactive'}</strong>.
</p>
<p className="mt-1 text-sm text-gray-500">
We use Stripe to process payments. Your card details are never stored on our
servers.
</p>
<div className="mt-8">
<Button onClick={() => {}}>Manage my subscription</Button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-10 max-w-[1100px]" hidden={!!user.email}> <div className="mt-10 max-w-[1100px]" hidden={!!user.email}>
<div className="ph-item"> <div className="ph-item">
<div className="ph-col-12"> <div className="ph-col-12">

View File

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

View File

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

View File

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

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

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

1808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3" "bcryptjs": "^2.4.3",
"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,12 @@
export const STRIPE_PLANS = [
{
name: "Community Plan",
period: "monthly",
priceId: process.env.STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ?? "",
},
{
name: "Community Plan",
period: "yearly",
priceId: process.env.STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ?? "",
},
];

View File

@ -0,0 +1 @@
export

View File

@ -0,0 +1,155 @@
import prisma from "@documenso/prisma";
import { SubscriptionStatus } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { stripe } from "../index";
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",
});
}
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
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",
});
}
return res.status(400).json({
success: false,
message: "Unhandled webhook event",
});
};

View File

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

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[] accounts Account[]
sessions Session[] sessions Session[]
Document Document[] 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 { model Account {