feat: move billing UI to stripe pricing table

This commit is contained in:
Matti Nannt
2026-03-02 19:16:11 +01:00
parent a5da77ab28
commit 680e46a4f1
6 changed files with 152 additions and 735 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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