diff --git a/.env.example b/.env.example index 44baac9c8..7bd71c04b 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,7 @@ NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_FREE_PLAN_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/apps/marketing/process-env.d.ts b/apps/marketing/process-env.d.ts index 3dfdcb30f..942007d17 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -7,6 +7,7 @@ declare namespace NodeJS { NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_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_WEBHOOK_SECRET: string; diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index 4149423dd..f775cb7d8 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -7,6 +7,7 @@ declare namespace NodeJS { NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_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_WEBHOOK_SECRET: string; 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 new file mode 100644 index 000000000..994f7c221 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -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 ( + + ); +} 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 new file mode 100644 index 000000000..f235cb846 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -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`, + }); +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index a5c672971..b2378d22a 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,16 +1,15 @@ -import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; -import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; +import { match } from 'ts-pattern'; + 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 { SubscriptionStatus } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; import { LocaleDate } from '~/components/formatter/locale-date'; +import BillingPortalButton from './billing-portal-button'; + export default async function BillingSettingsPage() { const { user } = await getRequiredServerComponentSession(); @@ -21,57 +20,74 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => { - if (sub) { - return sub; - } + const subscription = await getSubscriptionByUserId({ userId: user.id }); - // If we don't have a customer record, create one as well as an empty subscription. - return createCustomer({ user }); - }); + let subscriptionProduct: Stripe.Product | null = null; - let billingPortalUrl = ''; + if (subscription?.planId) { + const foundSubscriptionProduct = (await stripe.products.list()).data.find( + (item) => item.default_price === subscription.planId, + ); - if (subscription.customerId) { - billingPortalUrl = await getPortalSession({ - customerId: subscription.customerId, - returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, - }); + subscriptionProduct = foundSubscriptionProduct ?? null; } + const isMissingOrInactiveOrFreePlan = + !subscription || + subscription.status === 'INACTIVE' || + subscription?.planId === process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID; + return (
- Your subscription is{' '}
- {subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
- {subscription?.periodEnd && (
- <>
- {' '}
- Your next payment is due on{' '}
-
-
+ You are currently on the Free Plan. +
)} - + + {!isMissingOrInactiveOrFreePlan && + match(subscription.status) + .with('ACTIVE', () => ( +
+ {subscriptionProduct ? (
+
+ You are currently subscribed to{' '}
+ {subscriptionProduct.name}
+
+ ) : (
+ You currently have an active plan
+ )}
+ {subscription.periodEnd && (
+
+ {' '}
+ which is set to{' '}
+ {subscription.cancelAtPeriodEnd ? (
+
+ end on{' '}
+
Your current plan is past due. Please update your payment information.
+ )) + .otherwise(() => null)} +- You do not currently have a customer record, this should not happen. Please contact - support for assistance. -
- )} +