diff --git a/apps/web/modules/ee/billing/components/select-plan-card.tsx b/apps/web/modules/ee/billing/components/select-plan-card.tsx
index 0614cd9e6b..cdb7e3a8cb 100644
--- a/apps/web/modules/ee/billing/components/select-plan-card.tsx
+++ b/apps/web/modules/ee/billing/components/select-plan-card.tsx
@@ -11,7 +11,7 @@ import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
import githubLogo from "@/images/customer-logos/github-logo.png";
import siemensLogo from "@/images/customer-logos/siemens.png";
-import { startScaleTrialAction } from "@/modules/ee/billing/actions";
+import { startProTrialAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button";
interface SelectPlanCardProps {
@@ -34,17 +34,21 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
const { t } = useTranslation();
const TRIAL_FEATURE_KEYS = [
- t("environments.settings.billing.trial_feature_whitelabel"),
- t("environments.settings.billing.trial_feature_collaboration"),
+ t("environments.settings.billing.trial_feature_unlimited_seats"),
+ t("environments.settings.billing.trial_feature_hide_branding"),
+ t("environments.settings.billing.trial_feature_respondent_identification"),
+ t("environments.settings.billing.trial_feature_contact_segment_management"),
+ t("environments.settings.billing.trial_feature_attribute_segmentation"),
+ t("environments.settings.billing.trial_feature_mobile_sdks"),
+ t("environments.settings.billing.trial_feature_email_followups"),
t("environments.settings.billing.trial_feature_webhooks"),
t("environments.settings.billing.trial_feature_api_access"),
- t("environments.settings.billing.trial_feature_quotas"),
] as const;
const handleStartTrial = async () => {
setIsStartingTrial(true);
try {
- const result = await startScaleTrialAction({ organizationId });
+ const result = await startProTrialAction({ organizationId });
if (result?.data) {
router.push(nextUrl);
} else if (result?.serverError === "trial_already_used") {
diff --git a/apps/web/modules/ee/billing/components/trial-alert.tsx b/apps/web/modules/ee/billing/components/trial-alert.tsx
new file mode 100644
index 0000000000..106020f923
--- /dev/null
+++ b/apps/web/modules/ee/billing/components/trial-alert.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, AlertTitle } from "@/modules/ui/components/alert";
+
+type TrialAlertVariant = "error" | "warning" | "info" | "success";
+
+const getTrialVariant = (daysRemaining: number): TrialAlertVariant => {
+ if (daysRemaining <= 3) return "error";
+ if (daysRemaining <= 7) return "warning";
+ return "info";
+};
+
+interface TrialAlertProps {
+ trialDaysRemaining: number;
+ size?: "small";
+ hasPaymentMethod?: boolean;
+ children?: React.ReactNode;
+}
+
+export const TrialAlert = ({
+ trialDaysRemaining,
+ size,
+ hasPaymentMethod = false,
+ children,
+}: TrialAlertProps) => {
+ const { t } = useTranslation();
+
+ const title = useMemo(() => {
+ if (trialDaysRemaining <= 0) return t("common.trial_expired");
+ if (trialDaysRemaining === 1) return t("common.trial_one_day_remaining");
+ return t("common.trial_days_remaining", { count: trialDaysRemaining });
+ }, [trialDaysRemaining, t]);
+
+ const variant = hasPaymentMethod ? "success" : getTrialVariant(trialDaysRemaining);
+
+ return (
+
+ {title}
+ {children}
+
+ );
+};
diff --git a/apps/web/modules/ee/billing/lib/cloud-billing-display.test.ts b/apps/web/modules/ee/billing/lib/cloud-billing-display.test.ts
index dc17f745e4..6c143c711d 100644
--- a/apps/web/modules/ee/billing/lib/cloud-billing-display.test.ts
+++ b/apps/web/modules/ee/billing/lib/cloud-billing-display.test.ts
@@ -30,6 +30,7 @@ describe("cloud-billing-display", () => {
organizationId: "org_1",
currentCloudPlan: "pro",
currentSubscriptionStatus: null,
+ trialDaysRemaining: null,
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
billing,
diff --git a/apps/web/modules/ee/billing/lib/cloud-billing-display.ts b/apps/web/modules/ee/billing/lib/cloud-billing-display.ts
index b18c888e1e..c2583ee758 100644
--- a/apps/web/modules/ee/billing/lib/cloud-billing-display.ts
+++ b/apps/web/modules/ee/billing/lib/cloud-billing-display.ts
@@ -10,6 +10,7 @@ export type TCloudBillingDisplayContext = {
organizationId: string;
currentCloudPlan: TCloudBillingDisplayPlan;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
+ trialDaysRemaining: number | null;
usageCycleStart: Date;
usageCycleEnd: Date;
billing: NonNullable
>>;
@@ -27,6 +28,22 @@ const resolveCurrentSubscriptionStatus = (
return billing.stripe?.subscriptionStatus ?? null;
};
+const MS_PER_DAY = 86_400_000;
+
+const resolveTrialDaysRemaining = (
+ billing: NonNullable>>
+): number | null => {
+ if (billing.stripe?.subscriptionStatus !== "trialing" || !billing.stripe.trialEnd) {
+ return null;
+ }
+
+ const trialEndDate = new Date(billing.stripe.trialEnd);
+ if (!Number.isFinite(trialEndDate.getTime())) {
+ return null;
+ }
+ return Math.ceil((trialEndDate.getTime() - Date.now()) / MS_PER_DAY);
+};
+
export const getCloudBillingDisplayContext = async (
organizationId: string
): Promise => {
@@ -42,6 +59,7 @@ export const getCloudBillingDisplayContext = async (
organizationId,
currentCloudPlan: resolveCurrentCloudPlan(billing),
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
+ trialDaysRemaining: resolveTrialDaysRemaining(billing),
usageCycleStart: usageCycleWindow.start,
usageCycleEnd: usageCycleWindow.end,
billing,
diff --git a/apps/web/modules/ee/billing/lib/organization-billing.ts b/apps/web/modules/ee/billing/lib/organization-billing.ts
index 371deb6fc6..7114527f32 100644
--- a/apps/web/modules/ee/billing/lib/organization-billing.ts
+++ b/apps/web/modules/ee/billing/lib/organization-billing.ts
@@ -300,10 +300,10 @@ const ensureHobbySubscription = async (
};
/**
- * Checks whether the given email has already used a Scale trial on any Stripe customer.
+ * Checks whether the given email has already used a Pro trial on any Stripe customer.
* Searches all customers with that email and inspects their subscription history.
*/
-const hasEmailUsedScaleTrial = async (email: string, scaleProductId: string): Promise => {
+const hasEmailUsedProTrial = async (email: string, proProductId: string): Promise => {
if (!stripeClient) return false;
const customers = await stripeClient.customers.list({
@@ -318,23 +318,23 @@ const hasEmailUsedScaleTrial = async (email: string, scaleProductId: string): Pr
limit: 100,
});
- const hadScaleTrial = subscriptions.data.some(
+ const hadProTrial = subscriptions.data.some(
(sub) =>
sub.trial_start != null &&
sub.items.data.some((item) => {
const productId =
typeof item.price.product === "string" ? item.price.product : item.price.product.id;
- return productId === scaleProductId;
+ return productId === proProductId;
})
);
- if (hadScaleTrial) return true;
+ if (hadProTrial) return true;
}
return false;
};
-export const createScaleTrialSubscription = async (
+export const createProTrialSubscription = async (
organizationId: string,
customerId: string
): Promise => {
@@ -345,30 +345,29 @@ export const createScaleTrialSubscription = async (
limit: 100,
});
- const scaleProduct = products.data.find((product) => product.metadata.formbricks_plan === "scale");
- if (!scaleProduct) {
- throw new Error("Stripe product metadata formbricks_plan=scale not found");
+ const proProduct = products.data.find((product) => product.metadata.formbricks_plan === "pro");
+ if (!proProduct) {
+ throw new Error("Stripe product metadata formbricks_plan=pro not found");
}
- // Check if the email has already used a Scale trial across any Stripe customer
const customer = await stripeClient.customers.retrieve(customerId);
if (!customer.deleted && customer.email) {
- const alreadyUsed = await hasEmailUsedScaleTrial(customer.email, scaleProduct.id);
+ const alreadyUsed = await hasEmailUsedProTrial(customer.email, proProduct.id);
if (alreadyUsed) {
throw new OperationNotAllowedError("trial_already_used");
}
}
const defaultPrice =
- typeof scaleProduct.default_price === "string" ? null : (scaleProduct.default_price ?? null);
+ typeof proProduct.default_price === "string" ? null : (proProduct.default_price ?? null);
const fallbackPrices = await stripeClient.prices.list({
- product: scaleProduct.id,
+ product: proProduct.id,
active: true,
limit: 100,
});
- const scalePrice =
+ const proPrice =
defaultPrice ??
fallbackPrices.data.find(
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
@@ -376,14 +375,14 @@ export const createScaleTrialSubscription = async (
fallbackPrices.data[0] ??
null;
- if (!scalePrice) {
- throw new Error(`No active price found for Stripe scale product ${scaleProduct.id}`);
+ if (!proPrice) {
+ throw new Error(`No active price found for Stripe pro product ${proProduct.id}`);
}
await stripeClient.subscriptions.create(
{
customer: customerId,
- items: [{ price: scalePrice.id, quantity: 1 }],
+ items: [{ price: proPrice.id, quantity: 1 }],
trial_period_days: 14,
trial_settings: {
end_behavior: {
@@ -395,7 +394,7 @@ export const createScaleTrialSubscription = async (
},
metadata: { organizationId },
},
- { idempotencyKey: `create-scale-trial-${organizationId}` }
+ { idempotencyKey: `create-pro-trial-${organizationId}` }
);
};
@@ -629,10 +628,14 @@ export const syncOrganizationBillingFromStripe = async (
plan: cloudPlan,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
+ hasPaymentMethod: subscription?.default_payment_method != null,
features: featureLookupKeys,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),
lastSyncedAt: new Date().toISOString(),
lastSyncedEventId: event?.id ?? existingStripeSnapshot?.lastSyncedEventId ?? null,
+ trialEnd: subscription?.trial_end
+ ? new Date(subscription.trial_end * 1000).toISOString()
+ : (existingStripeSnapshot?.trialEnd ?? null),
},
};
diff --git a/apps/web/modules/ee/billing/page.tsx b/apps/web/modules/ee/billing/page.tsx
index 991e24d896..e7798f33d4 100644
--- a/apps/web/modules/ee/billing/page.tsx
+++ b/apps/web/modules/ee/billing/page.tsx
@@ -59,6 +59,7 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
+ trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
/>
);
diff --git a/packages/types/organizations.ts b/packages/types/organizations.ts
index 40de19170b..5fc20d4df5 100644
--- a/packages/types/organizations.ts
+++ b/packages/types/organizations.ts
@@ -19,10 +19,12 @@ export const ZOrganizationStripeBilling = z.object({
plan: ZCloudBillingPlan.optional(),
subscriptionStatus: ZOrganizationStripeSubscriptionStatus.nullable().optional(),
subscriptionId: z.string().nullable().optional(),
+ hasPaymentMethod: z.boolean().optional(),
features: z.array(z.string()).optional(),
lastStripeEventCreatedAt: z.string().nullable().optional(),
lastSyncedAt: z.string().nullable().optional(),
lastSyncedEventId: z.string().nullable().optional(),
+ trialEnd: z.string().nullable().optional(),
});
export type TOrganizationStripeBilling = z.infer;