refactor: simplify Stripe integration and rename enterprise to custom (#6720)

This commit is contained in:
Johannes
2025-10-28 00:45:59 -07:00
committed by GitHub
parent fe5ff9a71c
commit d7bbd219a3
22 changed files with 188 additions and 351 deletions

View File

@@ -182,21 +182,17 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
export enum PROJECT_FEATURE_KEYS {
FREE = "free",
STARTUP = "startup",
SCALE = "scale",
ENTERPRISE = "enterprise",
CUSTOM = "custom",
}
export enum STRIPE_PROJECT_NAMES {
STARTUP = "Formbricks Startup",
SCALE = "Formbricks Scale",
ENTERPRISE = "Formbricks Enterprise",
CUSTOM = "Formbricks Custom",
}
export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly",
}
export const BILLING_LIMITS = {
@@ -210,10 +206,10 @@ export const BILLING_LIMITS = {
RESPONSES: 5000,
MIU: 7500,
},
SCALE: {
PROJECTS: 5,
RESPONSES: 10000,
MIU: 30000,
CUSTOM: {
PROJECTS: null,
RESPONSES: null,
MIU: null,
},
} as const;

View File

@@ -986,15 +986,12 @@
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"remove_branding": "Branding entfernen",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_miu": "Unbegrenzte MIU",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"remove_branding": "Remove Branding",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_miu": "Unlimited MIU",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs mensuels identifiés",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Assistance premium avec accord de niveau de service",
"remove_branding": "Suppression du logo",
"startup": "Initial",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Gestion des accès",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_miu": "MIU Illimité",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "サブスクリプションを管理",
"monthly": "月間",
"monthly_identified_users": "月間識別ユーザー数",
"per_month": "月",
"per_year": "年",
"plan_upgraded_successfully": "プランを正常にアップグレードしました",
"premium_support_with_slas": "SLA付きプレミアムサポート",
"remove_branding": "ブランディングを削除",
"startup": "スタートアップ",
"startup_description": "無料プランのすべての機能に追加機能。",
"switch_plan": "プランを切り替え",
"switch_plan_confirmation_text": "本当に {plan} プランに切り替えますか? {price} {period} が請求されます。",
"team_access_roles": "チームアクセスロール",
"unable_to_upgrade_plan": "プランをアップグレードできません",
"unlimited_miu": "無制限のMIU",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Remover Marca",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Possibilidade de remover o logo",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "Gestionați abonamentul",
"monthly": "Lunar",
"monthly_identified_users": "Utilizatori identificați lunar",
"per_month": "pe lună",
"per_year": "pe an",
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri",
"remove_branding": "Eliminare branding",
"startup": "Pornire",
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
"switch_plan": "Schimbă planul",
"switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.",
"team_access_roles": "Roluri acces echipă",
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
"unlimited_miu": "MIU Nelimitat",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "管理 订阅",
"monthly": "每月",
"monthly_identified_users": "每月 已识别的 用户",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "计划 升级 成功",
"premium_support_with_slas": "优质支持与 SLAs",
"remove_branding": "移除 品牌",
"startup": "初创企业",
"startup_description": "包含免费版的所有功能以及附加功能.",
"switch_plan": "切换 计划",
"switch_plan_confirmation_text": "你确定要切换到 {plan} 计划吗?你将被收取 {price} {period} 。",
"team_access_roles": "团队访问角色",
"unable_to_upgrade_plan": "无法升级计划",
"unlimited_miu": "无限 MIU",

View File

@@ -986,15 +986,12 @@
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"remove_branding": "移除品牌",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_miu": "無限 MIU",

View File

@@ -1,43 +1,67 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: STRIPE_API_VERSION,
});
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata || !checkoutSession.metadata.organizationId)
if (!checkoutSession.metadata?.organizationId)
throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id);
const stripeSubscriptionObject = await stripe.subscriptions.retrieve(
checkoutSession.subscription as string
);
const { customer: stripeCustomer } = (await stripe.checkout.sessions.retrieve(checkoutSession.id, {
expand: ["customer"],
})) as { customer: Stripe.Customer };
const organization = await getOrganization(checkoutSession.metadata!.organizationId);
const organization = await getOrganization(checkoutSession.metadata.organizationId);
if (!organization)
throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId);
await stripe.subscriptions.update(stripeSubscriptionObject.id, {
metadata: {
organizationId: organization.id,
responses: checkoutSession.metadata.responses,
miu: checkoutSession.metadata.miu,
const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string, {
expand: ["items.data.price"],
});
let period: "monthly" | "yearly" = "monthly";
if (subscription.items?.data && subscription.items.data.length > 0) {
const firstItem = subscription.items.data[0];
const interval = firstItem.price?.recurring?.interval;
period = interval === "year" ? "yearly" : "monthly";
}
await updateOrganization(checkoutSession.metadata.organizationId, {
billing: {
...organization.billing,
stripeCustomerId: checkoutSession.customer as string,
plan: PROJECT_FEATURE_KEYS.STARTUP,
period,
limits: {
projects: BILLING_LIMITS.STARTUP.PROJECTS,
monthly: {
responses: BILLING_LIMITS.STARTUP.RESPONSES,
miu: BILLING_LIMITS.STARTUP.MIU,
},
},
periodStart: new Date(),
},
});
await stripe.customers.update(stripeCustomer.id, {
name: organization.name,
metadata: { organizationId: organization.id },
invoice_settings: {
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
logger.info(
{
organizationId: checkoutSession.metadata.organizationId,
plan: PROJECT_FEATURE_KEYS.STARTUP,
period,
checkoutSessionId: checkoutSession.id,
},
});
"Subscription activated"
);
const stripeCustomer = await stripe.customers.retrieve(checkoutSession.customer as string);
if (stripeCustomer && !stripeCustomer.deleted) {
await stripe.customers.update(stripeCustomer.id, {
name: organization.name,
metadata: { organizationId: organization.id },
});
}
};

View File

@@ -58,7 +58,7 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
};
const customPlan: TPricingPlan = {
id: "enterprise",
id: "custom",
name: t("environments.settings.billing.custom"),
featured: false,
CTA: t("common.request_pricing"),

View File

@@ -16,22 +16,17 @@ export const createSubscription = async (
try {
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Organization not found.");
let isNewOrganization =
!organization.billing.stripeCustomerId ||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
const priceObject = (
await stripe.prices.list({
lookup_keys: [priceLookupKey],
expand: ["data.product"],
})
).data[0];
if (!priceObject) throw new Error("Price not found");
const responses = parseInt((priceObject.product as Stripe.Product).metadata.responses);
const miu = parseInt((priceObject.product as Stripe.Product).metadata.miu);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
// Always create a checkout session - let Stripe handle existing customers
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
@@ -41,63 +36,20 @@ export const createSubscription = async (
],
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
customer: organization.billing.stripeCustomerId ?? undefined,
allow_promotion_codes: true,
subscription_data: {
metadata: { organizationId },
trial_period_days: 30,
trial_period_days: 15,
},
metadata: { organizationId, responses, miu },
metadata: { organizationId },
billing_address_collection: "required",
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
payment_method_data: { allow_redisplay: "always" },
...(!isNewOrganization && {
customer: organization.billing.stripeCustomerId ?? undefined,
customer_update: {
name: "auto",
},
}),
};
// if the organization has never purchased a plan then we just create a new session and store their stripe customer id
if (isNewOrganization) {
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
}
const existingSubscription = await stripe.subscriptions.list({
customer: organization.billing.stripeCustomerId as string,
});
if (existingSubscription.data?.length > 0) {
const existingSubscriptionItem = existingSubscription.data[0].items.data[0];
await stripe.subscriptions.update(existingSubscription.data[0].id, {
items: [
{
id: existingSubscriptionItem.id,
deleted: true,
},
{
price: priceObject.id,
},
],
cancel_at_period_end: false,
});
} else {
// Create a new checkout again if there is no active subscription
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
}
return {
status: 200,
data: "Congrats! Added to your existing subscription!",
newPlan: false,
url: "",
};
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
} catch (err) {
logger.error(err, "Error creating subscription");
return {

View File

@@ -1,32 +1,68 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
const invoice = event.data.object as Stripe.Invoice;
const stripeSubscriptionDetails = invoice.subscription_details;
const organizationId = stripeSubscriptionDetails?.metadata?.organizationId;
if (!organizationId) {
throw new Error("No organizationId found in subscription");
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) {
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
return { status: 400, message: "No subscription ID found in invoice" };
}
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
try {
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const organizationId = subscription.metadata?.organizationId;
if (!organizationId) {
logger.warn(
{
subscriptionId,
invoiceId: invoice.id,
},
"No organizationId found in subscription metadata"
);
return { status: 400, message: "No organizationId found in subscription" };
}
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const periodStartTimestamp = invoice.lines.data[0]?.period?.start;
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
await updateOrganization(organizationId, {
billing: {
...organization.billing,
periodStart,
},
});
logger.info(
{
organizationId,
periodStart,
invoiceId: invoice.id,
},
"Billing period updated successfully"
);
return { status: 200, message: "Billing period updated successfully" };
} catch (error) {
logger.error(error, "Error updating billing period", {
invoiceId: invoice.id,
subscriptionId,
});
return { status: 500, message: "Error updating billing period" };
}
const periodStartTimestamp = invoice.lines.data[0].period.start;
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
await updateOrganization(organizationId, {
...organization,
billing: {
...organization.billing,
stripeCustomerId: invoice.customer as string,
periodStart,
},
});
return { status: 200, message: "success" };
};

View File

@@ -4,7 +4,6 @@ import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
import { handleSubscriptionCreatedOrUpdated } from "@/modules/ee/billing/api/lib/subscription-created-or-updated";
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
@@ -20,7 +19,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
if (err! instanceof Error) logger.error(err, "Error in Stripe webhook handler");
if (err instanceof Error) logger.error(err, "Error in Stripe webhook handler");
return { status: 400, message: `Webhook Error: ${errorMessage}` };
}
@@ -28,11 +27,6 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleCheckoutSessionCompleted(event);
} else if (event.type === "invoice.finalized") {
await handleInvoiceFinalized(event);
} else if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated"
) {
await handleSubscriptionCreatedOrUpdated(event);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event);
}

View File

@@ -1,125 +0,0 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TOrganizationBillingPeriod,
TOrganizationBillingPlan,
ZOrganizationBillingPeriod,
ZOrganizationBillingPlan,
} from "@formbricks/types/organizations";
import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: STRIPE_API_VERSION,
});
export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) => {
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
const organizationId = stripeSubscriptionObject.metadata.organizationId;
if (
!["active", "trialing"].includes(stripeSubscriptionObject.status) ||
stripeSubscriptionObject.cancel_at_period_end
) {
return;
}
if (!organizationId) {
logger.error({ event, organizationId }, "No organizationId found in subscription");
return { status: 400, message: "skipping, no organizationId found" };
}
const organization = await getOrganization(organizationId);
if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId);
const subscriptionPrice = stripeSubscriptionObject.items.data[0].price;
const product = await stripe.products.retrieve(subscriptionPrice.product as string);
if (!product)
throw new ResourceNotFoundError(
"Product not found",
stripeSubscriptionObject.items.data[0].price.product.toString()
);
let period: TOrganizationBillingPeriod = "monthly";
const periodParsed = ZOrganizationBillingPeriod.safeParse(subscriptionPrice.metadata.period);
if (periodParsed.success) {
period = periodParsed.data;
}
let updatedBillingPlan: TOrganizationBillingPlan = organization.billing.plan;
let responses: number | null = null;
let miu: number | null = null;
let projects: number | null = null;
if (product.metadata.responses === "unlimited") {
responses = null;
} else if (parseInt(product.metadata.responses) > 0) {
responses = parseInt(product.metadata.responses);
} else {
logger.error({ responses: product.metadata.responses }, "Invalid responses metadata in product");
throw new Error("Invalid responses metadata in product");
}
if (product.metadata.miu === "unlimited") {
miu = null;
} else if (parseInt(product.metadata.miu) > 0) {
miu = parseInt(product.metadata.miu);
} else {
logger.error({ miu: product.metadata.miu }, "Invalid miu metadata in product");
throw new Error("Invalid miu metadata in product");
}
if (product.metadata.projects === "unlimited") {
projects = null;
} else if (parseInt(product.metadata.projects) > 0) {
projects = parseInt(product.metadata.projects);
} else {
logger.error({ projects: product.metadata.projects }, "Invalid projects metadata in product");
throw new Error("Invalid projects metadata in product");
}
const plan = ZOrganizationBillingPlan.parse(product.metadata.plan);
switch (plan) {
case PROJECT_FEATURE_KEYS.FREE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
break;
case PROJECT_FEATURE_KEYS.STARTUP:
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
break;
case PROJECT_FEATURE_KEYS.ENTERPRISE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.ENTERPRISE;
break;
}
await updateOrganization(organizationId, {
billing: {
...organization.billing,
stripeCustomerId: stripeSubscriptionObject.customer as string,
plan: updatedBillingPlan,
period,
limits: {
projects,
monthly: {
responses,
miu,
},
},
},
});
await stripe.customers.update(stripeSubscriptionObject.customer as string, {
name: organization.name,
metadata: { organizationId: organization.id },
invoice_settings: {
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
},
});
};

View File

@@ -30,4 +30,12 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
period: "monthly",
},
});
logger.info(
{
organizationId,
subscriptionId: stripeSubscriptionObject.id,
},
"Subscription cancelled - downgraded to FREE plan"
);
};

View File

@@ -19,7 +19,7 @@ interface PricingCardProps {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
ENTERPRISE: string;
CUSTOM: string;
};
}
@@ -33,17 +33,21 @@ export const PricingCard = ({
}: PricingCardProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const displayPrice = (() => {
if (plan.id === projectFeatureKeys.CUSTOM) {
return plan.price.monthly;
}
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
})();
const isCurrentPlan = useMemo(() => {
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
return true;
}
if (
organization.billing.plan === projectFeatureKeys.ENTERPRISE &&
plan.id === projectFeatureKeys.ENTERPRISE
) {
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
return true;
}
@@ -53,7 +57,7 @@ export const PricingCard = ({
organization.billing.plan,
plan.id,
planPeriod,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
]);
@@ -62,7 +66,7 @@ export const PricingCard = ({
return null;
}
if (plan.id === projectFeatureKeys.ENTERPRISE) {
if (plan.id === projectFeatureKeys.CUSTOM) {
return (
<Button
variant="outline"
@@ -97,7 +101,7 @@ export const PricingCard = ({
<Button
loading={loading}
onClick={() => {
setUpgradeModalOpen(true);
setContactModalOpen(true);
}}
className="flex justify-center">
{t("environments.settings.billing.switch_plan")}
@@ -115,7 +119,7 @@ export const PricingCard = ({
plan.featured,
plan.href,
plan.id,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
@@ -151,13 +155,9 @@ export const PricingCard = ({
plan.featured ? "text-slate-900" : "text-slate-800",
"text-4xl font-bold tracking-tight"
)}>
{plan.id !== projectFeatureKeys.ENTERPRISE
? planPeriod === "monthly"
? plan.price.monthly
: plan.price.yearly
: plan.price.monthly}
{displayPrice}
</p>
{plan.id !== projectFeatureKeys.ENTERPRISE && (
{plan.id !== projectFeatureKeys.CUSTOM && (
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"}
@@ -203,28 +203,13 @@ export const PricingCard = ({
</div>
<ConfirmationModal
title={t("environments.settings.billing.switch_plan")}
buttonText={t("common.confirm")}
onConfirm={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
setUpgradeModalOpen(false);
}}
open={upgradeModalOpen}
setOpen={setUpgradeModalOpen}
body={t("environments.settings.billing.switch_plan_confirmation_text", {
plan: plan.name,
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
period:
planPeriod === "monthly"
? t("environments.settings.billing.per_month")
: t("environments.settings.billing.per_year"),
})}
title="Please reach out to us"
open={contactModalOpen}
setOpen={setContactModalOpen}
onConfirm={() => setContactModalOpen(false)}
buttonText="Close"
buttonVariant="default"
buttonLoading={loading}
closeOnOutsideClick={false}
hideCloseButton
body="To switch your billing rhythm, please reach out to hola@formbricks.com"
/>
</div>
);

View File

@@ -26,7 +26,7 @@ interface PricingTableProps {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
ENTERPRISE: string;
CUSTOM: string;
};
hasBillingRights: boolean;
}
@@ -127,11 +127,11 @@ export const PricingTable = ({
};
const responsesUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null;
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null;
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
const projectsUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.projects === null;
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
return (
<main>

View File

@@ -94,7 +94,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
@@ -129,7 +129,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
@@ -154,27 +154,17 @@ describe("License Utils", () => {
expect(result).toBe(true);
});
test("should return true if license active, accessControl enabled and plan is SCALE (cloud)", async () => {
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active, accessControl enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, accessControl enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, accessControl enabled but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
@@ -184,6 +174,16 @@ describe("License Utils", () => {
expect(result).toBe(false);
});
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getAccessControlPermission(mockOrganization.billing.plan);
@@ -211,7 +211,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
@@ -243,27 +243,17 @@ describe("License Utils", () => {
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => {
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, multiLanguageSurveys enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, multiLanguageSurveys enabled but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
@@ -273,6 +263,16 @@ describe("License Utils", () => {
expect(result).toBe(false);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
@@ -420,17 +420,17 @@ describe("License Utils", () => {
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true; // reset for other tests
});
test("should return true if license active, feature enabled, and plan is SCALE (cloud)", async () => {
test("should return true if license active, feature enabled, and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, spamProtection: true },
});
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return false if license active, feature enabled, but plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, feature enabled, but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,

View File

@@ -111,9 +111,7 @@ export const getIsSpamProtectionEnabled = async (
if (IS_FORMBRICKS_CLOUD) {
return (
license.active &&
!!license.features?.spamProtection &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
license.active && !!license.features?.spamProtection && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM
);
}
@@ -122,11 +120,7 @@ export const getIsSpamProtectionEnabled = async (
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};