Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes
21da0e1b39 refactor: rename 'start_free_trial' to 'upgrade_plan' across various components and localization files 2026-03-13 17:37:25 +01:00
Johannes
b112559d5f add button 2026-03-13 17:08:12 +01:00
18 changed files with 88 additions and 313 deletions

View File

@@ -60,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[
{
text: IS_FORMBRICKS_CLOUD
? t("common.start_free_trial")
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`

View File

@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -372,7 +372,7 @@ checksums:
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: 4fab76a3fc5d5c94e3248cd279cfdd14
common/upgrade_plan: 4fab76a3fc5d5c94e3248cd279cfdd14
common/status: 4e1fcce15854d824919b4a582c697c90
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6

View File

@@ -399,7 +399,7 @@
"something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"sort_by": "Sort by",
"start_free_trial": "Start Free Trial",
"start_free_trial": "Upgrade plan",
"status": "Status",
"step_by_step_manual": "Step by step manual",
"storage_not_configured": "File storage not set up, uploads will likely fail",

View File

@@ -1,172 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { startHobbyAction, startProTrialAction } from "./actions";
const mocks = vi.hoisted(() => ({
checkAuthorizationUpdated: vi.fn(),
getOrganization: vi.fn(),
createProTrialSubscription: vi.fn(),
ensureCloudStripeSetupForOrganization: vi.fn(),
ensureStripeCustomerForOrganization: vi.fn(),
reconcileCloudStripeSubscriptionsForOrganization: vi.fn(),
syncOrganizationBillingFromStripe: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
createCustomerPortalSession: vi.fn(),
createSetupCheckoutSession: vi.fn(),
isSubscriptionCancelled: vi.fn(),
stripeCustomerSessionsCreate: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://app.formbricks.com",
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: mocks.getOrganization,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/billing/api/lib/create-customer-portal-session", () => ({
createCustomerPortalSession: mocks.createCustomerPortalSession,
}));
vi.mock("@/modules/ee/billing/api/lib/create-setup-checkout-session", () => ({
createSetupCheckoutSession: mocks.createSetupCheckoutSession,
}));
vi.mock("@/modules/ee/billing/api/lib/is-subscription-cancelled", () => ({
isSubscriptionCancelled: mocks.isSubscriptionCancelled,
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
createProTrialSubscription: mocks.createProTrialSubscription,
ensureCloudStripeSetupForOrganization: mocks.ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization: mocks.ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization: mocks.reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe: mocks.syncOrganizationBillingFromStripe,
}));
vi.mock("@/modules/ee/billing/lib/stripe-client", () => ({
stripeClient: {
customerSessions: {
create: mocks.stripeCustomerSessionsCreate,
},
},
}));
describe("billing actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: null,
},
});
mocks.ensureStripeCustomerForOrganization.mockResolvedValue({ customerId: "cus_1" });
mocks.createProTrialSubscription.mockResolvedValue(undefined);
mocks.reconcileCloudStripeSubscriptionsForOrganization.mockResolvedValue(undefined);
mocks.syncOrganizationBillingFromStripe.mockResolvedValue(undefined);
});
test("startHobbyAction ensures a customer, reconciles hobby, and syncs billing", async () => {
const result = await startHobbyAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startHobbyAction reuses an existing stripe customer id", async () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: "cus_existing",
},
});
const result = await startHobbyAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startProTrialAction uses ensured customer when org snapshot has no stripe customer id", async () => {
const result = await startProTrialAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startProTrialAction reuses an existing stripe customer id", async () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: "cus_existing",
},
});
const result = await startProTrialAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
});

View File

@@ -15,7 +15,6 @@ import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscri
import {
createProTrialSubscription,
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "@/modules/ee/billing/lib/organization-billing";
@@ -199,37 +198,6 @@ const ZStartScaleTrialAction = z.object({
organizationId: ZId,
});
export const startHobbyAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
export const startProTrialAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
@@ -249,14 +217,11 @@ export const startProTrialAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await createProTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };

View File

@@ -307,7 +307,8 @@ export const PricingTable = ({
title={t("environments.settings.billing.subscription")}
description={t("environments.settings.billing.subscription_description")}
buttonInfo={
canManageSubscription && currentSubscriptionStatus !== "trialing"
(canManageSubscription && currentSubscriptionStatus !== "trialing") ||
(hasBillingRights && !!organization.billing.stripe?.hasPaymentMethod)
? {
text: t("environments.settings.billing.manage_subscription"),
onClick: () => void openCustomerPortal(),

View File

@@ -12,7 +12,6 @@ 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 { startProTrialAction } from "@/modules/ee/billing/actions";
import { startHobbyAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button";
interface SelectPlanCardProps {
@@ -32,7 +31,6 @@ const CUSTOMER_LOGOS = [
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
const router = useRouter();
const [isStartingTrial, setIsStartingTrial] = useState(false);
const [isStartingHobby, setIsStartingHobby] = useState(false);
const { t } = useTranslation();
const TRIAL_FEATURE_KEYS = [
@@ -66,20 +64,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
}
};
const handleContinueHobby = async () => {
setIsStartingHobby(true);
try {
const result = await startHobbyAction({ organizationId });
if (result?.data) {
router.push(nextUrl);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStartingHobby(false);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStartingHobby(false);
}
const handleContinueFree = () => {
router.push(nextUrl);
};
return (
@@ -112,8 +98,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
onClick={handleStartTrial}
className="mt-4 w-full"
loading={isStartingTrial}
disabled={isStartingTrial || isStartingHobby}>
{t("common.start_free_trial")}
disabled={isStartingTrial}>
{t("common.upgrade_plan")}
</Button>
</div>
@@ -138,10 +124,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
</div>
<button
onClick={handleContinueHobby}
disabled={isStartingTrial || isStartingHobby}
onClick={handleContinueFree}
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
{t("environments.settings.billing.stay_on_hobby_plan")}
</button>
</div>
);

View File

@@ -278,40 +278,6 @@ describe("organization-billing", () => {
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe stores hobby plan when customer has no active subscription", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: { lastSyncedEventId: null },
});
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.entitlementsList.mockResolvedValue({ data: [], has_more: false });
const result = await syncOrganizationBillingFromStripe("org_1");
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
data: expect.objectContaining({
stripeCustomerId: "cus_1",
stripe: expect.objectContaining({
plan: "hobby",
subscriptionStatus: null,
subscriptionId: null,
features: [],
lastSyncedAt: expect.any(String),
}),
}),
});
expect(result?.stripe?.plan).toBe("hobby");
expect(result?.stripe?.subscriptionStatus).toBeNull();
});
test("syncOrganizationBillingFromStripe ignores duplicate webhook events", async () => {
const billing = {
stripeCustomerId: "cus_1",
@@ -776,50 +742,81 @@ describe("organization-billing", () => {
expect(mocks.prismaOrganizationFindUnique).not.toHaveBeenCalled();
});
test("ensureCloudStripeSetupForOrganization creates customer and syncs billing without provisioning hobby", async () => {
test("ensureCloudStripeSetupForOrganization provisions hobby subscription when org has no active subscription", async () => {
mocks.prismaOrganizationFindUnique.mockResolvedValueOnce({
id: "org_1",
name: "Org 1",
});
mocks.prismaMembershipFindFirst.mockResolvedValue({
user: { email: "owner@example.com", name: "Owner Name" },
});
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
// ensureStripeCustomerForOrganization no longer reads billing;
// reconcile and sync each read billing once
mocks.prismaOrganizationBillingFindUnique
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
usageCycleAnchor: new Date(),
stripe: {},
})
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.subscriptionsList
.mockResolvedValueOnce({ data: [] }) // reconciliation initial list (status: "all")
.mockResolvedValueOnce({ data: [] }) // fresh re-check before hobby creation (status: "active")
.mockResolvedValueOnce({
// sync reads subscriptions after hobby is created
data: [
{
id: "sub_hobby",
created: 1739923200,
status: "active",
billing_cycle_anchor: 1739923200,
items: {
data: [
{
price: {
product: { id: "prod_hobby" },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.customersCreate).toHaveBeenCalledWith(
{
name: "Owner Name",
email: "owner@example.com",
metadata: { organizationId: "org_1", organizationName: "Org 1" },
},
{ idempotencyKey: "ensure-customer-org_1" }
);
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
data: expect.objectContaining({
stripeCustomerId: "cus_new",
stripe: expect.objectContaining({
plan: "hobby",
subscriptionStatus: null,
subscriptionId: null,
}),
}),
expect(mocks.productsList).toHaveBeenCalledWith({
active: true,
limit: 100,
});
expect(mocks.pricesList).toHaveBeenCalledWith({
product: "prod_hobby",
active: true,
limit: 100,
});
expect(mocks.subscriptionsCreate).toHaveBeenCalledWith(
{
customer: "cus_new",
items: [{ price: "price_hobby_1", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
});
test("reconcileCloudStripeSubscriptionsForOrganization cancels hobby when paid subscription is active", async () => {

View File

@@ -807,5 +807,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};

View File

@@ -46,7 +46,7 @@ export const ContactsPageLayout = async ({
description={upgradePromptDescription ?? t("environments.contacts.unlock_contacts_description")}
buttons={[
{
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -174,9 +174,7 @@ export const QuotasCard = ({
description={t("common.quotas_description")}
buttons={[
{
text: isFormbricksCloud
? t("common.start_free_trial")
: t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -37,7 +37,7 @@ export const TeamsView = async ({
const buttons: [ModalButton, ModalButton] = [
{
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",

View File

@@ -181,7 +181,7 @@ export const EmailCustomizationSettings = ({
const buttons: [ModalButton, ModalButton] = [
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -149,7 +149,7 @@ export const FaviconCustomizationSettings = ({
const buttons: [ModalButton, ModalButton] = [
{
text: t("common.start_free_trial"),
text: t("common.upgrade_plan"),
href: `/environments/${environmentId}/settings/billing`,
},
{

View File

@@ -23,7 +23,7 @@ export const BrandingSettingsCard = async ({
const buttons: [ModalButton, ModalButton] = [
{
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -193,7 +193,7 @@ export const IndividualInviteTab = ({
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license"
}>
{t("common.start_free_trial")}
{t("common.upgrade_plan")}
</Link>
</AlertDescription>
</Alert>

View File

@@ -43,7 +43,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
description={t("environments.surveys.edit.unlock_targeting_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",