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-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 new file mode 100644 index 000000000..8fd78cae3 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -0,0 +1,55 @@ +'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 const 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..cef36ee3f --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -0,0 +1,48 @@ +'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'; + +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 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 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 a5c672971..ce41f4f6d 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,16 +1,18 @@ -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 { 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 { SubscriptionStatus } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; import { LocaleDate } from '~/components/formatter/locale-date'; +import { BillingPlans } from './billing-plans'; +import { BillingPortalButton } from './billing-portal-button'; + export default async function BillingSettingsPage() { const { user } = await getRequiredServerComponentSession(); @@ -21,57 +23,75 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => { - if (sub) { - return sub; - } + const [subscription, prices] = await Promise.all([ + getSubscriptionByUserId({ userId: user.id }), + getPricesByInterval(), + ]); - // 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'; + 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. -

- )} + {isMissingOrInactiveOrFreePlan ? : }
); } diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 9efab2a78..9f1a6937a 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; + + 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-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-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/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); +}; 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 340b98528..76c1ab552 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -51,15 +51,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 1db1ec36a..aec4f1d89 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -10,6 +10,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/turbo.json b/turbo.json index 10ff74735..9628c6bcc 100644 --- a/turbo.json +++ b/turbo.json @@ -33,6 +33,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_DIRECT_DATABASE_URL", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", @@ -77,4 +78,4 @@ "E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_PASSWORD" ] -} +} \ No newline at end of file