mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
refactor: simplify Stripe integration and rename enterprise to custom (#6720)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user