From 323380d7574c55e0b6da91ced6d02d0c7f44540d Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sat, 9 Dec 2023 10:37:16 +0530 Subject: [PATCH 01/15] feat: env variable to disable signing up --- apps/web/src/app/(unauthenticated)/signin/page.tsx | 14 ++++++++------ apps/web/src/app/(unauthenticated)/signup/page.tsx | 5 +++++ packages/trpc/server/auth-router/router.ts | 7 +++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index a4890d849..0b0333b65 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -13,12 +13,14 @@ export default function SignInPage() { -

- Don't have an account?{' '} - - Sign up - -

+ {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && ( +

+ Don't have an account?{' '} + + Sign up + +

+ )}

Create a new account

diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 59c51ade5..24dd272ee 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -11,6 +11,13 @@ import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; export const authRouter = router({ signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => { try { + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Signups are disabled.', + }); + } + const { name, email, password, signature } = input; const user = await createUser({ name, email, password, signature }); From dbdef79263f5ae3acae594d9df78ea2598987226 Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sat, 9 Dec 2023 10:38:48 +0530 Subject: [PATCH 02/15] chore: remove old env variable from docker compose --- docker/compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/compose.yml b/docker/compose.yml index b427f419c..9d4f0e951 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -33,7 +33,6 @@ services: - SMTP_MAIL_USER=username - SMTP_MAIL_PASSWORD=password - MAIL_FROM=admin@example.com - - NEXT_PUBLIC_ALLOW_SIGNUP=true ports: - 3000:3000 volumes: From 78a1ee2af0ddc3465648e9c21aea351d73101570 Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sat, 9 Dec 2023 11:35:45 +0530 Subject: [PATCH 03/15] feat: disable oauth signup when DISABLE_SIGNUP is true --- apps/web/src/components/forms/signin.tsx | 2 ++ packages/lib/next-auth/auth-options.ts | 10 ++++++++++ packages/lib/next-auth/error-codes.ts | 1 + packages/tsconfig/process-env.d.ts | 2 ++ 4 files changed, 15 insertions(+) diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 0d7dd723f..95dc6f9af 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -24,6 +24,7 @@ const ERROR_MESSAGES: Partial> = { 'This account appears to be using a social login method, please sign in using that method', [ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect', [ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect', + [ErrorCode.SIGNUP_DISABLED]: 'Creating new accounts is currently disabled', }; const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS; @@ -146,6 +147,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { try { await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH }); } catch (err) { + console.error(err); toast({ title: 'An unknown error occurred', description: diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 6d59b0666..1d900c391 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -162,5 +162,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { return session; }, + + async signIn({ user }) { + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + const userData = await getUserByEmail({ email: user.email! }); + + return !!userData; + } else { + return true; + } + }, }, }; diff --git a/packages/lib/next-auth/error-codes.ts b/packages/lib/next-auth/error-codes.ts index c3dfafece..f69206456 100644 --- a/packages/lib/next-auth/error-codes.ts +++ b/packages/lib/next-auth/error-codes.ts @@ -19,4 +19,5 @@ export const ErrorCode = { INCORRECT_PASSWORD: 'INCORRECT_PASSWORD', MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY', MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE', + SIGNUP_DISABLED: 'SIGNUP_DISABLED', } as const; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 717f13ade..7169120c8 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -55,6 +55,8 @@ declare namespace NodeJS { NEXT_PRIVATE_SMTP_FROM_NAME?: string; NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string; + NEXT_PUBLIC_DISABLE_SIGNUP?: string; + /** * Vercel environment variables */ From 3b3987dcf8d55095384a3e74c427fbb7caa147b3 Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sat, 9 Dec 2023 11:43:30 +0530 Subject: [PATCH 04/15] chore: add env to env.example --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index 45c26f6be..dff33d77c 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,8 @@ NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com" # OPTIONAL: Leave blank to disable billing. NEXT_PUBLIC_FEATURE_BILLING_ENABLED= +# OPTIONAL: Leave blank to allow users to signup through /signup page. +NEXT_PUBLIC_DISABLE_SIGNUP= # This is only required for the marketing site # [[REDIS]] From ee5ce78c822f512117f530aa886dc254c602b7c0 Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sat, 9 Dec 2023 11:48:46 +0530 Subject: [PATCH 05/15] chore: remove unused code --- apps/web/src/components/forms/signin.tsx | 1 - packages/lib/next-auth/error-codes.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 95dc6f9af..9694bd581 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -24,7 +24,6 @@ const ERROR_MESSAGES: Partial> = { 'This account appears to be using a social login method, please sign in using that method', [ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect', [ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect', - [ErrorCode.SIGNUP_DISABLED]: 'Creating new accounts is currently disabled', }; const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS; diff --git a/packages/lib/next-auth/error-codes.ts b/packages/lib/next-auth/error-codes.ts index f69206456..c3dfafece 100644 --- a/packages/lib/next-auth/error-codes.ts +++ b/packages/lib/next-auth/error-codes.ts @@ -19,5 +19,4 @@ export const ErrorCode = { INCORRECT_PASSWORD: 'INCORRECT_PASSWORD', MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY', MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE', - SIGNUP_DISABLED: 'SIGNUP_DISABLED', } as const; From 95041fa2e4d8fb58948e56420e79fe010c75d006 Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sat, 9 Dec 2023 12:05:36 +0530 Subject: [PATCH 06/15] fix: build error --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index 36b169a80..9ee878150 100644 --- a/turbo.json +++ b/turbo.json @@ -45,6 +45,7 @@ "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_FREE_PLAN_ID", + "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", From 5c1d30bfbb8e935459885ac871b71c0fcbe7fbdd Mon Sep 17 00:00:00 2001 From: Navindu Amarakoon Date: Sun, 10 Dec 2023 09:23:31 +0530 Subject: [PATCH 07/15] chore: remove console log --- apps/web/src/components/forms/signin.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 9694bd581..0d7dd723f 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -146,7 +146,6 @@ export const SignInForm = ({ className }: SignInFormProps) => { try { await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH }); } catch (err) { - console.error(err); toast({ title: 'An unknown error occurred', description: From 2d931b2c9ba683a6ae64f52b1f8b7b00bcc3706d Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 11 Dec 2023 23:36:54 +0530 Subject: [PATCH 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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", From 682cb37786ea6ac78cdefb88063d000110d02c1d Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 15 Dec 2023 20:41:54 +1100 Subject: [PATCH 13/15] fix: update auth-options --- packages/lib/next-auth/auth-options.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 1d900c391..3b9492807 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -164,13 +164,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }, async signIn({ user }) { + // We do this to stop OAuth providers from creating an account + // when signups are disabled if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { const userData = await getUserByEmail({ email: user.email! }); return !!userData; - } else { - return true; } + + return true; }, }, }; From 83dfe92d7afbe4d5f1c144dd18fc1df97234e228 Mon Sep 17 00:00:00 2001 From: Apoorv Taneja Date: Fri, 15 Dec 2023 18:37:45 +0530 Subject: [PATCH 14/15] refactor: used constant for steps instead of strings (#751) fixes #750 --- .../src/components/(marketing)/widget.tsx | 22 +++++++++++-------- apps/marketing/src/components/constants.ts | 5 +++++ 2 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 apps/marketing/src/components/constants.ts diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 7fd4aaa49..c1ceadafe 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -26,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { claimPlan } from '~/api/claim-plan/fetcher'; +import { STEP } from '../constants'; import { FormErrorMessage } from '../form/form-error-message'; const ZWidgetFormSchema = z @@ -48,13 +49,16 @@ const ZWidgetFormSchema = z export type TWidgetFormSchema = z.infer; +type StepKeys = keyof typeof STEP; +type StepValues = (typeof STEP)[StepKeys]; + export type WidgetProps = HTMLAttributes; export const Widget = ({ className, children, ...props }: WidgetProps) => { const { toast } = useToast(); const event = usePlausible(); - const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL'); + const [step, setStep] = useState(STEP.EMAIL); const [showSigningDialog, setShowSigningDialog] = useState(false); const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState(null); @@ -81,11 +85,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { const signatureText = watch('signatureText'); const stepsRemaining = useMemo(() => { - if (step === 'NAME') { + if (step === STEP.NAME) { return 2; } - if (step === 'SIGN') { + if (step === STEP.EMAIL) { return 1; } @@ -93,16 +97,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { }, [step]); const onNextStepClick = () => { - if (step === 'EMAIL') { - setStep('NAME'); + if (step === STEP.EMAIL) { + setStep(STEP.NAME); setTimeout(() => { document.querySelector('#name')?.focus(); }, 0); } - if (step === 'NAME') { - setStep('SIGN'); + if (step === STEP.NAME) { + setStep(STEP.SIGN); setTimeout(() => { document.querySelector('#signatureText')?.focus(); @@ -226,7 +230,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { type="button" className="bg-primary h-full w-14 rounded" disabled={!field.value || !!errors.email?.message} - onClick={() => step === 'EMAIL' && onNextStepClick()} + onClick={() => step === STEP.EMAIL && onNextStepClick()} > Next @@ -238,7 +242,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { - {(step === 'NAME' || step === 'SIGN') && ( + {(step === STEP.NAME || step === STEP.SIGN) && ( Date: Sat, 16 Dec 2023 00:30:52 +0530 Subject: [PATCH 15/15] fixed z-index --- packages/ui/primitives/toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/primitives/toast.tsx b/packages/ui/primitives/toast.tsx index 8b8355323..89616c132 100644 --- a/packages/ui/primitives/toast.tsx +++ b/packages/ui/primitives/toast.tsx @@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<