mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Merge branch 'main' into surveyBg
This commit is contained in:
22
.github/workflows/cron-reportUsageToStripe.yml
vendored
Normal file
22
.github/workflows/cron-reportUsageToStripe.yml
vendored
Normal 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
|
||||
32
README.md
32
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)
|
||||
|
||||
<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
|
||||
|
||||
[](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
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 'Powered by Formbricks' Signature</Label>
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature in Link Surveys</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
74
apps/web/app/api/cron/report-usage/route.ts
Normal file
74
apps/web/app/api/cron/report-usage/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,4 +18,5 @@ export {
|
||||
ZSurveySingleUse,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
export { ZTeamBilling } from "@formbricks/types/teams";
|
||||
export { ZUserNotificationSettings } from "@formbricks/types/users";
|
||||
|
||||
@@ -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 } };
|
||||
};
|
||||
|
||||
|
||||
83
packages/ee/billing/handlers/checkoutSessionCompleted.ts
Normal file
83
packages/ee/billing/handlers/checkoutSessionCompleted.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
96
packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts
Normal file
96
packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
53
packages/ee/billing/handlers/subscriptionDeleted.ts
Normal file
53
packages/ee/billing/handlers/subscriptionDeleted.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
19
packages/ee/billing/lib/constants.ts
Normal file
19
packages/ee/billing/lib/constants.ts
Normal 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",
|
||||
}
|
||||
@@ -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;
|
||||
181
packages/ee/billing/lib/createSubscription.ts
Normal file
181
packages/ee/billing/lib/createSubscription.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
};
|
||||
121
packages/ee/billing/lib/removeSubscription.ts
Normal file
121
packages/ee/billing/lib/removeSubscription.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
};
|
||||
60
packages/ee/billing/lib/reportUsage.ts
Normal file
60
packages/ee/billing/lib/reportUsage.ts
Normal 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;
|
||||
@@ -52,7 +52,7 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
|
||||
renderSurveyModal({
|
||||
survey: survey,
|
||||
brandColor,
|
||||
formbricksSignature: product.formbricksSignature,
|
||||
formbricksSignature: true,
|
||||
clickOutside,
|
||||
darkOverlay,
|
||||
highlightBorderColor,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
25
packages/lib/team/auth.ts
Normal 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)] }
|
||||
)();
|
||||
@@ -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,
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
61
packages/ui/BillingSlider/index.tsx
Normal file
61
packages/ui/BillingSlider/index.tsx
Normal 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;
|
||||
174
packages/ui/PricingCard/index.tsx
Normal file
174
packages/ui/PricingCard/index.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
||||
@@ -66,6 +66,7 @@
|
||||
"INTERNAL_SECRET",
|
||||
"MAIL_FROM",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
"FORMBRICKS_ENCRYPTION_KEY",
|
||||
"GOOGLE_AUTH_ENABLED",
|
||||
"GITHUB_AUTH_ENABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
|
||||
Reference in New Issue
Block a user