From 773566f19384921b98f720fb8222a87f17d87a20 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 18 Sep 2023 22:33:07 +1000 Subject: [PATCH 1/6] feat: add free tier Stripe subscription --- .env.example | 1 + apps/marketing/process-env.d.ts | 1 + apps/web/process-env.d.ts | 1 + .../billing/billing-portal-button.tsx | 54 +++++++++ .../billing/create-billing-portal.action.ts | 80 ++++++++++++++ .../app/(dashboard)/settings/billing/page.tsx | 103 ++++++++++-------- .../web/src/pages/api/stripe/webhook/index.ts | 40 +++++++ .../ee/server-only/stripe/get-customer.ts | 19 ++++ .../migration.sql | 17 +++ packages/prisma/schema.prisma | 19 ++-- packages/tsconfig/process-env.d.ts | 1 + packages/ui/primitives/button.tsx | 4 +- turbo.json | 16 ++- 13 files changed, 298 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts create mode 100644 packages/ee/server-only/stripe/get-customer.ts create mode 100644 packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql diff --git a/.env.example b/.env.example index 6f32b5a63..4450ef310 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,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 ac170a616..53b126e57 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -6,6 +6,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 1cb0018ac..dfada0e57 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -6,6 +6,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 555c645ce..bbc158fe0 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,16 +1,16 @@ -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 { 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 { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag'; +import BillingPortalButton from './billing-portal-button'; + export default async function BillingSettingsPage() { const user = await getRequiredServerComponentSession(); @@ -21,57 +21,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_SITE_URL}/settings/billing`, - }); + subscriptionProduct = foundSubscriptionProduct ?? null; } + const isMissingOrInactiveOrFreePlan = + !subscription || + subscription.status === 'INACTIVE' || + subscription?.planId === process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID; + return (

Billing

-

- Your subscription is{' '} - {subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}. - {subscription?.periodEnd && ( - <> - {' '} - Your next payment is due on{' '} - - - - . - +

+ {isMissingOrInactiveOrFreePlan && ( +

+ 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{' '} + . + + ) : ( + + automatically renew on{' '} + . + + )} + + )} +

+ )) + .with('PAST_DUE', () => ( +

Your current plan is past due. Please update your payment information.

+ )) + .otherwise(() => null)} +

- {billingPortalUrl && ( - - )} - - {!billingPortalUrl && ( -

- You do not currently have a customer record, this should not happen. Please contact - support for assistance. -

- )} +
); } diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 9efab2a78..06f63e293 100644 --- a/apps/web/src/pages/api/stripe/webhook/index.ts +++ b/apps/web/src/pages/api/stripe/webhook/index.ts @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { randomBytes } from 'crypto'; import { readFileSync } from 'fs'; import { buffer } from 'micro'; +import { match } from 'ts-pattern'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; @@ -16,6 +17,7 @@ import { ReadStatus, SendStatus, SigningStatus, + SubscriptionStatus, } from '@documenso/prisma/client'; 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); + 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') { // 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 @@ -195,3 +209,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) 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(), + }, + }); +}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts new file mode 100644 index 000000000..11e782966 --- /dev/null +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -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; + } +}; diff --git a/packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql b/packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql new file mode 100644 index 000000000..8125b0cb1 --- /dev/null +++ b/packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql @@ -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"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1ff3d7a75..72d54f4ca 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -41,15 +41,16 @@ enum SubscriptionStatus { } 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 + id Int @id @default(autoincrement()) + status SubscriptionStatus @default(INACTIVE) + planId String? + priceId String? + customerId String + periodEnd DateTime? + userId Int @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + cancelAtPeriodEnd Boolean @default(false) User User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index b0852b4f4..19447957f 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -9,6 +9,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/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index c67117d6f..31df69dee 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -56,14 +56,14 @@ export interface ButtonProps } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading, ...props }, ref) => { if (asChild) { return ( ); } - const showLoader = props.loading === true; + const showLoader = loading === true; const isDisabled = props.disabled || showLoader; return ( diff --git a/turbo.json b/turbo.json index a5b333c66..9fea252d0 100644 --- a/turbo.json +++ b/turbo.json @@ -2,8 +2,13 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**" + ] }, "lint": {}, "dev": { @@ -11,7 +16,9 @@ "persistent": true } }, - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local" + ], "globalEnv": [ "APP_VERSION", "NEXTAUTH_URL", @@ -23,6 +30,7 @@ "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", + "NEXT_PUBLIC_STRIPE_FREE_PLAN_ID", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_NEXT_AUTH_SECRET", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", @@ -50,4 +58,4 @@ "NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_STRIPE_API_KEY" ] -} +} \ No newline at end of file From 027a588604504f4a1b4bb4ed7b26f1dc1db72c6b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 18 Sep 2023 22:47:46 +1000 Subject: [PATCH 2/6] feat: wip --- .../settings/billing/create-billing-portal.action.ts | 4 ++-- apps/web/src/pages/api/stripe/webhook/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 f235cb846..d42d1e97e 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 @@ -40,10 +40,10 @@ export const createBillingPortal = async () => { }); } - const stripeCustomerSubsriptions = stripeCustomer.subscriptions?.data ?? []; + const stripeCustomerSubscriptions = stripeCustomer.subscriptions?.data ?? []; // Create a free subscription for user if it does not exist. - if (!existingSubscription && stripeCustomerSubsriptions.length === 0) { + if (!existingSubscription && stripeCustomerSubscriptions.length === 0) { const newSubscription = await stripe.subscriptions.create({ customer: stripeCustomer.id, items: [ diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 06f63e293..9f1a6937a 100644 --- a/apps/web/src/pages/api/stripe/webhook/index.ts +++ b/apps/web/src/pages/api/stripe/webhook/index.ts @@ -212,7 +212,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) 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 { plan } = subscription as unknown as Stripe.SubscriptionItem; const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; From cbe118b74fb4bbf42a27c57744f97f6a9c62b875 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 19 Sep 2023 15:14:47 +1000 Subject: [PATCH 3/6] fix: merge issues --- .../settings/billing/create-billing-portal.action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d42d1e97e..98c3d2a96 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 @@ -75,6 +75,6 @@ export const createBillingPortal = async () => { return getPortalSession({ customerId: stripeCustomer.id, - returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`, + returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, }); }; From 4d485940eaa01ac93d7fb49d508806af588182d3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 19 Sep 2023 15:30:58 +1000 Subject: [PATCH 4/6] fix: stripe customer fetch logic --- .../settings/billing/create-billing-portal.action.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 98c3d2a96..331943648 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 @@ -26,8 +26,10 @@ export const createBillingPortal = async () => { } } - // Fallback to check if a Stripe customer already exists for the current user. - stripeCustomer = await getStripeCustomerByEmail(user.email); + // 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) { From 4d5275f915a2a6391cfccd0c57779519efd094ab Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 13 Oct 2023 23:33:40 +1100 Subject: [PATCH 5/6] fix: create custom pricing table --- .../settings/billing/billing-plans.tsx | 133 ++++++++++++++++++ .../billing/billing-portal-button.tsx | 1 + .../billing/create-billing-portal.action.ts | 36 +---- .../billing/create-checkout.action.ts | 59 ++++++++ .../app/(dashboard)/settings/billing/page.tsx | 19 +-- .../stripe/get-checkout-session.ts | 31 ++++ .../stripe/get-prices-by-interval.ts | 40 ++++++ packages/lib/server-only/stripe/index.ts | 1 + packages/lib/server-only/stripe/stripe.d.ts | 7 + .../lib/universal/stripe/to-human-price.ts | 3 + 10 files changed, 287 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts create mode 100644 packages/ee/server-only/stripe/get-checkout-session.ts create mode 100644 packages/ee/server-only/stripe/get-prices-by-interval.ts create mode 100644 packages/lib/server-only/stripe/stripe.d.ts create mode 100644 packages/lib/universal/stripe/to-human-price.ts 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 e48a2a40f..58fa6e5b7 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -2,13 +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 { Stripe, stripe } from '@documenso/lib/server-only/stripe'; 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() { @@ -21,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; @@ -33,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. @@ -61,6 +63,7 @@ export default async function BillingSettingsPage() { ) : ( You currently have an active plan )} + {subscription.periodEnd && ( {' '} @@ -88,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); +}; From ede9eb052dd4fc96ea90dac983c0484110dc9feb Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 13 Oct 2023 23:56:11 +1100 Subject: [PATCH 6/6] fix: named exports --- .../(dashboard)/settings/billing/billing-portal-button.tsx | 4 ++-- apps/web/src/app/(dashboard)/settings/billing/page.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 9add70263..8fd78cae3 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 @@ -7,7 +7,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { createBillingPortal } from './create-billing-portal.action'; -export default function BillingPortalButton() { +export const BillingPortalButton = () => { const { toast } = useToast(); const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false); @@ -52,4 +52,4 @@ export default function BillingPortalButton() { Manage Subscription ); -} +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 58fa6e5b7..ce41f4f6d 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -11,7 +11,7 @@ import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription import { LocaleDate } from '~/components/formatter/locale-date'; import { BillingPlans } from './billing-plans'; -import BillingPortalButton from './billing-portal-button'; +import { BillingPortalButton } from './billing-portal-button'; export default async function BillingSettingsPage() { const { user } = await getRequiredServerComponentSession();