mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
fix: create custom pricing table
This commit is contained in:
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createCheckout } from './create-checkout.action';
|
||||
|
||||
type Interval = keyof PriceIntervals;
|
||||
|
||||
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||
|
||||
const FRIENDLY_INTERVALS: Record<Interval, string> = {
|
||||
day: 'Daily',
|
||||
week: 'Weekly',
|
||||
month: 'Monthly',
|
||||
year: 'Yearly',
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export type BillingPlansProps = {
|
||||
prices: PriceIntervals;
|
||||
};
|
||||
|
||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
||||
|
||||
const onSubscribeClick = async (priceId: string) => {
|
||||
try {
|
||||
setIsFetchingCheckoutSession(true);
|
||||
|
||||
const url = await createCheckout({ priceId });
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Unable to create session');
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while trying to create a checkout session.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingCheckoutSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||
<TabsList>
|
||||
{INTERVALS.map(
|
||||
(interval) =>
|
||||
prices[interval].length > 0 && (
|
||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||
{FRIENDLY_INTERVALS[interval]}
|
||||
</TabsTrigger>
|
||||
),
|
||||
)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{prices[interval].map((price) => (
|
||||
<MotionCard
|
||||
key={price.id}
|
||||
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col p-6">
|
||||
<CardTitle>{price.product.name}</CardTitle>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||
<span className="text-xs">per {interval}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||
{price.product.description}
|
||||
</div>
|
||||
|
||||
{price.product.features && price.product.features.length > 0 && (
|
||||
<div className="text-muted-foreground mt-4">
|
||||
<div className="text-sm font-medium">Includes:</div>
|
||||
|
||||
<ul className="mt-1 divide-y text-sm">
|
||||
{price.product.features.map((feature, index) => (
|
||||
<li key={index} className="py-2">
|
||||
{feature.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
loading={isFetchingCheckoutSession}
|
||||
onClick={() => void onSubscribeClick(price.id)}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -21,6 +21,7 @@ export default function BillingPortalButton() {
|
||||
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal();
|
||||
|
||||
if (!sessionUrl) {
|
||||
throw new Error('NO_SESSION');
|
||||
}
|
||||
|
||||
@ -8,10 +8,9 @@ import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-se
|
||||
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 { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
@ -42,39 +41,6 @@ export const createBillingPortal = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const stripeCustomerSubscriptions = stripeCustomer.subscriptions?.data ?? [];
|
||||
|
||||
// Create a free subscription for user if it does not exist.
|
||||
if (!existingSubscription && stripeCustomerSubscriptions.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_WEBAPP_URL}/settings/billing`,
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
'use server';
|
||||
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
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';
|
||||
|
||||
export type CreateCheckoutOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||
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');
|
||||
}
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||
if (!stripeCustomer) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -2,12 +2,15 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
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 { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { BillingPlans } from './billing-plans';
|
||||
import BillingPortalButton from './billing-portal-button';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
@ -20,7 +23,10 @@ export default async function BillingSettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
const subscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
const [subscription, prices] = await Promise.all([
|
||||
getSubscriptionByUserId({ userId: user.id }),
|
||||
getPricesByInterval(),
|
||||
]);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
@ -32,16 +38,13 @@ export default async function BillingSettingsPage() {
|
||||
subscriptionProduct = foundSubscriptionProduct ?? null;
|
||||
}
|
||||
|
||||
const isMissingOrInactiveOrFreePlan =
|
||||
!subscription ||
|
||||
subscription.status === 'INACTIVE' ||
|
||||
subscription?.planId === process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID;
|
||||
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
|
||||
<div className="mt-2 text-sm text-slate-500">
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
@ -60,6 +63,7 @@ export default async function BillingSettingsPage() {
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
@ -87,7 +91,7 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<BillingPortalButton />
|
||||
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
31
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
@ -0,0 +1,31 @@
|
||||
'use server';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
};
|
||||
|
||||
export const getCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
}: GetCheckoutSessionOptions) => {
|
||||
'use server';
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
});
|
||||
|
||||
return session.url;
|
||||
};
|
||||
40
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
40
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
// Utility type to handle usage of the `expand` option.
|
||||
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||
|
||||
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||
|
||||
export const getPricesByInterval = async () => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const intervals: PriceIntervals = {
|
||||
day: [],
|
||||
week: [],
|
||||
month: [],
|
||||
year: [],
|
||||
};
|
||||
|
||||
// Add each price to the correct interval.
|
||||
for (const price of prices) {
|
||||
if (price.recurring?.interval) {
|
||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
intervals[price.recurring.interval].push(price as PriceWithProduct);
|
||||
}
|
||||
}
|
||||
|
||||
// Order all prices by unit_amount.
|
||||
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
|
||||
return intervals;
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
/// <reference types="./stripe.d.ts" />
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {
|
||||
|
||||
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
declare module 'stripe' {
|
||||
namespace Stripe {
|
||||
interface Product {
|
||||
features?: Array<{ name: string }>;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const toHumanPrice = (price: number) => {
|
||||
return Number(price / 100).toFixed(2);
|
||||
};
|
||||
Reference in New Issue
Block a user