feat: add free tier Stripe subscription

This commit is contained in:
David Nguyen
2023-09-18 22:33:07 +10:00
committed by Mythie
parent 5a79535080
commit 2856cd9c15
12 changed files with 296 additions and 57 deletions

View File

@ -73,6 +73,7 @@ NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]] # [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags. # OPTIONAL: Leave blank to disable PostHog and feature flags.

View File

@ -7,6 +7,7 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -7,6 +7,7 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export default function BillingPortalButton() {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
const handleFetchPortalUrl = async () => {
if (isFetchingPortalUrl) {
return;
}
setIsFetchingPortalUrl(true);
try {
const sessionUrl = await createBillingPortal();
if (!sessionUrl) {
throw new Error('NO_SESSION');
}
window.open(sessionUrl, '_blank');
} catch (e) {
let description =
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
if (e.message === 'CUSTOMER_NOT_FOUND') {
description =
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
}
toast({
title: 'Something went wrong',
description,
variant: 'destructive',
duration: 10000,
});
}
setIsFetchingPortalUrl(false);
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
Manage Subscription
</Button>
);
}

View File

@ -0,0 +1,80 @@
'use server';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { prisma } from '@documenso/prisma';
export const createBillingPortal = async () => {
const user = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user.
stripeCustomer = await getStripeCustomerByEmail(user.email);
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
const stripeCustomerSubsriptions = stripeCustomer.subscriptions?.data ?? [];
// Create a free subscription for user if it does not exist.
if (!existingSubscription && stripeCustomerSubsriptions.length === 0) {
const newSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID,
},
],
});
await prisma.subscription.upsert({
where: {
userId: user.id,
customerId: stripeCustomer.id,
},
create: {
userId: user.id,
customerId: stripeCustomer.id,
planId: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID,
periodEnd: new Date(newSubscription.current_period_end * 1000),
status: 'ACTIVE',
},
update: {
planId: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID,
periodEnd: new Date(newSubscription.current_period_end * 1000),
status: 'ACTIVE',
},
});
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
};

View File

@ -1,16 +1,15 @@
import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; import { match } from 'ts-pattern';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; 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 { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
import BillingPortalButton from './billing-portal-button';
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
@ -21,57 +20,74 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile'); redirect('/settings/profile');
} }
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => { const subscription = await getSubscriptionByUserId({ userId: user.id });
if (sub) {
return sub; let subscriptionProduct: Stripe.Product | null = null;
if (subscription?.planId) {
const foundSubscriptionProduct = (await stripe.products.list()).data.find(
(item) => item.default_price === subscription.planId,
);
subscriptionProduct = foundSubscriptionProduct ?? null;
} }
// If we don't have a customer record, create one as well as an empty subscription. const isMissingOrInactiveOrFreePlan =
return createCustomer({ user }); !subscription ||
}); subscription.status === 'INACTIVE' ||
subscription?.planId === process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID;
let billingPortalUrl = '';
if (subscription.customerId) {
billingPortalUrl = await getPortalSession({
customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
return ( return (
<div> <div>
<h3 className="text-lg font-medium">Billing</h3> <h3 className="text-lg font-medium">Billing</h3>
<p className="text-muted-foreground mt-2 text-sm"> <div className="mt-2 text-sm text-slate-500">
Your subscription is{' '} {isMissingOrInactiveOrFreePlan && (
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}. <p>
{subscription?.periodEnd && ( You are currently on the <span className="font-semibold">Free Plan</span>.
<> </p>
{' '} )}
Your next payment is due on{' '}
<span className="font-semibold"> {!isMissingOrInactiveOrFreePlan &&
<LocaleDate date={subscription.periodEnd} /> match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProduct ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProduct.name}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
) : (
<span>
automatically renew on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
)}
</span> </span>
.
</>
)} )}
</p> </p>
))
.with('PAST_DUE', () => (
<p>Your current plan is past due. Please update your payment information.</p>
))
.otherwise(() => null)}
</div>
<hr className="my-4" /> <hr className="my-4" />
{billingPortalUrl && ( <BillingPortalButton />
<Button asChild>
<Link href={billingPortalUrl}>Manage Subscription</Link>
</Button>
)}
{!billingPortalUrl && (
<p className="text-muted-foreground max-w-[60ch] text-base">
You do not currently have a customer record, this should not happen. Please contact
support for assistance.
</p>
)}
</div> </div>
); );
} }

View File

@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { buffer } from 'micro'; import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; 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 { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
@ -16,6 +17,7 @@ import {
ReadStatus, ReadStatus,
SendStatus, SendStatus,
SigningStatus, SigningStatus,
SubscriptionStatus,
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
const log = (...args: unknown[]) => console.log('[stripe]', ...args); const log = (...args: unknown[]) => console.log('[stripe]', ...args);
@ -54,6 +56,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
); );
log('event-type:', event.type); 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') { if (event.type === 'checkout.session.completed') {
// This is required since we don't want to create a guard for every event type // 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 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@ -195,3 +209,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
message: 'Unhandled webhook event', 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).plan;
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(),
},
});
};

View File

@ -0,0 +1,19 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data[0] ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
return !stripeCustomer.deleted ? stripeCustomer : null;
} catch {
return null;
}
};

View File

@ -0,0 +1,17 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
*/
DELETE FROM "Subscription"
WHERE "customerId" IS NULL;
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "customerId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");

View File

@ -55,11 +55,12 @@ model Subscription {
status SubscriptionStatus @default(INACTIVE) status SubscriptionStatus @default(INACTIVE)
planId String? planId String?
priceId String? priceId String?
customerId String? customerId String
periodEnd DateTime? periodEnd DateTime?
userId Int userId Int @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -10,6 +10,7 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -2,8 +2,13 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": [
"outputs": [".next/**", "!.next/cache/**"] "^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
}, },
"lint": {}, "lint": {},
"clean": { "clean": {
@ -20,7 +25,9 @@
"dependsOn": ["^build"] "dependsOn": ["^build"]
} }
}, },
"globalDependencies": ["**/.env.*local"], "globalDependencies": [
"**/.env.*local"
],
"globalEnv": [ "globalEnv": [
"APP_VERSION", "APP_VERSION",
"NEXTAUTH_URL", "NEXTAUTH_URL",
@ -33,6 +40,7 @@
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
"NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_ID",