mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
feat: scaffhold subscription table and ui
This commit is contained in:
@ -37,6 +37,12 @@ SMTP_MAIL_PASSWORD=''
|
||||
# Sender for signing requests and completion mails.
|
||||
MAIL_FROM='documenso@localhost.com'
|
||||
|
||||
# STRIPE
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ID=
|
||||
|
||||
#FEATURE FLAGS
|
||||
# Allow users to register via the /signup page. Otherwise they will be redirect to the home page.
|
||||
ALLOW_SIGNUP=true
|
||||
ALLOW_SUBSCRIPTIONS=true
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -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"]
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { useRouter } from "next/router";
|
||||
import { updateUser } from "@documenso/features";
|
||||
import { getUser } from "@documenso/lib/api";
|
||||
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";
|
||||
|
||||
const subNavigation = [
|
||||
@ -21,6 +21,12 @@ const subNavigation = [
|
||||
icon: KeyIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
|
||||
function classNames(...classes: any) {
|
||||
@ -33,7 +39,6 @@ export default function Setttings() {
|
||||
email: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getUser().then((res: any) => {
|
||||
res.json().then((j: any) => {
|
||||
@ -158,6 +163,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 +177,33 @@ export default function Setttings() {
|
||||
</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 className="mt-10 max-w-[1100px]" hidden={!!user.email}>
|
||||
<div className="ph-item">
|
||||
<div className="ph-col-12">
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
1
apps/web/pages/settings/billing.tsx
Normal file
1
apps/web/pages/settings/billing.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default } from ".";
|
||||
24
apps/web/process-env.d.ts
vendored
Normal file
24
apps/web/process-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,6 @@
|
||||
"../../packages/types/next-auth.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
, "../../packages/lib/process-env.d.ts" ],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
1808
package-lock.json
generated
1808
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3"
|
||||
"bcryptjs": "^2.4.3",
|
||||
"stripe": "^12.4.0"
|
||||
}
|
||||
}
|
||||
24
packages/lib/process-env.d.ts
vendored
Normal file
24
packages/lib/process-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
12
packages/lib/stripe/data/plans.ts
Normal file
12
packages/lib/stripe/data/plans.ts
Normal 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 ?? "",
|
||||
},
|
||||
];
|
||||
1
packages/lib/stripe/handlers/portal-session.ts
Normal file
1
packages/lib/stripe/handlers/portal-session.ts
Normal file
@ -0,0 +1 @@
|
||||
export
|
||||
155
packages/lib/stripe/handlers/webhook.ts
Normal file
155
packages/lib/stripe/handlers/webhook.ts
Normal 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",
|
||||
});
|
||||
};
|
||||
6
packages/lib/stripe/index.ts
Normal file
6
packages/lib/stripe/index.ts
Normal 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,
|
||||
});
|
||||
@ -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;
|
||||
@ -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");
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SubscriptionStatus" ADD VALUE 'PAST_DUE';
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user