Merge branch 'main' into surveyBg

This commit is contained in:
Johannes
2023-10-30 21:45:20 +00:00
committed by GitHub
45 changed files with 1823 additions and 307 deletions

View File

@@ -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

View File

@@ -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)
<a id="features"></a>
### 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/)
<a id="getting-started"></a>
## 🚀 Getting started
We've got several options depending on your need to help you quickly get started with Formbricks.
<a id="cloud-version"></a>
### ☁️ 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).
<a id="self-hosted-version"></a>
### 🐳 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)
<a id="development"></a>
### 👨‍💻 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)
<a id="contribution"></a>
## ✍️ 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
<img src="https://contrib.rocks/image?repo=formbricks/formbricks" />
</a>
<a id="contact-us"></a>
## 📆 Contact us
Let's have a chat about your survey needs and get you started.
<a href="https://cal.com/johannes/onboarding?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
<a id="license"></a>
## ⚖️ License
Distributed under the AGPLv3 License. See [`LICENSE`](./LICENSE) for more information.
<a id="security"></a>
## 🔒 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.

View File

@@ -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 (
<div className="grid grid-cols-1 px-4 md:gap-4 md:px-16 ">
<div className="rounded-xl px-4 md:px-12">

View File

@@ -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() {
<div className="my-6 sm:flex-auto">
<h1 className="text-center text-xl font-semibold text-slate-900">Upgrade successful</h1>
<p className="mt-2 text-center text-sm text-slate-700">
Thanks a lot for upgrading your Formbricks subscription. You have now unlimited access.
Thanks a lot for upgrading your Formbricks subscription.
</p>
</div>
<Button variant="darkCTA" className="w-full justify-center" href="/">
Back to my surveys
<Button
variant="darkCTA"
className="w-full justify-center"
href={`/environments/${environmentId}/settings/billing`}>
Back to billing overview
</Button>
</div>
</ContentWrapper>

View File

@@ -1,5 +1,7 @@
import ConfirmationPage from "./components/ConfirmationPage";
export default function BillingConfirmation({}) {
return <ConfirmationPage />;
export default function BillingConfirmation({ searchParams }) {
const { environmentId } = searchParams;
return <ConfirmationPage environmentId={environmentId?.toString()} />;
}

View File

@@ -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;
}

View File

@@ -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<StripePriceLookupKeys>();
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 (
<div className="relative">
@@ -67,103 +156,187 @@ export default function PricingTable({ team }: PricingTableProps) {
<LoadingSpinner />
</div>
)}
<div className="grid grid-cols-2 gap-4 rounded-lg bg-white p-8">
<div className="">
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="p-8">
<h2 className="mr-2 inline-flex text-3xl font-bold text-slate-700">Free</h2>
{team.plan === "free" && <Badge text="Current Plan" size="normal" type="success" />}
<p className="mt-4 whitespace-pre-wrap text-sm text-slate-600">
Always free. Giving back to the community.
</p>
<ul className="mt-4 space-y-4">
{freeFeatures.map((feature, index) => (
<li key={index} className="flex items-start">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature}</span>
</li>
))}
</ul>
<p className="mt-6 text-3xl">
<span className="text-slate-800font-light">Always free</span>
</p>
{team.plan === "free" ? (
<Button variant="minimal" disabled className="mt-6 w-full justify-center py-4 shadow-sm">
Your current plan
</Button>
) : (
<Button
variant="secondary"
className="mt-6 w-full justify-center py-4 shadow-sm"
onClick={() => openCustomerPortal()}>
Change Plan
</Button>
)}
</div>
<div className="justify-between gap-4 rounded-lg">
{team.billing.stripeCustomerId ? (
<div className="flex w-full justify-end">
<Button
variant="secondary"
className="justify-center py-2 shadow-sm"
loading={loadingCustomerPortal}
onClick={openCustomerPortal}>
{team.billing.features.inAppSurvey.unlimited ? "Manage Subscription" : "Manage Card details"}
</Button>
</div>
</div>
<div className="">
<div className="rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="p-8">
<h2 className="mr-2 inline-flex text-3xl font-bold text-slate-700">Pro</h2>
{team.plan === "pro" && <Badge text="Current Plan" size="normal" type="success" />}
<p className="mt-4 whitespace-pre-wrap text-sm text-slate-600">
All features included. Unlimited usage.
</p>
<ul className="mt-4 space-y-4">
{proFeatures.map((feature, index) => (
<li key={index} className="flex items-start">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature}</span>
</li>
))}
</ul>
<p className="mt-6">
<span className="text-3xl font-bold text-slate-800">$99</span>
) : (
<>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-gray-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
cy={512}
r={512}
fill="url(#759c1415-0410-454c-8f7c-9a820de03641)"
fillOpacity="0.7"
/>
<defs>
<radialGradient id="759c1415-0410-454c-8f7c-9a820de03641">
<stop stopColor="#00E6CA" />
<stop offset={0} stopColor="#00C4B8" />
</radialGradient>
</defs>
</svg>
<div className="mx-auto max-w-md text-center lg:mx-0 lg:flex-auto lg:py-16 lg:text-left">
<h2 className="text-2xl font-bold text-white sm:text-3xl">
Launch Special:
<br /> Go Unlimited! Forever!
</h2>
<p className="text-md mt-6 leading-8 text-gray-300">
Get access to all pro features and unlimited responses + identified users for a flat fee of
only $99/month.
<br /> <br />
<span className="text-gray-400">
This deal ends on 31st of October 2023 at 11:59 PM PST.
</span>
</p>
</div>
<div className="flex flex-1 flex-col items-center justify-center lg:pr-8">
<Button
variant="minimal"
className="w-full justify-center bg-white py-2 text-gray-800 shadow-sm"
loading={upgradingPlan}
onClick={() =>
upgradePlan([
StripePriceLookupKeys.inAppSurveyUnlimited,
StripePriceLookupKeys.linkSurveyUnlimited,
StripePriceLookupKeys.userTargetingUnlimited,
])
}>
Upgrade now at $99/month
</Button>
</div>
</div>
{/* <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-gray-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
cy={512}
r={512}
fill="url(#759c1415-0410-454c-8f7c-9a820de03641)"
fillOpacity="0.7"
/>
<defs>
<radialGradient id="759c1415-0410-454c-8f7c-9a820de03641">
<stop stopColor="#00E6CA" />
<stop offset={0} stopColor="#00C4B8" />
</radialGradient>
</defs>
</svg>
<div className="mx-auto max-w-md text-center lg:mx-0 lg:flex-auto lg:py-16 lg:text-left">
<h2 className="text-2xl font-bold text-white sm:text-3xl">Get the most out of Formbricks</h2>
<p className="text-md mt-6 leading-8 text-gray-300">
Get access to all features by upgrading to a paid plan.
<br />
With our metered billing you will not be charged until you exceed the free tier limits.{" "}
</p>
</div>
<div className="flex flex-1 flex-col items-center justify-center lg:pr-8">
<Button
variant="darkCTA"
className="w-full justify-center py-2 text-white shadow-sm"
loading={upgradingPlan}
onClick={() =>
upgradePlan([
StripePriceLookupKeys.inAppSurveyUnlimited,
StripePriceLookupKeys.linkSurveyUnlimited,
StripePriceLookupKeys.userTargetingUnlimited,
])
}>
Upgrade now at $99/month
</Button>
</div>
</div> */}
</>
)}
<span className="text-base font-medium text-slate-400">/ month</span>
</p>
{team.plan === "pro" ? (
<Button
variant="secondary"
className="mt-6 w-full justify-center py-4 shadow-sm"
onClick={() => openCustomerPortal()}>
Manage Subscription
</Button>
) : (
<Button
variant="darkCTA"
className="mt-6 w-full justify-center py-4 text-white shadow-sm"
onClick={() => router.push(`${stripeURl}?client_reference_id=${team.id}`)}>
Upgrade
</Button>
)}
</div>
</div>
</div>
<PricingCard
title={"Core & App Surveys"}
subtitle={"Get up to 250 free responses every month"}
featureName={ProductFeatureKeys[ProductFeatureKeys.inAppSurvey]}
monthlyPrice={0}
actionText={"Starting at"}
team={team}
metric="responses"
sliderValue={responseCount}
sliderLimit={350}
freeTierLimit={appSurveyFreeResponses}
paidFeatures={coreAndWebAppSurveyFeatures.filter((feature) => {
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])}
/>
<div className="col-span-2">
<div className="rounded-lg border border-slate-100 shadow-sm">
<div className="p-8">
<h2 className="inline-flex text-2xl font-bold text-slate-700">Open-source</h2>
<p className=" mt-4 whitespace-pre-wrap text-sm text-slate-600">
Self-host Formbricks with all perks: Data ownership, customizability, limitless use.
</p>
<Button
variant="secondary"
className="mt-6 justify-center py-4 shadow-sm"
href="https://formbricks.com/docs/self-hosting/deployment"
target="_blank">
Read Deployment Docs
</Button>
</div>
</div>
</div>
<PricingCard
title={"Link Survey"}
subtitle={"Link Surveys include unlimited surveys and responses for free."}
featureName={ProductFeatureKeys[ProductFeatureKeys.linkSurvey]}
monthlyPrice={30}
actionText={""}
team={team}
paidFeatures={linkSurveysFeatures}
loading={upgradingPlan}
onUpgrade={() => upgradePlan([StripePriceLookupKeys.linkSurvey])}
onUbsubscribe={(e) => handleUnsubscribe(e, ProductFeatureKeys[ProductFeatureKeys.linkSurvey])}
/>
<PricingCard
title={"User Targeting"}
subtitle={"Target up to 2500 users every month"}
featureName={ProductFeatureKeys[ProductFeatureKeys.userTargeting]}
monthlyPrice={0}
actionText={"Starting at"}
team={team}
metric="people"
sliderValue={peopleCount}
sliderLimit={3500}
freeTierLimit={userTargetingFreeMtu}
paidFeatures={userTargetingFeatures.filter((feature) => {
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])}
/>
</div>
<AlertDialog
confirmWhat="that you want to unsubscribe?"
open={openDeleteModal}
setOpen={setOpenDeleteModal}
onDiscard={() => {
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"
/>
</div>
);
}

View File

@@ -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 (
<>
<div>
<SettingsTitle title="Billing & Plan" />
<PricingTable team={team} />
<PricingTable
team={team}
environmentId={params.environmentId}
peopleCount={peopleCount}
responseCount={responseCount}
userTargetingFreeMtu={PRICING_USERTARGETING_FREE_MTU}
inAppSurveyFreeResponses={PRICING_APPSURVEYS_FREE_RESPONSES}
/>
</div>
</>
);

View File

@@ -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 (
<div className="w-full items-center">
{!canRemoveSignature && (
<div className="mb-4">
<Alert>
<AlertDescription>
To remove the Formbricks branding from the link surveys, please{" "}
<span className="underline">
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade</Link>
</span>{" "}
your plan.
</AlertDescription>
</Alert>
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="signature"
checked={formbricksSignature}
onCheckedChange={toggleSignature}
disabled={updatingSignature}
disabled={!canRemoveSignature || updatingSignature}
/>
<Label htmlFor="signature">Show &apos;Powered by Formbricks&apos; Signature</Label>
<Label htmlFor="signature">Show &apos;Powered by Formbricks&apos; Signature in Link Surveys</Label>
</div>
</div>
);

View File

@@ -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 (
<div>
@@ -36,7 +44,11 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
<SettingsCard
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<EditFormbricksSignature product={product} />
<EditFormbricksSignature
product={product}
canRemoveSignature={canRemoveSignature}
environmentId={params.environmentId}
/>
</SettingsCard>
</div>
);

View File

@@ -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)}
/>
</div>

View File

@@ -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<NextResponse> {
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
);
}
}

View File

@@ -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);
}

View File

@@ -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<NextResponse> {
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
);
}
}

View File

@@ -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<NextResponse> {
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
env.ENCRYPTION_KEY
);
if (!validated) {
@@ -85,7 +85,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}
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);

View File

@@ -39,7 +39,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
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);
}

View File

@@ -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),
]);

View File

@@ -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<NextResponse> {
const accessType = "public"; // public files are accessible by anyone
@@ -69,7 +69,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
env.ENCRYPTION_KEY
);
if (!validated) {

View File

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

View File

@@ -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)) {

View File

@@ -12,6 +12,7 @@ import {
TSurveyThankYouCard,
TSurveyVerifyEmail,
} from "@formbricks/types/surveys";
import { TTeamBilling } from "@formbricks/types/teams";
import { TUserNotificationSettings } from "@formbricks/types/users";
declare global {
@@ -31,6 +32,7 @@ declare global {
export type SurveyClosedMessage = TSurveyClosedMessage;
export type SurveySingleUse = TSurveySingleUse;
export type SurveyVerifyEmail = TSurveyVerifyEmail;
export type TeamBilling = TTeamBilling;
export type UserNotificationSettings = TUserNotificationSettings;
}
}

View File

@@ -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";

View File

@@ -391,21 +391,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 {

View File

@@ -18,4 +18,5 @@ export {
ZSurveySingleUse,
} from "@formbricks/types/surveys";
export { ZTeamBilling } from "@formbricks/types/teams";
export { ZUserNotificationSettings } from "@formbricks/types/users";

View File

@@ -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 } };
};

View File

@@ -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,
},
});
};

View File

@@ -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,
},
});
};

View File

@@ -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,
},
});
};

View File

@@ -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",
}

View File

@@ -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;

View File

@@ -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`,
};
}
};

View File

@@ -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`,
};
}
};

View File

@@ -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;

View File

@@ -52,7 +52,7 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
renderSurveyModal({
survey: survey,
brandColor,
formbricksSignature: product.formbricksSignature,
formbricksSignature: true,
clickOutside,
darkOverlay,
highlightBorderColor,

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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));
}
},
};

View File

@@ -533,3 +533,34 @@ export const getResponseCountBySurveyId = async (surveyId: string): Promise<numb
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getMonthlyResponseCount = async (environmentId: string): Promise<number> =>
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,
}
)();

25
packages/lib/team/auth.ts Normal file
View File

@@ -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<boolean> =>
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)] }
)();

View File

@@ -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<TTeam[]> =>
unstable_cache(
async () => {
@@ -94,6 +100,35 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise<TTe
}
)();
export const getTeam = async (teamId: string): Promise<TTeam | null> =>
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<TTeam> => {
try {
validateInputs([teamInput, ZTeamUpdateInput]);
@@ -208,3 +243,97 @@ export const deleteTeam = async (teamId: string): Promise<TTeam> => {
throw error;
}
};
export const getTeamsWithPaidPlan = async (): Promise<TTeam[]> => {
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<number> =>
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<number> =>
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,
}
)();

View File

@@ -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<typeof ZSubscription>;
export const ZTeamBilling = z.object({
stripeCustomerId: z.string().nullable(),
features: z.object({
inAppSurvey: ZSubscription,
linkSurvey: ZSubscription,
userTargeting: ZSubscription,
}),
});
export type TTeamBilling = z.infer<typeof ZTeamBilling>;
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<typeof ZTeamUpdateInput>;

View File

@@ -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 (
<Modal open={open} setOpen={setOpen} title={`Confirm ${confirmWhat}`}>
<p>{text || "Are you sure? This action cannot be undone."}</p>
<p className="mb-6 text-sm">{text || "Are you sure? This action cannot be undone."}</p>
<div className="space-x-2 text-right">
<Button variant="warn" onClick={onDiscard}>
Discard
@@ -32,12 +32,12 @@ export default function AlertDialog({
<Button
variant="darkCTA"
onClick={() => {
if (useSaveInsteadOfCancel && onSave) {
if (onSave) {
onSave();
}
setOpen(false);
}}>
{useSaveInsteadOfCancel ? "Save" : "Cancel"}
{confirmButtonLabel}
</Button>
</div>
</Modal>

View File

@@ -0,0 +1,61 @@
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<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, value, max, freeTierLimit, metric, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-300">
<div
style={{ width: `calc(${Math.min(value / max, 0.93) * 100}%)` }}
className="absolute h-full bg-slate-800"></div>
<div
style={{
width: `${((freeTierLimit - value) / max) * 100}%`,
left: `${(value / max) * 100}%`,
}}
className="absolute h-full bg-slate-500"></div>
</SliderPrimitive.Track>
<div
style={{ left: `calc(${Math.min(value / max, 0.93) * 100}%)` }}
className="absolute mt-4 h-6 w-px bg-slate-500"></div>
<div
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">
<p className="text-xs">
Current:
<br />
{value} {metric}
</p>
</div>
<div
style={{ left: `${(freeTierLimit / max) * 100}%` }}
className="absolute mt-4 h-6 w-px bg-slate-300"></div>
<div
style={{ left: `calc(${(freeTierLimit / max) * 100}% + 0.5rem)` }}
className="absolute mt-12 text-sm text-slate-700">
<p className="text-xs">
Free Tier Limit
<br />
{freeTierLimit} {metric}
</p>
</div>
</SliderPrimitive.Root>
)
);
BillingSlider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -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 (
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="relative p-8">
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">{title}</h2>
{team.billing.features[featureNameKey].status === "active" ? (
team.billing.features[featureNameKey].unlimited ? (
<Badge text="Unlimited" size="normal" type="success" />
) : (
<>
<Badge text="Subscribed" size="normal" type="success" />
<Button
variant="secondary"
onClick={(e) => onUbsubscribe(e)}
className="absolute right-12 top-10">
Unsubscribe
</Button>
</>
)
) : team.billing.features[featureNameKey].status === "cancelled" ? (
<Badge text="Cancelling at End of this Month" size="normal" type="warning" />
) : null}
<p className=" whitespace-pre-wrap text-sm text-slate-600">{subtitle}</p>
{metric && perMetricCharge && (
<div className="rounded-xl bg-slate-100 py-4 dark:bg-slate-800">
<div className="mb-2 flex items-center gap-x-4"></div>
{team.billing.features[featureNameKey].unlimited ? (
<p>
<span className="text-sm font-medium text-slate-400">
Usage this month: {sliderValue} {metric}
</span>
</p>
) : (
<div className="relative mb-16 mt-4">
<BillingSlider
className="slider-class"
value={sliderValue || 0}
max={sliderLimit || 100}
freeTierLimit={freeTierLimit || 0}
metric={metric}
/>
</div>
)}
<hr className="mt-6" />
</div>
)}
<div className="flex py-3">
<div className="w-3/5">
{team.billing.features[featureNameKey].status === "inactive" && (
<p className=" whitespace-pre-wrap text-sm text-slate-600">
You&apos;re on the <b>Free plan</b> in {title}.<br />
Upgrade now to unlock the below features:
</p>
)}
<ul className="mt-4 space-y-4">
{paidFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature.title}</span>
{feature.comingSoon && (
<span className=" mx-2 bg-blue-100 p-1 text-xs text-slate-400 dark:bg-slate-700 dark:text-teal-500">
coming soon
</span>
)}
</li>
))}
</ul>
</div>
<div className="w-1/5"></div>
<div className="w-1/5">
{!team.billing.features[featureNameKey].unlimited && (
<div className="my-2">
{team.billing.features[featureNameKey].status !== "inactive" ? (
<div className="mt-8">
{perMetricCharge ? (
<>
<span className="text-sm font-medium text-slate-400">Approximately</span>
<br />
<span className="text-3xl font-bold text-slate-800">$</span>
<span className="text-3xl font-bold text-slate-800">
{(sliderValue! > freeTierLimit!
? (sliderValue! - freeTierLimit!) * perMetricCharge
: 0
).toFixed(2)}
</span>
<br />
<span className="text-sm font-medium text-slate-400">Month-to-Date</span>
</>
) : (
<>
<span className="text-3xl font-bold text-slate-800">${monthlyPrice}</span>
<span className="text-base font-medium text-slate-400">/ month</span>
</>
)}
</div>
) : (
<div>
<span className="text-sm font-medium text-slate-400">{actionText}</span>
<br />
<span className="text-3xl font-bold text-slate-800">${monthlyPrice}</span>
<span className="text-base font-medium text-slate-400">/ month</span>
</div>
)}
</div>
)}
{team.billing.features[featureNameKey].status === "inactive" && (
<Button
variant="darkCTA"
className="w-full justify-center py-2 text-white shadow-sm"
loading={loading}
onClick={() => onUpgrade()}>
Upgrade
</Button>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -66,6 +66,7 @@
"INTERNAL_SECRET",
"MAIL_FROM",
"EMAIL_VERIFICATION_DISABLED",
"FORMBRICKS_ENCRYPTION_KEY",
"GOOGLE_AUTH_ENABLED",
"GITHUB_AUTH_ENABLED",
"IS_FORMBRICKS_CLOUD",