From d33304e3ad54b4bedcf715568694185ac3e96048 Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Fri, 14 Jun 2024 18:47:33 +0530 Subject: [PATCH] feat: pricing & payments v2 for formbricks cloud (#2648) Co-authored-by: pandeymangg Co-authored-by: Matthias Nannt --- .../workflows/cron-reportUsageToStripe.yml | 24 - .../images/team-settings-menu.webp | Bin 0 -> 19532 bytes .../environments/[environmentId]/layout.tsx | 4 +- .../components/FileUploadQuestionForm.tsx | 2 +- .../[personId]/components/ActivitySection.tsx | 3 +- .../[environmentId]/actions/page.tsx | 3 +- .../components/EnvironmentLayout.tsx | 17 + .../components/PosthogIdentify.tsx | 27 +- .../environments/[environmentId]/layout.tsx | 4 +- .../(organization)/billing/actions.ts | 46 +- .../billing/components/PricingTable.tsx | 472 +++++++----------- .../(organization)/billing/layout.tsx | 5 +- .../settings/(organization)/billing/page.tsx | 12 +- .../(organization)/billing/unlimited/page.tsx | 29 -- .../billing/unlimited99/page.tsx | 29 -- .../components/OrganizationSettingsNavbar.tsx | 2 +- .../components/EditOrganizationName.tsx | 96 ++++ apps/web/app/api/cron/report-usage/route.ts | 71 --- .../app/api/v1/(legacy)/js/sync/lib/sync.ts | 33 +- .../client/[environmentId]/actions/route.ts | 3 +- .../app/sync/[userId]/route.ts | 98 ++-- .../[environmentId]/app/sync/lib/posthog.ts | 24 - .../storage/lib/uploadPrivateFile.ts | 10 +- .../[environmentId]/storage/local/route.ts | 14 +- .../client/[environmentId]/storage/route.ts | 7 +- .../[environmentId]/website/sync/route.ts | 41 +- .../api/v1/management/storage/local/route.ts | 2 +- apps/web/next.config.mjs | 1 - .../data-migration.ts | 183 +++++++ .../20240613070218_pricing_v2/migration.sql | 5 + packages/database/package.json | 1 + packages/database/schema.prisma | 7 +- packages/ee/billing/api/stripe-webhook.ts | 28 +- .../handlers/checkout-session-completed.ts | 92 +--- .../ee/billing/handlers/invoice-finalized.ts | 32 ++ .../subscription-created-or-updated.ts | 226 +++------ .../billing/handlers/subscription-deleted.ts | 57 +-- packages/ee/billing/lib/constants.ts | 98 +++- .../ee/billing/lib/create-subscription.ts | 199 +++----- packages/ee/billing/lib/downgrade-plan.ts | 23 - .../billing/lib/is-subscription-cancelled.ts | 53 ++ .../ee/billing/lib/remove-subscription.ts | 132 ----- packages/ee/billing/lib/report-usage.ts | 68 --- packages/ee/lib/service.ts | 35 +- packages/lib/constants.ts | 47 +- packages/lib/organization/service.ts | 61 ++- packages/lib/posthogServer.ts | 27 + packages/lib/response/service.ts | 32 +- packages/lib/response/tests/response.test.ts | 22 +- packages/lib/storage/service.ts | 15 +- .../lib/survey/tests/__mock__/survey.mock.ts | 23 +- packages/types/organizations.ts | 31 +- packages/ui/BillingSlider/index.tsx | 2 +- packages/ui/ConfirmationModal/index.tsx | 19 +- packages/ui/DevEnvironmentBanner/index.tsx | 2 +- packages/ui/LimitsReachedBanner/index.tsx | 50 ++ packages/ui/PricingCard/index.tsx | 358 +++++++------ turbo.json | 1 + 58 files changed, 1497 insertions(+), 1511 deletions(-) delete mode 100644 .github/workflows/cron-reportUsageToStripe.yml create mode 100644 apps/docs/app/global/access-roles/images/team-settings-menu.webp delete mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited/page.tsx delete mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited99/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx delete mode 100644 apps/web/app/api/cron/report-usage/route.ts delete mode 100644 apps/web/app/api/v1/client/[environmentId]/app/sync/lib/posthog.ts create mode 100644 packages/database/data-migrations/20240613070218_pricing_v2/data-migration.ts create mode 100644 packages/database/migrations/20240613070218_pricing_v2/migration.sql create mode 100644 packages/ee/billing/handlers/invoice-finalized.ts delete mode 100644 packages/ee/billing/lib/downgrade-plan.ts create mode 100644 packages/ee/billing/lib/is-subscription-cancelled.ts delete mode 100644 packages/ee/billing/lib/remove-subscription.ts delete mode 100644 packages/ee/billing/lib/report-usage.ts create mode 100644 packages/ui/LimitsReachedBanner/index.tsx diff --git a/.github/workflows/cron-reportUsageToStripe.yml b/.github/workflows/cron-reportUsageToStripe.yml deleted file mode 100644 index e40a1cb4d8..0000000000 --- a/.github/workflows/cron-reportUsageToStripe.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Cron - Report usage to Stripe - -on: - workflow_dispatch: - # "Scheduled workflows run on the latest commit on the default or base branch." - # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule - # schedule: - # This will run the job at 20:00 UTC every day of every month. - # - cron: "0 20 * * *" -jobs: - cron-reportUsageToStripe: - env: - APP_URL: ${{ secrets.APP_URL }} - CRON_SECRET: ${{ secrets.CRON_SECRET }} - runs-on: ubuntu-latest - steps: - - name: cURL request - if: ${{ env.APP_URL && env.CRON_SECRET }} - run: | - curl ${{ env.APP_URL }}/api/cron/report-usage \ - -X POST \ - -H 'x-api-key: ${{ env.CRON_SECRET }}' \ - -H 'Cache-Control: no-cache' \ - --fail diff --git a/apps/docs/app/global/access-roles/images/team-settings-menu.webp b/apps/docs/app/global/access-roles/images/team-settings-menu.webp new file mode 100644 index 0000000000000000000000000000000000000000..63794142c6b8c46dd57f2da2a86011c71eb98ace GIT binary patch literal 19532 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 apXoqb-)?|ZOWXMnpLn&41AqVk0000|DevU~ literal 0 HcmV?d00001 diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx index 4a6d5757a4..e18d4ac5fe 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx @@ -40,9 +40,7 @@ const EnvLayout = async ({ children, params }) => { environmentId={params.environmentId} organizationId={organization.id} organizationName={organization.name} - inAppSurveyBillingStatus={organization.billing.features.inAppSurvey.status} - linkSurveyBillingStatus={organization.billing.features.linkSurvey.status} - userTargetingBillingStatus={organization.billing.features.userTargeting.status} + organizationBilling={organization.billing} /> diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx index 44aaf8d999..4960a7c3eb 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx @@ -103,7 +103,7 @@ export const FileUploadQuestionForm = ({ return 10; } - if (billingInfo.features.linkSurvey.status === "active") { + if (billingInfo.plan !== "free") { // 1GB in MB return 1024; } diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivitySection.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivitySection.tsx index c9e11b79d6..e83da42e48 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivitySection.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivitySection.tsx @@ -1,4 +1,5 @@ import { ActivityTimeline } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivityTimeline"; +import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service"; import { getActionsByPersonId } from "@formbricks/lib/action/service"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; @@ -19,7 +20,7 @@ export const ActivitySection = async ({ // On Formbricks Cloud only render the timeline if the user targeting feature is booked const isUserTargetingEnabled = IS_FORMBRICKS_CLOUD - ? organization.billing.features.userTargeting.status === "active" + ? await getAdvancedTargetingPermission(organization) : true; const [environment, actions] = await Promise.all([ diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx index e48bc1cd02..7856b4aabd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx @@ -3,6 +3,7 @@ import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/act import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading"; import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal"; import { Metadata } from "next"; +import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service"; import { getActionClasses } from "@formbricks/lib/actionClass/service"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; @@ -25,7 +26,7 @@ const Page = async ({ params }) => { // On Formbricks Cloud only render the timeline if the user targeting feature is booked const isUserTargetingEnabled = IS_FORMBRICKS_CLOUD - ? organization.billing.features.userTargeting.status === "active" + ? await getAdvancedTargetingPermission(organization) : true; const renderAddActionButton = () => ( diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index ae39e443cd..615a625537 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -6,12 +6,15 @@ import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { + getMonthlyActiveOrganizationPeopleCount, + getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId, getOrganizationsByUserId, } from "@formbricks/lib/organization/service"; import { getProducts } from "@formbricks/lib/product/service"; import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner"; import { ErrorComponent } from "@formbricks/ui/ErrorComponent"; +import { LimitsReachedBanner } from "@formbricks/ui/LimitsReachedBanner"; interface EnvironmentLayoutProps { environmentId: string; @@ -42,9 +45,23 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const isMultiOrgEnabled = await getIsMultiOrgEnabled(); + const [peopleCount, responseCount] = await Promise.all([ + getMonthlyActiveOrganizationPeopleCount(organization.id), + getMonthlyOrganizationResponseCount(organization.id), + ]); + return (
+ + {IS_FORMBRICKS_CLOUD && ( + + )} +
{ const posthog = usePostHog(); @@ -43,22 +39,13 @@ export const PosthogIdentify = ({ if (organizationId) { posthog.group("organization", organizationId, { name: organizationName, - inAppSurveyBillingStatus, - linkSurveyBillingStatus, - userTargetingBillingStatus, + plan: organizationBilling?.plan, + responseLimit: organizationBilling?.limits.monthly.responses, + miuLimit: organizationBilling?.limits.monthly.miu, }); } } - }, [ - posthog, - session.user, - environmentId, - organizationId, - organizationName, - inAppSurveyBillingStatus, - linkSurveyBillingStatus, - userTargetingBillingStatus, - ]); + }, [posthog, session.user, environmentId, organizationId, organizationName, organizationBilling]); return null; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index 5bba2f112b..c19fd1ab05 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -33,9 +33,7 @@ const EnvLayout = async ({ children, params }) => { environmentId={params.environmentId} organizationId={organization.id} organizationName={organization.name} - inAppSurveyBillingStatus={organization.billing.features.inAppSurvey.status} - linkSurveyBillingStatus={organization.billing.features.linkSurvey.status} - userTargetingBillingStatus={organization.billing.features.userTargeting.status} + organizationBilling={organization.billing} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/actions.ts index 61ee776f25..4134905161 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/actions.ts @@ -1,20 +1,21 @@ "use server"; import { getServerSession } from "next-auth"; -import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants"; import { createCustomerPortalSession } from "@formbricks/ee/billing/lib/create-customer-portal-session"; import { createSubscription } from "@formbricks/ee/billing/lib/create-subscription"; -import { removeSubscription } from "@formbricks/ee/billing/lib/remove-subscription"; +import { isSubscriptionCancelled } from "@formbricks/ee/billing/lib/is-subscription-cancelled"; import { authOptions } from "@formbricks/lib/authOptions"; +import { STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants"; import { WEBAPP_URL } from "@formbricks/lib/constants"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; import { getOrganization } from "@formbricks/lib/organization/service"; -import { AuthorizationError } from "@formbricks/types/errors"; +import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; export const upgradePlanAction = async ( organizationId: string, environmentId: string, - priceLookupKeys: StripePriceLookupKeys[] + priceLookupKey: STRIPE_PRICE_LOOKUP_KEYS ) => { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); @@ -22,7 +23,17 @@ export const upgradePlanAction = async ( const isAuthorized = await canUserAccessOrganization(session.user.id, organizationId); if (!isAuthorized) throw new AuthorizationError("Not authorized"); - const subscriptionSession = await createSubscription(organizationId, environmentId, priceLookupKeys); + const organization = await getOrganization(organizationId); + if (!organization) { + throw new ResourceNotFoundError("organization", organizationId); + } + + const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId); + if (membership?.role !== "owner") { + throw new AuthorizationError("Only organization owner can upgrade plan"); + } + + const subscriptionSession = await createSubscription(organizationId, environmentId, priceLookupKey); return subscriptionSession; }; @@ -35,8 +46,18 @@ export const manageSubscriptionAction = async (organizationId: string, environme if (!isAuthorized) throw new AuthorizationError("Not authorized"); const organization = await getOrganization(organizationId); - if (!organization || !organization.billing.stripeCustomerId) + if (!organization) { + throw new ResourceNotFoundError("organization", organizationId); + } + + if (!organization.billing.stripeCustomerId) { throw new AuthorizationError("You do not have an associated Stripe CustomerId"); + } + + const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId); + if (membership?.role !== "owner") { + throw new AuthorizationError("Only organization owner can upgrade plan"); + } const sessionUrl = await createCustomerPortalSession( organization.billing.stripeCustomerId, @@ -45,18 +66,17 @@ export const manageSubscriptionAction = async (organizationId: string, environme return sessionUrl; }; -export const removeSubscriptionAction = async ( - organizationId: string, - environmentId: string, - priceLookupKeys: StripePriceLookupKeys[] -) => { +export const isSubscriptionCancelledAction = async (organizationId: string) => { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); const isAuthorized = await canUserAccessOrganization(session.user.id, organizationId); if (!isAuthorized) throw new AuthorizationError("Not authorized"); - const removedSubscription = await removeSubscription(organizationId, environmentId, priceLookupKeys); + const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId); + if (membership?.role !== "owner") { + throw new AuthorizationError("Only organization owner can upgrade plan"); + } - return removedSubscription.url; + return await isSubscriptionCancelled(organizationId); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/components/PricingTable.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/components/PricingTable.tsx index bd22a255ce..284daf07e6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/components/PricingTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/components/PricingTable.tsx @@ -1,18 +1,19 @@ "use client"; import { + isSubscriptionCancelledAction, manageSubscriptionAction, - removeSubscriptionAction, upgradePlanAction, } from "@/app/(app)/environments/[environmentId]/settings/(organization)/billing/actions"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; -import { ProductFeatureKeys, StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants"; -import { TOrganization } from "@formbricks/types/organizations"; -import { AlertDialog } from "@formbricks/ui/AlertDialog"; +import { CLOUD_PRICING_DATA } from "@formbricks/ee/billing/lib/constants"; +import { cn } from "@formbricks/lib/cn"; +import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations"; +import { Badge } from "@formbricks/ui/Badge"; +import { BillingSlider } from "@formbricks/ui/BillingSlider"; import { Button } from "@formbricks/ui/Button"; -import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner"; import { PricingCard } from "@formbricks/ui/PricingCard"; interface PricingTableProps { @@ -20,341 +21,220 @@ interface PricingTableProps { environmentId: string; peopleCount: number; responseCount: number; - userTargetingFreeMtu: number; - inAppSurveyFreeResponses: number; + stripePriceLookupKeys: { + STARTUP_MONTHLY: string; + STARTUP_YEARLY: string; + SCALE_MONTHLY: string; + SCALE_YEARLY: string; + }; + productFeatureKeys: { + FREE: string; + STARTUP: string; + SCALE: string; + ENTERPRISE: string; + }; } export const PricingTable = ({ - organization, environmentId, + organization, peopleCount, + productFeatureKeys, responseCount, - userTargetingFreeMtu, - inAppSurveyFreeResponses: appSurveyFreeResponses, + stripePriceLookupKeys, }: PricingTableProps) => { - const router = useRouter(); - const [loadingCustomerPortal, setLoadingCustomerPortal] = useState(false); - const [upgradingPlan, setUpgradingPlan] = useState(false); - const [openDeleteModal, setOpenDeleteModal] = useState(false); - const [activeLookupKey, setActiveLookupKey] = useState(); + const [planPeriod, setPlanPeriod] = useState( + organization.billing.period ?? "monthly" + ); - const openCustomerPortal = async () => { - setLoadingCustomerPortal(true); - const sessionUrl = await manageSubscriptionAction(organization.id, environmentId); - router.push(sessionUrl); - setLoadingCustomerPortal(false); + const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => { + setPlanPeriod(period); }; - const upgradePlan = async (priceLookupKeys: StripePriceLookupKeys[]) => { + const router = useRouter(); + const [cancellingOn, setCancellingOn] = useState(null); + + useEffect(() => { + const checkSubscriptionStatus = async () => { + const isCancelled = await isSubscriptionCancelledAction(organization.id); + if (isCancelled) { + setCancellingOn(isCancelled.date); + } + }; + checkSubscriptionStatus(); + }, [organization.id]); + + const openCustomerPortal = async () => { + const sessionUrl = await manageSubscriptionAction(organization.id, environmentId); + router.push(sessionUrl); + }; + + const upgradePlan = async (priceLookupKey) => { try { - setUpgradingPlan(true); const { status, newPlan, url } = await upgradePlanAction( organization.id, environmentId, - priceLookupKeys + priceLookupKey ); - setUpgradingPlan(false); + if (status != 200) { throw new Error("Something went wrong"); } if (!newPlan) { toast.success("Plan upgraded successfully"); - router.refresh(); } else if (newPlan && url) { router.push(url); } else { throw new Error("Something went wrong"); } } catch (err) { + console.log({ err }); toast.error("Unable to upgrade plan"); - } finally { - setUpgradingPlan(false); } }; - const handleUnsubscribe = async (e, lookupKey) => { - try { - e.preventDefault(); - setActiveLookupKey(lookupKey); - setOpenDeleteModal(true); - } catch (err) { - toast.error("Unable to open delete modal"); + const onUpgrade = async (planId: string) => { + if (planId === "scale") { + await upgradePlan( + planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY + ); + return; + } + + if (planId === "startup") { + await upgradePlan( + planPeriod === "monthly" + ? stripePriceLookupKeys.STARTUP_MONTHLY + : stripePriceLookupKeys.STARTUP_YEARLY + ); + return; + } + + if (planId === "enterprise") { + window.location.href = "https://cal.com/johannes/license"; + return; + } + + if (planId === "free") { + toast.error("Everybody has the free plan by default!"); + return; } }; - const handleDeleteSubscription = async () => { - try { - if (!activeLookupKey) throw new Error("No active lookup key"); - await removeSubscriptionAction(organization.id, environmentId, [activeLookupKey]); - router.refresh(); - toast.success("Subscription deleted successfully"); - } catch (err) { - toast.error("Unable to delete subscription"); - } finally { - setOpenDeleteModal(false); - } - }; - - const coreAndWebAppSurveyFeatures = [ - { - title: "Remove Formbricks Branding", - comingSoon: false, - }, - { - title: "Organization Roles", - comingSoon: false, - }, - { - title: "250 responses / month free", - comingSoon: false, - unlimited: false, - }, - { - title: "$0.15 / responses afterwards", - comingSoon: false, - unlimited: false, - }, - { - title: "Multi-Language Surveys", - comingSoon: false, - }, - { - title: "Unlimited Responses", - unlimited: true, - }, - ]; - - const userTargetingFeatures = [ - { - title: "2.500 identified users / month free", - comingSoon: false, - unlimited: false, - }, - { - title: "$0.01 / identified user afterwards", - comingSoon: false, - unlimited: false, - }, - { - title: "Advanced Targeting", - comingSoon: false, - }, - { - title: "Unlimited User Identification", - unlimited: true, - }, - { - title: "Reusable Segments", - comingSoon: false, - unlimited: true, - }, - ]; - - const linkSurveysFeatures = [ - { - title: "Remove Formbricks Branding", - comingSoon: false, - }, - { - title: "File Uploads up to 1 GB", - comingSoon: false, - }, - { - title: "Custom Domain", - comingSoon: true, - }, - { - title: "Multi-Language Surveys", - comingSoon: false, - }, - ]; + const responsesUnlimitedCheck = + organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null; + const peopleUnlimitedCheck = + organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null; return ( -
- {loadingCustomerPortal && ( -
- -
- )} -
- {organization.billing.stripeCustomerId ? ( -
- - -
- ) : ( - <> - {/*
-
+
+
+

+ Current Plan: {organization.billing.plan} + {cancellingOn && ( + - - - - - - - -
-

- Launch Special: -
Go Unlimited! Forever! -

-

- Get access to all pro features and unlimited responses + identified users for a flat fee of - only $99/month. -

- - This deal ends on 31st of October 2023 at 11:59 PM PST. - -

-
-
+ )} +

+ + {organization.billing.stripeCustomerId && organization.billing.plan === "free" && ( +
-
*/} + )} +
-
-
+

Responses

+ {organization.billing.limits.monthly.responses && ( + - - - - - - - -
-

- Unlock the full power of Formbricks, for free. -

-

- Add a credit card, get access to all features. -
- You will not be charged until you exceed the free tier limits. -

-
+ )} + + {responsesUnlimitedCheck && }
- - )} - { - if (organization.billing.features.inAppSurvey.unlimited) { - return feature.unlimited !== false; - } else { - return feature.unlimited !== true; - } - })} - perMetricCharge={0.15} - loading={upgradingPlan} - onUpgrade={() => upgradePlan([StripePriceLookupKeys.inAppSurvey])} - onUnsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.inAppSurvey])} - /> +
+

Monthly Identified Users

+ {organization.billing.limits.monthly.miu && ( + + )} - upgradePlan([StripePriceLookupKeys.linkSurvey])} - onUnsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.linkSurvey])} - /> + {peopleUnlimitedCheck && } +
+
+
- { - if (organization.billing.features.userTargeting.unlimited) { - return feature.unlimited !== false; - } else { - return feature.unlimited !== true; - } - })} - perMetricCharge={0.01} - loading={upgradingPlan} - onUpgrade={() => upgradePlan([StripePriceLookupKeys.userTargeting])} - onUnsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.userTargeting])} - /> +
+
+
handleMonthlyToggle("monthly")}> + Monthly +
+
handleMonthlyToggle("yearly")}> + Yearly +
+
+
+ +
- - { - setOpenDeleteModal(false); - }} - mainText="Your subscription for this product will be canceled at the End of this Month! After that, you won't have access to the Paid features anymore" - onConfirm={() => handleDeleteSubscription()} - confirmBtnLabel="Unsubscribe" - /> -
+ ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/layout.tsx index 95c681d73f..8b2803fa2f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/layout.tsx @@ -28,10 +28,9 @@ const BillingLayout = async ({ children, params }) => { } const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isAdmin, isOwner } = getAccessFlags(currentUserMembership?.role); - const isPricingDisabled = !isOwner && !isAdmin; + const { isOwner } = getAccessFlags(currentUserMembership?.role); - return <>{!isPricingDisabled ? <>{children} : }; + return <>{isOwner ? <>{children} : }; }; export default BillingLayout; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.tsx index f02305336e..7487ee8770 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.tsx @@ -1,11 +1,8 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { getServerSession } from "next-auth"; import { authOptions } from "@formbricks/lib/authOptions"; -import { - IS_FORMBRICKS_CLOUD, - PRICING_APPSURVEYS_FREE_RESPONSES, - PRICING_USERTARGETING_FREE_MTU, -} from "@formbricks/lib/constants"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { PRODUCT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMonthlyActiveOrganizationPeopleCount, @@ -44,13 +41,14 @@ const Page = async ({ params }) => { activeId="billing" /> + ); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited/page.tsx deleted file mode 100644 index 0cf9d99c36..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect } from "next/navigation"; -import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { upgradePlanAction } from "../actions"; - -const Page = async ({ params }) => { - const organization = await getOrganizationByEnvironmentId(params.environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - - const { status, newPlan, url } = await upgradePlanAction(organization.id, params.environmentId, [ - StripePriceLookupKeys.inAppSurveyUnlimitedPlan90, - StripePriceLookupKeys.linkSurveyUnlimitedPlan19, - StripePriceLookupKeys.userTargetingUnlimitedPlan90, - ]); - if (status != 200) { - throw new Error("Something went wrong"); - } - if (newPlan && url) { - redirect(url); - } else if (!newPlan) { - redirect(`/billing-confirmation?environmentId=${params.environmentId}`); - } else { - throw new Error("Something went wrong"); - } -}; - -export default Page; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited99/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited99/page.tsx deleted file mode 100644 index 847cb5e6fe..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/unlimited99/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect } from "next/navigation"; -import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { upgradePlanAction } from "../actions"; - -const Page = async ({ params }) => { - const organization = await getOrganizationByEnvironmentId(params.environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - - const { status, newPlan, url } = await upgradePlanAction(organization.id, params.environmentId, [ - StripePriceLookupKeys.inAppSurveyUnlimitedPlan33, - StripePriceLookupKeys.linkSurveyUnlimitedPlan33, - StripePriceLookupKeys.userTargetingUnlimitedPlan33, - ]); - if (status != 200) { - throw new Error("Something went wrong"); - } - if (newPlan && url) { - redirect(url); - } else if (!newPlan) { - redirect(`/billing-confirmation?environmentId=${params.environmentId}`); - } else { - throw new Error("Something went wrong"); - } -}; - -export default Page; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index 2d9c962115..a5cf55893d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -35,7 +35,7 @@ export const OrganizationSettingsNavbar = ({ label: "Billing & Plan", href: `/environments/${environmentId}/settings/billing`, icon: , - hidden: !isFormbricksCloud || isPricingDisabled, + hidden: !isFormbricksCloud || !isOwner, current: pathname?.includes("/billing"), }, { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx new file mode 100644 index 0000000000..92c8f6c8ed --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { SubmitHandler, useForm, useWatch } from "react-hook-form"; +import toast from "react-hot-toast"; + +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganization } from "@formbricks/types/organizations"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; + +interface EditOrganizationNameForm { + name: string; +} + +interface EditOrganizationNameProps { + environmentId: string; + organization: TOrganization; + membershipRole?: TMembershipRole; +} + +export const EditOrganizationName = ({ organization, membershipRole }: EditOrganizationNameProps) => { + const router = useRouter(); + const { + register, + control, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + name: organization.name, + }, + }); + const [isUpdatingOrganization, setIsUpdatingOrganization] = useState(false); + const { isViewer } = getAccessFlags(membershipRole); + + const organizationName = useWatch({ + control, + name: "name", + }); + + const isOrganizationNameInputEmpty = !organizationName?.trim(); + const currentOrganizationName = organizationName?.trim().toLowerCase() ?? ""; + const previousOrganizationName = organization?.name?.trim().toLowerCase() ?? ""; + + const handleUpdateOrganizationName: SubmitHandler = async (data) => { + try { + data.name = data.name.trim(); + setIsUpdatingOrganization(true); + await updateOrganizationNameAction(organization.id, data.name); + + setIsUpdatingOrganization(false); + toast.success("Organization name updated successfully."); + + router.refresh(); + } catch (err) { + setIsUpdatingOrganization(false); + toast.error(`Error: ${err.message}`); + } + }; + + return isViewer ? ( +

You are not authorized to perform this action.

+ ) : ( +
+ + + + {errors?.name?.message &&

{errors.name.message}

} + + +
+ ); +}; diff --git a/apps/web/app/api/cron/report-usage/route.ts b/apps/web/app/api/cron/report-usage/route.ts deleted file mode 100644 index f53cdf662c..0000000000 --- a/apps/web/app/api/cron/report-usage/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { headers } from "next/headers"; -import { ProductFeatureKeys } from "@formbricks/ee/billing/lib/constants"; -import { reportUsageToStripe } from "@formbricks/ee/billing/lib/report-usage"; -import { CRON_SECRET, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { - getMonthlyActiveOrganizationPeopleCount, - getMonthlyOrganizationResponseCount, - getOrganizationsWithPaidPlan, -} from "@formbricks/lib/organization/service"; -import { TOrganization } from "@formbricks/types/organizations"; - -const reportOrganizationUsage = async (organization: TOrganization) => { - const stripeCustomerId = organization.billing.stripeCustomerId; - if (!stripeCustomerId) { - return; - } - - if (!IS_FORMBRICKS_CLOUD) { - return; - } - - let calculateResponses = - organization.billing.features.inAppSurvey.status !== "inactive" && - !organization.billing.features.inAppSurvey.unlimited; - let calculatePeople = - organization.billing.features.userTargeting.status !== "inactive" && - !organization.billing.features.userTargeting.unlimited; - - if (!calculatePeople && !calculateResponses) { - return; - } - let people = await getMonthlyActiveOrganizationPeopleCount(organization.id); - let responses = await getMonthlyOrganizationResponseCount(organization.id); - - if (calculatePeople) { - await reportUsageToStripe( - stripeCustomerId, - people, - ProductFeatureKeys.userTargeting, - Math.floor(Date.now() / 1000) - ); - } - if (calculateResponses) { - await reportUsageToStripe( - stripeCustomerId, - responses, - ProductFeatureKeys.inAppSurvey, - Math.floor(Date.now() / 1000) - ); - } -}; - -export const POST = async (): Promise => { - const headersList = headers(); - const apiKey = headersList.get("x-api-key"); - - if (!apiKey || apiKey !== CRON_SECRET) { - return responses.notAuthenticatedResponse(); - } - - try { - const organizationsWithPaidPlan = await getOrganizationsWithPaidPlan(); - await Promise.all(organizationsWithPaidPlan.map(reportOrganizationUsage)); - - return responses.successResponse({}, true); - } catch (error) { - console.error(error); - return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true); - } -}; diff --git a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts index 10f7eacf2c..03d6987192 100644 --- a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts +++ b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts @@ -1,10 +1,5 @@ import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { - IS_FORMBRICKS_CLOUD, - MAU_LIMIT, - PRICING_APPSURVEYS_FREE_RESPONSES, - PRICING_USERTARGETING_FREE_MTU, -} from "@formbricks/lib/constants"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { reverseTranslateSurvey } from "@formbricks/lib/i18n/reverseTranslation"; import { @@ -52,13 +47,13 @@ export const getUpdatedState = async (environmentId: string, personId?: string): // check if Monthly Active Users limit is reached if (IS_FORMBRICKS_CLOUD) { - const hasUserTargetingSubscription = - organization?.billing?.features.userTargeting.status && - ["active", "canceled"].includes(organization?.billing?.features.userTargeting.status); const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id); - const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU; + const monthlyMiuLimit = organization.billing.limits.monthly.miu; + + const isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit; + if (isMauLimitReached) { - const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`; + const errorMessage = `Monthly Active Users limit reached in ${environmentId}`; if (!personId) { // don't allow new people or sessions throw new Error(errorMessage); @@ -78,24 +73,20 @@ export const getUpdatedState = async (environmentId: string, personId?: string): person = { id: "legacy" }; } } - // check if App Survey limit is reached - let isAppSurveyLimitReached = false; + + // check if responses limit is reached + let isResponsesLimitReached = false; if (IS_FORMBRICKS_CLOUD) { - const hasAppSurveySubscription = - organization?.billing?.features.inAppSurvey.status && - ["active", "canceled"].includes(organization?.billing?.features.inAppSurvey.status); const monthlyResponsesCount = await getMonthlyOrganizationResponseCount(organization.id); - isAppSurveyLimitReached = - IS_FORMBRICKS_CLOUD && - !hasAppSurveySubscription && - monthlyResponsesCount >= PRICING_APPSURVEYS_FREE_RESPONSES; + const monthlyResponseLimit = organization.billing.limits.monthly.responses; + isResponsesLimitReached = monthlyResponseLimit !== null && monthlyResponsesCount >= monthlyResponseLimit; } const isPerson = Object.keys(person).length > 0; let surveys; - if (isAppSurveyLimitReached) { + if (isResponsesLimitReached) { surveys = []; } else if (isPerson) { surveys = await getSyncSurveys(environmentId, (person as TPerson).id); diff --git a/apps/web/app/api/v1/client/[environmentId]/actions/route.ts b/apps/web/app/api/v1/client/[environmentId]/actions/route.ts index 1e03c36d39..7098c42709 100644 --- a/apps/web/app/api/v1/client/[environmentId]/actions/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/actions/route.ts @@ -1,5 +1,6 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service"; import { createAction } from "@formbricks/lib/action/service"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; @@ -36,7 +37,7 @@ export const POST = async (req: Request, context: Context): Promise => // Formbricks Cloud: Make sure environment is part of a paid plan if (IS_FORMBRICKS_CLOUD) { const organization = await getOrganizationByEnvironmentId(context.params.environmentId); - if (!organization || organization.billing.features.userTargeting.status !== "active") { + if (!organization || !(await getAdvancedTargetingPermission(organization))) { // temporary return status code 200 to avoid CORS issues; will be changed to 400 in the future return responses.successResponse({}, true); //return responses.badRequestResponse("Storing actions is only possible in a paid plan", {}, true); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts index 8488510c5d..76640f6956 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts @@ -1,4 +1,3 @@ -import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/app/sync/lib/posthog"; import { replaceAttributeRecall, replaceAttributeRecallInLegacySurveys, @@ -8,11 +7,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest, userAgent } from "next/server"; import { getActionClasses } from "@formbricks/lib/actionClass/service"; import { getAttributes } from "@formbricks/lib/attribute/service"; -import { - IS_FORMBRICKS_CLOUD, - PRICING_APPSURVEYS_FREE_RESPONSES, - PRICING_USERTARGETING_FREE_MTU, -} from "@formbricks/lib/constants"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service"; import { getMonthlyActiveOrganizationPeopleCount, @@ -20,6 +15,7 @@ import { getOrganizationByEnvironmentId, } from "@formbricks/lib/organization/service"; import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; import { getSyncSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service"; @@ -87,32 +83,39 @@ export const GET = async ( // check if MAU limit is reached let isMauLimitReached = false; - let isInAppSurveyLimitReached = false; + let isMonthlyResponsesLimitReached = false; + if (IS_FORMBRICKS_CLOUD) { - // check userTargeting subscription - const hasUserTargetingSubscription = - organization.billing.features.userTargeting.status && - ["active", "canceled"].includes(organization.billing.features.userTargeting.status); const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id); - isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU; - // check inAppSurvey subscription - const hasInAppSurveySubscription = - organization.billing.features.inAppSurvey.status && - ["active", "canceled"].includes(organization.billing.features.inAppSurvey.status); + const monthlyResponseLimit = organization.billing.limits.monthly.responses; + const monthlyMiuLimit = organization.billing.limits.monthly.miu; + + isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit; + const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); - isInAppSurveyLimitReached = - !hasInAppSurveySubscription && currentResponseCount >= PRICING_APPSURVEYS_FREE_RESPONSES; + isMonthlyResponsesLimitReached = + monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; } let person = await getPersonByUserId(environmentId, userId); - if (!isMauLimitReached) { - // MAU limit not reached: create person if not exists - if (!person) { - person = await createPerson(environmentId, userId); - } - } else { + + if (isMauLimitReached) { // MAU limit reached: check if person has been active this month; only continue if person has been active - await sendFreeLimitReachedEventToPosthogBiWeekly(environmentId, "userTargeting"); + + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + monthly: { + miu: organization.billing.limits.monthly.miu, + responses: organization.billing.limits.monthly.responses, + }, + }, + }); + } catch (err) { + console.error(`Error sending plan limits reached event to Posthog: ${err}`); + } + const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`; if (!person) { // if it's a new person and MAU limit is reached, throw an error @@ -121,21 +124,38 @@ export const GET = async ( true, "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" ); - } else { - // check if person has been active this month - const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id); - if (!isPersonMonthlyActive) { - return responses.tooManyRequestsResponse( - errorMessage, - true, - "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" - ); - } + } + + // check if person has been active this month + const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id); + if (!isPersonMonthlyActive) { + return responses.tooManyRequestsResponse( + errorMessage, + true, + "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" + ); + } + } else { + // MAU limit not reached: create person if not exists + if (!person) { + person = await createPerson(environmentId, userId); } } - if (isInAppSurveyLimitReached) { - await sendFreeLimitReachedEventToPosthogBiWeekly(environmentId, "inAppSurvey"); + if (isMonthlyResponsesLimitReached) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + monthly: { + miu: organization.billing.limits.monthly.miu, + responses: organization.billing.limits.monthly.responses, + }, + }, + }); + } catch (err) { + console.error(`Error sending plan limits reached event to Posthog: ${err}`); + } } const [surveys, actionClasses, product] = await Promise.all([ @@ -167,7 +187,7 @@ export const GET = async ( // creating state object let state: TJsAppStateSync | TJsAppLegacyStateSync = { - surveys: !isInAppSurveyLimitReached + surveys: !isMonthlyResponsesLimitReached ? transformedSurveys.map((survey) => replaceAttributeRecall(survey, attributes)) : [], actionClasses, @@ -188,7 +208,7 @@ export const GET = async ( ); state = { - surveys: !isInAppSurveyLimitReached + surveys: !isMonthlyResponsesLimitReached ? transformedSurveys.map((survey) => replaceAttributeRecallInLegacySurveys(survey, attributes)) : [], person, diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/posthog.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/posthog.ts deleted file mode 100644 index 93473f9d0e..0000000000 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/posthog.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { cache } from "@formbricks/lib/cache"; -import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; - -export const sendFreeLimitReachedEventToPosthogBiWeekly = ( - environmentId: string, - plan: "inAppSurvey" | "userTargeting" -): Promise => - cache( - async () => { - try { - await capturePosthogEnvironmentEvent(environmentId, "free limit reached", { - plan, - }); - return "success"; - } catch (error) { - console.error(error); - throw error; - } - }, - [`sendFreeLimitReachedEventToPosthogBiWeekly-${plan}-${environmentId}`], - { - revalidate: 60 * 60 * 24 * 15, // 15 days - } - )(); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts index 323f265264..d884b5527d 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts @@ -5,13 +5,19 @@ export const uploadPrivateFile = async ( fileName: string, environmentId: string, fileType: string, - plan: "free" | "pro" + isBiggerFileUploadAllowed: boolean = false ) => { const accessType = "private"; // private files are only accessible by the user who has access to the environment // if s3 is not configured, we'll upload to a local folder named uploads try { - const signedUrlResponse = await getUploadSignedUrl(fileName, environmentId, fileType, accessType, plan); + const signedUrlResponse = await getUploadSignedUrl( + fileName, + environmentId, + fileType, + accessType, + isBiggerFileUploadAllowed + ); return responses.successResponse({ ...signedUrlResponse, diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts index a65172830e..5ab070b22f 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts @@ -4,6 +4,7 @@ import { responses } from "@/app/lib/api/response"; import { headers } from "next/headers"; import { NextRequest } from "next/server"; +import { getBiggerUploadFileSizePermission } from "@formbricks/ee/lib/service"; import { ENCRYPTION_KEY, UPLOADS_DIR } from "@formbricks/lib/constants"; import { validateLocalSignedUrl } from "@formbricks/lib/crypto"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; @@ -107,13 +108,18 @@ export const POST = async (req: NextRequest, context: Context): Promise= PRICING_APPSURVEYS_FREE_RESPONSES; - if (isInAppSurveyLimitReached) { - await sendFreeLimitReachedEventToPosthogBiWeekly(environmentId, "inAppSurvey"); + const monthlyResponseLimit = organization.billing.limits.monthly.responses; + + isWebsiteSurveyResponseLimitReached = + monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; + + if (isWebsiteSurveyResponseLimitReached) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { monthly: { responses: monthlyResponseLimit, miu: null } }, + }); + } catch (error) { + console.error(`Error sending plan limits reached event to Posthog: ${error}`); + } } } @@ -118,7 +119,7 @@ export const GET = async ( // Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey. let transformedSurveys: TLegacySurvey[] | TSurvey[] = filteredSurveys; let state: TJsWebsiteStateSync | TJsWebsiteLegacyStateSync = { - surveys: !isInAppSurveyLimitReached ? transformedSurveys : [], + surveys: !isWebsiteSurveyResponseLimitReached ? transformedSurveys : [], actionClasses, product: updatedProduct, }; @@ -136,7 +137,7 @@ export const GET = async ( ); state = { - surveys: isInAppSurveyLimitReached ? [] : transformedSurveys, + surveys: isWebsiteSurveyResponseLimitReached ? [] : transformedSurveys, noCodeActionClasses, product: updatedProduct, }; diff --git a/apps/web/app/api/v1/management/storage/local/route.ts b/apps/web/app/api/v1/management/storage/local/route.ts index 15e1fb9cf1..ad483a0354 100644 --- a/apps/web/app/api/v1/management/storage/local/route.ts +++ b/apps/web/app/api/v1/management/storage/local/route.ts @@ -88,7 +88,7 @@ export const POST = async (req: NextRequest): Promise => { const bytes = await file.arrayBuffer(); const fileBuffer = Buffer.from(bytes); - await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR, true); + await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR); return responses.successResponse({ message: "File uploaded successfully", diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 276603964c..8f574ec31e 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -21,7 +21,6 @@ const nextConfig = { output: "standalone", serverExternalPackages: ["@aws-sdk"], experimental: { - //instrumentationHook: true, outputFileTracingIncludes: { "app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"], }, diff --git a/packages/database/data-migrations/20240613070218_pricing_v2/data-migration.ts b/packages/database/data-migrations/20240613070218_pricing_v2/data-migration.ts new file mode 100644 index 0000000000..36169b80f3 --- /dev/null +++ b/packages/database/data-migrations/20240613070218_pricing_v2/data-migration.ts @@ -0,0 +1,183 @@ +import { Prisma, PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +type TSubscriptionStatusLegacy = "active" | "cancelled" | "inactive"; + +interface TSubscriptionLegacy { + status: TSubscriptionStatusLegacy; + unlimited: boolean; +} + +interface TFeatures { + inAppSurvey: TSubscriptionLegacy; + linkSurvey: TSubscriptionLegacy; + userTargeting: TSubscriptionLegacy; + multiLanguage: TSubscriptionLegacy; +} + +interface TOrganizationBillingLegacy { + stripeCustomerId: string | null; + features: TFeatures; +} + +const now = new Date(); +const firstOfMonthUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + +async function main() { + await prisma.$transaction( + async (tx) => { + // const startTime = Date.now(); + console.log("Starting data migration for pricing v2..."); + + // Free tier + const orgsWithoutBilling = await tx.organization.findMany({ + where: { + billing: { + path: ["stripeCustomerId"], + equals: Prisma.AnyNull, + }, + }, + }); + + console.log( + `Found ${orgsWithoutBilling.length} organizations without billing information. Moving them to free plan...` + ); + + const freePlanPromises = orgsWithoutBilling + // if the organization has a plan, it means it's already migrated + .filter((org) => !(org.billing.plan && typeof org.billing.plan === "string")) + .map((organization) => + tx.organization.update({ + where: { + id: organization.id, + }, + data: { + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + monthly: { + responses: 500, + miu: 1000, + }, + }, + periodStart: new Date(), + }, + }, + }) + ); + + await Promise.all(freePlanPromises); + console.log("Moved all organizations without billing to free plan"); + + const orgsWithBilling = await tx.organization.findMany({ + where: { + billing: { + path: ["stripeCustomerId"], + not: Prisma.AnyNull, + }, + }, + }); + + console.log(`Found ${orgsWithBilling.length} organizations with billing information`); + + for (const org of orgsWithBilling) { + const billing = org.billing as TOrganizationBillingLegacy; + + console.log("Current organization: ", org.id); + + // @ts-expect-error + if (billing.plan && typeof billing.plan === "string") { + // no migration needed, already following the latest schema + continue; + } + + if ( + (billing.features.linkSurvey?.status === "active" && billing.features.linkSurvey?.unlimited) || + (billing.features.inAppSurvey?.status === "active" && billing.features.inAppSurvey?.unlimited) || + (billing.features.userTargeting?.status === "active" && billing.features.userTargeting?.unlimited) + ) { + await tx.organization.update({ + where: { + id: org.id, + }, + data: { + billing: { + plan: "enterprise", + period: "monthly", + limits: { + monthly: { + responses: null, + miu: null, + }, + }, + stripeCustomerId: billing.stripeCustomerId, + periodStart: firstOfMonthUTC, + }, + }, + }); + + console.log("Updated org with unlimited to enterprise plan: ", org.id); + continue; + } + + if (billing.features.linkSurvey.status === "active") { + await tx.organization.update({ + where: { + id: org.id, + }, + data: { + billing: { + plan: "startup", + period: "monthly", + limits: { + monthly: { + responses: 2000, + miu: 2500, + }, + }, + stripeCustomerId: billing.stripeCustomerId, + periodStart: firstOfMonthUTC, + }, + }, + }); + + console.log("Updated org with linkSurvey to pro plan: ", org.id); + continue; + } + + await tx.organization.update({ + where: { + id: org.id, + }, + data: { + billing: { + plan: "free", + period: "monthly", + limits: { + monthly: { + responses: 500, + miu: 1000, + }, + }, + stripeCustomerId: billing.stripeCustomerId, + periodStart: new Date(), + }, + }, + }); + + console.log("Updated org to free plan: ", org.id); + } + }, + { timeout: 50000 } + ); +} + +main() + .catch(async (e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => await prisma.$disconnect()); diff --git a/packages/database/migrations/20240613070218_pricing_v2/migration.sql b/packages/database/migrations/20240613070218_pricing_v2/migration.sql new file mode 100644 index 0000000000..e74f934a9b --- /dev/null +++ b/packages/database/migrations/20240613070218_pricing_v2/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Organization" ALTER COLUMN "billing" DROP DEFAULT; + +-- CreateIndex +CREATE INDEX "Response_personId_created_at_idx" ON "Response"("personId", "created_at"); diff --git a/packages/database/package.json b/packages/database/package.json index e7a53b8814..186e8ed015 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -33,6 +33,7 @@ "data-migration:refactor-actions": "ts-node ./data-migrations/20240501111944_refactors_actions_and_removes_inline_triggers/data-migration.ts", "data-migration:mls-welcomeCard-fix": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration-welcomeCard-fix.ts", "data-migration:v2.0": "pnpm data-migration:mls && pnpm data-migration:styling && pnpm data-migration:styling-fix && pnpm data-migration:website-surveys && pnpm data-migration:userId && pnpm data-migration:mls-welcomeCard-fix && pnpm data-migration:refactor-actions", + "data-migration:pricing-v2": "dotenv -e ../../.env -- ts-node ./data-migrations/20240613070218_pricing_v2/data-migration.ts", "data-migration:extended-noCodeActions": "ts-node ./data-migrations/20240524053239_extends_no_code_action_schema/data-migration.ts", "data-migration:v2.1": "pnpm data-migration:extended-noCodeActions", "data-migration:product-config": "ts-node ./data-migrations/20240612115151_adds_product_config/data-migration.ts" diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 0c1987fd84..77a6269952 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -135,6 +135,7 @@ model Response { @@unique([surveyId, singleUseId]) @@index([surveyId, createdAt]) // to determine monthly response count + @@index([personId, createdAt]) // to determine monthly identified users (persons) @@index([surveyId]) } @@ -458,9 +459,9 @@ model Organization { name String memberships Membership[] products Product[] - /// @zod.custom(imports.ZOrganizationBilling) - /// [OrganizationBilling] - billing Json @default("{\"stripeCustomerId\": null, \"features\": {\"inAppSurvey\": {\"status\": \"inactive\", \"unlimited\": false}, \"linkSurvey\": {\"status\": \"inactive\", \"unlimited\": false}, \"userTargeting\": {\"status\": \"inactive\", \"unlimited\": false}}}") + /// @zod.custom(imports.ZTeamBilling) + /// [TeamBilling] + billing Json invites Invite[] } diff --git a/packages/ee/billing/api/stripe-webhook.ts b/packages/ee/billing/api/stripe-webhook.ts index efd21624de..91f96eb3a5 100644 --- a/packages/ee/billing/api/stripe-webhook.ts +++ b/packages/ee/billing/api/stripe-webhook.ts @@ -2,23 +2,21 @@ import Stripe from "stripe"; import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; import { handleCheckoutSessionCompleted } from "../handlers/checkout-session-completed"; -import { handleSubscriptionUpdatedOrCreated } from "../handlers/subscription-created-or-updated"; +import { handleInvoiceFinalized } from "../handlers/invoice-finalized"; +import { handleSubscriptionCreatedOrUpdated } from "../handlers/subscription-created-or-updated"; import { handleSubscriptionDeleted } from "../handlers/subscription-deleted"; +const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + apiVersion: STRIPE_API_VERSION, +}); + +const webhookSecret: string = env.STRIPE_WEBHOOK_SECRET!; + export const webhookHandler = async (requestBody: string, stripeSignature: string) => { let event: Stripe.Event; - if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET) { - console.error("Stripe is not enabled, skipping webhook"); - return { status: 400, message: "Stripe is not enabled, skipping webhook" }; - } - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - try { - event = stripe.webhooks.constructEvent(requestBody, stripeSignature, env.STRIPE_WEBHOOK_SECRET); + event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; if (err! instanceof Error) console.error(err); @@ -27,11 +25,13 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin if (event.type === "checkout.session.completed") { await handleCheckoutSessionCompleted(event); + } else if (event.type === "invoice.finalized") { + await handleInvoiceFinalized(event); } else if ( - event.type === "customer.subscription.updated" || - event.type === "customer.subscription.created" + event.type === "customer.subscription.created" || + event.type === "customer.subscription.updated" ) { - await handleSubscriptionUpdatedOrCreated(event); + await handleSubscriptionCreatedOrUpdated(event); } else if (event.type === "customer.subscription.deleted") { await handleSubscriptionDeleted(event); } diff --git a/packages/ee/billing/handlers/checkout-session-completed.ts b/packages/ee/billing/handlers/checkout-session-completed.ts index 2fd8618b1d..65d360bc51 100644 --- a/packages/ee/billing/handlers/checkout-session-completed.ts +++ b/packages/ee/billing/handlers/checkout-session-completed.ts @@ -1,97 +1,41 @@ import Stripe from "stripe"; import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; -import { - getMonthlyActiveOrganizationPeopleCount, - getMonthlyOrganizationResponseCount, - getOrganization, - updateOrganization, -} from "@formbricks/lib/organization/service"; -import { ProductFeatureKeys, StripePriceLookupKeys, StripeProductNames } from "../lib/constants"; -import { reportUsage } from "../lib/report-usage"; +import { getOrganization } from "@formbricks/lib/organization/service"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; + +const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + // https://github.com/stripe/stripe-node#configuration + apiVersion: STRIPE_API_VERSION, +}); export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - const checkoutSession = event.data.object as Stripe.Checkout.Session; - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); + if (!checkoutSession.metadata || !checkoutSession.metadata.organizationId) + throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id); const stripeSubscriptionObject = await stripe.subscriptions.retrieve( checkoutSession.subscription as string ); - const { customer: stripeCustomer } = (await stripe.checkout.sessions.retrieve(checkoutSession.id, { expand: ["customer"], })) as { customer: Stripe.Customer }; - const organization = await getOrganization(stripeSubscriptionObject.metadata.organizationId); - if (!organization) throw new Error("Organization not found."); - const updatedFeatures = organization.billing.features; + const organization = await getOrganization(checkoutSession.metadata!.organizationId); + if (!organization) + throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId); - for (const item of stripeSubscriptionObject.items.data) { - const product = await stripe.products.retrieve(item.price.product as string); - - switch (product.name) { - case StripeProductNames.inAppSurvey: - updatedFeatures.inAppSurvey.status = "active"; - const isInAppSurveyUnlimited = - item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan90 || - item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan33; - if (isInAppSurveyUnlimited) { - updatedFeatures.inAppSurvey.unlimited = true; - } else { - const countForOrganization = await getMonthlyOrganizationResponseCount(organization.id); - await reportUsage( - stripeSubscriptionObject.items.data, - ProductFeatureKeys.inAppSurvey, - countForOrganization - ); - } - break; - - case StripeProductNames.linkSurvey: - updatedFeatures.linkSurvey.status = "active"; - const isLinkSurveyUnlimited = - item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan19 || - item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan33; - if (isLinkSurveyUnlimited) { - updatedFeatures.linkSurvey.unlimited = true; - } - break; - - case StripeProductNames.userTargeting: - updatedFeatures.userTargeting.status = "active"; - const isUserTargetingUnlimited = - item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan90 || - item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan33; - if (isUserTargetingUnlimited) { - updatedFeatures.userTargeting.unlimited = true; - } else { - const countForOrganization = await getMonthlyActiveOrganizationPeopleCount(organization.id); - - await reportUsage( - stripeSubscriptionObject.items.data, - ProductFeatureKeys.userTargeting, - countForOrganization - ); - } - break; - } - } - - await updateOrganization(organization.id, { - billing: { - stripeCustomerId: stripeCustomer.id, - features: updatedFeatures, + await stripe.subscriptions.update(stripeSubscriptionObject.id, { + metadata: { + organizationId: organization.id, + responses: checkoutSession.metadata.responses, + miu: checkoutSession.metadata.miu, }, }); await stripe.customers.update(stripeCustomer.id, { name: organization.name, - metadata: { organization: organization.id }, + metadata: { organizationId: organization.id }, invoice_settings: { default_payment_method: stripeSubscriptionObject.default_payment_method as string, }, diff --git a/packages/ee/billing/handlers/invoice-finalized.ts b/packages/ee/billing/handlers/invoice-finalized.ts new file mode 100644 index 0000000000..77b7cfd779 --- /dev/null +++ b/packages/ee/billing/handlers/invoice-finalized.ts @@ -0,0 +1,32 @@ +import Stripe from "stripe"; +import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; + +export const handleInvoiceFinalized = async (event: Stripe.Event) => { + const invoice = event.data.object as Stripe.Invoice; + + const stripeSubscriptionDetails = invoice.subscription_details; + const organizationId = stripeSubscriptionDetails?.metadata?.organizationId; + + if (!organizationId) { + throw new Error("No organizationId found in subscription"); + } + + const organization = await getOrganization(organizationId); + if (!organization) { + throw new Error("Organization not found"); + } + + const periodStartTimestamp = invoice.lines.data[0].period.start; + const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date(); + + await updateOrganization(organizationId, { + ...organization, + billing: { + ...organization.billing, + stripeCustomerId: invoice.customer as string, + periodStart, + }, + }); + + return { status: 200, message: "success" }; +}; diff --git a/packages/ee/billing/handlers/subscription-created-or-updated.ts b/packages/ee/billing/handlers/subscription-created-or-updated.ts index 7d4e3fc020..02688e6f60 100644 --- a/packages/ee/billing/handlers/subscription-created-or-updated.ts +++ b/packages/ee/billing/handlers/subscription-created-or-updated.ts @@ -1,52 +1,29 @@ import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; +import { PRODUCT_FEATURE_KEYS, STRIPE_API_VERSION } from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; +import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { - getMonthlyActiveOrganizationPeopleCount, - getMonthlyOrganizationResponseCount, - getOrganization, - updateOrganization, -} from "@formbricks/lib/organization/service"; -import { ProductFeatureKeys, StripePriceLookupKeys, StripeProductNames } from "../lib/constants"; -import { reportUsage } from "../lib/report-usage"; + TOrganizationBillingPeriod, + TOrganizationBillingPlan, + ZOrganizationBillingPeriod, + ZOrganizationBillingPlan, +} from "@formbricks/types/organizations"; -const isProductScheduled = async ( - scheduledSubscriptions: Stripe.SubscriptionSchedule[], - productName: StripeProductNames -) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - - for (const scheduledSub of scheduledSubscriptions) { - if (scheduledSub.phases && scheduledSub.phases.length > 0) { - const firstPhase = scheduledSub.phases[0]; - for (const item of firstPhase.items) { - const price = item.price as string; - const priceObject = await stripe.prices.retrieve(price); - const product = await stripe.products.retrieve(priceObject.product as string); - if (product.name === productName) { - return true; - } - } - } - } - return false; -}; - -export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); +const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + // https://github.com/stripe/stripe-node#configuration + apiVersion: STRIPE_API_VERSION, +}); +export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) => { const stripeSubscriptionObject = event.data.object as Stripe.Subscription; + console.log("subscription created or updated: ", JSON.stringify(stripeSubscriptionObject, null, 2)); const organizationId = stripeSubscriptionObject.metadata.organizationId; - if (stripeSubscriptionObject.status !== "active") { + if ( + !["active", "trialing"].includes(stripeSubscriptionObject.status) || + stripeSubscriptionObject.cancel_at_period_end + ) { return; } @@ -56,127 +33,86 @@ export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) => } const organization = await getOrganization(organizationId); - if (!organization) throw new Error("Organization not found."); - const updatedFeatures = organization.billing.features; + if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId); - let scheduledSubscriptions: Stripe.SubscriptionSchedule[] = []; - if (stripeSubscriptionObject.cancel_at_period_end) { - const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({ - customer: organization.billing.stripeCustomerId!, - }); - scheduledSubscriptions = allScheduledSubscriptions.data.filter( - (scheduledSub) => scheduledSub.status === "not_started" + const subscriptionPrice = stripeSubscriptionObject.items.data[0].price; + const product = await stripe.products.retrieve(subscriptionPrice.product as string); + + if (!product) + throw new ResourceNotFoundError( + "Product not found", + stripeSubscriptionObject.items.data[0].price.product.toString() ); + + let period: TOrganizationBillingPeriod = "monthly"; + const periodParsed = ZOrganizationBillingPeriod.safeParse(subscriptionPrice.metadata.period); + if (periodParsed.success) { + period = periodParsed.data; } - for (const item of stripeSubscriptionObject.items.data) { - const product = await stripe.products.retrieve(item.price.product as string); + let updatedBillingPlan: TOrganizationBillingPlan = organization.billing.plan; - switch (product.name) { - case StripeProductNames.inAppSurvey: - const isInAppSurveyUnlimited = - item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan90 || - item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan33; + let responses: number | null = null; + let miu: number | null = null; - // If the current subscription is scheduled to cancel at the end of the period - if (stripeSubscriptionObject.cancel_at_period_end) { - // Check if the organization has a scheduled subscription for this product - const isInAppProductScheduled = await isProductScheduled( - scheduledSubscriptions, - StripeProductNames.inAppSurvey - ); + if (product.metadata.responses === "unlimited") { + responses = null; + } else if (parseInt(product.metadata.responses) > 0) { + responses = parseInt(product.metadata.responses); + } else { + console.error("Invalid responses metadata in product: ", product.metadata.responses); + throw new Error("Invalid responses metadata in product"); + } - // If the organization does not have a scheduled subscription for this product, we cancel the feature - if (organization.billing.features.inAppSurvey.status === "active" && !isInAppProductScheduled) { - organization.billing.features.inAppSurvey.status = "cancelled"; - } + if (product.metadata.miu === "unlimited") { + miu = null; + } else if (parseInt(product.metadata.miu) > 0) { + miu = parseInt(product.metadata.miu); + } else { + console.error("Invalid miu metadata in product: ", product.metadata.miu); + throw new Error("Invalid miu metadata in product"); + } - // If the organization has a scheduled subscription for this product, we don't cancel the feature - if (isInAppProductScheduled) { - updatedFeatures.inAppSurvey.status = "active"; - } - } else { - // Current subscription is not scheduled to cancel at the end of the period - updatedFeatures.inAppSurvey.status = "active"; - // If the current subscription is unlimited, we don't need to report usage - if (isInAppSurveyUnlimited) { - updatedFeatures.inAppSurvey.unlimited = true; - } else { - const countForOrganization = await getMonthlyOrganizationResponseCount(organization.id); - await reportUsage( - stripeSubscriptionObject.items.data, - ProductFeatureKeys.inAppSurvey, - countForOrganization - ); - } - } - break; + const plan = ZOrganizationBillingPlan.parse(product.metadata.plan); - case StripeProductNames.linkSurvey: - const isLinkSurveyUnlimited = - item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan19 || - item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan33; + switch (plan) { + case PRODUCT_FEATURE_KEYS.FREE: + updatedBillingPlan = PRODUCT_FEATURE_KEYS.STARTUP; + break; - if (stripeSubscriptionObject.cancel_at_period_end) { - const isLinkSurveyScheduled = await isProductScheduled( - scheduledSubscriptions, - StripeProductNames.linkSurvey - ); + case PRODUCT_FEATURE_KEYS.STARTUP: + updatedBillingPlan = PRODUCT_FEATURE_KEYS.STARTUP; + break; - if (organization.billing.features.linkSurvey.status === "active" && !isLinkSurveyScheduled) { - organization.billing.features.linkSurvey.status = "cancelled"; - } + case PRODUCT_FEATURE_KEYS.SCALE: + updatedBillingPlan = PRODUCT_FEATURE_KEYS.SCALE; + break; - if (isLinkSurveyScheduled) { - updatedFeatures.linkSurvey.status = "active"; - } - } else { - updatedFeatures.linkSurvey.status = "active"; - if (isLinkSurveyUnlimited) { - updatedFeatures.inAppSurvey.unlimited = true; - } - } - break; - - case StripeProductNames.userTargeting: - const isUserTargetingUnlimited = - item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan90 || - item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan33; - - if (stripeSubscriptionObject.cancel_at_period_end) { - const isUserTargetingScheduled = await isProductScheduled( - scheduledSubscriptions, - StripeProductNames.userTargeting - ); - - if (organization.billing.features.userTargeting.status === "active" && !isUserTargetingScheduled) { - organization.billing.features.userTargeting.status = "cancelled"; - } - - if (isUserTargetingScheduled) { - updatedFeatures.userTargeting.status = "active"; - } - } else { - updatedFeatures.userTargeting.status = "active"; - if (isUserTargetingUnlimited) { - updatedFeatures.userTargeting.unlimited = true; - } else { - const countForOrganization = await getMonthlyActiveOrganizationPeopleCount(organization.id); - await reportUsage( - stripeSubscriptionObject.items.data, - ProductFeatureKeys.userTargeting, - countForOrganization - ); - } - } - break; - } + case PRODUCT_FEATURE_KEYS.ENTERPRISE: + updatedBillingPlan = PRODUCT_FEATURE_KEYS.ENTERPRISE; + break; } await updateOrganization(organizationId, { billing: { + ...organization.billing, stripeCustomerId: stripeSubscriptionObject.customer as string, - features: updatedFeatures, + plan: updatedBillingPlan, + period, + limits: { + monthly: { + responses, + miu, + }, + }, + }, + }); + + await stripe.customers.update(stripeSubscriptionObject.customer as string, { + name: organization.name, + metadata: { organizationId: organization.id }, + invoice_settings: { + default_payment_method: stripeSubscriptionObject.default_payment_method as string, }, }); }; diff --git a/packages/ee/billing/handlers/subscription-deleted.ts b/packages/ee/billing/handlers/subscription-deleted.ts index e40d1272fe..7f816ad97c 100644 --- a/packages/ee/billing/handlers/subscription-deleted.ts +++ b/packages/ee/billing/handlers/subscription-deleted.ts @@ -1,17 +1,9 @@ import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; +import { BILLING_LIMITS, PRODUCT_FEATURE_KEYS } from "@formbricks/lib/constants"; import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; -import { ProductFeatureKeys, StripeProductNames } from "../lib/constants"; -import { unsubscribeCoreAndAppSurveyFeatures, unsubscribeLinkSurveyProFeatures } from "../lib/downgrade-plan"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; export const handleSubscriptionDeleted = async (event: Stripe.Event) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - const stripeSubscriptionObject = event.data.object as Stripe.Subscription; const organizationId = stripeSubscriptionObject.metadata.organizationId; if (!organizationId) { @@ -20,45 +12,20 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => { } const organization = await getOrganization(organizationId); - if (!organization) throw new Error("Organization not found."); - - const updatedFeatures = organization.billing.features; - - for (const item of stripeSubscriptionObject.items.data) { - const product = await stripe.products.retrieve(item.price.product as string); - - switch (product.name) { - case StripeProductNames.inAppSurvey: - updatedFeatures[ProductFeatureKeys.inAppSurvey as keyof typeof organization.billing.features].status = - "inactive"; - updatedFeatures[ - ProductFeatureKeys.inAppSurvey as keyof typeof organization.billing.features - ].unlimited = false; - await unsubscribeCoreAndAppSurveyFeatures(organizationId); - break; - case StripeProductNames.linkSurvey: - updatedFeatures[ProductFeatureKeys.linkSurvey as keyof typeof organization.billing.features].status = - "inactive"; - updatedFeatures[ - ProductFeatureKeys.linkSurvey as keyof typeof organization.billing.features - ].unlimited = false; - await unsubscribeLinkSurveyProFeatures(organizationId); - break; - case StripeProductNames.userTargeting: - updatedFeatures[ - ProductFeatureKeys.userTargeting as keyof typeof organization.billing.features - ].status = "inactive"; - updatedFeatures[ - ProductFeatureKeys.userTargeting as keyof typeof organization.billing.features - ].unlimited = false; - break; - } - } + if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId); await updateOrganization(organizationId, { billing: { ...organization.billing, - features: updatedFeatures, + plan: PRODUCT_FEATURE_KEYS.FREE, + limits: { + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + periodStart: new Date(), + period: "monthly", }, }); }; diff --git a/packages/ee/billing/lib/constants.ts b/packages/ee/billing/lib/constants.ts index 1dfcac0801..ee7180d5a1 100644 --- a/packages/ee/billing/lib/constants.ts +++ b/packages/ee/billing/lib/constants.ts @@ -1,23 +1,75 @@ -export enum ProductFeatureKeys { - inAppSurvey = "inAppSurvey", - linkSurvey = "linkSurvey", - userTargeting = "userTargeting", -} - -export enum StripeProductNames { - inAppSurvey = "Formbricks In App Survey", - linkSurvey = "Formbricks Link Survey", - userTargeting = "Formbricks User Identification", -} -export enum StripePriceLookupKeys { - inAppSurvey = "inAppSurvey", - linkSurvey = "linkSurvey", - userTargeting = "userTargeting", - inAppSurveyUnlimitedPlan90 = "survey-unlimited-03112023", - linkSurveyUnlimitedPlan19 = "linkSurvey-unlimited-03112023", - userTargetingUnlimitedPlan90 = "userTargeting-unlimited-03112023", - - inAppSurveyUnlimitedPlan33 = "survey-unlimited-33-27022024", - linkSurveyUnlimitedPlan33 = "linkSurvey-unlimited-33-27022024", - userTargetingUnlimitedPlan33 = "userTargeting-unlimited-33-27022024", -} +export const CLOUD_PRICING_DATA = { + plans: [ + { + name: "Free", + id: "free", + featured: false, + description: "Unlimited Surveys, Team Members, and more.", + price: { monthly: "€0", yearly: "€0" }, + mainFeatures: [ + "Unlimited Surveys", + "Unlimited Team Members", + "500 Monthly Responses", + "1.000 Monthly Identified Users", + "Website Surveys", + "App Surveys", + "Unlimited Apps & Websites", + "Link Surveys (Shareable)", + "Email Embedded Surveys", + "Logic Jumps, Hidden Fields, Recurring Surveys, etc.", + "API & Webhooks", + "All Integrations", + "All surveying features", + ], + href: "https://app.formbricks.com/auth/signup?plan=free", + }, + { + name: "Startup", + id: "startup", + featured: false, + description: "Everything in Free with additional features.", + price: { monthly: "€59", yearly: "€49" }, + mainFeatures: [ + "Everything in Free", + "Remove Branding", + "2.000 Monthly Responses", + "2.500 Monthly Identified Users", + "Email Support", + ], + href: "https://app.formbricks.com/auth/signup?plan=startup", + }, + { + name: "Scale", + id: "scale", + featured: true, + description: "Advanced features for scaling your business.", + price: { monthly: "€199", yearly: "€179" }, + mainFeatures: [ + "Everything in Startup", + "Team Access Roles", + "Multi-Language Surveys", + "Advanced Targeting", + "Priority Support", + "5.000 Monthly Responses", + "20.000 Monthly Identified Users", + ], + href: "https://app.formbricks.com/auth/signup?plan=scale", + }, + { + name: "Enterprise", + id: "enterprise", + featured: false, + description: "Premium support and custom limits.", + price: { monthly: "Say Hi!", yearly: "Say Hi!" }, + mainFeatures: [ + "Everything in Scale", + "Custom MIU limit", + "Premium support with SLAs", + "Uptime SLA (99%)", + "Customer Success Manager", + "Technical Onboarding", + ], + href: "https://cal.com/johannes/enterprise-cloud", + }, + ], +}; diff --git a/packages/ee/billing/lib/create-subscription.ts b/packages/ee/billing/lib/create-subscription.ts index 5ee416048c..5670238997 100644 --- a/packages/ee/billing/lib/create-subscription.ts +++ b/packages/ee/billing/lib/create-subscription.ts @@ -1,172 +1,90 @@ import Stripe from "stripe"; import { STRIPE_API_VERSION, WEBAPP_URL } from "@formbricks/lib/constants"; +import { STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; import { getOrganization } from "@formbricks/lib/organization/service"; -import type { StripePriceLookupKeys } from "./constants"; -export const getFirstOfNextMonthTimestamp = (): number => { - const nextMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1); - return Math.floor(nextMonth.getTime() / 1000); -}; +const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + apiVersion: STRIPE_API_VERSION, +}); export const createSubscription = async ( organizationId: string, environmentId: string, - priceLookupKeys: StripePriceLookupKeys[] + priceLookupKey: STRIPE_PRICE_LOOKUP_KEYS ) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - try { const organization = await getOrganization(organizationId); if (!organization) throw new Error("Organization not found."); - const isNewOrganization = + let isNewOrganization = !organization.billing.stripeCustomerId || !(await stripe.customers.retrieve(organization.billing.stripeCustomerId)); - const lineItems: { price: string; quantity?: number }[] = []; - - const prices = ( + const priceObject = ( await stripe.prices.list({ - lookup_keys: priceLookupKeys, + lookup_keys: [priceLookupKey], + expand: ["data.product"], }) - ).data; - if (!prices) throw new Error("Price not found."); + ).data[0]; - prices.forEach((price) => { - lineItems.push({ - price: price.id, - ...(price.billing_scheme === "per_unit" && { quantity: 1 }), - }); - }); + if (!priceObject) throw new Error("Price not found"); + const responses = parseInt((priceObject.product as Stripe.Product).metadata.responses); + const miu = parseInt((priceObject.product as Stripe.Product).metadata.miu); + + const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { + mode: "subscription", + line_items: [ + { + price: priceObject.id, + quantity: 1, + }, + ], + success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`, + cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`, + allow_promotion_codes: true, + subscription_data: { + metadata: { organizationId }, + trial_period_days: 30, + }, + metadata: { organizationId, responses, miu }, + automatic_tax: { enabled: true }, + payment_method_data: { allow_redisplay: "always" }, + ...(!isNewOrganization && { + customer: organization.billing.stripeCustomerId ?? undefined, + }), + }; // if the organization has never purchased a plan then we just create a new session and store their stripe customer id if (isNewOrganization) { - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - line_items: lineItems, - success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`, - cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`, - allow_promotion_codes: true, - subscription_data: { - billing_cycle_anchor: getFirstOfNextMonthTimestamp(), - metadata: { organizationId }, - }, - automatic_tax: { enabled: true }, - }); + const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams); return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url }; } - const existingSubscription = ( - (await stripe.customers.retrieve(organization.billing.stripeCustomerId!, { - expand: ["subscriptions"], - })) as any - ).subscriptions.data[0] as Stripe.Subscription; + const existingSubscription = await stripe.subscriptions.list({ + customer: organization.billing.stripeCustomerId as string, + }); - // the organization has an active subscription - if (existingSubscription) { - // now we see if the organization's current subscription is scheduled to cancel at the month end - // this is a case where the organization cancelled an already purchased product - if (existingSubscription.cancel_at_period_end) { - const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({ - customer: organization.billing.stripeCustomerId!, - }); - const scheduledSubscriptions = allScheduledSubscriptions.data.filter( - (scheduledSub) => scheduledSub.status === "not_started" - ); + if (existingSubscription.data?.length > 0) { + const existingSubscriptionItem = existingSubscription.data[0].items.data[0]; - // if an organization has a scheduled subscritpion upcoming, then we update that as well with their - // newly purchased product since the current one is ending this month end - if (scheduledSubscriptions.length) { - const existingItemsInScheduledSubscription = scheduledSubscriptions[0].phases[0].items.map( - (item) => { - return { - ...(item.quantity && { quantity: item.quantity }), // Only include quantity if it's defined - price: item.price as string, - }; - } - ); - - const combinedLineItems = [...lineItems, ...existingItemsInScheduledSubscription]; - - const uniqueItemsMap = combinedLineItems.reduce< - Record - >((acc, item) => { - acc[item.price] = item; // This will overwrite duplicate items based on price - return acc; - }, {}); - - const lineItemsForScheduledSubscription = Object.values(uniqueItemsMap); - - await stripe.subscriptionSchedules.update(scheduledSubscriptions[0].id, { - end_behavior: "release", - phases: [ - { - start_date: getFirstOfNextMonthTimestamp(), - items: lineItemsForScheduledSubscription, - iterations: 1, - metadata: { organizationId }, - }, - ], - metadata: { organizationId }, - }); - } else { - // if they do not have an upcoming new subscription schedule, - // we create one since the current one with other products is expiring - // so the new schedule only has the new product the organization has subscribed to - await stripe.subscriptionSchedules.create({ - customer: organization.billing.stripeCustomerId!, - start_date: getFirstOfNextMonthTimestamp(), - end_behavior: "release", - phases: [ - { - items: lineItems, - iterations: 1, - metadata: { organizationId }, - }, - ], - metadata: { organizationId }, - }); - } - } - - // the below check is to make sure that if a product is about to be cancelled but is still a part - // of the current subscription then we do not update its status back to active - for (const priceLookupKey of priceLookupKeys) { - if (priceLookupKey.includes("unlimited")) continue; - if ( - !( - existingSubscription.cancel_at_period_end && - organization.billing.features[priceLookupKey as keyof typeof organization.billing.features] - .status === "cancelled" - ) - ) { - let alreadyInSubscription = false; - - existingSubscription.items.data.forEach((item) => { - if (item.price.lookup_key === priceLookupKey) { - alreadyInSubscription = true; - } - }); - - if (!alreadyInSubscription) { - await stripe.subscriptions.update(existingSubscription.id, { items: lineItems }); - } - } - } - } else { - // case where organization does not have a subscription but has a stripe customer id - // so we just attach that to a new subscription - await stripe.subscriptions.create({ - customer: organization.billing.stripeCustomerId!, - items: lineItems, - billing_cycle_anchor: getFirstOfNextMonthTimestamp(), - metadata: { organizationId }, + await stripe.subscriptions.update(existingSubscription.data[0].id, { + items: [ + { + id: existingSubscriptionItem.id, + deleted: true, + }, + { + price: priceObject.id, + }, + ], + cancel_at_period_end: false, }); + } else { + // Create a new checkout again if there is no active subscription + const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams); + + return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url }; } return { @@ -179,7 +97,6 @@ export const createSubscription = async ( console.error(err); return { status: 500, - data: "Something went wrong!", newPlan: true, url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`, }; diff --git a/packages/ee/billing/lib/downgrade-plan.ts b/packages/ee/billing/lib/downgrade-plan.ts deleted file mode 100644 index 840d1380e8..0000000000 --- a/packages/ee/billing/lib/downgrade-plan.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getProducts, updateProduct } from "@formbricks/lib/product/service"; - -export const unsubscribeLinkSurveyProFeatures = async (organizationId: string) => { - const productsOfOrganization = await getProducts(organizationId); - for (const product of productsOfOrganization) { - if (!product.linkSurveyBranding) { - await updateProduct(product.id, { - linkSurveyBranding: true, - }); - } - } -}; - -export const unsubscribeCoreAndAppSurveyFeatures = async (organizationId: string) => { - const productsOfOrganization = await getProducts(organizationId); - for (const product of productsOfOrganization) { - if (!product.inAppSurveyBranding) { - await updateProduct(product.id, { - inAppSurveyBranding: true, - }); - } - } -}; diff --git a/packages/ee/billing/lib/is-subscription-cancelled.ts b/packages/ee/billing/lib/is-subscription-cancelled.ts new file mode 100644 index 0000000000..1929054054 --- /dev/null +++ b/packages/ee/billing/lib/is-subscription-cancelled.ts @@ -0,0 +1,53 @@ +import Stripe from "stripe"; +import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; +import { env } from "@formbricks/lib/env"; +import { getOrganization } from "@formbricks/lib/organization/service"; + +const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + apiVersion: STRIPE_API_VERSION, +}); + +export const isSubscriptionCancelled = async ( + organizationId: string +): Promise<{ + cancelled: boolean; + date: Date | null; +}> => { + try { + const organization = await getOrganization(organizationId); + if (!organization) throw new Error("Team not found."); + let isNewTeam = + !organization.billing.stripeCustomerId || + !(await stripe.customers.retrieve(organization.billing.stripeCustomerId)); + + if (!organization.billing.stripeCustomerId || isNewTeam) { + return { + cancelled: false, + date: null, + }; + } + + const subscriptions = await stripe.subscriptions.list({ + customer: organization.billing.stripeCustomerId, + }); + + for (const subscription of subscriptions.data) { + if (subscription.cancel_at_period_end) { + return { + cancelled: true, + date: new Date(subscription.current_period_end * 1000), + }; + } + } + return { + cancelled: false, + date: null, + }; + } catch (err) { + console.error(err); + return { + cancelled: false, + date: null, + }; + } +}; diff --git a/packages/ee/billing/lib/remove-subscription.ts b/packages/ee/billing/lib/remove-subscription.ts deleted file mode 100644 index 6fb55bfd61..0000000000 --- a/packages/ee/billing/lib/remove-subscription.ts +++ /dev/null @@ -1,132 +0,0 @@ -import Stripe from "stripe"; -import { STRIPE_API_VERSION, WEBAPP_URL } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; -import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; -import type { StripePriceLookupKeys } from "./constants"; -import { getFirstOfNextMonthTimestamp } from "./create-subscription"; - -const baseUrl = process.env.NODE_ENV === "production" ? WEBAPP_URL : "http://localhost:3000"; - -const retrievePriceLookup = async (priceId: string) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - - return (await stripe.prices.retrieve(priceId)).lookup_key; -}; - -export const removeSubscription = async ( - organizationId: string, - environmentId: string, - priceLookupKeys: StripePriceLookupKeys[] -) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - - try { - const organization = await getOrganization(organizationId); - if (!organization) throw new Error("Organization not found."); - if (!organization.billing.stripeCustomerId) { - return { status: 400, data: "No subscription exists for given organization!", newPlan: false, url: "" }; - } - - const existingCustomer = (await stripe.customers.retrieve(organization.billing.stripeCustomerId, { - expand: ["subscriptions"], - })) as Stripe.Customer; - const existingSubscription = existingCustomer.subscriptions?.data[0]!; - - const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({ - customer: organization.billing.stripeCustomerId, - }); - const scheduledSubscriptions = allScheduledSubscriptions.data.filter( - (scheduledSub) => scheduledSub.status === "not_started" - ); - const newPriceIds: string[] = []; - - if (scheduledSubscriptions.length) { - const priceIds = scheduledSubscriptions[0].phases[0].items.map((item) => item.price); - for (const priceId of priceIds) { - const priceLookUpKey = await retrievePriceLookup(priceId as string); - if (!priceLookUpKey) continue; - if (!priceLookupKeys.includes(priceLookUpKey as StripePriceLookupKeys)) { - newPriceIds.push(priceId as string); - } - } - - if (!newPriceIds.length) { - await stripe.subscriptionSchedules.cancel(scheduledSubscriptions[0].id); - } else { - await stripe.subscriptionSchedules.update(scheduledSubscriptions[0].id, { - end_behavior: "release", - phases: [ - { - start_date: getFirstOfNextMonthTimestamp(), - items: newPriceIds.map((priceId) => ({ price: priceId })), - iterations: 1, - metadata: { organizationId }, - }, - ], - metadata: { organizationId }, - }); - } - } else { - const validSubItems = existingSubscription.items.data.filter( - (subItem) => - subItem.price.lookup_key && - !priceLookupKeys.includes(subItem.price.lookup_key as StripePriceLookupKeys) - ); - newPriceIds.push(...validSubItems.map((subItem) => subItem.price.id)); - - if (newPriceIds.length) { - await stripe.subscriptionSchedules.create({ - customer: organization.billing.stripeCustomerId, - start_date: getFirstOfNextMonthTimestamp(), - end_behavior: "release", - phases: [ - { - items: newPriceIds.map((priceId) => ({ price: priceId })), - iterations: 1, - metadata: { organizationId }, - }, - ], - metadata: { organizationId }, - }); - } - } - - await stripe.subscriptions.update(existingSubscription.id, { cancel_at_period_end: true }); - - const updatedFeatures = organization.billing.features; - for (const priceLookupKey of priceLookupKeys) { - updatedFeatures[priceLookupKey as keyof typeof updatedFeatures].status = "cancelled"; - } - - await updateOrganization(organizationId, { - billing: { - ...organization.billing, - features: updatedFeatures, - }, - }); - - return { - status: 200, - data: "Successfully removed from your existing subscription!", - newPlan: false, - url: "", - }; - } catch (err) { - console.error(`Error in removing subscription: ${err}`); - - return { - status: 500, - data: "Something went wrong!", - newPlan: true, - url: `${baseUrl}/environments/${environmentId}/settings/billing`, - }; - } -}; diff --git a/packages/ee/billing/lib/report-usage.ts b/packages/ee/billing/lib/report-usage.ts deleted file mode 100644 index b6fa10d405..0000000000 --- a/packages/ee/billing/lib/report-usage.ts +++ /dev/null @@ -1,68 +0,0 @@ -import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; -import { ProductFeatureKeys } from "./constants"; - -export const reportUsage = async ( - items: Stripe.SubscriptionItem[], - lookupKey: ProductFeatureKeys, - quantity: number -) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - - const subscriptionItem = items.find( - (subItem) => subItem.price.lookup_key === ProductFeatureKeys[lookupKey] - ); - - if (!subscriptionItem) { - throw new Error(`No such product found: ${ProductFeatureKeys[lookupKey]}`); - } - - await stripe.subscriptionItems.createUsageRecord(subscriptionItem.id, { - action: "set", - quantity, - timestamp: Math.floor(Date.now() / 1000), - }); -}; - -export const reportUsageToStripe = async ( - stripeCustomerId: string, - usage: number, - lookupKey: ProductFeatureKeys, - timestamp: number -) => { - if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); - - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: STRIPE_API_VERSION, - }); - - try { - const subscription = await stripe.subscriptions.list({ - customer: stripeCustomerId, - }); - - const subscriptionItem = subscription.data[0].items.data.filter( - (subItem) => subItem.price.lookup_key === ProductFeatureKeys[lookupKey] - ); - - if (!subscriptionItem) { - return { status: 400, data: "No such Product found" }; - } - const subId = subscriptionItem[0].id; - - const usageRecord = await stripe.subscriptionItems.createUsageRecord(subId, { - action: "set", - quantity: usage, - timestamp, - }); - - return { status: 200, data: usageRecord.quantity }; - } catch (error) { - return { status: 500, data: `Something went wrong: ${error}` }; - } -}; diff --git a/packages/ee/lib/service.ts b/packages/ee/lib/service.ts index 8edb3544a8..e986f3ca38 100644 --- a/packages/ee/lib/service.ts +++ b/packages/ee/lib/service.ts @@ -3,7 +3,12 @@ import { HttpsProxyAgent } from "https-proxy-agent"; import fetch from "node-fetch"; import { prisma } from "@formbricks/database"; import { cache, revalidateTag } from "@formbricks/lib/cache"; -import { E2E_TESTING, ENTERPRISE_LICENSE_KEY, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { + E2E_TESTING, + ENTERPRISE_LICENSE_KEY, + IS_FORMBRICKS_CLOUD, + PRODUCT_FEATURE_KEYS, +} from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; import { hashString } from "@formbricks/lib/hashString"; import { TOrganization } from "@formbricks/types/organizations"; @@ -191,31 +196,49 @@ export const fetchLicense = async () => { }; export const getRemoveInAppBrandingPermission = (organization: TOrganization): boolean => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.features.inAppSurvey.status !== "inactive"; + if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE; else if (!IS_FORMBRICKS_CLOUD) return true; return false; }; export const getRemoveLinkBrandingPermission = (organization: TOrganization): boolean => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.features.linkSurvey.status !== "inactive"; + if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE; else if (!IS_FORMBRICKS_CLOUD) return true; return false; }; export const getRoleManagementPermission = async (organization: TOrganization): Promise => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.features.inAppSurvey.status !== "inactive"; + if (IS_FORMBRICKS_CLOUD) + return ( + organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE || + organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE + ); else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition(); return false; }; export const getAdvancedTargetingPermission = async (organization: TOrganization): Promise => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.features.userTargeting.status !== "inactive"; + if (IS_FORMBRICKS_CLOUD) + return ( + organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE || + organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE + ); + else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition(); + else return false; +}; + +export const getBiggerUploadFileSizePermission = async (organization: TOrganization): Promise => { + if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE; else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition(); return false; }; export const getMultiLanguagePermission = async (organization: TOrganization): Promise => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.features.inAppSurvey.status !== "inactive"; + if (IS_FORMBRICKS_CLOUD) + return ( + organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE || + organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE + ); else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition(); return false; }; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 34ac086d3d..a0ca9a6ba7 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -2,7 +2,6 @@ import "server-only"; import { env } from "./env"; export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1"; -export const MAU_LIMIT = IS_FORMBRICKS_CLOUD ? 9000 : 1000000; // URLs export const WEBAPP_URL = @@ -92,9 +91,8 @@ export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL; export const S3_BUCKET_NAME = env.S3_BUCKET_NAME; export const UPLOADS_DIR = env.UPLOADS_DIR || "./uploads"; export const MAX_SIZES = { - public: 1024 * 1024 * 10, // 10MB - free: 1024 * 1024 * 10, // 10MB - pro: 1024 * 1024 * 1024, // 1GB + standard: 1024 * 1024 * 10, // 10MB + big: 1024 * 1024 * 1024, // 1GB } as const; // Function to check if the necessary S3 configuration is set up @@ -105,10 +103,6 @@ export const isS3Configured = () => { return !!S3_BUCKET_NAME; }; -// Pricing -export const PRICING_USERTARGETING_FREE_MTU = 2500; -export const PRICING_APPSURVEYS_FREE_RESPONSES = 250; - // Colors for Survey Bg export const SURVEY_BG_COLORS = [ "#FFF2D8", @@ -178,3 +172,40 @@ export const STRIPE_API_VERSION = "2024-04-10"; // Maximum number of attribute classes allowed: export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150 as const; + +// Billing constants + +export enum PRODUCT_FEATURE_KEYS { + FREE = "free", + STARTUP = "startup", + SCALE = "scale", + ENTERPRISE = "enterprise", +} + +export enum STRIPE_PRODUCT_NAMES { + STARTUP = "Formbricks Startup", + SCALE = "Formbricks Scale", + ENTERPRISE = "Formbricks Enterprise", +} + +export enum STRIPE_PRICE_LOOKUP_KEYS { + STARTUP_MONTHLY = "formbricks_startup_monthly", + STARTUP_YEARLY = "formbricks_startup_yearly", + SCALE_MONTHLY = "formbricks_scale_monthly", + SCALE_YEARLY = "formbricks_scale_yearly", +} + +export const BILLING_LIMITS = { + FREE: { + RESPONSES: 500, + MIU: 1000, + }, + STARTUP: { + RESPONSES: 2000, + MIU: 2500, + }, + SCALE: { + RESPONSES: 5000, + MIU: 20000, + }, +}; diff --git a/packages/lib/organization/service.ts b/packages/lib/organization/service.ts index a8332b1584..268d80eada 100644 --- a/packages/lib/organization/service.ts +++ b/packages/lib/organization/service.ts @@ -13,7 +13,8 @@ import { } from "@formbricks/types/organizations"; import { TUserNotificationSettings } from "@formbricks/types/user"; import { cache } from "../cache"; -import { ITEMS_PER_PAGE } from "../constants"; +import { ITEMS_PER_PAGE, PRODUCT_FEATURE_KEYS } from "../constants"; +import { BILLING_LIMITS } from "../constants"; import { environmentCache } from "../environment/cache"; import { getProducts } from "../product/service"; import { getUsersWithOrganization, updateUser } from "../user/service"; @@ -140,7 +141,21 @@ export const createOrganization = async ( validateInputs([organizationInput, ZOrganizationCreateInput]); const organization = await prisma.organization.create({ - data: organizationInput, + data: { + ...organizationInput, + billing: { + plan: PRODUCT_FEATURE_KEYS.FREE, + limits: { + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: new Date(), + period: "monthly", + }, + }, select, }); @@ -181,7 +196,7 @@ export const updateOrganization = async ( // revalidate cache for environments updatedOrganization?.products.forEach((product) => { - product.environments.forEach((environment) => { + product.environments.forEach(async (environment) => { organizationCache.revalidate({ environmentId: environment.id, }); @@ -303,8 +318,13 @@ export const getMonthlyActiveOrganizationPeopleCount = (organizationId: string): try { // Define the start of the month - const now = new Date(); - const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + // const now = new Date(); + // const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const organization = await getOrganization(organizationId); + if (!organization) { + throw new ResourceNotFoundError("Organization", organizationId); + } // Get all environment IDs for the organization const products = await getProducts(organizationId); @@ -319,11 +339,22 @@ export const getMonthlyActiveOrganizationPeopleCount = (organizationId: string): AND: [ { environmentId: { in: environmentIds } }, { - actions: { - some: { - createdAt: { gte: firstDayOfMonth }, + OR: [ + { + actions: { + some: { + createdAt: { gte: organization.billing.periodStart }, + }, + }, }, - }, + { + responses: { + some: { + createdAt: { gte: organization.billing.periodStart }, + }, + }, + }, + ], }, ], }, @@ -351,8 +382,13 @@ export const getMonthlyOrganizationResponseCount = (organizationId: string): Pro try { // Define the start of the month - const now = new Date(); - const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + // const now = new Date(); + // const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const organization = await getOrganization(organizationId); + if (!organization) { + throw new ResourceNotFoundError("Organization", organizationId); + } // Get all environment IDs for the organization const products = await getProducts(organizationId); @@ -366,8 +402,7 @@ export const getMonthlyOrganizationResponseCount = (organizationId: string): Pro where: { AND: [ { survey: { environmentId: { in: environmentIds } } }, - { survey: { type: { in: ["app", "website"] } } }, - { createdAt: { gte: firstDayOfMonth } }, + { createdAt: { gte: organization.billing.periodStart } }, ], }, }); diff --git a/packages/lib/posthogServer.ts b/packages/lib/posthogServer.ts index 6ad14c772e..5df1934a07 100644 --- a/packages/lib/posthogServer.ts +++ b/packages/lib/posthogServer.ts @@ -1,4 +1,6 @@ import { PostHog } from "posthog-node"; +import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations"; +import { cache } from "./cache"; import { env } from "./env"; const enabled = @@ -33,3 +35,28 @@ export const capturePosthogEnvironmentEvent = async ( console.error("error sending posthog event:", error); } }; + +export const sendPlanLimitsReachedEventToPosthogWeekly = ( + environmentId: string, + billing: { + plan: TOrganizationBillingPlan; + limits: TOrganizationBillingPlanLimits; + } +): Promise => + cache( + async () => { + try { + await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", { + ...billing, + }); + return "success"; + } catch (error) { + console.error(error); + throw error; + } + }, + [`sendPlanLimitsReachedEventToPosthogWeekly-${billing.plan}-${environmentId}`], + { + revalidate: 60 * 60 * 24 * 7, // 7 days + } + )(); diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index a87fdd34b3..1580fa95f2 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -21,10 +21,12 @@ import { TSurveySummary } from "@formbricks/types/surveys"; import { TTag } from "@formbricks/types/tags"; import { getAttributes } from "../attribute/service"; import { cache } from "../cache"; -import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants"; +import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE, WEBAPP_URL } from "../constants"; import { displayCache } from "../display/cache"; import { deleteDisplayByResponseId, getDisplayCountBySurveyId } from "../display/service"; +import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId } from "../organization/service"; import { createPerson, getPerson, getPersonByUserId } from "../person/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer"; import { responseNoteCache } from "../responseNote/cache"; import { getResponseNotes } from "../responseNote/service"; import { putFile } from "../storage/service"; @@ -205,6 +207,11 @@ export const createResponse = async (responseInput: TResponseInput): Promise= responsesLimit) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + monthly: { + responses: responsesLimit, + miu: null, + }, + }, + }); + } catch (err) { + // Log error but do not throw + console.error(`Error sending plan limits reached event to Posthog: ${err}`); + } + } + } + return response; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -338,6 +367,7 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput): responseNoteCache.revalidate({ responseId: response.id, }); + return response; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/lib/response/tests/response.test.ts b/packages/lib/response/tests/response.test.ts index 6389bb5d1a..d56349e829 100644 --- a/packages/lib/response/tests/response.test.ts +++ b/packages/lib/response/tests/response.test.ts @@ -18,7 +18,7 @@ import { mockUserId, } from "./__mocks__/data.mock"; import { Prisma } from "@prisma/client"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vitest } from "vitest"; import { testInputValidation } from "vitestSetup"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { @@ -29,7 +29,11 @@ import { } from "@formbricks/types/responses"; import { TTag } from "@formbricks/types/tags"; import { selectPerson } from "../../person/service"; -import { mockAttributeClass, mockSurveyOutput } from "../../survey/tests/__mock__/survey.mock"; +import { + mockAttributeClass, + mockOrganizationOutput, + mockSurveyOutput, +} from "../../survey/tests/__mock__/survey.mock"; import { createResponse, createResponseLegacy, @@ -47,6 +51,13 @@ import { import { buildWhereClause } from "../utils"; import { constantsForTests } from "./constants"; +// vitest.mock("../../organization/service", async (methods) => { +// return { +// ...methods, +// getOrganizationByEnvironmentId: vitest.fn(), +// }; +// }); + const expectedResponseWithoutPerson: TResponse = { ...mockResponse, person: null, @@ -125,6 +136,12 @@ beforeEach(() => { prisma.display.delete.mockResolvedValue({ ...mockDisplay, status: "seen" }); prisma.response.count.mockResolvedValue(1); + + prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput); + prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput); + prisma.product.findMany.mockResolvedValue([]); + // @ts-expect-error + prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } }); }); describe("Tests for getResponsesByPersonId", () => { @@ -217,6 +234,7 @@ describe("Tests for createResponse service", () => { prisma.person.findFirst.mockResolvedValue(null); prisma.person.create.mockResolvedValue(mockPerson); prisma.attribute.findMany.mockResolvedValue([]); + const response = await createResponse(mockResponseInputWithUserId); expect(response).toEqual(expectedResponseWithPerson); diff --git a/packages/lib/storage/service.ts b/packages/lib/storage/service.ts index 01eb4bd68b..244a7a0b1f 100644 --- a/packages/lib/storage/service.ts +++ b/packages/lib/storage/service.ts @@ -149,7 +149,7 @@ export const getUploadSignedUrl = async ( environmentId: string, fileType: string, accessType: TAccessType, - plan: "free" | "pro" = "free" + isBiggerFileUploadAllowed: boolean = false ): Promise => { // add a unique id to the file name @@ -191,8 +191,7 @@ export const getUploadSignedUrl = async ( fileType, accessType, environmentId, - accessType === "public", - plan + isBiggerFileUploadAllowed ); return { @@ -210,10 +209,9 @@ export const getS3UploadSignedUrl = async ( contentType: string, accessType: string, environmentId: string, - isPublic: boolean, - plan: "free" | "pro" = "free" + isBiggerFileUploadAllowed: boolean = false ) => { - const maxSize = isPublic ? MAX_SIZES.public : MAX_SIZES[plan]; + const maxSize = isBiggerFileUploadAllowed ? MAX_SIZES.big : MAX_SIZES.standard; const postConditions: PresignedPostOptions["Conditions"] = [["content-length-range", 0, maxSize]]; try { @@ -243,8 +241,7 @@ export const putFileToLocalStorage = async ( accessType: string, environmentId: string, rootDir: string, - isPublic: boolean = false, - plan: "free" | "pro" = "free" + isBiggerFileUploadAllowed: boolean = false ) => { try { await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`); @@ -254,7 +251,7 @@ export const putFileToLocalStorage = async ( const buffer = Buffer.from(fileBuffer); const bufferBytes = buffer.byteLength; - const maxSize = isPublic ? MAX_SIZES.public : MAX_SIZES[plan]; + const maxSize = isBiggerFileUploadAllowed ? MAX_SIZES.big : MAX_SIZES.standard; if (bufferBytes > maxSize) { const err = new Error(`File size exceeds the ${maxSize / (1024 * 1024)} MB limit`); diff --git a/packages/lib/survey/tests/__mock__/survey.mock.ts b/packages/lib/survey/tests/__mock__/survey.mock.ts index e91b8d2e25..2285e99ba7 100644 --- a/packages/lib/survey/tests/__mock__/survey.mock.ts +++ b/packages/lib/survey/tests/__mock__/survey.mock.ts @@ -191,24 +191,15 @@ export const mockOrganizationOutput: TOrganization = { updatedAt: currentDate, billing: { stripeCustomerId: null, - features: { - inAppSurvey: { - status: "inactive", - unlimited: false, - }, - linkSurvey: { - status: "inactive", - unlimited: false, - }, - userTargeting: { - status: "inactive", - unlimited: false, - }, - multiLanguage: { - status: "inactive", - unlimited: false, + plan: "free", + period: "monthly", + limits: { + monthly: { + responses: 500, + miu: 1000, }, }, + periodStart: currentDate, }, }; diff --git a/packages/types/organizations.ts b/packages/types/organizations.ts index 1e44edda83..48295aaf84 100644 --- a/packages/types/organizations.ts +++ b/packages/types/organizations.ts @@ -1,24 +1,32 @@ import { z } from "zod"; -export const ZSubscriptionStatus = z.enum(["active", "cancelled", "inactive"]).default("inactive"); +export const ZOrganizationBillingPlan = z.enum(["free", "startup", "scale", "enterprise"]); +export type TOrganizationBillingPlan = z.infer; -export type TSubscriptionStatus = z.infer; +export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]); +export type TOrganizationBillingPeriod = z.infer; -export const ZSubscription = z.object({ - status: ZSubscriptionStatus, - unlimited: z.boolean().default(false), +// responses and miu can be null to support the unlimited plan +export const ZOrganizationBillingPlanLimits = z.object({ + monthly: z.object({ + responses: z.number().nullable(), + miu: z.number().nullable(), + }), }); -export type TSubscription = z.infer; +export type TOrganizationBillingPlanLimits = z.infer; export const ZOrganizationBilling = z.object({ stripeCustomerId: z.string().nullable(), - features: z.object({ - inAppSurvey: ZSubscription, - linkSurvey: ZSubscription, - userTargeting: ZSubscription, - multiLanguage: ZSubscription, + plan: ZOrganizationBillingPlan.default("free"), + period: ZOrganizationBillingPeriod.default("monthly"), + limits: ZOrganizationBillingPlanLimits.default({ + monthly: { + responses: 500, + miu: 1000, + }, }), + periodStart: z.date(), }); export type TOrganizationBilling = z.infer; @@ -36,7 +44,6 @@ export const ZOrganization = z.object({ export const ZOrganizationCreateInput = z.object({ id: z.string().cuid2().optional(), name: z.string(), - billing: ZOrganizationBilling.optional(), }); export type TOrganizationCreateInput = z.infer; diff --git a/packages/ui/BillingSlider/index.tsx b/packages/ui/BillingSlider/index.tsx index 818eafd147..92d9020de3 100644 --- a/packages/ui/BillingSlider/index.tsx +++ b/packages/ui/BillingSlider/index.tsx @@ -49,7 +49,7 @@ export const BillingSlider = React.forwardRef

- Free Tier Limit + Current Tier Limit
{freeTierLimit} {metric}

diff --git a/packages/ui/ConfirmationModal/index.tsx b/packages/ui/ConfirmationModal/index.tsx index cd1b4eb58c..2eef215732 100644 --- a/packages/ui/ConfirmationModal/index.tsx +++ b/packages/ui/ConfirmationModal/index.tsx @@ -11,6 +11,9 @@ type ConfirmationModalProps = { buttonText: string; isButtonDisabled?: boolean; buttonVariant?: "warn" | "darkCTA"; + buttonLoading?: boolean; + closeOnOutsideClick?: boolean; + hideCloseButton?: boolean; }; export const ConfirmationModal = ({ @@ -22,6 +25,9 @@ export const ConfirmationModal = ({ buttonText, isButtonDisabled = false, buttonVariant = "warn", + buttonLoading = false, + closeOnOutsideClick = true, + hideCloseButton, }: ConfirmationModalProps) => { const handleButtonAction = async () => { if (isButtonDisabled) return; @@ -29,7 +35,12 @@ export const ConfirmationModal = ({ }; return ( - +

{text}

@@ -38,7 +49,11 @@ export const ConfirmationModal = ({ -
diff --git a/packages/ui/DevEnvironmentBanner/index.tsx b/packages/ui/DevEnvironmentBanner/index.tsx index 421d985241..c034bbdf00 100644 --- a/packages/ui/DevEnvironmentBanner/index.tsx +++ b/packages/ui/DevEnvironmentBanner/index.tsx @@ -8,7 +8,7 @@ export const DevEnvironmentBanner = ({ environment }: DevEnvironmentBannerProps) return ( <> {environment.type === "development" && ( -
+
You're in an development environment. Set it up to test surveys, actions and attributes.
)} diff --git a/packages/ui/LimitsReachedBanner/index.tsx b/packages/ui/LimitsReachedBanner/index.tsx new file mode 100644 index 0000000000..98f4321f5a --- /dev/null +++ b/packages/ui/LimitsReachedBanner/index.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; +import { TOrganization } from "@formbricks/types/organizations"; + +interface LimitsReachedBannerProps { + organization: TOrganization; + peopleCount: number; + responseCount: number; +} + +export const LimitsReachedBanner = ({ + organization, + peopleCount, + responseCount, +}: LimitsReachedBannerProps) => { + const orgBillingPeopleLimit = organization.billing?.limits?.monthly?.miu; + const orgBillingResponseLimit = organization.billing?.limits?.monthly?.responses; + + const isPeopleLimitReached = orgBillingPeopleLimit !== null && peopleCount >= orgBillingPeopleLimit; + const isResponseLimitReached = orgBillingResponseLimit !== null && responseCount >= orgBillingResponseLimit; + + if (isPeopleLimitReached && isResponseLimitReached) { + return ( + <> +
+ You have reached your monthly MIU limit of {orgBillingPeopleLimit} and response limit of{" "} + {orgBillingResponseLimit}. Learn more +
+ + ); + } + + return ( + <> +
+ {isPeopleLimitReached && ( +
+ You have reached your monthly MIU limit of {orgBillingPeopleLimit}.{" "} + Learn more +
+ )} + {isResponseLimitReached && ( +
+ You have reached your monthly response limit of {orgBillingResponseLimit}.{" "} + Learn more +
+ )} +
+ + ); +}; diff --git a/packages/ui/PricingCard/index.tsx b/packages/ui/PricingCard/index.tsx index c2717af921..f5e91f3661 100644 --- a/packages/ui/PricingCard/index.tsx +++ b/packages/ui/PricingCard/index.tsx @@ -1,169 +1,221 @@ import { CheckIcon } from "lucide-react"; -import { TOrganization } from "@formbricks/types/organizations"; +import { useMemo, useState } from "react"; +import { cn } from "@formbricks/lib/cn"; +import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations"; import { Badge } from "../Badge"; -import { BillingSlider } from "../BillingSlider"; import { Button } from "../Button"; +import { ConfirmationModal } from "../ConfirmationModal"; + +interface PricingCardProps { + plan: { + id: string; + name: string; + featured: boolean; + price: { + monthly: string; + yearly: string; + }; + mainFeatures: string[]; + href: string; + }; + planPeriod: TOrganizationBillingPeriod; + organization: TOrganization; + onUpgrade: () => Promise; + onManageSubscription: () => Promise; + productFeatureKeys: { + FREE: string; + STARTUP: string; + SCALE: string; + ENTERPRISE: string; + }; +} export const PricingCard = ({ - title, - subtitle, - featureName, - monthlyPrice, - actionText, - organization, - metric, - sliderValue, - sliderLimit, - freeTierLimit, - paidFeatures, - perMetricCharge, - loading, + planPeriod, + plan, onUpgrade, - onUnsubscribe, -}: { - title: string; - subtitle: string; - featureName: string; - monthlyPrice: number; - actionText: string; - organization: TOrganization; - metric?: string; - sliderValue?: number; - sliderLimit?: number; - freeTierLimit?: number; - paidFeatures: { - title: string; - comingSoon?: boolean; - unlimited?: boolean; - }[]; - perMetricCharge?: number; - loading: boolean; - onUpgrade: any; - onUnsubscribe: any; -}) => { - const featureNameKey = featureName as keyof typeof organization.billing.features; + onManageSubscription, + organization, + productFeatureKeys, +}: PricingCardProps) => { + const [loading, setLoading] = useState(false); + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + + const isCurrentPlan = useMemo(() => { + if (organization.billing.plan === productFeatureKeys.FREE && plan.id === productFeatureKeys.FREE) { + return true; + } + + if ( + organization.billing.plan === productFeatureKeys.ENTERPRISE && + plan.id === productFeatureKeys.ENTERPRISE + ) { + return true; + } + + return organization.billing.plan === plan.id && organization.billing.period === planPeriod; + }, [ + organization.billing.period, + organization.billing.plan, + plan.id, + planPeriod, + productFeatureKeys.ENTERPRISE, + productFeatureKeys.FREE, + ]); + + const CTAButton = useMemo(() => { + if (isCurrentPlan) { + return null; + } + + if (plan.id !== productFeatureKeys.ENTERPRISE && plan.id !== productFeatureKeys.FREE) { + if (organization.billing.plan === productFeatureKeys.FREE) { + return ( + + ); + } + + return ( + + ); + } + + return <>; + }, [ + isCurrentPlan, + loading, + onUpgrade, + organization.billing.plan, + plan.id, + productFeatureKeys.ENTERPRISE, + productFeatureKeys.FREE, + ]); + return ( -
-
-

{title}

- {organization.billing.features[featureNameKey].status === "active" ? ( - organization.billing.features[featureNameKey].unlimited ? ( - - ) : ( - <> - - - - ) - ) : organization.billing.features[featureNameKey].status === "cancelled" ? ( - - ) : null} +
+
+ {isCurrentPlan && } -

{subtitle}

+

+ {plan.name} +

+
+
+

+ {planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly} +

+ {plan.name !== "Enterprise" && ( +
+

/ Month

+

{`Billed ${ + planPeriod === "monthly" ? "monthly" : "yearly" + }`}

+
+ )} +
- {metric && perMetricCharge && ( -
-
- {organization.billing.features[featureNameKey].unlimited ? ( -

- - Usage this month: {sliderValue} {metric} - -

- ) : ( -
- { + setLoading(true); + await onManageSubscription(); + setLoading(false); + }} + className="flex justify-center"> + Manage Subscription + + )} + + {organization.billing.plan !== plan.id && plan.id === productFeatureKeys.ENTERPRISE && ( + + )} +
+
+
    + {plan.mainFeatures.map((mainFeature) => ( +
  • +
- )} -
-
- )} -
-
- {organization.billing.features[featureNameKey].status === "inactive" && ( -

- You're on the Free plan in {title}.
- Upgrade now to unlock the following: -

- )} - -
    - {paidFeatures.map((feature, index) => ( -
  • -
    - -
    - {feature.title} - {feature.comingSoon && ( - - coming soon - - )} -
  • - ))} -
-
- -
- {!organization.billing.features[featureNameKey].unlimited && ( -
- {organization.billing.features[featureNameKey].status !== "inactive" ? ( -
- {perMetricCharge ? ( - <> - Approximately -
- - $ - - {(sliderValue! > freeTierLimit! - ? (sliderValue! - freeTierLimit!) * perMetricCharge - : 0 - ).toFixed(2)} - -
- Month-to-Date - - ) : ( - <> - ${monthlyPrice} - - / month - - )} -
- ) : ( -
- {actionText} -
- - ${monthlyPrice} - - / month -
- )} -
- )} - {organization.billing.features[featureNameKey].status === "inactive" && ( - - )} -
+ {mainFeature} + + ))} +
+ + { + setLoading(true); + await onUpgrade(); + setLoading(false); + setUpgradeModalOpen(false); + }} + open={upgradeModalOpen} + setOpen={setUpgradeModalOpen} + text={`Are you sure you want to switch to the ${plan.name} plan? You will be charged ${ + planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly + } per month.`} + buttonVariant="darkCTA" + buttonLoading={loading} + closeOnOutsideClick={false} + hideCloseButton + />
); }; diff --git a/turbo.json b/turbo.json index 4698907a47..ade03fa19b 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turborepo.org/schema.json", + "ui": "stream", "tasks": { "@formbricks/web#go": { "cache": false,