diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx new file mode 100644 index 000000000..ba4c0f818 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx @@ -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 = { + 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('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 ( +
+ isInterval(value) && setInterval(value)}> + + {INTERVALS.map( + (interval) => + prices[interval].length > 0 && ( + + {FRIENDLY_INTERVALS[interval]} + + ), + )} + + + +
+ + {prices[interval].map((price) => ( + + + {price.product.name} + +
+ ${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '} + per {interval} +
+ +
+ {price.product.description} +
+ + {price.product.features && price.product.features.length > 0 && ( +
+
Includes:
+ +
    + {price.product.features.map((feature, index) => ( +
  • + {feature.name} +
  • + ))} +
+
+ )} + +
+ + + + + ))} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx index 994f7c221..9add70263 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -21,6 +21,7 @@ export default function BillingPortalButton() { try { const sessionUrl = await createBillingPortal(); + if (!sessionUrl) { throw new Error('NO_SESSION'); } diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index 331943648..cef36ee3f 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -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`, diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts new file mode 100644 index 000000000..f556133f0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts @@ -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`, + }); +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index b2378d22a..58fa6e5b7 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -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 (

Billing

-
+
{isMissingOrInactiveOrFreePlan && (

You are currently on the Free Plan. @@ -60,6 +63,7 @@ export default async function BillingSettingsPage() { ) : ( You currently have an active plan )} + {subscription.periodEnd && ( {' '} @@ -87,7 +91,7 @@ export default async function BillingSettingsPage() {


- + {isMissingOrInactiveOrFreePlan ? : }
); } diff --git a/packages/ee/server-only/stripe/get-checkout-session.ts b/packages/ee/server-only/stripe/get-checkout-session.ts new file mode 100644 index 000000000..a99ecd5f9 --- /dev/null +++ b/packages/ee/server-only/stripe/get-checkout-session.ts @@ -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; +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts new file mode 100644 index 000000000..5dfc3d7ea --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -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; + +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; +}; diff --git a/packages/lib/server-only/stripe/index.ts b/packages/lib/server-only/stripe/index.ts index 505beaec8..4c3669408 100644 --- a/packages/lib/server-only/stripe/index.ts +++ b/packages/lib/server-only/stripe/index.ts @@ -1,3 +1,4 @@ +/// import Stripe from 'stripe'; export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', { diff --git a/packages/lib/server-only/stripe/stripe.d.ts b/packages/lib/server-only/stripe/stripe.d.ts new file mode 100644 index 000000000..51ea902ea --- /dev/null +++ b/packages/lib/server-only/stripe/stripe.d.ts @@ -0,0 +1,7 @@ +declare module 'stripe' { + namespace Stripe { + interface Product { + features?: Array<{ name: string }>; + } + } +} diff --git a/packages/lib/universal/stripe/to-human-price.ts b/packages/lib/universal/stripe/to-human-price.ts new file mode 100644 index 000000000..a839c5fba --- /dev/null +++ b/packages/lib/universal/stripe/to-human-price.ts @@ -0,0 +1,3 @@ +export const toHumanPrice = (price: number) => { + return Number(price / 100).toFixed(2); +};