From 2d931b2c9ba683a6ae64f52b1f8b7b00bcc3706d Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 11 Dec 2023 23:36:54 +0530 Subject: [PATCH 1/5] chore: fix caching issue in workflows Signed-off-by: Adithya Krishna --- .github/workflows/issue-assignee-check.yml | 4 ++++ .github/workflows/pr-review-reminder.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/issue-assignee-check.yml b/.github/workflows/issue-assignee-check.yml index 1ce7a02be..dbd321509 100644 --- a/.github/workflows/issue-assignee-check.yml +++ b/.github/workflows/issue-assignee-check.yml @@ -12,6 +12,10 @@ jobs: if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User' runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index cc272fbfe..78f927e61 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -12,6 +12,10 @@ jobs: if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v4 with: From f9139a54a53a12efc98aea39ddfad86aa40491af Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 11 Dec 2023 23:37:28 +0530 Subject: [PATCH 2/5] chore: prevent frequent commenting for semantic pr titles Signed-off-by: Adithya Krishna --- .github/workflows/semantic-pull-requests.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 7685562b5..37b764652 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -16,6 +16,24 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: + - name: Check PR creator's previous activity + id: check_activity + run: | + CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') + ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') + if [ "$ACTIVITY" -eq 0 ]; then + echo "::set-output name=is_new::true" + else + echo "::set-output name=is_new::false" + fi + + - name: Count PRs created by user + id: count_prs + run: | + CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') + PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') + echo "::set-output name=pr_count::$PR_COUNT" + - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title env: @@ -36,7 +54,7 @@ jobs: ${{ steps.lint_pr_title.outputs.error_message }} ``` - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} + - if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}} uses: marocchino/sticky-pull-request-comment@v2 with: header: pr-title-lint-error From f2d4c0721dc005b5b72f65afbdd975fd35c2dedc Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 11 Dec 2023 23:38:07 +0530 Subject: [PATCH 3/5] chore: removed packageManager as we have engines Signed-off-by: Adithya Krishna --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 780f76793..30076100f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "turbo": "^1.9.3" }, "name": "@documenso/root", - "packageManager": "npm@8.19.2", "workspaces": [ "apps/*", "packages/*" From 6d34ebd91bca211c5853c6c5bc900684ecc0274f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 13 Dec 2023 22:49:58 +1100 Subject: [PATCH 4/5] fix: no longer available client component --- apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx index 8c7051caa..39bfba935 100644 --- a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import Link from 'next/link'; From 88534fa1c665eb7544f697c6adedf9e640bce196 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 14 Dec 2023 15:22:54 +1100 Subject: [PATCH 5/5] feat: add multi subscription support (#734) ## Description Previously we assumed that there can only be 1 subscription per user. However, that will soon no longer the case with the introduction of the Teams subscription. This PR will apply the required migrations to support multiple subscriptions. ## Changes Made - Updated the Prisma schema to allow for multiple `Subscriptions` per `User` - Added a Stripe `customerId` field to the `User` model - Updated relevant billing sections to support multiple subscriptions ## Testing Performed - Tested running the Prisma migration on a demo database created on the main branch Will require a lot of additional testing. ## Checklist - [ ] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. ## Additional Notes Added the following custom SQL statement to the migration: > DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL; Prior to deployment this will require changes to Stripe products: - Adding `type` meta attribute --------- Co-authored-by: Lucas Smith --- .env.example | 4 - apps/marketing/process-env.d.ts | 2 - .../(marketing)/claim-plan-dialog.tsx | 160 ------------------ .../components/(marketing)/pricing-table.tsx | 11 +- apps/web/process-env.d.ts | 2 - .../admin/users/data-table-users.tsx | 21 ++- .../src/app/(dashboard)/admin/users/page.tsx | 17 +- .../settings/billing/billing-plans.tsx | 2 +- .../billing/create-billing-portal.action.ts | 37 +--- .../billing/create-checkout.action.ts | 43 ++--- .../app/(dashboard)/settings/billing/page.tsx | 33 +++- packages/ee/server-only/limits/server.ts | 38 +++-- .../ee/server-only/stripe/create-customer.ts | 31 ---- .../ee/server-only/stripe/get-customer.ts | 75 ++++++++ .../stripe/get-prices-by-interval.ts | 15 +- .../server-only/stripe/get-prices-by-type.ts | 11 ++ .../ee/server-only/stripe/webhook/handler.ts | 32 ++-- .../stripe/webhook/on-subscription-deleted.ts | 7 +- .../stripe/webhook/on-subscription-updated.ts | 11 +- .../lib/server-only/admin/get-users-stats.ts | 4 +- .../get-subscription-by-user-id.ts | 15 -- .../get-subscriptions-by-user-id.ts | 15 ++ packages/lib/server-only/user/create-user.ts | 17 +- .../migration.sql | 31 ++++ packages/prisma/schema.prisma | 11 +- packages/tsconfig/process-env.d.ts | 2 - render.yaml | 4 - turbo.json | 3 - 28 files changed, 288 insertions(+), 366 deletions(-) delete mode 100644 apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx delete mode 100644 packages/ee/server-only/stripe/create-customer.ts create mode 100644 packages/ee/server-only/stripe/get-prices-by-type.ts delete mode 100644 packages/lib/server-only/subscription/get-subscription-by-user-id.ts create mode 100644 packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts create mode 100644 packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql diff --git a/.env.example b/.env.example index 968dd05e5..4c3c8f2e9 100644 --- a/.env.example +++ b/.env.example @@ -77,14 +77,10 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= 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. NEXT_PUBLIC_POSTHOG_KEY="" -# OPTIONAL: Defines the host to use for PostHog. -NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com" # OPTIONAL: Leave blank to disable billing. NEXT_PUBLIC_FEATURE_BILLING_ENABLED= diff --git a/apps/marketing/process-env.d.ts b/apps/marketing/process-env.d.ts index 942007d17..207bacef5 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -6,8 +6,6 @@ declare namespace NodeJS { NEXT_PRIVATE_DATABASE_URL: string; 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/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx deleted file mode 100644 index 8f826b4de..000000000 --- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; - -import { useSearchParams } from 'next/navigation'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Info } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -export const ZClaimPlanDialogFormSchema = z.object({ - name: z.string().trim().min(3, { message: 'Please enter a valid name.' }), - email: z.string().email(), -}); - -export type TClaimPlanDialogFormSchema = z.infer; - -export type ClaimPlanDialogProps = { - className?: string; - planId: string; - children: React.ReactNode; -}; - -export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { - const params = useSearchParams(); - const analytics = useAnalytics(); - const event = usePlausible(); - - const { toast } = useToast(); - - const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - reset, - } = useForm({ - defaultValues: { - name: params?.get('name') ?? '', - email: params?.get('email') ?? '', - }, - resolver: zodResolver(ZClaimPlanDialogFormSchema), - }); - - const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { - try { - const delay = new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - const [redirectUrl] = await Promise.all([ - claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), - delay, - ]); - - event('claim-plan-pricing'); - analytics.capture('Marketing: Claim plan', { planId, email }); - - window.location.href = redirectUrl; - } catch (error) { - event('claim-plan-failed'); - analytics.capture('Marketing: Claim plan failure', { planId, email }); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - useEffect(() => { - if (!isSubmitting && !open) { - reset(); - } - }, [open]); - - return ( - !isSubmitting && setOpen(value)}> - {children} - - - - Claim your plan - - - We're almost there! Please enter your email address and name to claim your plan. - - - -
-
- {params?.get('cancelled') === 'true' && ( -
-
-
- -
-
-

- You have cancelled the payment process. If you didn't mean to do this, please - try again. -

-
-
-
- )} - -
- - - - - -
- -
- - - - - -
- - -
-
-
-
- ); -}; diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index 712435e68..b65411064 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -1,9 +1,9 @@ 'use client'; -import { HTMLAttributes, useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { usePlausible } from 'next-plausible'; @@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; export const PricingTable = ({ className, ...props }: PricingTableProps) => { - const params = useSearchParams(); const event = usePlausible(); - const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID - ? 'YEARLY' - : 'MONTHLY', - ); + const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY'); return (
diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index f775cb7d8..0c00cb4c1 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -6,8 +6,6 @@ declare namespace NodeJS { NEXT_PRIVATE_DATABASE_URL: string; 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)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index f0c91615b..a8e02ca9f 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { Document, Role, Subscription } from '@documenso/prisma/client'; +import type { Document, Role, Subscription } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -19,7 +19,7 @@ type UserData = { name: string | null; email: string; roles: Role[]; - Subscription?: SubscriptionLite | null; + Subscription?: SubscriptionLite[] | null; Document: DocumentLite[]; }; @@ -35,9 +35,16 @@ type UsersDataTableProps = { totalPages: number; perPage: number; page: number; + individualPriceIds: string[]; }; -export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => { +export const UsersDataTable = ({ + users, + totalPages, + perPage, + page, + individualPriceIds, +}: UsersDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); const [searchString, setSearchString] = useState(''); @@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa { header: 'Subscription', accessorKey: 'subscription', - cell: ({ row }) => row.original.Subscription?.status ?? 'NONE', + cell: ({ row }) => { + const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) => + individualPriceIds.includes(sub.priceId), + ); + + return foundIndividualSubscription?.status ?? 'NONE'; + }, }, { header: 'Documents', diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 686ce7669..069378274 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,3 +1,5 @@ +import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; + import { UsersDataTable } from './data-table-users'; import { search } from './fetch-users.actions'; @@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const perPage = Number(searchParams.perPage) || 10; const searchString = searchParams.search || ''; - const { users, totalPages } = await search(searchString, page, perPage); + const [{ users, totalPages }, individualPrices] = await Promise.all([ + search(searchString, page, perPage), + getPricesByType('individual'), + ]); + + const individualPriceIds = individualPrices.map((price) => price.id); return (

Manage users

- +
); } diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx index ba4c0f818..a931f489b 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import type { 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'; 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 ee5dbf175..885414515 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 @@ -1,46 +1,13 @@ 'use server'; -import { - getStripeCustomerByEmail, - getStripeCustomerById, -} from '@documenso/ee/server-only/stripe/get-customer'; +import { getStripeCustomerByUser } 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-component-session'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { 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, - }, - }); - } + const { stripeCustomer } = await getStripeCustomerByUser(user); return getPortalSession({ customerId: stripeCustomer.id, 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 index 465d662a1..f8f20030c 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts @@ -1,55 +1,36 @@ 'use server'; -import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; -import { - getStripeCustomerByEmail, - getStripeCustomerById, -} from '@documenso/ee/server-only/stripe/get-customer'; +import { getStripeCustomerByUser } 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-component-session'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; export type CreateCheckoutOptions = { priceId: string; }; export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => { - const { user } = await getRequiredServerComponentSession(); + const session = await getRequiredServerComponentSession(); - const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); + const { user, stripeCustomer } = await getStripeCustomerByUser(session.user); - let stripeCustomer: Stripe.Customer | null = null; + const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id }); - // Find the Stripe customer for the current user subscription. - if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) { - stripeCustomer = await getStripeCustomerById(existingSubscription.customerId); - - if (!stripeCustomer) { - throw new Error('Missing Stripe customer for subscription'); - } + const foundSubscription = existingSubscriptions.find( + (subscription) => + subscription.priceId === priceId && + subscription.periodEnd && + subscription.periodEnd >= new Date(), + ); + if (foundSubscription) { 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) { - await createCustomer({ - user, - }); - - stripeCustomer = await getStripeCustomerByEmail(user.email); - } - return getCheckoutSession({ customerId: stripeCustomer.id, priceId, diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 61dff3216..74e4bd685 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 { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { type Stripe } from '@documenso/lib/server-only/stripe'; +import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; +import { SubscriptionStatus } from '@documenso/prisma/client'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans'; import { BillingPortalButton } from './billing-portal-button'; export default async function BillingSettingsPage() { - const { user } = await getRequiredServerComponentSession(); + let { user } = await getRequiredServerComponentSession(); const isBillingEnabled = await getServerComponentFlag('app_billing'); @@ -24,20 +27,36 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const [subscription, prices] = await Promise.all([ - getSubscriptionByUserId({ userId: user.id }), - getPricesByInterval(), + if (!user.customerId) { + user = await getStripeCustomerByUser(user).then((result) => result.user); + } + + const [subscriptions, prices, individualPrices] = await Promise.all([ + getSubscriptionsByUserId({ userId: user.id }), + getPricesByInterval({ type: 'individual' }), + getPricesByType('individual'), ]); + const individualPriceIds = individualPrices.map(({ id }) => id); + let subscriptionProduct: Stripe.Product | null = null; + const individualUserSubscriptions = subscriptions.filter(({ priceId }) => + individualPriceIds.includes(priceId), + ); + + const subscription = + individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? + individualUserSubscriptions[0]; + if (subscription?.priceId) { subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( () => null, ); } - const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; + const isMissingOrInactiveOrFreePlan = + !subscription || subscription.status === SubscriptionStatus.INACTIVE; return (
diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 548ad108a..f256c6356 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -1,10 +1,10 @@ import { DateTime } from 'luxon'; -import { stripe } from '@documenso/lib/server-only/stripe'; import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; +import { getPricesByType } from '../stripe/get-prices-by-type'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; import { ZLimitsSchema } from './schema'; @@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { let quota = structuredClone(FREE_PLAN_LIMITS); let remaining = structuredClone(FREE_PLAN_LIMITS); - // Since we store details and allow for past due plans we need to check if the subscription is active. - if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) { - const { product } = await stripe.prices - .retrieve(user.Subscription.priceId, { - expand: ['product'], - }) - .catch((err) => { - console.error(err); - throw err; - }); + const activeSubscriptions = user.Subscription.filter( + ({ status }) => status === SubscriptionStatus.ACTIVE, + ); - if (typeof product === 'string') { - throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED); + if (activeSubscriptions.length > 0) { + const individualPrices = await getPricesByType('individual'); + + for (const subscription of activeSubscriptions) { + const price = individualPrices.find((price) => price.id === subscription.priceId); + if (!price || typeof price.product === 'string' || price.product.deleted) { + continue; + } + + const currentQuota = ZLimitsSchema.parse( + 'metadata' in price.product ? price.product.metadata : {}, + ); + + // Use the subscription with the highest quota. + if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) { + quota = currentQuota; + remaining = structuredClone(quota); + } } - - quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {}); - remaining = structuredClone(quota); } const documents = await prisma.document.count({ diff --git a/packages/ee/server-only/stripe/create-customer.ts b/packages/ee/server-only/stripe/create-customer.ts deleted file mode 100644 index 175298d0b..000000000 --- a/packages/ee/server-only/stripe/create-customer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { 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'; -import { User } from '@documenso/prisma/client'; - -export type CreateCustomerOptions = { - user: User; -}; - -export const createCustomer = async ({ user }: CreateCustomerOptions) => { - const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); - - if (existingSubscription) { - throw new Error('User already has a subscription'); - } - - const customer = await stripe.customers.create({ - name: user.name ?? undefined, - email: user.email, - metadata: { - userId: user.id, - }, - }); - - return await prisma.subscription.create({ - data: { - userId: user.id, - customerId: customer.id, - }, - }); -}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts index 11e782966..c85488e6f 100644 --- a/packages/ee/server-only/stripe/get-customer.ts +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -1,4 +1,8 @@ import { stripe } from '@documenso/lib/server-only/stripe'; +import { prisma } from '@documenso/prisma'; +import type { User } from '@documenso/prisma/client'; + +import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; export const getStripeCustomerByEmail = async (email: string) => { const foundStripeCustomers = await stripe.customers.list({ @@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => { return null; } }; + +/** + * Get a stripe customer by user. + * + * Will create a Stripe customer and update the relevant user if one does not exist. + */ +export const getStripeCustomerByUser = async (user: User) => { + if (user.customerId) { + const stripeCustomer = await getStripeCustomerById(user.customerId); + + if (!stripeCustomer) { + throw new Error('Missing Stripe customer'); + } + + return { + user, + stripeCustomer, + }; + } + + let stripeCustomer = await getStripeCustomerByEmail(user.email); + + const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted); + + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ + name: user.name ?? undefined, + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + const updatedUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + customerId: stripeCustomer.id, + }, + }); + + // Sync subscriptions if the customer already exists for back filling the DB + // and local development. + if (isSyncRequired) { + await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => { + console.error(e); + }); + } + + return { + user: updatedUser, + stripeCustomer, + }; +}; + +const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => { + const stripeSubscriptions = await stripe.subscriptions.list({ + customer: stripeCustomerId, + }); + + await Promise.all( + stripeSubscriptions.data.map(async (subscription) => + onSubscriptionUpdated({ + userId, + subscription, + }), + ), + ); +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts index f621425cc..a5578a813 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -1,4 +1,4 @@ -import Stripe from 'stripe'; +import type Stripe from 'stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; @@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product }; export type PriceIntervals = Record; -export const getPricesByInterval = async () => { +export type GetPricesByIntervalOptions = { + /** + * Filter products by their meta 'type' attribute. + */ + type?: 'individual'; +}; + +export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => { let { data: prices } = await stripe.prices.search({ query: `active:'true' type:'recurring'`, expand: ['data.product'], @@ -19,8 +26,10 @@ export const getPricesByInterval = async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const product = price.product as Stripe.Product; + const filter = !type || product.metadata?.type === type; + // Filter out prices for products that are not active. - return product.active; + return product.active && filter; }); const intervals: PriceIntervals = { diff --git a/packages/ee/server-only/stripe/get-prices-by-type.ts b/packages/ee/server-only/stripe/get-prices-by-type.ts new file mode 100644 index 000000000..22124562c --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-type.ts @@ -0,0 +1,11 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export const getPricesByType = async (type: 'individual') => { + const { data: prices } = await stripe.prices.search({ + query: `metadata['type']:'${type}' type:'recurring'`, + expand: ['data.product'], + limit: 100, + }); + + return prices; +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 3058ed261..047de7962 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -75,23 +75,23 @@ export const stripeWebhookHandler = async ( // Finally, attempt to get the user ID from the subscription within the database. if (!userId && customerId) { - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - userId = result.userId; + userId = result.id; } const subscriptionId = @@ -124,23 +124,23 @@ export const stripeWebhookHandler = async ( ? subscription.customer : subscription.customer.id; - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, @@ -182,23 +182,23 @@ export const stripeWebhookHandler = async ( }); } - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, @@ -233,23 +233,23 @@ export const stripeWebhookHandler = async ( }); } - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts index 27ff0cf4d..df1b36f55 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts @@ -1,4 +1,4 @@ -import { Stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; @@ -7,12 +7,9 @@ export type OnSubscriptionDeletedOptions = { }; export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => { - const customerId = - typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id; - await prisma.subscription.update({ where: { - customerId, + planId: subscription.id, }, data: { status: SubscriptionStatus.INACTIVE, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts index dfa22d128..d7ce7b062 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -1,6 +1,6 @@ import { match } from 'ts-pattern'; -import { Stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; @@ -13,9 +13,6 @@ export const onSubscriptionUpdated = async ({ userId, subscription, }: OnSubscriptionUpdatedOptions) => { - 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) @@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({ await prisma.subscription.upsert({ where: { - customerId, + planId: subscription.id, }, create: { - customerId, status: status, planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), userId, + cancelAtPeriodEnd: subscription.cancel_at_period_end, }, update: { - customerId, status: status, planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, }, }); }; diff --git a/packages/lib/server-only/admin/get-users-stats.ts b/packages/lib/server-only/admin/get-users-stats.ts index 13db21d83..09892171a 100644 --- a/packages/lib/server-only/admin/get-users-stats.ts +++ b/packages/lib/server-only/admin/get-users-stats.ts @@ -9,7 +9,9 @@ export const getUsersWithSubscriptionsCount = async () => { return await prisma.user.count({ where: { Subscription: { - status: SubscriptionStatus.ACTIVE, + some: { + status: SubscriptionStatus.ACTIVE, + }, }, }, }); diff --git a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts b/packages/lib/server-only/subscription/get-subscription-by-user-id.ts deleted file mode 100644 index 772134f7c..000000000 --- a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts +++ /dev/null @@ -1,15 +0,0 @@ -'use server'; - -import { prisma } from '@documenso/prisma'; - -export type GetSubscriptionByUserIdOptions = { - userId: number; -}; - -export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => { - return await prisma.subscription.findFirst({ - where: { - userId, - }, - }); -}; diff --git a/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts b/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts new file mode 100644 index 000000000..33f6255bd --- /dev/null +++ b/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts @@ -0,0 +1,15 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type GetSubscriptionsByUserIdOptions = { + userId: number; +}; + +export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => { + return await prisma.subscription.findMany({ + where: { + userId, + }, + }); +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index 46c43b93b..f7db60c85 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -1,9 +1,11 @@ import { hash } from 'bcrypt'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { prisma } from '@documenso/prisma'; import { IdentityProvider } from '@documenso/prisma/client'; import { SALT_ROUNDS } from '../../constants/auth'; +import { getFlag } from '../../universal/get-feature-flag'; export interface CreateUserOptions { name: string; @@ -13,6 +15,8 @@ export interface CreateUserOptions { } export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { + const isBillingEnabled = await getFlag('app_billing'); + const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -25,7 +29,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new Error('User already exists'); } - return await prisma.user.create({ + let user = await prisma.user.create({ data: { name, email: email.toLowerCase(), @@ -34,4 +38,15 @@ export const createUser = async ({ name, email, password, signature }: CreateUse identityProvider: IdentityProvider.DOCUMENSO, }, }); + + if (isBillingEnabled) { + try { + const stripeSession = await getStripeCustomerByUser(user); + user = stripeSession.user; + } catch (e) { + console.error(e); + } + } + + return user; }; diff --git a/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql b/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql new file mode 100644 index 000000000..931815bf0 --- /dev/null +++ b/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - A unique constraint covering the columns `[planId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Made the column `planId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + - Made the column `priceId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + +*/ +-- Custom migration statement +DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL; + +-- DropIndex +DROP INDEX "Subscription_customerId_key"; + +-- DropIndex +DROP INDEX "Subscription_userId_key"; + +-- AlterTable +ALTER TABLE "Subscription" ALTER COLUMN "planId" SET NOT NULL, +ALTER COLUMN "priceId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "customerId" TEXT; +ALTER TABLE "Subscription" DROP COLUMN "customerId"; + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_planId_key" ON "Subscription"("planId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75c175adc..88b79517b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -21,6 +21,7 @@ enum Role { model User { id Int @id @default(autoincrement()) name String? + customerId String? @unique email String @unique emailVerified DateTime? password String? @@ -34,7 +35,7 @@ model User { accounts Account[] sessions Session[] Document Document[] - Subscription Subscription? + Subscription Subscription[] PasswordResetToken PasswordResetToken[] twoFactorSecret String? twoFactorEnabled Boolean @default(false) @@ -72,18 +73,16 @@ enum SubscriptionStatus { model Subscription { id Int @id @default(autoincrement()) status SubscriptionStatus @default(INACTIVE) - planId String? - priceId String? - customerId String + planId String @unique + priceId String periodEnd DateTime? - userId Int @unique + userId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean @default(false) User User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([customerId]) @@index([userId]) } diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 717f13ade..dda8f771b 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -10,8 +10,6 @@ declare namespace NodeJS { NEXT_PRIVATE_ENCRYPTION_KEY: string; 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/render.yaml b/render.yaml index eb213c32c..9fe1bd2e9 100644 --- a/render.yaml +++ b/render.yaml @@ -67,14 +67,10 @@ services: sync: false - key: NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID sync: false - - key: NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID - sync: false # Features - key: NEXT_PUBLIC_POSTHOG_KEY sync: false - - key: NEXT_PUBLIC_POSTHOG_HOST - value: 'https://eu.posthog.com' - key: NEXT_PUBLIC_FEATURE_BILLING_ENABLED sync: false diff --git a/turbo.json b/turbo.json index 36b169a80..d96f681d8 100644 --- a/turbo.json +++ b/turbo.json @@ -40,11 +40,8 @@ "NEXT_PUBLIC_WEBAPP_URL", "NEXT_PUBLIC_MARKETING_URL", "NEXT_PUBLIC_POSTHOG_KEY", - "NEXT_PUBLIC_POSTHOG_HOST", "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",