mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 10:19:31 -06:00
feat: move billing UI to stripe pricing table
This commit is contained in:
@@ -9,47 +9,11 @@ import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { ZCloudUpgradePriceLookupKey } from "@/modules/billing/lib/stripe-catalog";
|
||||
import { stripeClient } from "@/modules/billing/lib/stripe-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
|
||||
import { createSubscription } from "@/modules/ee/billing/api/lib/create-subscription";
|
||||
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
|
||||
|
||||
const ZUpgradePlanAction = z.object({
|
||||
environmentId: ZId,
|
||||
priceLookupKey: ZCloudUpgradePriceLookupKey,
|
||||
});
|
||||
|
||||
export const upgradePlanAction = authenticatedActionClient.schema(ZUpgradePlanAction).action(
|
||||
withAuditLogging(
|
||||
"subscriptionUpdated",
|
||||
"organization",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createSubscription(
|
||||
organizationId,
|
||||
parsedInput.environmentId,
|
||||
parsedInput.priceLookupKey
|
||||
);
|
||||
ctx.auditLoggingCtx.newObject = { priceLookupKey: parsedInput.priceLookupKey };
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZManageSubscriptionAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
@@ -111,3 +75,39 @@ export const isSubscriptionCancelledAction = authenticatedActionClient
|
||||
|
||||
return await isSubscriptionCancelled(parsedInput.organizationId);
|
||||
});
|
||||
|
||||
const ZCreatePricingTableCustomerSessionAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const createPricingTableCustomerSessionAction = authenticatedActionClient
|
||||
.schema(ZCreatePricingTableCustomerSessionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization?.billing.stripeCustomerId || !stripeClient) {
|
||||
return { clientSecret: null };
|
||||
}
|
||||
|
||||
const customerSession = await stripeClient.customerSessions.create({
|
||||
customer: organization.billing.stripeCustomerId,
|
||||
components: {
|
||||
pricing_table: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { clientSecret: customerSession.client_secret ?? null };
|
||||
});
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
export type TPricingPlan = {
|
||||
id: "hobby" | "pro" | "scale";
|
||||
name: string;
|
||||
featured: boolean;
|
||||
CTA?: string;
|
||||
description: string;
|
||||
price: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
mainFeatures: string[];
|
||||
};
|
||||
|
||||
export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } => {
|
||||
// Keep legacy billing translation keys referenced until locale cleanup/migration is done.
|
||||
void [
|
||||
t("common.request_pricing"),
|
||||
t("environments.settings.billing.1000_monthly_responses"),
|
||||
t("environments.settings.billing.1_workspace"),
|
||||
t("environments.settings.billing.2000_contacts"),
|
||||
t("environments.settings.billing.3_workspaces"),
|
||||
t("environments.settings.billing.5000_monthly_responses"),
|
||||
t("environments.settings.billing.7500_contacts"),
|
||||
t("environments.settings.billing.api_webhooks"),
|
||||
t("environments.settings.billing.attribute_based_targeting"),
|
||||
t("environments.settings.billing.custom"),
|
||||
t("environments.settings.billing.custom_contacts_limit"),
|
||||
t("environments.settings.billing.custom_response_limit"),
|
||||
t("environments.settings.billing.custom_workspace_limit"),
|
||||
t("environments.settings.billing.email_embedded_surveys"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.enterprise_description"),
|
||||
t("environments.settings.billing.everything_in_free"),
|
||||
t("environments.settings.billing.everything_in_startup"),
|
||||
t("environments.settings.billing.free"),
|
||||
t("environments.settings.billing.free_description"),
|
||||
t("environments.settings.billing.hosted_in_frankfurt"),
|
||||
t("environments.settings.billing.ios_android_sdks"),
|
||||
t("environments.settings.billing.monthly_identified_users"),
|
||||
t("environments.settings.billing.premium_support_with_slas"),
|
||||
t("environments.settings.billing.startup"),
|
||||
t("environments.settings.billing.startup_description"),
|
||||
t("environments.settings.billing.switch_plan"),
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.unlimited_team_members"),
|
||||
t("environments.settings.billing.unlimited_miu"),
|
||||
t("environments.settings.billing.uptime_sla_99"),
|
||||
t("environments.settings.billing.website_surveys"),
|
||||
];
|
||||
|
||||
const hobbyPlan: TPricingPlan = {
|
||||
id: "hobby",
|
||||
name: "Hobby",
|
||||
featured: false,
|
||||
CTA: "Get started",
|
||||
description: "Start free",
|
||||
price: { monthly: "$0", yearly: "$0" },
|
||||
mainFeatures: [
|
||||
"1 Workspace",
|
||||
"250 Responses / month",
|
||||
t("environments.settings.billing.link_surveys"),
|
||||
t("environments.settings.billing.app_surveys"),
|
||||
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
|
||||
"Hosted in Frankfurt \ud83c\uddea\ud83c\uddfa",
|
||||
],
|
||||
};
|
||||
|
||||
const proPlan: TPricingPlan = {
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
featured: true,
|
||||
CTA: t("common.start_free_trial"),
|
||||
description: "Most popular",
|
||||
price: { monthly: "$89", yearly: "$890" },
|
||||
mainFeatures: [
|
||||
"Everything in Hobby",
|
||||
"3 Workspaces",
|
||||
"2,000 Responses / month (dynamic overage)",
|
||||
t("environments.settings.billing.remove_branding"),
|
||||
"Respondent Identification",
|
||||
"Email Follow-ups",
|
||||
"Custom Webhooks",
|
||||
t("environments.settings.billing.all_integrations"),
|
||||
],
|
||||
};
|
||||
|
||||
const scalePlan: TPricingPlan = {
|
||||
id: "scale",
|
||||
name: "Scale",
|
||||
featured: false,
|
||||
CTA: t("common.start_free_trial"),
|
||||
description: "Advanced controls for scaling teams",
|
||||
price: { monthly: "$390", yearly: "$3,900" },
|
||||
mainFeatures: [
|
||||
"Everything in Pro",
|
||||
"5 Workspaces",
|
||||
"5,000 Responses / month (dynamic overage)",
|
||||
t("environments.settings.billing.team_access_roles"),
|
||||
"Full API Access",
|
||||
"Quota Management",
|
||||
"Two-Factor Auth",
|
||||
"Spam Protection (reCAPTCHA)",
|
||||
"SSO Enforcement",
|
||||
"Custom SSO",
|
||||
"Hosting in USA \ud83c\uddfa\ud83c\uddf8",
|
||||
"SOC-2 Verification",
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
plans: [hobbyPlan, proPlan, scalePlan],
|
||||
};
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
|
||||
CLOUD_STRIPE_PRODUCT_IDS,
|
||||
} from "@/modules/billing/lib/stripe-catalog";
|
||||
import { createSubscription } from "./create-subscription";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pricesList: vi.fn(),
|
||||
subscriptionsList: vi.fn(),
|
||||
checkoutSessionCreate: vi.fn(),
|
||||
getOrganization: vi.fn(),
|
||||
ensureStripeCustomerForOrganization: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
STRIPE_SECRET_KEY: "sk_test_123",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
WEBAPP_URL: "https://app.formbricks.com",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: mocks.getOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/billing/lib/organization-billing", () => ({
|
||||
ensureStripeCustomerForOrganization: mocks.ensureStripeCustomerForOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: mocks.loggerError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("stripe", () => ({
|
||||
default: class Stripe {
|
||||
prices = { list: mocks.pricesList };
|
||||
subscriptions = { list: mocks.subscriptionsList };
|
||||
checkout = { sessions: { create: mocks.checkoutSessionCreate } };
|
||||
},
|
||||
}));
|
||||
|
||||
describe("createSubscription", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.getOrganization.mockResolvedValue({ id: "org_1", name: "Org 1" });
|
||||
mocks.ensureStripeCustomerForOrganization.mockResolvedValue({ customerId: "cus_1" });
|
||||
mocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: CLOUD_STRIPE_PRODUCT_IDS.HOBBY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.checkoutSessionCreate.mockResolvedValue({ url: "https://stripe.test/session_1" });
|
||||
});
|
||||
|
||||
test("does not send quantity for metered prices and applies trial for first paid upgrade", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "price_pro_monthly",
|
||||
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
|
||||
recurring: { usage_type: "licensed" },
|
||||
},
|
||||
{
|
||||
id: "price_pro_usage",
|
||||
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES,
|
||||
recurring: { usage_type: "metered" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createSubscription("org_1", "env_1", CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY);
|
||||
|
||||
expect(mocks.checkoutSessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "subscription",
|
||||
customer: "cus_1",
|
||||
line_items: [{ price: "price_pro_monthly", quantity: 1 }, { price: "price_pro_usage" }],
|
||||
subscription_data: expect.objectContaining({
|
||||
trial_period_days: 14,
|
||||
}),
|
||||
customer_update: { address: "auto", name: "auto" },
|
||||
})
|
||||
);
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
test("does not apply trial when customer already has paid subscription history", async () => {
|
||||
mocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: CLOUD_STRIPE_PRODUCT_IDS.PRO,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "price_pro_monthly",
|
||||
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
|
||||
recurring: { usage_type: "licensed" },
|
||||
},
|
||||
{
|
||||
id: "price_pro_usage",
|
||||
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES,
|
||||
recurring: { usage_type: "metered" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createSubscription("org_1", "env_1", CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY);
|
||||
|
||||
expect(mocks.checkoutSessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscription_data: expect.not.objectContaining({
|
||||
trial_period_days: 14,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns newPlan false on checkout creation error", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "price_pro_monthly",
|
||||
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
|
||||
recurring: { usage_type: "licensed" },
|
||||
},
|
||||
{
|
||||
id: "price_pro_usage",
|
||||
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES,
|
||||
recurring: { usage_type: "metered" },
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.checkoutSessionCreate.mockRejectedValue(new Error("stripe down"));
|
||||
|
||||
const result = await createSubscription("org_1", "env_1", CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY);
|
||||
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.newPlan).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { STRIPE_API_VERSION, WEBAPP_URL } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { ensureStripeCustomerForOrganization } from "@/modules/billing/lib/organization-billing";
|
||||
import {
|
||||
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
|
||||
CLOUD_STRIPE_PRODUCT_IDS,
|
||||
TCloudUpgradePriceLookupKey,
|
||||
getCloudPlanFromProductId,
|
||||
} from "@/modules/billing/lib/stripe-catalog";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const createSubscription = async (
|
||||
organizationId: string,
|
||||
environmentId: string,
|
||||
priceLookupKey: TCloudUpgradePriceLookupKey
|
||||
) => {
|
||||
try {
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
|
||||
const { customerId } = await ensureStripeCustomerForOrganization(organizationId);
|
||||
if (!customerId) throw new Error("Stripe customer unavailable");
|
||||
|
||||
const existingSubscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const hasPaidSubscriptionHistory = existingSubscriptions.data.some((subscription) =>
|
||||
subscription.items.data.some((item) => {
|
||||
const product = item.price.product;
|
||||
const productId = typeof product === "string" ? product : product.id;
|
||||
|
||||
// "unknown" products are treated as paid history to avoid repeated free trials.
|
||||
const plan = getCloudPlanFromProductId(productId);
|
||||
return plan !== "hobby" && productId !== CLOUD_STRIPE_PRODUCT_IDS.HOBBY;
|
||||
})
|
||||
);
|
||||
|
||||
const lookupKeys: string[] = [priceLookupKey];
|
||||
|
||||
if (
|
||||
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY ||
|
||||
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_YEARLY
|
||||
) {
|
||||
lookupKeys.push(CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES);
|
||||
}
|
||||
|
||||
if (
|
||||
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_MONTHLY ||
|
||||
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_YEARLY
|
||||
) {
|
||||
lookupKeys.push(CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_USAGE_RESPONSES);
|
||||
}
|
||||
|
||||
const prices = await stripe.prices.list({
|
||||
lookup_keys: lookupKeys,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (prices.data.length !== lookupKeys.length) {
|
||||
throw new Error(`One or more prices not found in Stripe for ${lookupKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
const getPriceByLookupKey = (lookupKey: string) => {
|
||||
const price = prices.data.find((entry) => entry.lookup_key === lookupKey);
|
||||
if (!price) throw new Error(`Price ${lookupKey} not found`);
|
||||
return price;
|
||||
};
|
||||
|
||||
const lineItems = lookupKeys.map((lookupKey) => {
|
||||
const price = getPriceByLookupKey(lookupKey);
|
||||
|
||||
if (price.recurring?.usage_type === "metered") {
|
||||
return { price: price.id };
|
||||
}
|
||||
|
||||
return { price: price.id, quantity: 1 };
|
||||
});
|
||||
|
||||
// Always create a checkout session - let Stripe handle existing customers
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: lineItems,
|
||||
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
|
||||
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
|
||||
customer: customerId,
|
||||
allow_promotion_codes: true,
|
||||
subscription_data: {
|
||||
metadata: { organizationId },
|
||||
...(hasPaidSubscriptionHistory ? {} : { trial_period_days: 14 }),
|
||||
},
|
||||
metadata: { organizationId },
|
||||
billing_address_collection: "required",
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
name: "auto",
|
||||
},
|
||||
automatic_tax: { enabled: true },
|
||||
tax_id_collection: { enabled: true },
|
||||
payment_method_data: { allow_redisplay: "always" },
|
||||
});
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
} catch (err) {
|
||||
logger.error(err, "Error creating subscription");
|
||||
return {
|
||||
status: 500,
|
||||
newPlan: false,
|
||||
url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
plan: TPricingPlan;
|
||||
planPeriod: TOrganizationBillingPeriod;
|
||||
currentPlan: "hobby" | "pro" | "scale" | "trial" | "unknown";
|
||||
onUpgrade: () => Promise<void>;
|
||||
onManageSubscription: () => Promise<void>;
|
||||
}
|
||||
|
||||
const getDisplayPlanLevel = (plan: "hobby" | "pro" | "scale" | "trial" | "unknown") => {
|
||||
if (plan === "hobby") return 0;
|
||||
if (plan === "pro" || plan === "trial") return 1;
|
||||
if (plan === "scale") return 2;
|
||||
return -1;
|
||||
};
|
||||
|
||||
const getTargetPlanLevel = (plan: TPricingPlan["id"]) => {
|
||||
if (plan === "hobby") return 0;
|
||||
if (plan === "pro") return 1;
|
||||
return 2;
|
||||
};
|
||||
|
||||
export const PricingCard = ({
|
||||
planPeriod,
|
||||
plan,
|
||||
onUpgrade,
|
||||
onManageSubscription,
|
||||
currentPlan,
|
||||
}: PricingCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const displayPrice = planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
|
||||
const isCurrentPlan = useMemo(() => {
|
||||
if (currentPlan === "trial") {
|
||||
return plan.id === "pro";
|
||||
}
|
||||
|
||||
return currentPlan === plan.id;
|
||||
}, [currentPlan, plan.id]);
|
||||
|
||||
const ctaLabel = useMemo(() => {
|
||||
if (plan.id === "hobby") {
|
||||
return plan.CTA ?? t("environments.settings.billing.upgrade");
|
||||
}
|
||||
|
||||
const currentLevel = getDisplayPlanLevel(currentPlan);
|
||||
const targetLevel = getTargetPlanLevel(plan.id);
|
||||
const canStartTrial = currentLevel <= 0;
|
||||
|
||||
if (canStartTrial) {
|
||||
return plan.CTA ?? t("common.start_free_trial");
|
||||
}
|
||||
|
||||
if (targetLevel > currentLevel) {
|
||||
return t("environments.settings.billing.upgrade");
|
||||
}
|
||||
|
||||
return t("environments.settings.billing.switch_plan");
|
||||
}, [currentPlan, plan.CTA, plan.id, t]);
|
||||
|
||||
const CTAButton = useMemo(() => {
|
||||
if (isCurrentPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant={plan.featured ? "default" : "secondary"}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
);
|
||||
}, [ctaLabel, isCurrentPlan, loading, onUpgrade, plan.featured]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
plan.featured
|
||||
? "z-10 bg-white shadow-lg ring-1 ring-slate-900/10"
|
||||
: "bg-slate-100 ring-1 ring-white/10 lg:bg-transparent lg:pb-8 lg:ring-0",
|
||||
"relative rounded-xl"
|
||||
)}>
|
||||
<div className="p-8 lg:pt-12 xl:p-10 xl:pt-14">
|
||||
<div className="flex gap-x-2">
|
||||
<h2
|
||||
id={plan.id}
|
||||
className={cn(
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-sm font-semibold leading-6"
|
||||
)}>
|
||||
{plan.name}
|
||||
</h2>
|
||||
{isCurrentPlan && (
|
||||
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-6 sm:flex-row sm:justify-between lg:flex-col lg:items-stretch">
|
||||
<div className="mt-2 flex items-end gap-x-1">
|
||||
<p
|
||||
className={cn(
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-4xl font-bold tracking-tight"
|
||||
)}>
|
||||
{displayPrice}
|
||||
</p>
|
||||
<div className="text-sm leading-5">
|
||||
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
|
||||
/{" "}
|
||||
{planPeriod === "monthly"
|
||||
? t("environments.settings.billing.month")
|
||||
: t("environments.settings.billing.year")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{CTAButton}
|
||||
|
||||
{plan.id !== "hobby" && isCurrentPlan && (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center bg-[#635bff]">
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flow-root sm:mt-10">
|
||||
<ul
|
||||
className={cn(
|
||||
plan.featured
|
||||
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
|
||||
: "divide-white/5 border-white/5 text-slate-800",
|
||||
"-my-2 divide-y border-t text-sm leading-6 lg:border-t-0"
|
||||
)}>
|
||||
{plan.mainFeatures.map((mainFeature) => (
|
||||
<li key={mainFeature} className="flex gap-x-3 py-2">
|
||||
<CheckIcon
|
||||
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{mainFeature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Script from "next/script";
|
||||
import { createElement, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
|
||||
type TCloudUpgradePriceLookupKey,
|
||||
} from "@/modules/billing/lib/stripe-catalog";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
|
||||
import { getCloudPricingData } from "../api/lib/constants";
|
||||
import {
|
||||
createPricingTableCustomerSessionAction,
|
||||
isSubscriptionCancelledAction,
|
||||
manageSubscriptionAction,
|
||||
} from "../actions";
|
||||
import { BillingSlider } from "./billing-slider";
|
||||
import { PricingCard } from "./pricing-card";
|
||||
|
||||
const STRIPE_MONTHLY_PRICING_TABLE_ID = "prctbl_1T6ZLKCng0KywbKlSUAiFqH5";
|
||||
const STRIPE_MONTHLY_PRICING_PUBLISHABLE_KEY =
|
||||
"pk_test_51Sqt6uCng0KywbKlmnLtd8p2B1FfEAcM8O9IDiYdo1F2B6X7VYdMALhrpOU1vDB8SB3ikJshBeHz8Kj9iv89K6j3009S9mmY0h";
|
||||
const STRIPE_SUPPORTED_LOCALES = new Set([
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"en-GB",
|
||||
"es",
|
||||
"es-419",
|
||||
"et",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"fr-CA",
|
||||
"hr",
|
||||
"hu",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"ms",
|
||||
"mt",
|
||||
"nb",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sl",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"vi",
|
||||
"zh",
|
||||
"zh-HK",
|
||||
"zh-TW",
|
||||
]);
|
||||
|
||||
const getStripeLocaleOverride = (locale?: string): string | undefined => {
|
||||
if (!locale) return undefined;
|
||||
|
||||
const normalizedLocale = locale.trim();
|
||||
if (STRIPE_SUPPORTED_LOCALES.has(normalizedLocale)) {
|
||||
return normalizedLocale;
|
||||
}
|
||||
|
||||
const baseLocale = normalizedLocale.split("-")[0];
|
||||
if (STRIPE_SUPPORTED_LOCALES.has(baseLocale)) {
|
||||
return baseLocale;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface PricingTableProps {
|
||||
organization: TOrganization;
|
||||
@@ -57,19 +117,18 @@ export const PricingTable = ({
|
||||
projectCount,
|
||||
hasBillingRights,
|
||||
}: PricingTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [planPeriod, setPlanPeriod] = useState<TOrganizationBillingPeriod>(
|
||||
organization.billing.period ?? "monthly"
|
||||
);
|
||||
|
||||
const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => {
|
||||
setPlanPeriod(period);
|
||||
};
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
|
||||
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const currentCloudPlan = useMemo(() => getCurrentCloudPlan(organization), [organization]);
|
||||
const stripeLocaleOverride = useMemo(
|
||||
() => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
|
||||
[i18n.language, i18n.resolvedLanguage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSubscriptionStatus = async () => {
|
||||
@@ -83,6 +142,20 @@ export const PricingTable = ({
|
||||
checkSubscriptionStatus();
|
||||
}, [organization.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasBillingRights) {
|
||||
setPricingTableCustomerSessionClientSecret(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPricingTableCustomerSession = async () => {
|
||||
const response = await createPricingTableCustomerSessionAction({ environmentId });
|
||||
setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
|
||||
};
|
||||
|
||||
loadPricingTableCustomerSession();
|
||||
}, [environmentId, hasBillingRights]);
|
||||
|
||||
const openCustomerPortal = async () => {
|
||||
const manageSubscriptionResponse = await manageSubscriptionAction({
|
||||
environmentId,
|
||||
@@ -92,65 +165,6 @@ export const PricingTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const upgradePlan = async (priceLookupKey: TCloudUpgradePriceLookupKey) => {
|
||||
try {
|
||||
const upgradePlanResponse = await upgradePlanAction({
|
||||
environmentId,
|
||||
priceLookupKey,
|
||||
});
|
||||
|
||||
if (!upgradePlanResponse?.data) {
|
||||
throw new Error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
const { status, newPlan, url } = upgradePlanResponse.data;
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
if (!newPlan) {
|
||||
toast.success(t("environments.settings.billing.plan_upgraded_successfully"));
|
||||
} else if (newPlan && url) {
|
||||
router.push(url);
|
||||
} else {
|
||||
throw new Error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onUpgrade = async (planId: "hobby" | "pro" | "scale") => {
|
||||
if (planId === "hobby") {
|
||||
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "pro") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly"
|
||||
? CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY
|
||||
: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_YEARLY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "scale") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly"
|
||||
? CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_MONTHLY
|
||||
: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_YEARLY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`${t("environments.settings.billing.unable_to_upgrade_plan")}: ${planId}`);
|
||||
};
|
||||
|
||||
const responsesUnlimitedCheck =
|
||||
currentCloudPlan === "scale" && organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck =
|
||||
@@ -251,46 +265,29 @@ export const PricingTable = ({
|
||||
</div>
|
||||
|
||||
{hasBillingRights && (
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<button
|
||||
aria-pressed={planPeriod === "monthly"}
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={planPeriod === "yearly"}
|
||||
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
{t("environments.settings.billing.annually")}
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{getCloudPricingData(t).plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
currentPlan={currentCloudPlan}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
/>
|
||||
))}
|
||||
<div className="mb-12 w-full">
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-[1200px]">
|
||||
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
|
||||
{createElement("stripe-pricing-table", {
|
||||
"pricing-table-id": STRIPE_MONTHLY_PRICING_TABLE_ID,
|
||||
"publishable-key": STRIPE_MONTHLY_PRICING_PUBLISHABLE_KEY,
|
||||
...(stripeLocaleOverride
|
||||
? {
|
||||
"__locale-override": stripeLocaleOverride,
|
||||
}
|
||||
: {}),
|
||||
...(pricingTableCustomerSessionClientSecret
|
||||
? {
|
||||
"customer-session-client-secret": pricingTableCustomerSessionClientSecret,
|
||||
}
|
||||
: {}),
|
||||
...(!pricingTableCustomerSessionClientSecret
|
||||
? {
|
||||
"client-reference-id": organization.id,
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user