From 7f8b7e2a2018e0eaa556d5f88d6114ebbde7d140 Mon Sep 17 00:00:00 2001 From: Adarsh Jha <132337675+adarsh-jha-dev@users.noreply.github.com> Date: Mon, 30 Oct 2023 23:15:20 +0530 Subject: [PATCH 1/3] chore: Add Table of Contents to README (#1427) Co-authored-by: Matthias Nannt --- README.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d97dd46104..7f100e6796 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,21 @@ Formbricks is your go-to solution for in-product micro-surveys that will superch ## 💪 Mission: Make customer-centric decisions based on data. -Formbricks helps you apply best practices from data-driven work and experience management to make better business decisions. Ask users as they experience your product - and leverage a significantly higher conversion rate. Gather all insights you can - including partial submissions and build conviction for the next product decision. Better data, better business. +Formbricks is a powerful tool for creating in-product micro-surveys - and leverage a significantly higher conversion rate. It allows you to gather valuable insights from your users, enabling you to make data-driven decisions that enhance your product's user experience. With Formbricks, you can create surveys with our no-code editor, choose from a variety of templates, target specific user groups, and much more. + +### Table of Contents + +- [Features](#features) +- [Getting Started](#getting-started) + - [Cloud Version](#cloud-version) + - [Self-hosted Version](#self-hosted-version) + - [Development](#development) +- [Contribution](#contribution) +- [Contact](#contact-us) +- [License](#license) +- [Security](#security) + + ### Features @@ -74,14 +88,20 @@ Formbricks helps you apply best practices from data-driven work and experience m - 🔒 [Auth.js](https://authjs.dev/) - 🧘‍♂️ [Zod](https://zod.dev/) + + ## 🚀 Getting started We've got several options depending on your need to help you quickly get started with Formbricks. + + ### ☁️ Cloud Version Formbricks has a hosted cloud offering with a generous free plan to get you up and running as quickly as possible. To get started, please visit [formbricks.com](https://formbricks.com). + + ### 🐳 Self-hosted version Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription. @@ -102,6 +122,8 @@ You can deploy Formbricks on [Railway](https://railway.app) using the button bel [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd) + + ### 👨‍💻 Development #### Prerequisites @@ -124,6 +146,8 @@ To get started locally, we've got a [guide to help you](https://formbricks.com/d [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks) + + ## ✍️ Contribution We are very happy if you are interested in contributing to Formbricks 🤗 @@ -142,16 +166,22 @@ Please check out [our contribution guide](https://formbricks.com/docs/contributi + + ## 📆 Contact us Let's have a chat about your survey needs and get you started. Book us with Cal.com + + ## ⚖️ License Distributed under the AGPLv3 License. See [`LICENSE`](./LICENSE) for more information. + + ## 🔒 Security We take security very seriously. If you come across any security vulnerabilities, please disclose them by sending an email to security@formbricks.com. We appreciate your help in making our platform as secure as possible and are committed to working with you to resolve any issues quickly and efficiently. See [`SECURITY.md`](./SECURITY.md) for more information. From 9971662077f3a1ac1ac6162f89dbd5fec78c1a5b Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Tue, 31 Oct 2023 02:03:19 +0530 Subject: [PATCH 2/3] feat: stripe integration (#858) Co-authored-by: Matthias Nannt --- .../workflows/cron-reportUsageToStripe.yml | 22 + .../components/shared/PricingTable.tsx | 26 +- .../components/ConfirmationPage.tsx | 15 +- .../app/(app)/billing-confirmation/page.tsx | 6 +- .../settings/billing/actions.ts | 62 +++ .../billing/components/PricingTable.tsx | 439 ++++++++++++------ .../[environmentId]/settings/billing/page.tsx | 29 +- .../lookandfeel/components/EditSignature.tsx | 23 +- .../settings/lookandfeel/page.tsx | 16 +- .../edit/components/SurveyMenuBar.tsx | 2 +- .../create-customer-portal-session/route.ts | 19 - .../app/api/billing/stripe-webhook/route.ts | 2 +- apps/web/app/api/cron/report-usage/route.ts | 74 +++ .../app/api/v1/client/storage/local/route.ts | 6 +- apps/web/app/api/v1/client/storage/route.ts | 2 +- apps/web/app/api/v1/js/sync/lib/sync.ts | 77 ++- .../api/v1/management/storage/local/route.ts | 4 +- apps/web/app/api/v1/og/route.tsx | 3 +- apps/web/app/lib/singleUseSurveys.ts | 10 +- packages/database/jsonTypes.ts | 2 + .../migration.sql | 14 + packages/database/schema.prisma | 25 +- packages/database/zod-utils.ts | 1 + packages/ee/billing/api/stripe-webhook.ts | 42 +- .../handlers/checkoutSessionCompleted.ts | 83 ++++ .../handlers/subscriptionCreatedOrUpdated.ts | 96 ++++ .../billing/handlers/subscriptionDeleted.ts | 53 +++ packages/ee/billing/lib/constants.ts | 19 + .../createCustomerPortalSession.ts} | 5 +- packages/ee/billing/lib/createSubscription.ts | 181 ++++++++ packages/ee/billing/lib/removeSubscription.ts | 121 +++++ packages/ee/billing/lib/reportUsage.ts | 60 +++ packages/js/src/lib/widget.ts | 2 +- packages/lib/constants.ts | 78 ++-- packages/lib/crypto.ts | 4 +- packages/lib/display/cache.ts | 10 +- packages/lib/response/service.ts | 31 ++ packages/lib/team/auth.ts | 25 + packages/lib/team/service.ts | 139 +++++- packages/types/teams.ts | 24 +- packages/ui/AlertDialog/index.tsx | 10 +- packages/ui/BillingSlider/index.tsx | 57 +++ packages/ui/PricingCard/index.tsx | 174 +++++++ turbo.json | 1 + 44 files changed, 1788 insertions(+), 306 deletions(-) create mode 100644 .github/workflows/cron-reportUsageToStripe.yml create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/billing/actions.ts delete mode 100644 apps/web/app/api/billing/create-customer-portal-session/route.ts create mode 100644 apps/web/app/api/cron/report-usage/route.ts create mode 100644 packages/database/migrations/20231030174314_add_billing_to_team/migration.sql create mode 100644 packages/ee/billing/handlers/checkoutSessionCompleted.ts create mode 100644 packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts create mode 100644 packages/ee/billing/handlers/subscriptionDeleted.ts create mode 100644 packages/ee/billing/lib/constants.ts rename packages/ee/billing/{api/create-customer-portal-session.ts => lib/createCustomerPortalSession.ts} (66%) create mode 100644 packages/ee/billing/lib/createSubscription.ts create mode 100644 packages/ee/billing/lib/removeSubscription.ts create mode 100644 packages/ee/billing/lib/reportUsage.ts create mode 100644 packages/lib/team/auth.ts create mode 100644 packages/ui/BillingSlider/index.tsx create mode 100644 packages/ui/PricingCard/index.tsx diff --git a/.github/workflows/cron-reportUsageToStripe.yml b/.github/workflows/cron-reportUsageToStripe.yml new file mode 100644 index 0000000000..3a6d20bd96 --- /dev/null +++ b/.github/workflows/cron-reportUsageToStripe.yml @@ -0,0 +1,22 @@ +name: Cron - reportUsageToStripe + +on: + # "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 23:00 UTC every day of every month. + - cron: "0 21 * * *" +jobs: + cron-reportUsageToStripe: + env: + APP_URL: ${{ secrets.APP_URL }} + API_KEY: ${{ secrets.API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.API_KEY }} + run: | + curl ${{ env.APP_URL }}/api/cron/report-usage \ + -X GET \ + -H 'x-api-key: ${{ env.API_KEY }}' \ + --fail diff --git a/apps/formbricks-com/components/shared/PricingTable.tsx b/apps/formbricks-com/components/shared/PricingTable.tsx index b6ace77970..842ba9d459 100644 --- a/apps/formbricks-com/components/shared/PricingTable.tsx +++ b/apps/formbricks-com/components/shared/PricingTable.tsx @@ -1,7 +1,29 @@ -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@radix-ui/react-tooltip"; -export const PricingTable = ({ leadRow, pricing, endRow }) => { +interface SpecialRow { + title: string | JSX.Element; + free: string | JSX.Element; + paid: string | JSX.Element; +} + +interface PricingRow { + name: string; + free: string | boolean; + paid: string | boolean; + comingSoon?: boolean; + addOnText?: string; +} + +export const PricingTable = ({ + leadRow, + pricing, + endRow, +}: { + leadRow: SpecialRow; + pricing: PricingRow[]; + endRow: SpecialRow; +}) => { return (
diff --git a/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx b/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx index 6e3a25bb02..8306587d4a 100644 --- a/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx +++ b/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx @@ -5,7 +5,11 @@ import { Button } from "@formbricks/ui/Button"; import { Confetti } from "@formbricks/ui/Confetti"; import { useEffect, useState } from "react"; -export default function ConfirmationPage() { +interface ConfirmationPageProps { + environmentId: string; +} + +export default function ConfirmationPage({ environmentId }: ConfirmationPageProps) { const [showConfetti, setShowConfetti] = useState(false); useEffect(() => { setShowConfetti(true); @@ -18,11 +22,14 @@ export default function ConfirmationPage() {

Upgrade successful

- Thanks a lot for upgrading your Formbricks subscription. You have now unlimited access. + Thanks a lot for upgrading your Formbricks subscription.

-
diff --git a/apps/web/app/(app)/billing-confirmation/page.tsx b/apps/web/app/(app)/billing-confirmation/page.tsx index 9ee80a8382..802afb931f 100644 --- a/apps/web/app/(app)/billing-confirmation/page.tsx +++ b/apps/web/app/(app)/billing-confirmation/page.tsx @@ -1,5 +1,7 @@ import ConfirmationPage from "./components/ConfirmationPage"; -export default function BillingConfirmation({}) { - return ; +export default function BillingConfirmation({ searchParams }) { + const { environmentId } = searchParams; + + return ; } diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/billing/actions.ts new file mode 100644 index 0000000000..b49b6751ef --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/actions.ts @@ -0,0 +1,62 @@ +"use server"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import { WEBAPP_URL } from "@formbricks/lib/constants"; +import { canUserAccessTeam } from "@formbricks/lib/team/auth"; +import { getTeam } from "@formbricks/lib/team/service"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { getServerSession } from "next-auth"; +import { createSubscription } from "@formbricks/ee/billing/lib/createSubscription"; +import { createCustomerPortalSession } from "@formbricks/ee/billing/lib/createCustomerPortalSession"; +import { removeSubscription } from "@formbricks/ee/billing/lib/removeSubscription"; +import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants"; + +export async function upgradePlanAction( + teamId: string, + environmentId: string, + priceLookupKeys: StripePriceLookupKeys[] +) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessTeam(session.user.id, teamId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const subscriptionSession = await createSubscription(teamId, environmentId, priceLookupKeys); + + return subscriptionSession.url; +} + +export async function manageSubscriptionAction(teamId: string, environmentId: string) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessTeam(session.user.id, teamId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const team = await getTeam(teamId); + if (!team || !team.billing.stripeCustomerId) + throw new AuthorizationError("You do not have an associated Stripe CustomerId"); + + const sessionUrl = await createCustomerPortalSession( + team.billing.stripeCustomerId, + `${WEBAPP_URL}/environments/${environmentId}/settings/billing` + ); + return sessionUrl; +} + +export async function removeSubscriptionAction( + teamId: string, + environmentId: string, + priceLookupKeys: StripePriceLookupKeys[] +) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessTeam(session.user.id, teamId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const removedSubscription = await removeSubscription(teamId, environmentId, priceLookupKeys); + + return removedSubscription.url; +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx index 8a54b04eee..73093f0486 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx @@ -2,63 +2,152 @@ import { TTeam } from "@formbricks/types/teams"; import { Button } from "@formbricks/ui/Button"; -import { Badge } from "@formbricks/ui/Badge"; -import { CheckIcon } from "@heroicons/react/24/outline"; +import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; +import { PricingCard } from "@formbricks/ui/PricingCard"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; -// upated on 20th of July 2023 -const stripeURl = - process.env.NODE_ENV === "production" - ? "https://buy.stripe.com/5kA9ABal07ZjgEw3cc" - : "https://billing.formbricks.com/b/test_28o02W1MObwybewfZ1"; +import { + manageSubscriptionAction, + removeSubscriptionAction, + upgradePlanAction, +} from "@/app/(app)/environments/[environmentId]/settings/billing/actions"; + +import { StripePriceLookupKeys, ProductFeatureKeys } from "@formbricks/ee/billing/lib/constants"; +import toast from "react-hot-toast"; +import AlertDialog from "@formbricks/ui/AlertDialog"; + interface PricingTableProps { team: TTeam; + environmentId: string; + peopleCount: number; + responseCount: number; + userTargetingFreeMtu: number; + inAppSurveyFreeResponses: number; } -export default function PricingTable({ team }: PricingTableProps) { +export default function PricingTableComponent({ + team, + environmentId, + peopleCount, + responseCount, + userTargetingFreeMtu, + inAppSurveyFreeResponses: appSurveyFreeResponses, +}: PricingTableProps) { const router = useRouter(); const [loadingCustomerPortal, setLoadingCustomerPortal] = useState(false); + const [upgradingPlan, setUpgradingPlan] = useState(false); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [activeLookupKey, setActiveLookupKey] = useState(); const openCustomerPortal = async () => { setLoadingCustomerPortal(true); - const res = await fetch("/api/billing/create-customer-portal-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - stripeCustomerId: team.stripeCustomerId, - returnUrl: `${window.location}`, - }), - }); - if (!res.ok) { - setLoadingCustomerPortal(false); - alert("Error loading billing portal"); - } - const { - data: { sessionUrl }, - } = await res.json(); + const sessionUrl = await manageSubscriptionAction(team.id, environmentId); router.push(sessionUrl); + setLoadingCustomerPortal(true); }; - const freeFeatures = [ - "Unlimited surveys", - "Unlimited team members", - "Remove branding", - "Unlimited link survey responses", - "100 responses per web-app survey", - "Granular targeting", - "In-product surveys", - "Link surveys", - "30+ templates", - "API access", - "Webhooks", - "Integrations (Zapier)", + const upgradePlan = async (priceLookupKeys: StripePriceLookupKeys[]) => { + try { + setUpgradingPlan(true); + const paymentUrl = await upgradePlanAction(team.id, environmentId, priceLookupKeys); + setUpgradingPlan(false); + if (!paymentUrl || paymentUrl === "") { + toast.success("Plan upgraded successfully"); + router.refresh(); + } else { + router.push(paymentUrl); + } + } catch (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 handleDeleteSubscription = async () => { + try { + if (!activeLookupKey) throw new Error("No active lookup key"); + await removeSubscriptionAction(team.id, environmentId, [activeLookupKey]); + router.refresh(); + toast.success("Subscription deleted successfully"); + } catch (err) { + toast.error("Unable to delete subscription"); + } finally { + setOpenDeleteModal(false); + } + }; + + const coreAndWebAppSurveyFeatures = [ + { + title: "Team 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: true, + }, + { + title: "Unlimited Responses", + unlimited: true, + }, ]; - const proFeatures = ["All features of Free plan", "Unlimited responses"]; + 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: true, + }, + { + title: "Unlimited User Identification", + unlimited: true, + }, + ]; + + const linkSurveysFeatures = [ + { + title: "Remove Formbricks Branding", + comingSoon: false, + }, + { + title: "File Uploads upto 1 GB", + comingSoon: false, + }, + { + title: "Multi Language Surveys", + comingSoon: true, + }, + ]; return (
@@ -67,103 +156,187 @@ export default function PricingTable({ team }: PricingTableProps) {
)} -
-
-
-
-

Free

- {team.plan === "free" && } -

- Always free. Giving back to the community. -

-
    - {freeFeatures.map((feature, index) => ( -
  • -
    - -
    - {feature} -
  • - ))} -
-

- Always free -

- {team.plan === "free" ? ( - - ) : ( - - )} -
+
+ {team.billing.stripeCustomerId ? ( +
+
-
-
-
-
-

Pro

- {team.plan === "pro" && } -

- All features included. Unlimited usage. -

-
    - {proFeatures.map((feature, index) => ( -
  • -
    - -
    - {feature} -
  • - ))} -
-

- $99 + ) : ( + <> +

+ +
+

+ 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. + +

+
+
+ +
+
+ {/*
+ +
+

Get the most out of Formbricks

+

+ Get access to all features by upgrading to a paid plan. +
+ With our metered billing you will not be charged until you exceed the free tier limits.{" "} +

+
+
+ +
+
*/} + + )} - / month -

- {team.plan === "pro" ? ( - - ) : ( - - )} -
-
-
+ { + if (team.billing.features.inAppSurvey.unlimited) { + return feature.unlimited !== false; + } else { + return feature.unlimited !== true; + } + })} + perMetricCharge={0.15} + loading={upgradingPlan} + onUpgrade={() => upgradePlan([StripePriceLookupKeys.inAppSurvey])} + onUbsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.inAppSurvey])} + /> -
-
-
-

Open-source

-

- Self-host Formbricks with all perks: Data ownership, customizability, limitless use. -

- -
-
-
+ upgradePlan([StripePriceLookupKeys.linkSurvey])} + onUbsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.linkSurvey])} + /> + + { + if (team.billing.features.userTargeting.unlimited) { + return feature.unlimited !== false; + } else { + return feature.unlimited !== true; + } + })} + perMetricCharge={0.01} + loading={upgradingPlan} + onUpgrade={() => upgradePlan([StripePriceLookupKeys.userTargeting])} + onUbsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.userTargeting])} + />
+ + { + setOpenDeleteModal(false); + }} + text="Your subscription for this product will be canceled at the end of the month. After that, you won't have access to the pro features anymore" + onSave={() => handleDeleteSubscription()} + confirmButtonLabel="Unsubscribe" + />
); } diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx index b2dc786ca4..278f984a5c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx @@ -1,14 +1,22 @@ export const revalidate = REVALIDATION_INTERVAL; -import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { + IS_FORMBRICKS_CLOUD, + PRICING_APPSURVEYS_FREE_RESPONSES, + REVALIDATION_INTERVAL, +} from "@formbricks/lib/constants"; import { authOptions } from "@formbricks/lib/authOptions"; -import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; +import { + getMonthlyActiveTeamPeopleCount, + getMonthlyTeamResponseCount, + getTeamByEnvironmentId, +} from "@formbricks/lib/team/service"; import { getServerSession } from "next-auth"; import { notFound } from "next/navigation"; import SettingsTitle from "../components/SettingsTitle"; import PricingTable from "./components/PricingTable"; +import { PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants"; export default async function ProfileSettingsPage({ params }) { if (!IS_FORMBRICKS_CLOUD) { @@ -16,6 +24,7 @@ export default async function ProfileSettingsPage({ params }) { } const session = await getServerSession(authOptions); + const team = await getTeamByEnvironmentId(params.environmentId); if (!session) { @@ -26,11 +35,23 @@ export default async function ProfileSettingsPage({ params }) { throw new Error("Team not found"); } + const [peopleCount, responseCount] = await Promise.all([ + getMonthlyActiveTeamPeopleCount(team.id), + getMonthlyTeamResponseCount(team.id), + ]); + return ( <>
- +
); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/components/EditSignature.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/components/EditSignature.tsx index 0ab483cfe7..aadcdd78a2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/components/EditSignature.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/components/EditSignature.tsx @@ -1,17 +1,21 @@ "use client"; +import { Alert, AlertDescription } from "@formbricks/ui/Alert"; import { updateProductAction } from "../actions"; import { TProduct, TProductUpdateInput } from "@formbricks/types/product"; import { Label } from "@formbricks/ui/Label"; import { Switch } from "@formbricks/ui/Switch"; import { useState } from "react"; import toast from "react-hot-toast"; +import Link from "next/link"; interface EditSignatureProps { product: TProduct; + canRemoveSignature: boolean; + environmentId: string; } -export function EditFormbricksSignature({ product }: EditSignatureProps) { +export function EditFormbricksSignature({ product, canRemoveSignature, environmentId }: EditSignatureProps) { const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature); const [updatingSignature, setUpdatingSignature] = useState(false); @@ -36,14 +40,27 @@ export function EditFormbricksSignature({ product }: EditSignatureProps) { return (
+ {!canRemoveSignature && ( +
+ + + To remove the Formbricks branding from the link surveys, please{" "} + + upgrade + {" "} + your plan. + + +
+ )}
- +
); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx index 949edfd78f..30fcf34468 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx @@ -9,12 +9,20 @@ import { EditBrandColor } from "./components/EditBrandColor"; import { EditPlacement } from "./components/EditPlacement"; import { EditHighlightBorder } from "./components/EditHighlightBorder"; import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) { - const product = await getProductByEnvironmentId(params.environmentId); + const [team, product] = await Promise.all([ + getTeamByEnvironmentId(params.environmentId), + getProductByEnvironmentId(params.environmentId), + ]); + if (!team) { + throw new Error("Team not found"); + } if (!product) { throw new Error("Product not found"); } + const canRemoveSignature = team.billing.features.linkSurvey.status !== "inactive"; return (
@@ -36,7 +44,11 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro - +
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx index 2a00c819d9..724b91ceb2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx @@ -366,7 +366,7 @@ export default function SurveyMenuBar({ router.back(); }} text="You have unsaved changes in your survey. Would you like to save them before leaving?" - useSaveInsteadOfCancel={true} + confirmButtonLabel="Save" onSave={() => saveSurveyAction(true)} />
diff --git a/apps/web/app/api/billing/create-customer-portal-session/route.ts b/apps/web/app/api/billing/create-customer-portal-session/route.ts deleted file mode 100644 index de8e3ff930..0000000000 --- a/apps/web/app/api/billing/create-customer-portal-session/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import createCustomerPortalSession from "@formbricks/ee/billing/api/create-customer-portal-session"; -import { NextResponse } from "next/server"; - -export async function POST(request: Request): Promise { - try { - const responseInput = await request.json(); - const { stripeCustomerId, returnUrl } = responseInput; - const sessionUrl = await createCustomerPortalSession(stripeCustomerId, returnUrl); - - return responses.successResponse({ sessionUrl: sessionUrl }, true); - } catch (error) { - console.error(error); - return responses.internalServerErrorResponse( - "Unable to complete response. See server logs for details.", - true - ); - } -} diff --git a/apps/web/app/api/billing/stripe-webhook/route.ts b/apps/web/app/api/billing/stripe-webhook/route.ts index 411def3fea..0525a8ca1c 100644 --- a/apps/web/app/api/billing/stripe-webhook/route.ts +++ b/apps/web/app/api/billing/stripe-webhook/route.ts @@ -9,7 +9,7 @@ export async function POST(request: Request) { const { status, message } = await webhookHandler(body, signature); if (status != 200) { - return responses.badRequestResponse(message.toString()); + return responses.badRequestResponse(message?.toString() || "Something went wrong"); } return responses.successResponse({ message }, true); } diff --git a/apps/web/app/api/cron/report-usage/route.ts b/apps/web/app/api/cron/report-usage/route.ts new file mode 100644 index 0000000000..edc3cbca55 --- /dev/null +++ b/apps/web/app/api/cron/report-usage/route.ts @@ -0,0 +1,74 @@ +import { responses } from "@/app/lib/api/response"; +import { reportUsageToStripe } from "@formbricks/ee/billing/lib/reportUsage"; +import { ProductFeatureKeys } from "@formbricks/ee/billing/lib/constants"; +import { CRON_SECRET, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { + getMonthlyActiveTeamPeopleCount, + getMonthlyTeamResponseCount, + getTeamsWithPaidPlan, +} from "@formbricks/lib/team/service"; +import { TTeam } from "@formbricks/types/teams"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +async function reportTeamUsage(team: TTeam) { + const stripeCustomerId = team.billing.stripeCustomerId; + if (!stripeCustomerId) { + return; + } + + if (!IS_FORMBRICKS_CLOUD) { + return; + } + + let calculateResponses = + team.billing.features.inAppSurvey.status !== "inactive" && !team.billing.features.inAppSurvey.unlimited; + let calculatePeople = + team.billing.features.userTargeting.status !== "inactive" && + !team.billing.features.userTargeting.unlimited; + + if (!calculatePeople && !calculateResponses) { + return; + } + let people = await getMonthlyActiveTeamPeopleCount(team.id); + let responses = await getMonthlyTeamResponseCount(team.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 async function GET(): Promise { + const headersList = headers(); + const apiKey = headersList.get("x-api-key"); + + if (!apiKey || apiKey !== CRON_SECRET) { + return responses.notAuthenticatedResponse(); + } + + try { + const teamsWithPaidPlan = await getTeamsWithPaidPlan(); + await Promise.all(teamsWithPaidPlan.map(reportTeamUsage)); + + return responses.successResponse({}, true); + } catch (error) { + console.error(error); + return responses.internalServerErrorResponse( + "Unable to complete response. See server logs for details.", + true + ); + } +} diff --git a/apps/web/app/api/v1/client/storage/local/route.ts b/apps/web/app/api/v1/client/storage/local/route.ts index a856fa9ad7..45c757f391 100644 --- a/apps/web/app/api/v1/client/storage/local/route.ts +++ b/apps/web/app/api/v1/client/storage/local/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; import { putFileToLocalStorage } from "@formbricks/lib/storage/service"; import { UPLOADS_DIR } from "@formbricks/lib/constants"; -import { ENCRYPTION_KEY } from "@formbricks/lib/constants"; +import { env } from "@/env.mjs"; import { getSurvey } from "@formbricks/lib/survey/service"; import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { validateLocalSignedUrl } from "@formbricks/lib/crypto"; @@ -70,7 +70,7 @@ export async function POST(req: NextRequest): Promise { fileType, Number(signedTimestamp), signedSignature, - ENCRYPTION_KEY + env.ENCRYPTION_KEY ); if (!validated) { @@ -85,7 +85,7 @@ export async function POST(req: NextRequest): Promise { } try { - const { plan } = team; + const plan = team.billing.features.linkSurvey.status in ["active", "canceled"] ? "pro" : "free"; const bytes = await file.arrayBuffer(); const fileBuffer = Buffer.from(bytes); diff --git a/apps/web/app/api/v1/client/storage/route.ts b/apps/web/app/api/v1/client/storage/route.ts index 130fbecbf5..2691616011 100644 --- a/apps/web/app/api/v1/client/storage/route.ts +++ b/apps/web/app/api/v1/client/storage/route.ts @@ -39,7 +39,7 @@ export async function POST(req: NextRequest): Promise { return responses.notFoundResponse("TeamByEnvironmentId", environmentId); } - const { plan } = team; + const plan = team.billing.features.linkSurvey.status in ["active", "canceled"] ? "pro" : "free"; return await uploadPrivateFile(fileName, environmentId, fileType, plan); } diff --git a/apps/web/app/api/v1/js/sync/lib/sync.ts b/apps/web/app/api/v1/js/sync/lib/sync.ts index bb831e7a6a..1181229377 100644 --- a/apps/web/app/api/v1/js/sync/lib/sync.ts +++ b/apps/web/app/api/v1/js/sync/lib/sync.ts @@ -1,10 +1,20 @@ import { getSyncSurveysCached } from "@/app/api/v1/js/sync/lib/surveys"; -import { MAU_LIMIT } from "@formbricks/lib/constants"; 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 { getEnvironment } from "@formbricks/lib/environment/service"; -import { createPerson, getMonthlyActivePeopleCount, getPerson } from "@formbricks/lib/person/service"; +import { createPerson, getPerson } from "@formbricks/lib/person/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { createSession, extendSession, getSession } from "@formbricks/lib/session/service"; +import { + getMonthlyActiveTeamPeopleCount, + getMonthlyTeamResponseCount, + getTeamByEnvironmentId, +} from "@formbricks/lib/team/service"; import { captureTelemetry } from "@formbricks/lib/telemetry"; import { TEnvironment } from "@formbricks/types/environment"; import { TJsState } from "@formbricks/types/js"; @@ -32,25 +42,37 @@ export const getUpdatedState = async ( throw new Error("Environment does not exist"); } + // check team subscriptons + const team = await getTeamByEnvironmentId(environmentId); + + if (!team) { + throw new Error("Team does not exist"); + } + // check if Monthly Active Users limit is reached - const currentMau = await getMonthlyActivePeopleCount(environmentId); - const isMauLimitReached = currentMau >= MAU_LIMIT; - if (isMauLimitReached) { - const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`; - if (!personId || !sessionId) { - // don't allow new people or sessions - throw new Error(errorMessage); - } - const session = await getSession(sessionId); - if (!session) { - // don't allow new sessions - throw new Error(errorMessage); - } - // check if session was created this month (user already active this month) - const now = new Date(); - const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - if (new Date(session.createdAt) < firstDayOfMonth) { - throw new Error(errorMessage); + if (IS_FORMBRICKS_CLOUD) { + const hasUserTargetingSubscription = + team?.billing?.features.userTargeting.status && + team?.billing?.features.userTargeting.status in ["active", "canceled"]; + const currentMau = await getMonthlyActiveTeamPeopleCount(team.id); + const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU; + if (isMauLimitReached) { + const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`; + if (!personId || !sessionId) { + // don't allow new people or sessions + throw new Error(errorMessage); + } + const session = await getSession(sessionId); + if (!session) { + // don't allow new sessions + throw new Error(errorMessage); + } + // check if session was created this month (user already active this month) + const now = new Date(); + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + if (new Date(session.createdAt) < firstDayOfMonth) { + throw new Error(errorMessage); + } } } @@ -96,11 +118,22 @@ export const getUpdatedState = async ( } } } - // we now have a valid person & session + // check if App Survey limit is reached + let isAppSurveyLimitReached = false; + if (IS_FORMBRICKS_CLOUD) { + const hasAppSurveySubscription = + team?.billing?.features.inAppSurvey.status && + team?.billing?.features.inAppSurvey.status in ["active", "canceled"]; + const monthlyResponsesCount = await getMonthlyTeamResponseCount(team.id); + isAppSurveyLimitReached = + IS_FORMBRICKS_CLOUD && + !hasAppSurveySubscription && + monthlyResponsesCount >= PRICING_APPSURVEYS_FREE_RESPONSES; + } // get/create rest of the state const [surveys, noCodeActionClasses, product] = await Promise.all([ - getSyncSurveysCached(environmentId, person), + !isAppSurveyLimitReached ? getSyncSurveysCached(environmentId, person) : [], getActionClasses(environmentId), getProductByEnvironmentId(environmentId), ]); 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 3932c6c7b6..20b1ed1e08 100644 --- a/apps/web/app/api/v1/management/storage/local/route.ts +++ b/apps/web/app/api/v1/management/storage/local/route.ts @@ -10,7 +10,7 @@ import { authOptions } from "@formbricks/lib/authOptions"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { UPLOADS_DIR } from "@formbricks/lib/constants"; import { validateLocalSignedUrl } from "@formbricks/lib/crypto"; -import { ENCRYPTION_KEY } from "@formbricks/lib/constants"; +import { env } from "@/env.mjs"; export async function POST(req: NextRequest): Promise { const accessType = "public"; // public files are accessible by anyone @@ -69,7 +69,7 @@ export async function POST(req: NextRequest): Promise { fileType, Number(signedTimestamp), signedSignature, - ENCRYPTION_KEY + env.ENCRYPTION_KEY ); if (!validated) { diff --git a/apps/web/app/api/v1/og/route.tsx b/apps/web/app/api/v1/og/route.tsx index ad417bc508..0fee37182c 100644 --- a/apps/web/app/api/v1/og/route.tsx +++ b/apps/web/app/api/v1/og/route.tsx @@ -1,4 +1,5 @@ -import { ImageResponse, NextRequest } from "next/server"; +import { ImageResponse } from "@vercel/og"; +import { NextRequest } from "next/server"; /* import { NextRequest } from "next/server"; import { ImageResponse } from "next/og"; */ // App router includes @vercel/og. diff --git a/apps/web/app/lib/singleUseSurveys.ts b/apps/web/app/lib/singleUseSurveys.ts index 6b3ad476c2..c1b076c021 100644 --- a/apps/web/app/lib/singleUseSurveys.ts +++ b/apps/web/app/lib/singleUseSurveys.ts @@ -1,4 +1,4 @@ -import { FORMBRICKS_ENCRYPTION_KEY, ENCRYPTION_KEY } from "@formbricks/lib/constants"; +import { env } from "@/env.mjs"; import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto"; import cuid2 from "@paralleldrive/cuid2"; @@ -9,7 +9,7 @@ export const generateSurveySingleUseId = (isEncrypted: boolean): string => { return cuid; } - const encryptedCuid = symmetricEncrypt(cuid, ENCRYPTION_KEY); + const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY); return encryptedCuid; }; @@ -19,13 +19,13 @@ export const validateSurveySingleUseId = (surveySingleUseId: string): string | u let decryptedCuid: string | null = null; if (surveySingleUseId.length === 64) { - if (!FORMBRICKS_ENCRYPTION_KEY) { + if (!env.FORMBRICKS_ENCRYPTION_KEY) { throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); } - decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId); + decryptedCuid = decryptAES128(env.FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId); } else { - decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY); + decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY); } if (cuid2.isCuid(decryptedCuid)) { diff --git a/packages/database/jsonTypes.ts b/packages/database/jsonTypes.ts index e2b5b548f6..b4159cd9f1 100644 --- a/packages/database/jsonTypes.ts +++ b/packages/database/jsonTypes.ts @@ -11,6 +11,7 @@ import { TSurveyThankYouCard, TSurveyVerifyEmail, } from "@formbricks/types/surveys"; +import { TTeamBilling } from "@formbricks/types/teams"; import { TUserNotificationSettings } from "@formbricks/types/users"; declare global { @@ -29,6 +30,7 @@ declare global { export type SurveyClosedMessage = TSurveyClosedMessage; export type SurveySingleUse = TSurveySingleUse; export type SurveyVerifyEmail = TSurveyVerifyEmail; + export type TeamBilling = TTeamBilling; export type UserNotificationSettings = TUserNotificationSettings; } } diff --git a/packages/database/migrations/20231030174314_add_billing_to_team/migration.sql b/packages/database/migrations/20231030174314_add_billing_to_team/migration.sql new file mode 100644 index 0000000000..b7c0531dfa --- /dev/null +++ b/packages/database/migrations/20231030174314_add_billing_to_team/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `plan` on the `Team` table. All the data in the column will be lost. + - You are about to drop the column `stripeCustomerId` on the `Team` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Team" DROP COLUMN "plan", +DROP COLUMN "stripeCustomerId", +ADD COLUMN "billing" JSONB NOT NULL DEFAULT '{"stripeCustomerId": null, "features": {"inAppSurvey": {"status": "inactive", "unlimited": false}, "linkSurvey": {"status": "inactive", "unlimited": false}, "userTargeting": {"status": "inactive", "unlimited": false}}}'; + +-- DropEnum +DROP TYPE "Plan"; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index c3c02b4ad8..1025e4cf69 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -388,21 +388,18 @@ model Product { @@unique([teamId, name]) } -enum Plan { - free - pro -} - model Team { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") - name String - memberships Membership[] - products Product[] - plan Plan @default(free) - stripeCustomerId String? - invites Invite[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + memberships Membership[] + products Product[] + /// @zod.custom(imports.ZTeamBilling) + /// [TeamBilling] + billing Json @default("{\"stripeCustomerId\": null, \"features\": {\"inAppSurvey\": {\"status\": \"inactive\", \"unlimited\": false}, \"linkSurvey\": {\"status\": \"inactive\", \"unlimited\": false}, \"userTargeting\": {\"status\": \"inactive\", \"unlimited\": false}}}") + + invites Invite[] } enum MembershipRole { diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 6b944a801f..6a1275dac3 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -17,4 +17,5 @@ export { ZSurveySingleUse, } from "@formbricks/types/surveys"; +export { ZTeamBilling } from "@formbricks/types/teams"; export { ZUserNotificationSettings } from "@formbricks/types/users"; diff --git a/packages/ee/billing/api/stripe-webhook.ts b/packages/ee/billing/api/stripe-webhook.ts index d14ac48ff2..2eb349d985 100644 --- a/packages/ee/billing/api/stripe-webhook.ts +++ b/packages/ee/billing/api/stripe-webhook.ts @@ -1,8 +1,9 @@ -import { updateTeam } from "@formbricks/lib/team/service"; - import Stripe from "stripe"; +import { handleCheckoutSessionCompleted } from "../handlers/checkoutSessionCompleted"; +import { handleSubscriptionUpdatedOrCreated } from "../handlers/subscriptionCreatedOrUpdated"; +import { handleSubscriptionDeleted } from "../handlers/subscriptionDeleted"; + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - // https://github.com/stripe/stripe-node#configuration apiVersion: "2023-10-16", }); @@ -15,41 +16,20 @@ const webhookHandler = async (requestBody: string, stripeSignature: string) => { event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; - // On error, log and return the error message. if (err! instanceof Error) console.log(err); return { status: 400, message: `Webhook Error: ${errorMessage}` }; } - // Cast event data to Stripe object. if (event.type === "checkout.session.completed") { - const checkoutSession = event.data.object as Stripe.Checkout.Session; - const teamId = checkoutSession.client_reference_id; - if (!teamId) { - return { status: 400, message: "skipping, no teamId found" }; - } - const stripeCustomerId = checkoutSession.customer as string; - const plan = "pro"; - await updateTeam(teamId, { stripeCustomerId, plan }); - - const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string); - await stripe.subscriptions.update(subscription.id, { - metadata: { - teamId, - }, - }); + await handleCheckoutSessionCompleted(event); + } else if ( + event.type === "customer.subscription.updated" || + event.type === "customer.subscription.created" + ) { + await handleSubscriptionUpdatedOrCreated(event); } else if (event.type === "customer.subscription.deleted") { - const subscription = event.data.object as Stripe.Subscription; - const teamId = subscription.metadata.teamId; - if (!teamId) { - console.error("No teamId found in subscription"); - return { status: 400, message: "skipping, no teamId found" }; - } - await updateTeam(teamId, { plan: "free" }); - } else { - console.warn(`🤷‍♀️ Unhandled event type: ${event.type}`); + await handleSubscriptionDeleted(event); } - - // Return a response to acknowledge receipt of the event. return { status: 200, message: { received: true } }; }; diff --git a/packages/ee/billing/handlers/checkoutSessionCompleted.ts b/packages/ee/billing/handlers/checkoutSessionCompleted.ts new file mode 100644 index 0000000000..b3d2673554 --- /dev/null +++ b/packages/ee/billing/handlers/checkoutSessionCompleted.ts @@ -0,0 +1,83 @@ +import { + getMonthlyActiveTeamPeopleCount, + getMonthlyTeamResponseCount, + getTeam, + updateTeam, +} from "@formbricks/lib/team/service"; + +import Stripe from "stripe"; +import { StripePriceLookupKeys, ProductFeatureKeys, StripeProductNames } from "../lib/constants"; +import { reportUsage } from "../lib/reportUsage"; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + // https://github.com/stripe/stripe-node#configuration + apiVersion: "2023-10-16", +}); + +export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => { + const checkoutSession = event.data.object as Stripe.Checkout.Session; + 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 team = await getTeam(stripeSubscriptionObject.metadata.teamId); + if (!team) throw new Error("Team not found."); + let updatedFeatures = team.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.inAppSurvey.status = "active"; + if (item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimited) { + updatedFeatures.inAppSurvey.unlimited = true; + } else { + const countForTeam = await getMonthlyTeamResponseCount(team.id); + await reportUsage( + stripeSubscriptionObject.items.data, + ProductFeatureKeys.inAppSurvey, + countForTeam + ); + } + break; + + case StripeProductNames.linkSurvey: + updatedFeatures.linkSurvey.status = "active"; + if (item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimited) { + updatedFeatures.linkSurvey.unlimited = true; + } + break; + + case StripeProductNames.userTargeting: + updatedFeatures.userTargeting.status = "active"; + if (item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimited) { + updatedFeatures.userTargeting.unlimited = true; + } else { + const countForTeam = await getMonthlyActiveTeamPeopleCount(team.id); + + await reportUsage( + stripeSubscriptionObject.items.data, + ProductFeatureKeys.userTargeting, + countForTeam + ); + } + break; + } + } + + await stripe.customers.update(stripeCustomer.id, { + name: team.name, + metadata: { team: team.id }, + }); + + await updateTeam(team.id, { + billing: { + stripeCustomerId: stripeCustomer.id, + features: updatedFeatures, + }, + }); +}; diff --git a/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts b/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts new file mode 100644 index 0000000000..2ebd249855 --- /dev/null +++ b/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts @@ -0,0 +1,96 @@ +import { + getMonthlyActiveTeamPeopleCount, + getMonthlyTeamResponseCount, + getTeam, + updateTeam, +} from "@formbricks/lib/team/service"; +import Stripe from "stripe"; +import { StripePriceLookupKeys, ProductFeatureKeys, StripeProductNames } from "../lib/constants"; +import { reportUsage } from "../lib/reportUsage"; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + // https://github.com/stripe/stripe-node#configuration + apiVersion: "2023-10-16", +}); + +export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) => { + const stripeSubscriptionObject = event.data.object as Stripe.Subscription; + const teamId = stripeSubscriptionObject.metadata.teamId; + if (!teamId) { + console.error("No teamId found in subscription"); + return { status: 400, message: "skipping, no teamId found" }; + } + + const team = await getTeam(teamId); + if (!team) throw new Error("Team not found."); + let updatedFeatures = team.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: + if ( + !( + stripeSubscriptionObject.cancel_at_period_end && + team.billing.features.inAppSurvey.status === "cancelled" + ) + ) { + updatedFeatures.inAppSurvey.status = "active"; + } + if (item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimited) { + updatedFeatures.inAppSurvey.unlimited = true; + } else { + const countForTeam = await getMonthlyTeamResponseCount(team.id); + + await reportUsage( + stripeSubscriptionObject.items.data, + ProductFeatureKeys.inAppSurvey, + countForTeam + ); + } + break; + + case StripeProductNames.linkSurvey: + if ( + !( + stripeSubscriptionObject.cancel_at_period_end && + team.billing.features.linkSurvey.status === "cancelled" + ) + ) { + updatedFeatures.linkSurvey.status = "active"; + } + if (item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimited) { + updatedFeatures.linkSurvey.unlimited = true; + } + break; + case StripeProductNames.userTargeting: + if ( + !( + stripeSubscriptionObject.cancel_at_period_end && + team.billing.features.userTargeting.status === "cancelled" + ) + ) { + updatedFeatures.userTargeting.status = "active"; + } + if (item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimited) { + updatedFeatures.userTargeting.unlimited = true; + } else { + const countForTeam = await getMonthlyActiveTeamPeopleCount(team.id); + + await reportUsage( + stripeSubscriptionObject.items.data, + ProductFeatureKeys.userTargeting, + countForTeam + ); + } + break; + } + } + + await updateTeam(teamId, { + billing: { + ...team.billing, + features: updatedFeatures, + }, + }); +}; diff --git a/packages/ee/billing/handlers/subscriptionDeleted.ts b/packages/ee/billing/handlers/subscriptionDeleted.ts new file mode 100644 index 0000000000..e8f6152175 --- /dev/null +++ b/packages/ee/billing/handlers/subscriptionDeleted.ts @@ -0,0 +1,53 @@ +import { getTeam, updateTeam } from "@formbricks/lib/team/service"; +import Stripe from "stripe"; +import { ProductFeatureKeys, StripeProductNames } from "../lib/constants"; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + // https://github.com/stripe/stripe-node#configuration + apiVersion: "2023-10-16", +}); + +export const handleSubscriptionDeleted = async (event: Stripe.Event) => { + const stripeSubscriptionObject = event.data.object as Stripe.Subscription; + const teamId = stripeSubscriptionObject.metadata.teamId; + if (!teamId) { + console.error("No teamId found in subscription"); + return { status: 400, message: "skipping, no teamId found" }; + } + + const team = await getTeam(teamId); + if (!team) throw new Error("Team not found."); + + let updatedFeatures = team.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 team.billing.features].status = + "inactive"; + updatedFeatures[ProductFeatureKeys.inAppSurvey as keyof typeof team.billing.features].unlimited = + false; + break; + case StripeProductNames.linkSurvey: + updatedFeatures[ProductFeatureKeys.linkSurvey as keyof typeof team.billing.features].status = + "inactive"; + updatedFeatures[ProductFeatureKeys.linkSurvey as keyof typeof team.billing.features].unlimited = + false; + break; + case StripeProductNames.userTargeting: + updatedFeatures[ProductFeatureKeys.userTargeting as keyof typeof team.billing.features].status = + "inactive"; + updatedFeatures[ProductFeatureKeys.userTargeting as keyof typeof team.billing.features].unlimited = + false; + break; + } + } + + await updateTeam(teamId, { + billing: { + ...team.billing, + features: updatedFeatures, + }, + }); +}; diff --git a/packages/ee/billing/lib/constants.ts b/packages/ee/billing/lib/constants.ts new file mode 100644 index 0000000000..78135b3fb8 --- /dev/null +++ b/packages/ee/billing/lib/constants.ts @@ -0,0 +1,19 @@ +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", + inAppSurveyUnlimited = "survey-unlimited-30102023", + linkSurveyUnlimited = "linkSurvey-unlimited-30102023", + userTargetingUnlimited = "userTargeting-unlimited-30102023", +} diff --git a/packages/ee/billing/api/create-customer-portal-session.ts b/packages/ee/billing/lib/createCustomerPortalSession.ts similarity index 66% rename from packages/ee/billing/api/create-customer-portal-session.ts rename to packages/ee/billing/lib/createCustomerPortalSession.ts index 0662eda992..08c6004098 100644 --- a/packages/ee/billing/api/create-customer-portal-session.ts +++ b/packages/ee/billing/lib/createCustomerPortalSession.ts @@ -4,13 +4,10 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2023-10-16", }); -const createCustomerPortalSession = async (stripeCustomerId: string, returnUrl: string) => { - // Authenticate your user. +export const createCustomerPortalSession = async (stripeCustomerId: string, returnUrl: string) => { const session = await stripe.billingPortal.sessions.create({ customer: stripeCustomerId, return_url: returnUrl, }); return session.url; }; - -export default createCustomerPortalSession; diff --git a/packages/ee/billing/lib/createSubscription.ts b/packages/ee/billing/lib/createSubscription.ts new file mode 100644 index 0000000000..56ba44267e --- /dev/null +++ b/packages/ee/billing/lib/createSubscription.ts @@ -0,0 +1,181 @@ +import { getTeam } from "@formbricks/lib/team/service"; +import { StripePriceLookupKeys } from "./constants"; +import Stripe from "stripe"; +import { WEBAPP_URL } from "@formbricks/lib/constants"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2023-10-16", +}); + +const baseUrl = process.env.NODE_ENV === "production" ? WEBAPP_URL : "http://localhost:3000"; + +export const getFirstOfNextMonthTimestamp = (): number => { + const nextMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1); + return Math.floor(nextMonth.getTime() / 1000); +}; + +export const createSubscription = async ( + teamId: string, + environmentId: string, + priceLookupKeys: StripePriceLookupKeys[] +) => { + try { + const team = await getTeam(teamId); + if (!team) throw new Error("Team not found."); + let isNewTeam = + !team.billing.stripeCustomerId || !(await stripe.customers.retrieve(team.billing.stripeCustomerId)); + + let lineItems: { price: string; quantity?: number }[] = []; + + const prices = ( + await stripe.prices.list({ + lookup_keys: priceLookupKeys, + }) + ).data; + if (!prices) throw new Error("Price not found."); + + prices.forEach((price) => { + lineItems.push({ + price: price.id, + ...(price.billing_scheme === "per_unit" && { quantity: 1 }), + }); + }); + + // if the team has never purchased a plan then we just create a new session and store their stripe customer id + if (isNewTeam) { + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: lineItems, + success_url: `${baseUrl}/billing-confirmation?environmentId=${environmentId}`, + cancel_url: `${baseUrl}/environments/${environmentId}/settings/billing`, + allow_promotion_codes: true, + subscription_data: { + billing_cycle_anchor: getFirstOfNextMonthTimestamp(), + metadata: { teamId }, + }, + }); + + return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url }; + } + + const existingSubscription = ( + (await stripe.customers.retrieve(team.billing.stripeCustomerId as string, { + expand: ["subscriptions"], + })) as any + ).subscriptions.data[0] as Stripe.Subscription; + + // the team has an active subscription + if (existingSubscription) { + // now we see if the team's current subscription is scheduled to cancel at the month end + // this is a case where the team cancelled an already purchased product + if (existingSubscription.cancel_at_period_end) { + const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({ + customer: team.billing.stripeCustomerId as string, + }); + const scheduledSubscriptions = allScheduledSubscriptions.data.filter( + (scheduledSub) => scheduledSub.status === "not_started" + ); + + // if a team 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((acc, item) => { + acc[item.price] = item; // This will overwrite duplicate items based on price + return acc; + }, {} as { [key: string]: { price: string; quantity?: number } }); + + const lineItemsForScheduledSubscription = Object.values(uniqueItemsMap); + + await stripe.subscriptionSchedules.update(scheduledSubscriptions[0].id, { + end_behavior: "release", + phases: [ + { + start_date: getFirstOfNextMonthTimestamp(), + items: lineItemsForScheduledSubscription, + iterations: 1, + metadata: { teamId }, + }, + ], + metadata: { teamId }, + }); + } 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 team has subscribed to + await stripe.subscriptionSchedules.create({ + customer: team.billing.stripeCustomerId as string, + start_date: getFirstOfNextMonthTimestamp(), + end_behavior: "release", + phases: [ + { + items: lineItems, + iterations: 1, + metadata: { teamId }, + }, + ], + metadata: { teamId }, + }); + } + } + + // 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 && + team.billing.features[priceLookupKey as keyof typeof team.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 team 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: team.billing.stripeCustomerId as string, + items: lineItems, + billing_cycle_anchor: getFirstOfNextMonthTimestamp(), + metadata: { teamId }, + }); + } + + return { + status: 200, + data: "Congrats! Added to your existing subscription!", + newPlan: false, + url: "", + }; + } catch (err) { + console.error(err); + return { + status: 500, + data: "Something went wrong!", + newPlan: true, + url: `${baseUrl}/environments/${environmentId}/settings/billing`, + }; + } +}; diff --git a/packages/ee/billing/lib/removeSubscription.ts b/packages/ee/billing/lib/removeSubscription.ts new file mode 100644 index 0000000000..e49cce1f47 --- /dev/null +++ b/packages/ee/billing/lib/removeSubscription.ts @@ -0,0 +1,121 @@ +import { getTeam, updateTeam } from "@formbricks/lib/team/service"; +import { getFirstOfNextMonthTimestamp } from "./createSubscription"; +import Stripe from "stripe"; +import { StripePriceLookupKeys } from "./constants"; +import { WEBAPP_URL } from "@formbricks/lib/constants"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2023-10-16", +}); + +const baseUrl = process.env.NODE_ENV === "production" ? WEBAPP_URL : "http://localhost:3000"; + +const retrievePriceLookup = async (priceId: string) => (await stripe.prices.retrieve(priceId)).lookup_key; + +export const removeSubscription = async ( + teamId: string, + environmentId: string, + priceLookupKeys: StripePriceLookupKeys[] +) => { + try { + const team = await getTeam(teamId); + if (!team) throw new Error("Team not found."); + if (!team.billing.stripeCustomerId) { + return { status: 400, data: "No subscription exists for given team!", newPlan: false, url: "" }; + } + + const existingCustomer = (await stripe.customers.retrieve(team.billing.stripeCustomerId, { + expand: ["subscriptions"], + })) as Stripe.Customer; + const existingSubscription = existingCustomer.subscriptions?.data[0] as Stripe.Subscription; + + const allScheduledSubscriptions = await stripe.subscriptionSchedules.list({ + customer: team.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: { teamId }, + }, + ], + metadata: { teamId }, + }); + } + } 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: team.billing.stripeCustomerId, + start_date: getFirstOfNextMonthTimestamp(), + end_behavior: "release", + phases: [ + { + items: newPriceIds.map((priceId) => ({ price: priceId })), + iterations: 1, + metadata: { teamId }, + }, + ], + metadata: { teamId }, + }); + } + } + + await stripe.subscriptions.update(existingSubscription.id, { cancel_at_period_end: true }); + + let updatedFeatures = team.billing.features; + for (const priceLookupKey of priceLookupKeys) { + updatedFeatures[priceLookupKey as keyof typeof updatedFeatures].status = "cancelled"; + } + + await updateTeam(teamId, { + billing: { + ...team.billing, + features: updatedFeatures, + }, + }); + + return { + status: 200, + data: "Successfully removed from your existing subscription!", + newPlan: false, + url: "", + }; + } catch (err) { + console.log("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/reportUsage.ts b/packages/ee/billing/lib/reportUsage.ts new file mode 100644 index 0000000000..fd82ba3dc4 --- /dev/null +++ b/packages/ee/billing/lib/reportUsage.ts @@ -0,0 +1,60 @@ +import { ProductFeatureKeys } from "./constants"; +import Stripe from "stripe"; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + // https://github.com/stripe/stripe-node#configuration + apiVersion: "2023-10-16", +}); + +export const reportUsage = async ( + items: Stripe.SubscriptionItem[], + lookupKey: ProductFeatureKeys, + quantity: number +) => { + 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: quantity, + timestamp: Math.floor(Date.now() / 1000), + }); +}; + +export const reportUsageToStripe = async ( + stripeCustomerId: string, + usage: number, + lookupKey: ProductFeatureKeys, + timestamp: number +) => { + 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: timestamp, + }); + + return { status: 200, data: usageRecord.quantity }; + } catch (error) { + return { status: 500, data: "Something went wrong: " + error }; + } +}; + +export default reportUsageToStripe; diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index 944c9fd7fd..0a8291b09e 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -52,7 +52,7 @@ export const renderWidget = (survey: TSurveyWithTriggers) => { renderSurveyModal({ survey: survey, brandColor, - formbricksSignature: product.formbricksSignature, + formbricksSignature: true, clickOutside, darkOverlay, highlightBorderColor, diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index b9ea2c17c7..b770659468 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -1,60 +1,61 @@ import "server-only"; import path from "path"; -import { env } from "@/env.mjs"; -export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1"; +export const IS_FORMBRICKS_CLOUD = process.env.IS_FORMBRICKS_CLOUD === "1"; export const REVALIDATION_INTERVAL = 0; //TODO: find a good way to cache and revalidate data when it changes export const SERVICES_REVALIDATION_INTERVAL = 60 * 30; // 30 minutes export const MAU_LIMIT = IS_FORMBRICKS_CLOUD ? 9000 : 1000000; // URLs export const WEBAPP_URL = - env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000"; + process.env.WEBAPP_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : false) || + "http://localhost:3000"; -export const SHORT_URL_BASE = env.SHORT_URL_BASE ? env.SHORT_URL_BASE : WEBAPP_URL; +export const SHORT_URL_BASE = process.env.SHORT_URL_BASE ? process.env.SHORT_URL_BASE : WEBAPP_URL; // encryption keys -export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined; -export const ENCRYPTION_KEY = env.ENCRYPTION_KEY; +export const FORMBRICKS_ENCRYPTION_KEY = process.env.FORMBRICKS_ENCRYPTION_KEY || undefined; +export const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; // Other export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || ""; -export const CRON_SECRET = env.CRON_SECRET; +export const CRON_SECRET = process.env.CRON_SECRET; export const DEFAULT_BRAND_COLOR = "#64748b"; -export const PRIVACY_URL = env.PRIVACY_URL; -export const TERMS_URL = env.TERMS_URL; -export const IMPRINT_URL = env.IMPRINT_URL; +export const PRIVACY_URL = process.env.PRIVACY_URL; +export const TERMS_URL = process.env.TERMS_URL; +export const IMPRINT_URL = process.env.IMPRINT_URL; -export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1"; -export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1"; -export const GOOGLE_OAUTH_ENABLED = env.GOOGLE_AUTH_ENABLED === "1"; -export const GITHUB_OAUTH_ENABLED = env.GITHUB_AUTH_ENABLED === "1"; -export const AZURE_OAUTH_ENABLED = env.AZUREAD_AUTH_ENABLED === "1"; +export const PASSWORD_RESET_DISABLED = process.env.PASSWORD_RESET_DISABLED === "1"; +export const EMAIL_VERIFICATION_DISABLED = process.env.EMAIL_VERIFICATION_DISABLED === "1"; +export const GOOGLE_OAUTH_ENABLED = process.env.GOOGLE_AUTH_ENABLED === "1"; +export const GITHUB_OAUTH_ENABLED = process.env.GITHUB_AUTH_ENABLED === "1"; +export const AZURE_OAUTH_ENABLED = process.env.AZUREAD_AUTH_ENABLED === "1"; -export const GITHUB_ID = env.GITHUB_ID; -export const GITHUB_SECRET = env.GITHUB_SECRET; -export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID; -export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET; +export const GITHUB_ID = process.env.GITHUB_ID; +export const GITHUB_SECRET = process.env.GITHUB_SECRET; +export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; -export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1"; -export const INVITE_DISABLED = env.INVITE_DISABLED === "1"; +export const SIGNUP_ENABLED = process.env.SIGNUP_DISABLED !== "1"; +export const INVITE_DISABLED = process.env.INVITE_DISABLED === "1"; -export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID; -export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET; -export const GOOGLE_SHEETS_REDIRECT_URL = env.GOOGLE_SHEETS_REDIRECT_URL; +export const GOOGLE_SHEETS_CLIENT_ID = process.env.GOOGLE_SHEETS_CLIENT_ID; +export const GOOGLE_SHEETS_CLIENT_SECRET = process.env.GOOGLE_SHEETS_CLIENT_SECRET; +export const GOOGLE_SHEETS_REDIRECT_URL = process.env.GOOGLE_SHEETS_REDIRECT_URL; -export const AIR_TABLE_CLIENT_ID = env.AIR_TABLE_CLIENT_ID; +export const AIR_TABLE_CLIENT_ID = process.env.AIR_TABLE_CLIENT_ID; -export const SMTP_HOST = env.SMTP_HOST; -export const SMTP_PORT = env.SMTP_PORT; -export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1"; -export const SMTP_USER = env.SMTP_USER; -export const SMTP_PASSWORD = env.SMTP_PASSWORD; -export const MAIL_FROM = env.MAIL_FROM; +export const SMTP_HOST = process.env.SMTP_HOST; +export const SMTP_PORT = process.env.SMTP_PORT; +export const SMTP_SECURE_ENABLED = process.env.SMTP_SECURE_ENABLED === "1"; +export const SMTP_USER = process.env.SMTP_USER; +export const SMTP_PASSWORD = process.env.SMTP_PASSWORD; +export const MAIL_FROM = process.env.MAIL_FROM; -export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET; -export const NEXTAUTH_URL = env.NEXTAUTH_URL; +export const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET; +export const NEXTAUTH_URL = process.env.NEXTAUTH_URL; export const ITEMS_PER_PAGE = 50; export const RESPONSES_PER_PAGE = 10; export const OPEN_TEXT_RESPONSES_PER_PAGE = 5; @@ -67,8 +68,17 @@ export const MAX_SIZES = { pro: 1024 * 1024 * 1024, // 1GB } as const; export const IS_S3_CONFIGURED: boolean = - env.S3_ACCESS_KEY && env.S3_SECRET_KEY && env.S3_REGION && env.S3_BUCKET_NAME ? true : false; + process.env.S3_ACCESS_KEY && + process.env.S3_SECRET_KEY && + process.env.S3_REGION && + process.env.S3_BUCKET_NAME + ? true + : false; export const LOCAL_UPLOAD_URL = { public: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href, private: new URL(`${WEBAPP_URL}/api/v1/client/storage/local`).href, } as const; + +// Pricing +export const PRICING_USERTARGETING_FREE_MTU = 2500; +export const PRICING_APPSURVEYS_FREE_RESPONSES = 250; diff --git a/packages/lib/crypto.ts b/packages/lib/crypto.ts index 3a3c477642..62adeb6fbf 100644 --- a/packages/lib/crypto.ts +++ b/packages/lib/crypto.ts @@ -5,7 +5,7 @@ import { ENCRYPTION_KEY } from "./constants"; const ALGORITHM = "aes256"; const INPUT_ENCODING = "utf8"; const OUTPUT_ENCODING = "hex"; -const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex"; +const BUFFER_ENCODING = ENCRYPTION_KEY!.length === 32 ? "latin1" : "hex"; const IV_LENGTH = 16; // AES blocksize /** @@ -69,7 +69,7 @@ export function generateLocalSignedUrl( const uuid = randomBytes(16).toString("hex"); const timestamp = Date.now(); const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`; - const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex"); + const signature = createHmac("sha256", ENCRYPTION_KEY!).update(data).digest("hex"); return { signature, uuid, timestamp }; } diff --git a/packages/lib/display/cache.ts b/packages/lib/display/cache.ts index 17f874955b..bb6b628ae8 100644 --- a/packages/lib/display/cache.ts +++ b/packages/lib/display/cache.ts @@ -4,6 +4,7 @@ interface RevalidateProps { id?: string; surveyId?: string; personId?: string | null; + environmentId?: string; } export const displayCache = { @@ -17,8 +18,11 @@ export const displayCache = { byPersonId(personId: string) { return `people-${personId}-displays`; }, + byEnvironmentId(environmentId: string) { + return `environments-${environmentId}-displays`; + }, }, - revalidate({ id, surveyId, personId }: RevalidateProps): void { + revalidate({ id, surveyId, personId, environmentId }: RevalidateProps): void { if (id) { revalidateTag(this.tag.byId(id)); } @@ -30,5 +34,9 @@ export const displayCache = { if (personId) { revalidateTag(this.tag.byPersonId(personId)); } + + if (environmentId) { + revalidateTag(this.tag.byEnvironmentId(environmentId)); + } }, }; diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index db01daef63..7438c49ec9 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -533,3 +533,34 @@ export const getResponseCountBySurveyId = async (surveyId: string): Promise => + await unstable_cache( + async () => { + validateInputs([environmentId, ZId]); + + const now = new Date(); + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const responseAggregations = await prisma.response.aggregate({ + _count: { + id: true, + }, + where: { + survey: { + environmentId, + }, + createdAt: { + gte: firstDayOfMonth, + }, + }, + }); + + return responseAggregations._count.id; + }, + [`getMonthlyResponseCount-${environmentId}`], + { + tags: [responseCache.tag.byEnvironmentId(environmentId)], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); diff --git a/packages/lib/team/auth.ts b/packages/lib/team/auth.ts new file mode 100644 index 0000000000..a0eb38dad7 --- /dev/null +++ b/packages/lib/team/auth.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import { ZId } from "@formbricks/types/environment"; +import { validateInputs } from "../utils/validate"; +import { getTeamsByUserId } from "./service"; +import { unstable_cache } from "next/cache"; +import { SERVICES_REVALIDATION_INTERVAL } from "../constants"; +import { teamCache } from "../team/cache"; + +export const canUserAccessTeam = async (userId: string, teamId: string): Promise => + await unstable_cache( + async () => { + validateInputs([userId, ZId], [teamId, ZId]); + + const userTeams = await getTeamsByUserId(userId); + + const givenTeamExists = userTeams.filter((team) => (team.id = teamId)); + if (!givenTeamExists) { + return false; + } + return true; + }, + [`canUserAccessTeam-${userId}-${teamId}`], + { revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [teamCache.tag.byId(teamId)] } + )(); diff --git a/packages/lib/team/service.ts b/packages/lib/team/service.ts index eaa2d581ea..ec6a83e1e0 100644 --- a/packages/lib/team/service.ts +++ b/packages/lib/team/service.ts @@ -1,15 +1,18 @@ import "server-only"; import { prisma } from "@formbricks/database"; +import { ZOptionalNumber, ZString } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/environment"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TTeam, TTeamUpdateInput, ZTeamUpdateInput } from "@formbricks/types/teams"; import { Prisma } from "@prisma/client"; import { unstable_cache } from "next/cache"; -import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants"; -import { ZOptionalNumber, ZString } from "@formbricks/types/common"; -import { validateInputs } from "../utils/validate"; +import { getMonthlyActivePeopleCount } from "../person/service"; +import { getProducts } from "../product/service"; +import { getMonthlyResponseCount } from "../response/service"; +import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants"; import { environmentCache } from "../environment/cache"; +import { validateInputs } from "../utils/validate"; import { teamCache } from "./cache"; export const select = { @@ -17,10 +20,13 @@ export const select = { createdAt: true, updatedAt: true, name: true, - plan: true, - stripeCustomerId: true, + billing: true, }; +export const getTeamsTag = (teamId: string) => `teams-${teamId}`; +export const getTeamsByUserIdCacheTag = (userId: string) => `users-${userId}-teams`; +export const getTeamByEnvironmentIdCacheTag = (environmentId: string) => `environments-${environmentId}-team`; + export const getTeamsByUserId = async (userId: string, page?: number): Promise => unstable_cache( async () => { @@ -94,6 +100,35 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise => + unstable_cache( + async () => { + validateInputs([teamId, ZString]); + + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select, + }); + + return team; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeam-${teamId}`], + { + tags: [teamCache.tag.byId(teamId)], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); + export const createTeam = async (teamInput: TTeamUpdateInput): Promise => { try { validateInputs([teamInput, ZTeamUpdateInput]); @@ -208,3 +243,97 @@ export const deleteTeam = async (teamId: string): Promise => { throw error; } }; + +export const getTeamsWithPaidPlan = async (): Promise => { + const teams = await unstable_cache( + async () => { + try { + const fetchedTeams = await prisma.team.findMany({ + where: { + OR: [ + { + billing: { + path: ["features", "inAppSurvey", "status"], + not: "inactive", + }, + }, + { + billing: { + path: ["features", "userTargeting", "status"], + not: "inactive", + }, + }, + ], + }, + select, + }); + + return fetchedTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + }, + ["getTeamsWithPaidPlan"], + { + tags: [], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); + + return teams; +}; + +export const getMonthlyActiveTeamPeopleCount = async (teamId: string): Promise => + await unstable_cache( + async () => { + validateInputs([teamId, ZId]); + + const products = await getProducts(teamId); + + let peopleCount = 0; + + for (const product of products) { + for (const environment of product.environments) { + const peopleInThisEnvironment = await getMonthlyActivePeopleCount(environment.id); + + peopleCount += peopleInThisEnvironment; + } + } + + return peopleCount; + }, + [`getMonthlyActiveTeamPeopleCount-${teamId}`], + { + tags: [], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); + +export const getMonthlyTeamResponseCount = async (teamId: string): Promise => + await unstable_cache( + async () => { + validateInputs([teamId, ZId]); + + const products = await getProducts(teamId); + + let peopleCount = 0; + + for (const product of products) { + for (const environment of product.environments) { + const peopleInThisEnvironment = await getMonthlyResponseCount(environment.id); + + peopleCount += peopleInThisEnvironment; + } + } + + return peopleCount; + }, + [`getMonthlyTeamResponseCount-${teamId}`], + { + tags: [], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); diff --git a/packages/types/teams.ts b/packages/types/teams.ts index 617058a15a..d74a7386c2 100644 --- a/packages/types/teams.ts +++ b/packages/types/teams.ts @@ -1,18 +1,34 @@ import { z } from "zod"; +export const ZSubscription = z.object({ + status: z.enum(["active", "cancelled", "inactive"]).default("inactive"), + unlimited: z.boolean().default(false), +}); + +export type TSubscription = z.infer; + +export const ZTeamBilling = z.object({ + stripeCustomerId: z.string().nullable(), + features: z.object({ + inAppSurvey: ZSubscription, + linkSurvey: ZSubscription, + userTargeting: ZSubscription, + }), +}); + +export type TTeamBilling = z.infer; + export const ZTeam = z.object({ id: z.string().cuid2(), createdAt: z.date(), updatedAt: z.date(), name: z.string(), - plan: z.enum(["free", "pro"]), - stripeCustomerId: z.string().nullable(), + billing: ZTeamBilling, }); export const ZTeamUpdateInput = z.object({ name: z.string(), - plan: z.enum(["free", "pro"]).optional(), - stripeCustomerId: z.string().nullish(), + billing: ZTeamBilling.optional(), }); export type TTeamUpdateInput = z.infer; diff --git a/packages/ui/AlertDialog/index.tsx b/packages/ui/AlertDialog/index.tsx index ad14e2afb0..6e82c93aff 100644 --- a/packages/ui/AlertDialog/index.tsx +++ b/packages/ui/AlertDialog/index.tsx @@ -9,7 +9,7 @@ interface AlertDialogProps { confirmWhat: string; onDiscard: () => void; text?: string; - useSaveInsteadOfCancel?: boolean; + confirmButtonLabel: string; onSave?: () => void; } @@ -19,12 +19,12 @@ export default function AlertDialog({ confirmWhat, onDiscard, text, - useSaveInsteadOfCancel = false, + confirmButtonLabel, onSave, }: AlertDialogProps) { return ( -

{text || "Are you sure? This action cannot be undone."}

+

{text || "Are you sure? This action cannot be undone."}

diff --git a/packages/ui/BillingSlider/index.tsx b/packages/ui/BillingSlider/index.tsx new file mode 100644 index 0000000000..0de5108655 --- /dev/null +++ b/packages/ui/BillingSlider/index.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "@formbricks/lib/cn"; + +interface SliderProps { + className?: string; + value: number; + max: number; + freeTierLimit: number; + metric: string; +} + +export const BillingSlider = React.forwardRef, SliderProps>( + ({ className, value, max, freeTierLimit, metric, ...props }, ref) => ( + + +
+
+
+ +
+ +
+

+ Current: +
+ {value} {metric} +

+
+ +
+
+

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

+
+
+ ) +); +BillingSlider.displayName = SliderPrimitive.Root.displayName; diff --git a/packages/ui/PricingCard/index.tsx b/packages/ui/PricingCard/index.tsx new file mode 100644 index 0000000000..d35fee87c1 --- /dev/null +++ b/packages/ui/PricingCard/index.tsx @@ -0,0 +1,174 @@ +import { TTeam } from "@formbricks/types/teams"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import { Badge } from "../Badge"; +import { Button } from "../Button"; +import { BillingSlider } from "../BillingSlider"; + +export const PricingCard = ({ + title, + subtitle, + featureName, + monthlyPrice, + actionText, + team, + metric, + sliderValue, + sliderLimit, + freeTierLimit, + paidFeatures, + perMetricCharge, + loading, + onUpgrade, + onUbsubscribe, +}: { + title: string; + subtitle: string; + featureName: string; + monthlyPrice: number; + actionText: string; + team: TTeam; + metric?: string; + sliderValue?: number; + sliderLimit?: number; + freeTierLimit?: number; + paidFeatures: { + title: string; + comingSoon?: boolean; + unlimited?: boolean; + }[]; + perMetricCharge?: number; + loading: boolean; + onUpgrade: any; + onUbsubscribe: any; +}) => { + const featureNameKey = featureName as keyof typeof team.billing.features; + return ( +
+
+

{title}

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

{subtitle}

+ + {metric && perMetricCharge && ( +
+
+ {team.billing.features[featureNameKey].unlimited ? ( +

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

+ ) : ( +
+ +
+ )} +
+
+ )} + +
+
+ {team.billing.features[featureNameKey].status === "inactive" && ( +

+ You're on the Free plan in {title}.
+ Upgrade now to unlock the below features: +

+ )} + +
    + {paidFeatures.map((feature, index) => ( +
  • +
    + +
    + {feature.title} + {feature.comingSoon && ( + + coming soon + + )} +
  • + ))} +
+
+ +
+
+ {!team.billing.features[featureNameKey].unlimited && ( +
+ {team.billing.features[featureNameKey].status !== "inactive" ? ( +
+ {perMetricCharge ? ( + <> + Approximately +
+ + $ + + {(sliderValue! > freeTierLimit! + ? (sliderValue! - freeTierLimit!) * perMetricCharge + : 0 + ).toFixed(2)} + +
+ Month-to-Date + + ) : ( + <> + ${monthlyPrice} + + / month + + )} +
+ ) : ( +
+ {actionText} +
+ + ${monthlyPrice} + + / month +
+ )} +
+ )} + {team.billing.features[featureNameKey].status === "inactive" && ( + + )} +
+
+
+
+ ); +}; diff --git a/turbo.json b/turbo.json index 89314dd5bc..bd5e2c7f50 100644 --- a/turbo.json +++ b/turbo.json @@ -66,6 +66,7 @@ "INTERNAL_SECRET", "MAIL_FROM", "EMAIL_VERIFICATION_DISABLED", + "FORMBRICKS_ENCRYPTION_KEY", "GOOGLE_AUTH_ENABLED", "GITHUB_AUTH_ENABLED", "IS_FORMBRICKS_CLOUD", From 22f579389a883da41e01c82a5579e74a42d0864f Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Tue, 31 Oct 2023 03:07:02 +0530 Subject: [PATCH 3/3] fix: overloading slider on exceeding max limits in pricing tier (#1530) --- packages/ui/BillingSlider/index.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ui/BillingSlider/index.tsx b/packages/ui/BillingSlider/index.tsx index 0de5108655..e91dfb04f6 100644 --- a/packages/ui/BillingSlider/index.tsx +++ b/packages/ui/BillingSlider/index.tsx @@ -18,7 +18,9 @@ export const BillingSlider = React.forwardRef -
+
-
+
+ style={{ left: `calc(${Math.min(value / max, 0.93) * 100}% + 0.5rem)` }} + className="absolute mt-16 text-sm text-slate-700 dark:text-slate-200">

Current: