Compare commits

..

14 Commits

Author SHA1 Message Date
Matti Nannt
f304d27a6d Merge branch 'main' of github.com:formbricks/formbricks into codex/defer-hobby-subscription
# Conflicts:
#	apps/web/modules/ee/billing/actions.ts
#	apps/web/modules/ee/billing/components/select-plan-card.tsx
2026-03-13 16:52:45 +01:00
Matti Nannt
3e76d78394 refactor: align hobby plan card naming 2026-03-13 16:44:16 +01:00
Johannes
2dc5c50f4d feat: implement trial days remaining alert in billing components (#7474) 2026-03-13 16:38:43 +01:00
Matti Nannt
3f638c4ae8 fix: reuse existing stripe customer for billing actions 2026-03-13 16:37:35 +01:00
Matti Nannt
38e84f4e15 refactor: rename hobby start action 2026-03-13 16:33:33 +01:00
Matti Nannt
71672f206c fix: defer hobby subscription creation 2026-03-13 16:26:09 +01:00
Anshuman Pandey
bddcec0466 fix: adds monkey patching for replaceState (#7475) 2026-03-13 13:40:20 +00:00
Dhruwang Jariwala
92677e1ec0 fix: respect overwriteThemeStyling in link survey metadata (#7466)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-13 13:07:54 +00:00
Anshuman Pandey
b12228e305 fix: fixes button url fixes in survey editor (#7472) 2026-03-13 13:07:41 +00:00
Dhruwang Jariwala
91be2af30b fix: add missing Stripe billing setup for setup route org creation (#7470)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:18:01 +01:00
Anshuman Pandey
84c668be86 fix: fixes contact links api gating issue (#7468) 2026-03-13 11:09:53 +00:00
Dhruwang Jariwala
4015c76f2b fix: use logical CSS direction classes for RTL matrix question (#7463)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:06:41 +00:00
Dhruwang Jariwala
a7b2ade4a9 fix: remove follow-ups from trial features and gate trial page for subscribers (#7465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:00:23 +00:00
Dhruwang Jariwala
75f44952c7 fix: clear validation settings when disabling open text validation (#7464)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:39:42 +00:00
32 changed files with 1238 additions and 192 deletions

View File

@@ -28,6 +28,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
@@ -167,6 +168,20 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return (
@@ -241,6 +256,13 @@ export const MainNavigation = ({
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>

View File

@@ -1,12 +1,14 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import {
TSurvey,
TSurveyCreateInputWithEnvironmentId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { checkFeaturePermissions } from "./utils";
// Mock dependencies
@@ -24,6 +26,14 @@ vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
getSurveyFollowUpsPermission: vi.fn(),
}));
vi.mock("@/modules/survey/lib/permission", () => ({
getExternalUrlsPermission: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
}));
const mockOrganization: TOrganization = {
id: "test-org",
name: "Test Organization",
@@ -98,6 +108,13 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
};
describe("checkFeaturePermissions", () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
afterEach(() => {
vi.clearAllMocks();
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
});
test("should return null if no restricted features are used", async () => {
const surveyData = { ...baseSurveyData };
const result = await checkFeaturePermissions(surveyData, mockOrganization);
@@ -197,4 +214,315 @@ describe("checkFeaturePermissions", () => {
);
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
});
// External URLs - ending card button link tests
test("should return forbiddenResponse when adding new ending with buttonLink without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://example.com",
buttonLabel: { default: "Click" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"External URLs are not enabled for this organization. Upgrade to use external button links."
);
});
test("should return forbiddenResponse when changing ending buttonLink without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://new-url.com",
buttonLabel: { default: "Click" },
},
],
};
const oldSurvey = {
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://old-url.com",
buttonLabel: { default: "Click" },
},
],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
test("should allow keeping existing ending buttonLink without permission (grandfathering)", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://existing-url.com",
buttonLabel: { default: "Click" },
},
],
};
const oldSurvey = {
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://existing-url.com",
buttonLabel: { default: "Click" },
},
],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeNull();
});
test("should allow ending buttonLink when permission is granted", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://example.com",
buttonLabel: { default: "Click" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// External URLs - CTA external button tests
test("should return forbiddenResponse when adding CTA with external button without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
);
});
test("should return forbiddenResponse when changing CTA external button URL without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://new-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const oldSurvey = {
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://old-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
test("should allow keeping existing CTA external button without permission (grandfathering)", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://existing-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const oldSurvey = {
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://existing-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeNull();
});
test("should allow CTA external button when permission is granted", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
test("should return forbiddenResponse when switching CTA from internal to external without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const oldSurvey = {
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: false,
buttonUrl: "",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
});

View File

@@ -407,6 +407,9 @@ checksums:
common/title: 344e64395eaff6822a57d18623853e1a
common/top_left: aa61bb29b56df3e046b6d68d89ee8986
common/top_right: 241f95c923846911aaf13af6109333e5
common/trial_days_remaining: 914ff3132895e410bf0f862433ccb49e
common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
@@ -913,6 +916,7 @@ checksums:
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
@@ -937,14 +941,20 @@ checksums:
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9
environments/settings/billing/trial_alert_description: aba3076cc6814cc6128d425d3d1957e8
environments/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
environments/settings/billing/trial_feature_api_access: d7aabb2de18beb5bd30c274cd768a2a9
environments/settings/billing/trial_feature_collaboration: a43509fffe319e14d69a981ef2791517
environments/settings/billing/trial_feature_quotas: 3a67818b3901bdaa72abc62db72ab170
environments/settings/billing/trial_feature_webhooks: 8d7f034e006b2fe0eb8fa9b8f1abef51
environments/settings/billing/trial_feature_whitelabel: 624a7aeca6a0fa65935c63fd7a8e9638
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d
environments/settings/billing/trial_feature_attribute_segmentation: 90087da973ae48e32ec6d863516fc8c9
environments/settings/billing/trial_feature_contact_segment_management: 27f17a039ebed6413811ab3a461db2f4
environments/settings/billing/trial_feature_email_followups: 0cc02dc14aa28ce94ca6153c306924e5
environments/settings/billing/trial_feature_hide_branding: b8dbcb24e50e0eb4aeb0c97891cac61d
environments/settings/billing/trial_feature_mobile_sdks: 0963480a27df49657c1b7507adec9a06
environments/settings/billing/trial_feature_respondent_identification: a82e24ab4c27c5e485326678d9b7bd79
environments/settings/billing/trial_feature_unlimited_seats: a3257d5b6a23bfbc4b7fd1108087a823
environments/settings/billing/trial_feature_webhooks: 5ead39fba97fbd37835a476ee67fdd94
environments/settings/billing/trial_no_credit_card: 01c70aa6e1001815a9a11951394923ca
environments/settings/billing/trial_title: 23d0d2cbe306ae0f784b8289bf66a2c7
environments/settings/billing/trial_payment_method_added_description: 872a5c557f56bafc9b7ec4895f9c33e8
environments/settings/billing/trial_title: f2c3791c1fb2970617ec0f2d243a931b
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2

View File

@@ -434,6 +434,9 @@
"title": "Titel",
"top_left": "Oben links",
"top_right": "Oben rechts",
"trial_days_remaining": "Noch {count} Tage in deiner Testphase",
"trial_expired": "Deine Testphase ist abgelaufen",
"trial_one_day_remaining": "Noch 1 Tag in deiner Testphase",
"try_again": "Versuch's nochmal",
"type": "Typ",
"unknown_survey": "Unbekannte Umfrage",
@@ -968,6 +971,7 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"add_payment_method": "Zahlungsmethode hinzufügen",
"cancelling": "Wird storniert",
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
"manage_subscription": "Abonnement verwalten",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.",
"subscription": "Abonnement",
"subscription_description": "Verwalte Dein Abonnement und behalte Deine Nutzung im Blick",
"trial_alert_description": "Füge eine Zahlungsmethode hinzu, um weiterhin Zugriff auf alle Funktionen zu behalten.",
"trial_already_used": "Für diese E-Mail-Adresse wurde bereits eine kostenlose Testversion genutzt. Bitte upgraden Sie stattdessen auf einen kostenpflichtigen Plan.",
"trial_feature_api_access": "Vollen API-Zugriff erhalten",
"trial_feature_collaboration": "Alle Team- und Kollaborationsfunktionen",
"trial_feature_quotas": "Kontingente verwalten",
"trial_feature_webhooks": "Benutzerdefinierte Webhooks einrichten",
"trial_feature_whitelabel": "Vollständig white-labeled Umfragen",
"trial_feature_api_access": "API-Zugriff",
"trial_feature_attribute_segmentation": "Attributbasierte Segmentierung",
"trial_feature_contact_segment_management": "Kontakt- & Segmentverwaltung",
"trial_feature_email_followups": "E-Mail-Nachfassaktionen",
"trial_feature_hide_branding": "Formbricks-Branding ausblenden",
"trial_feature_mobile_sdks": "iOS & Android SDKs",
"trial_feature_respondent_identification": "Befragten-Identifikation",
"trial_feature_unlimited_seats": "Unbegrenzte Benutzerplätze",
"trial_feature_webhooks": "Individuelle Webhooks",
"trial_no_credit_card": "14 Tage Testversion, keine Kreditkarte erforderlich",
"trial_title": "Pro-Funktionen kostenlos testen!",
"trial_payment_method_added_description": "Alles bereit! Dein Pro-Tarif läuft nach Ende der Testphase automatisch weiter.",
"trial_title": "Hol dir Formbricks Pro kostenlos!",
"unlimited_responses": "Unbegrenzte Antworten",
"unlimited_workspaces": "Unbegrenzte Projekte",
"upgrade": "Upgrade",

View File

@@ -434,6 +434,9 @@
"title": "Title",
"top_left": "Top Left",
"top_right": "Top Right",
"trial_days_remaining": "{count} days left in your trial",
"trial_expired": "Your trial has expired",
"trial_one_day_remaining": "1 day left in your trial",
"try_again": "Try again",
"type": "Type",
"unknown_survey": "Unknown survey",
@@ -968,6 +971,7 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"add_payment_method": "Add payment method",
"cancelling": "Cancelling",
"failed_to_start_trial": "Failed to start trial. Please try again.",
"manage_subscription": "Manage subscription",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "Billing setup did not complete successfully. Please retry to activate your subscription.",
"subscription": "Subscription",
"subscription_description": "Manage your subscription plan and monitor your usage",
"trial_alert_description": "Add a payment method to keep access to all features.",
"trial_already_used": "A free trial has already been used for this email address. Please upgrade to a paid plan instead.",
"trial_feature_api_access": "Get full API access",
"trial_feature_collaboration": "All team & collaboration features",
"trial_feature_quotas": "Manage quotas",
"trial_feature_webhooks": "Setup custom webhooks",
"trial_feature_whitelabel": "Fully white-labeled surveys",
"trial_feature_api_access": "API Access",
"trial_feature_attribute_segmentation": "Attribute-based Segmentation",
"trial_feature_contact_segment_management": "Contact & Segment Management",
"trial_feature_email_followups": "Email Follow-ups",
"trial_feature_hide_branding": "Hide Formbricks Branding",
"trial_feature_mobile_sdks": "iOS & Android SDKs",
"trial_feature_respondent_identification": "Respondent Identification",
"trial_feature_unlimited_seats": "Unlimited Seats",
"trial_feature_webhooks": "Custom Webhooks",
"trial_no_credit_card": "14 days trial, no credit card required",
"trial_title": "Try Pro features for free!",
"trial_payment_method_added_description": "You're all set! Your Pro plan will continue automatically after the trial ends.",
"trial_title": "Get Formbricks Pro for free!",
"unlimited_responses": "Unlimited Responses",
"unlimited_workspaces": "Unlimited Workspaces",
"upgrade": "Upgrade",

View File

@@ -434,6 +434,9 @@
"title": "Título",
"top_left": "Superior izquierda",
"top_right": "Superior derecha",
"trial_days_remaining": "{count} días restantes en tu prueba",
"trial_expired": "Tu prueba ha expirado",
"trial_one_day_remaining": "1 día restante en tu prueba",
"try_again": "Intentar de nuevo",
"type": "Tipo",
"unknown_survey": "Encuesta desconocida",
@@ -968,6 +971,7 @@
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
},
"billing": {
"add_payment_method": "Añadir método de pago",
"cancelling": "Cancelando",
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
"manage_subscription": "Gestionar suscripción",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "La configuración de facturación no se completó correctamente. Por favor, vuelve a intentarlo para activar tu suscripción.",
"subscription": "Suscripción",
"subscription_description": "Gestiona tu plan de suscripción y monitorea tu uso",
"trial_alert_description": "Añade un método de pago para mantener el acceso a todas las funciones.",
"trial_already_used": "Ya se ha utilizado una prueba gratuita para esta dirección de correo electrónico. Por favor, actualiza a un plan de pago.",
"trial_feature_api_access": "Acceso completo a la API",
"trial_feature_collaboration": "Todas las funciones de equipo y colaboración",
"trial_feature_quotas": "Gestionar cuotas",
"trial_feature_webhooks": "Configurar webhooks personalizados",
"trial_feature_whitelabel": "Encuestas totalmente personalizadas",
"trial_feature_api_access": "Acceso a la API",
"trial_feature_attribute_segmentation": "Segmentación basada en atributos",
"trial_feature_contact_segment_management": "Gestión de contactos y segmentos",
"trial_feature_email_followups": "Seguimientos por correo electrónico",
"trial_feature_hide_branding": "Ocultar la marca Formbricks",
"trial_feature_mobile_sdks": "SDKs para iOS y Android",
"trial_feature_respondent_identification": "Identificación de encuestados",
"trial_feature_unlimited_seats": "Asientos ilimitados",
"trial_feature_webhooks": "Webhooks personalizados",
"trial_no_credit_card": "Prueba de 14 días, sin tarjeta de crédito",
"trial_title": "¡Prueba las funciones Pro gratis!",
"trial_payment_method_added_description": "¡Todo listo! Tu plan Pro continuará automáticamente cuando termine el periodo de prueba.",
"trial_title": "¡Consigue Formbricks Pro gratis!",
"unlimited_responses": "Respuestas ilimitadas",
"unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar",

View File

@@ -434,6 +434,9 @@
"title": "Titre",
"top_left": "En haut à gauche",
"top_right": "En haut à droite",
"trial_days_remaining": "{count} jours restants dans votre période d'essai",
"trial_expired": "Votre période d'essai a expiré",
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
"try_again": "Réessayer",
"type": "Type",
"unknown_survey": "Enquête inconnue",
@@ -968,6 +971,7 @@
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
},
"billing": {
"add_payment_method": "Ajouter un moyen de paiement",
"cancelling": "Annulation en cours",
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
"manage_subscription": "Gérer l'abonnement",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "La configuration de la facturation na pas abouti. Merci de réessayer pour activer ton abonnement.",
"subscription": "Abonnement",
"subscription_description": "Gère ton abonnement et surveille ta consommation",
"trial_alert_description": "Ajoute un moyen de paiement pour conserver l'accès à toutes les fonctionnalités.",
"trial_already_used": "Un essai gratuit a déjà été utilisé pour cette adresse e-mail. Passe plutôt à un plan payant.",
"trial_feature_api_access": "Accès complet à l'API",
"trial_feature_collaboration": "Toutes les fonctionnalités d'équipe et de collaboration",
"trial_feature_quotas": "Gère les quotas",
"trial_feature_webhooks": "Configure des webhooks personnalisés",
"trial_feature_whitelabel": "Enquêtes entièrement en marque blanche",
"trial_feature_api_access": "Accès API",
"trial_feature_attribute_segmentation": "Segmentation basée sur les attributs",
"trial_feature_contact_segment_management": "Gestion des contacts et segments",
"trial_feature_email_followups": "Relances par e-mail",
"trial_feature_hide_branding": "Masquer l'image de marque Formbricks",
"trial_feature_mobile_sdks": "SDKs iOS et Android",
"trial_feature_respondent_identification": "Identification des répondants",
"trial_feature_unlimited_seats": "Places illimitées",
"trial_feature_webhooks": "Webhooks personnalisés",
"trial_no_credit_card": "Essai de 14 jours, aucune carte bancaire requise",
"trial_title": "Essaie les fonctionnalités Pro gratuitement !",
"trial_payment_method_added_description": "Tout est prêt ! Votre abonnement Pro se poursuivra automatiquement après la fin de la période d'essai.",
"trial_title": "Obtenez Formbricks Pro gratuitement !",
"unlimited_responses": "Réponses illimitées",
"unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau",

View File

@@ -434,6 +434,9 @@
"title": "Cím",
"top_left": "Balra fent",
"top_right": "Jobbra fent",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból",
"trial_expired": "A próbaidőszak lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból",
"try_again": "Próbálja újra",
"type": "Típus",
"unknown_survey": "Ismeretlen kérdőív",
@@ -968,6 +971,7 @@
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
},
"billing": {
"add_payment_method": "Fizetési mód hozzáadása",
"cancelling": "Lemondás folyamatban",
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
"manage_subscription": "Előfizetés kezelése",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
"subscription": "Előfizetés",
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
"trial_alert_description": "Adjon hozzá fizetési módot, hogy megtarthassa a hozzáférést az összes funkcióhoz.",
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.",
"trial_feature_api_access": "Teljes API-hozzáférés megszerzése",
"trial_feature_collaboration": "Minden csapat- és együttműködési funkció",
"trial_feature_quotas": "Kvóták kezelése",
"trial_feature_webhooks": "Egyéni webhookok beállítása",
"trial_feature_whitelabel": "Teljesen fehércímkés felmérések",
"trial_feature_api_access": "API-hozzáférés",
"trial_feature_attribute_segmentation": "Attribútumalapú szegmentálás",
"trial_feature_contact_segment_management": "Kapcsolat- és szegmenskezelés",
"trial_feature_email_followups": "E-mail követések",
"trial_feature_hide_branding": "Formbricks márkajelzés elrejtése",
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
"trial_feature_unlimited_seats": "Korlátlan számú felhasználói hely",
"trial_feature_webhooks": "Egyéni webhookok",
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
"trial_title": "Próbálja ki a Pro funkciókat ingyen!",
"trial_payment_method_added_description": "Minden rendben! A Pro csomag automatikusan folytatódik a próbaidőszak lejárta után.",
"trial_title": "Szerezze meg a Formbricks Pro-t ingyen!",
"unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",

View File

@@ -434,6 +434,9 @@
"title": "タイトル",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "トライアル期間の残り{count}日",
"trial_expired": "トライアル期間が終了しました",
"trial_one_day_remaining": "トライアル期間の残り1日",
"try_again": "もう一度お試しください",
"type": "種類",
"unknown_survey": "不明なフォーム",
@@ -968,6 +971,7 @@
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
},
"billing": {
"add_payment_method": "支払い方法を追加",
"cancelling": "キャンセル中",
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
"manage_subscription": "サブスクリプションを管理",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
"subscription": "サブスクリプション",
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
"trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
"trial_feature_api_access": "フルAPIアクセスを利用",
"trial_feature_collaboration": "すべてのチーム・コラボレーション機能",
"trial_feature_quotas": "クォータの管理",
"trial_feature_webhooks": "カスタムWebhookの設定",
"trial_feature_whitelabel": "完全ホワイトラベル対応のアンケート",
"trial_feature_api_access": "APIアクセス",
"trial_feature_attribute_segmentation": "属性ベースのセグメンテーション",
"trial_feature_contact_segment_management": "連絡先とセグメントの管理",
"trial_feature_email_followups": "メールフォローアップ",
"trial_feature_hide_branding": "Formbricksブランディングを非表示",
"trial_feature_mobile_sdks": "iOS & Android SDK",
"trial_feature_respondent_identification": "回答者の識別",
"trial_feature_unlimited_seats": "無制限のシート数",
"trial_feature_webhooks": "カスタムWebhook",
"trial_no_credit_card": "14日間トライアル、クレジットカード不要",
"trial_title": "Pro機能を無料でお試し!",
"trial_payment_method_added_description": "準備完了ですトライアル終了後、Proプランが自動的に継続されます。",
"trial_title": "Formbricks Proを無料で入手しよう!",
"unlimited_responses": "無制限の回答",
"unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード",

View File

@@ -434,6 +434,9 @@
"title": "Titel",
"top_left": "Linksboven",
"top_right": "Rechtsboven",
"trial_days_remaining": "{count} dagen over in je proefperiode",
"trial_expired": "Je proefperiode is verlopen",
"trial_one_day_remaining": "1 dag over in je proefperiode",
"try_again": "Probeer het opnieuw",
"type": "Type",
"unknown_survey": "Onbekende enquête",
@@ -968,6 +971,7 @@
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
},
"billing": {
"add_payment_method": "Betaalmethode toevoegen",
"cancelling": "Bezig met annuleren",
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
"manage_subscription": "Abonnement beheren",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "Het instellen van de facturatie is niet gelukt. Probeer het opnieuw om je abonnement te activeren.",
"subscription": "Abonnement",
"subscription_description": "Beheer je abonnement en houd je gebruik bij",
"trial_alert_description": "Voeg een betaalmethode toe om toegang te houden tot alle functies.",
"trial_already_used": "Er is al een gratis proefperiode gebruikt voor dit e-mailadres. Upgrade in plaats daarvan naar een betaald abonnement.",
"trial_feature_api_access": "Krijg volledige API-toegang",
"trial_feature_collaboration": "Alle team- en samenwerkingsfuncties",
"trial_feature_quotas": "Quota's beheren",
"trial_feature_webhooks": "Aangepaste webhooks instellen",
"trial_feature_whitelabel": "Volledig white-label enquêtes",
"trial_feature_api_access": "API-toegang",
"trial_feature_attribute_segmentation": "Segmentatie op basis van attributen",
"trial_feature_contact_segment_management": "Contact- en segmentbeheer",
"trial_feature_email_followups": "E-mail follow-ups",
"trial_feature_hide_branding": "Verberg Formbricks-branding",
"trial_feature_mobile_sdks": "iOS- en Android-SDK's",
"trial_feature_respondent_identification": "Identificatie van respondenten",
"trial_feature_unlimited_seats": "Onbeperkt aantal gebruikers",
"trial_feature_webhooks": "Aangepaste webhooks",
"trial_no_credit_card": "14 dagen proefperiode, geen creditcard vereist",
"trial_title": "Probeer Pro-functies gratis!",
"trial_payment_method_added_description": "Je bent helemaal klaar! Je Pro-abonnement wordt automatisch voortgezet na afloop van de proefperiode.",
"trial_title": "Krijg Formbricks Pro gratis!",
"unlimited_responses": "Onbeperkte reacties",
"unlimited_workspaces": "Onbeperkt werkruimtes",
"upgrade": "Upgraden",

View File

@@ -434,6 +434,9 @@
"title": "Título",
"top_left": "Canto superior esquerdo",
"top_right": "Canto Superior Direito",
"trial_days_remaining": "{count} dias restantes no seu período de teste",
"trial_expired": "Seu período de teste expirou",
"trial_one_day_remaining": "1 dia restante no seu período de teste",
"try_again": "Tenta de novo",
"type": "Tipo",
"unknown_survey": "Pesquisa desconhecida",
@@ -968,6 +971,7 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"add_payment_method": "Adicionar forma de pagamento",
"cancelling": "Cancelando",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
"manage_subscription": "Gerenciar assinatura",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "A configuração de cobrança não foi concluída com sucesso. Tente novamente para ativar sua assinatura.",
"subscription": "Assinatura",
"subscription_description": "Gerencie seu plano de assinatura e acompanhe seu uso",
"trial_alert_description": "Adicione uma forma de pagamento para manter o acesso a todos os recursos.",
"trial_already_used": "Um período de teste gratuito já foi usado para este endereço de e-mail. Por favor, faça upgrade para um plano pago.",
"trial_feature_api_access": "Obtenha acesso completo à API",
"trial_feature_collaboration": "Todos os recursos de equipe e colaboração",
"trial_feature_quotas": "Gerencie cotas",
"trial_feature_webhooks": "Configure webhooks personalizados",
"trial_feature_whitelabel": "Pesquisas totalmente personalizadas",
"trial_feature_api_access": "Acesso à API",
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
"trial_feature_contact_segment_management": "Gerenciamento de Contatos e Segmentos",
"trial_feature_email_followups": "Follow-ups por E-mail",
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
"trial_feature_respondent_identification": "Identificação de Respondentes",
"trial_feature_unlimited_seats": "Assentos Ilimitados",
"trial_feature_webhooks": "Webhooks Personalizados",
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
"trial_title": "Experimente os recursos Pro gratuitamente!",
"trial_payment_method_added_description": "Tudo pronto! Seu plano Pro continuará automaticamente após o término do período de teste.",
"trial_title": "Ganhe o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",

View File

@@ -434,6 +434,9 @@
"title": "Título",
"top_left": "Superior Esquerdo",
"top_right": "Superior Direito",
"trial_days_remaining": "{count} dias restantes no teu período de teste",
"trial_expired": "O teu período de teste expirou",
"trial_one_day_remaining": "1 dia restante no teu período de teste",
"try_again": "Tente novamente",
"type": "Tipo",
"unknown_survey": "Inquérito desconhecido",
@@ -968,6 +971,7 @@
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"add_payment_method": "Adicionar método de pagamento",
"cancelling": "A cancelar",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
"manage_subscription": "Gerir subscrição",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "A configuração de faturação não foi concluída com sucesso. Por favor, tenta novamente para ativar a tua subscrição.",
"subscription": "Subscrição",
"subscription_description": "Gere o teu plano de subscrição e acompanha a tua utilização",
"trial_alert_description": "Adiciona um método de pagamento para manteres acesso a todas as funcionalidades.",
"trial_already_used": "Já foi utilizado um período de teste gratuito para este endereço de email. Por favor, atualiza para um plano pago.",
"trial_feature_api_access": "Obtém acesso completo à API",
"trial_feature_collaboration": "Todas as funcionalidades de equipa e colaboração",
"trial_feature_quotas": "Gere quotas",
"trial_feature_webhooks": "Configura webhooks personalizados",
"trial_feature_whitelabel": "Inquéritos totalmente personalizados",
"trial_feature_api_access": "Acesso à API",
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
"trial_feature_contact_segment_management": "Gestão de Contactos e Segmentos",
"trial_feature_email_followups": "Seguimentos por E-mail",
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
"trial_feature_respondent_identification": "Identificação de Inquiridos",
"trial_feature_unlimited_seats": "Lugares Ilimitados",
"trial_feature_webhooks": "Webhooks Personalizados",
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
"trial_title": "Experimenta as funcionalidades Pro gratuitamente!",
"trial_payment_method_added_description": "Está tudo pronto! O teu plano Pro continuará automaticamente após o fim do período experimental.",
"trial_title": "Obtém o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",

View File

@@ -434,6 +434,9 @@
"title": "Titlu",
"top_left": "Stânga Sus",
"top_right": "Dreapta Sus",
"trial_days_remaining": "{count} zile rămase în perioada ta de probă",
"trial_expired": "Perioada ta de probă a expirat",
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
"try_again": "Încearcă din nou",
"type": "Tip",
"unknown_survey": "Chestionar necunoscut",
@@ -968,6 +971,7 @@
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
},
"billing": {
"add_payment_method": "Adaugă o metodă de plată",
"cancelling": "Anulare în curs",
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
"manage_subscription": "Gestionează abonamentul",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "Configurarea facturării nu a fost finalizată cu succes. Încearcă din nou pentru a activa abonamentul.",
"subscription": "Abonament",
"subscription_description": "Gestionează-ți abonamentul și monitorizează-ți consumul",
"trial_alert_description": "Adaugă o metodă de plată pentru a păstra accesul la toate funcționalitățile.",
"trial_already_used": "O perioadă de probă gratuită a fost deja utilizată pentru această adresă de email. Te rugăm să treci la un plan plătit în schimb.",
"trial_feature_api_access": "Obține acces complet la API",
"trial_feature_collaboration": "Toate funcțiile de echipă și colaborare",
"trial_feature_quotas": "Gestionează cotele",
"trial_feature_webhooks": "Configurează webhook-uri personalizate",
"trial_feature_whitelabel": "Chestionare complet personalizate (white-label)",
"trial_feature_api_access": "Acces API",
"trial_feature_attribute_segmentation": "Segmentare bazată pe atribute",
"trial_feature_contact_segment_management": "Gestionare contacte și segmente",
"trial_feature_email_followups": "Urmăriri prin email",
"trial_feature_hide_branding": "Ascunde branding-ul Formbricks",
"trial_feature_mobile_sdks": "SDK-uri iOS și Android",
"trial_feature_respondent_identification": "Identificarea respondenților",
"trial_feature_unlimited_seats": "Locuri nelimitate",
"trial_feature_webhooks": "Webhook-uri personalizate",
"trial_no_credit_card": "14 zile de probă, fără card necesar",
"trial_title": "Încearcă funcțiile Pro gratuit!",
"trial_payment_method_added_description": "Totul este pregătit! Planul tău Pro va continua automat după ce se încheie perioada de probă.",
"trial_title": "Obține Formbricks Pro gratuit!",
"unlimited_responses": "Răspunsuri nelimitate",
"unlimited_workspaces": "Workspaces nelimitate",
"upgrade": "Actualizare",

View File

@@ -434,6 +434,9 @@
"title": "Заголовок",
"top_left": "Вверху слева",
"top_right": "Вверху справа",
"trial_days_remaining": "{count, plural, one {Остался # день пробного периода} few {Осталось # дня пробного периода} many {Осталось # дней пробного периода} other {Осталось # дней пробного периода}}",
"trial_expired": "Пробный период истёк",
"trial_one_day_remaining": "Остался 1 день пробного периода",
"try_again": "Попробуйте ещё раз",
"type": "Тип",
"unknown_survey": "Неизвестный опрос",
@@ -968,6 +971,7 @@
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
},
"billing": {
"add_payment_method": "Добавить способ оплаты",
"cancelling": "Отмена",
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
"manage_subscription": "Управление подпиской",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
"subscription": "Подписка",
"subscription_description": "Управляйте своим тарифом и следите за использованием",
"trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
"trial_feature_api_access": "Получите полный доступ к API",
"trial_feature_collaboration": "Все функции для работы в команде и совместной работы",
"trial_feature_quotas": "Управляйте квотами",
"trial_feature_webhooks": "Настройте собственные вебхуки",
"trial_feature_whitelabel": "Полностью персонализированные опросы без брендинга",
"trial_feature_api_access": "Доступ к API",
"trial_feature_attribute_segmentation": "Сегментация на основе атрибутов",
"trial_feature_contact_segment_management": "Управление контактами и сегментами",
"trial_feature_email_followups": "Email-уведомления",
"trial_feature_hide_branding": "Скрыть брендинг Formbricks",
"trial_feature_mobile_sdks": "iOS и Android SDK",
"trial_feature_respondent_identification": "Идентификация респондентов",
"trial_feature_unlimited_seats": "Неограниченное количество мест",
"trial_feature_webhooks": "Пользовательские вебхуки",
"trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется",
"trial_title": "Попробуйте Pro функции бесплатно!",
"trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.",
"trial_title": "Получите Formbricks Pro бесплатно!",
"unlimited_responses": "Неограниченное количество ответов",
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить",

View File

@@ -434,6 +434,9 @@
"title": "Titel",
"top_left": "Övre vänster",
"top_right": "Övre höger",
"trial_days_remaining": "{count} dagar kvar av din provperiod",
"trial_expired": "Din provperiod har gått ut",
"trial_one_day_remaining": "1 dag kvar av din provperiod",
"try_again": "Försök igen",
"type": "Typ",
"unknown_survey": "Okänd enkät",
@@ -968,6 +971,7 @@
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
},
"billing": {
"add_payment_method": "Lägg till betalningsmetod",
"cancelling": "Avbryter",
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
"manage_subscription": "Hantera prenumeration",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "Faktureringsinställningen slutfördes inte riktigt. Försök igen för att aktivera ditt abonnemang.",
"subscription": "Abonnemang",
"subscription_description": "Hantera din abonnemangsplan och följ din användning",
"trial_alert_description": "Lägg till en betalningsmetod för att behålla tillgång till alla funktioner.",
"trial_already_used": "En gratis provperiod har redan använts för denna e-postadress. Uppgradera till en betald plan istället.",
"trial_feature_api_access": "Få full API-åtkomst",
"trial_feature_collaboration": "Alla team- och samarbetsfunktioner",
"trial_feature_quotas": "Hantera kvoter",
"trial_feature_webhooks": "Konfigurera anpassade webhooks",
"trial_feature_whitelabel": "Helt white-label-anpassade enkäter",
"trial_feature_api_access": "API-åtkomst",
"trial_feature_attribute_segmentation": "Attributbaserad segmentering",
"trial_feature_contact_segment_management": "Kontakt- och segmenthantering",
"trial_feature_email_followups": "E-postuppföljningar",
"trial_feature_hide_branding": "Dölj Formbricks-branding",
"trial_feature_mobile_sdks": "iOS- och Android-SDK:er",
"trial_feature_respondent_identification": "Respondentidentifiering",
"trial_feature_unlimited_seats": "Obegränsade platser",
"trial_feature_webhooks": "Anpassade webhooks",
"trial_no_credit_card": "14 dagars provperiod, inget kreditkort krävs",
"trial_title": "Testa Pro-funktioner gratis!",
"trial_payment_method_added_description": "Du är redo! Din Pro-plan kommer att fortsätta automatiskt efter att provperioden slutar.",
"trial_title": "Få Formbricks Pro gratis!",
"unlimited_responses": "Obegränsade svar",
"unlimited_workspaces": "Obegränsat antal arbetsytor",
"upgrade": "Uppgradera",

View File

@@ -434,6 +434,9 @@
"title": "标题",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "试用期还剩 {count} 天",
"trial_expired": "您的试用期已过期",
"trial_one_day_remaining": "试用期还剩 1 天",
"try_again": "再试一次",
"type": "类型",
"unknown_survey": "未知调查",
@@ -968,6 +971,7 @@
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
},
"billing": {
"add_payment_method": "添加支付方式",
"cancelling": "正在取消",
"failed_to_start_trial": "试用启动失败,请重试。",
"manage_subscription": "管理订阅",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
"subscription": "订阅",
"subscription_description": "管理你的订阅套餐并监控用量",
"trial_alert_description": "添加支付方式以继续使用所有功能。",
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
"trial_feature_api_access": "获取完整 API 访问权限",
"trial_feature_collaboration": "所有团队和协作功能",
"trial_feature_quotas": "管理配额",
"trial_feature_webhooks": "设置自定义 Webhook",
"trial_feature_whitelabel": "完全白标化的问卷调查",
"trial_feature_api_access": "API 访问",
"trial_feature_attribute_segmentation": "基于属性的细分",
"trial_feature_contact_segment_management": "联系人和细分管理",
"trial_feature_email_followups": "电子邮件跟进",
"trial_feature_hide_branding": "隐藏 Formbricks 品牌标识",
"trial_feature_mobile_sdks": "iOS 和 Android SDK",
"trial_feature_respondent_identification": "受访者识别",
"trial_feature_unlimited_seats": "无限席位",
"trial_feature_webhooks": "自定义 Webhook",
"trial_no_credit_card": "14 天试用,无需信用卡",
"trial_title": "免费试用专业版功能!",
"trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。",
"trial_title": "免费获取 Formbricks Pro!",
"unlimited_responses": "无限反馈",
"unlimited_workspaces": "无限工作区",
"upgrade": "升级",

View File

@@ -434,6 +434,9 @@
"title": "標題",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "試用期剩餘 {count} 天",
"trial_expired": "您的試用期已結束",
"trial_one_day_remaining": "試用期剩餘 1 天",
"try_again": "再試一次",
"type": "類型",
"unknown_survey": "未知問卷",
@@ -968,6 +971,7 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"add_payment_method": "新增付款方式",
"cancelling": "正在取消",
"failed_to_start_trial": "無法開始試用。請再試一次。",
"manage_subscription": "管理訂閱",
@@ -992,14 +996,20 @@
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
"subscription": "訂閱",
"subscription_description": "管理您的訂閱方案並監控用量",
"trial_alert_description": "新增付款方式以繼續使用所有功能。",
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
"trial_feature_api_access": "獲得完整 API 存取權限",
"trial_feature_collaboration": "所有團隊與協作功能",
"trial_feature_quotas": "管理配額",
"trial_feature_webhooks": "設定自訂 Webhook",
"trial_feature_whitelabel": "完全白標問卷調查",
"trial_feature_api_access": "API 存取",
"trial_feature_attribute_segmentation": "基於屬性的分群",
"trial_feature_contact_segment_management": "聯絡人與分群管理",
"trial_feature_email_followups": "電子郵件追蹤",
"trial_feature_hide_branding": "隱藏 Formbricks 品牌標識",
"trial_feature_mobile_sdks": "iOS 與 Android SDK",
"trial_feature_respondent_identification": "受訪者識別",
"trial_feature_unlimited_seats": "無限座位數",
"trial_feature_webhooks": "自訂 Webhook",
"trial_no_credit_card": "14 天試用,無需信用卡",
"trial_title": "免費試用 Pro 功能!",
"trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。",
"trial_title": "免費獲得 Formbricks Pro",
"unlimited_responses": "無限回應",
"unlimited_workspaces": "無限工作區",
"upgrade": "升級",

View File

@@ -0,0 +1,172 @@
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

@@ -10,10 +10,12 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
import { createSetupCheckoutSession } from "@/modules/ee/billing/api/lib/create-setup-checkout-session";
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
import {
createScaleTrialSubscription,
createProTrialSubscription,
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "@/modules/ee/billing/lib/organization-billing";
@@ -145,11 +147,59 @@ export const retryStripeSetupAction = authenticatedActionClient
return { success: true };
});
const ZCreateTrialPaymentCheckoutAction = z.object({
environmentId: ZId,
});
export const createTrialPaymentCheckoutAction = authenticatedActionClient
.inputSchema(ZCreateTrialPaymentCheckoutAction)
.action(
withAuditLogging("subscriptionAccessed", "organization", 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) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing.stripeCustomerId) {
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
}
const subscriptionId = organization.billing.stripe?.subscriptionId;
if (!subscriptionId) {
throw new ResourceNotFoundError("subscription", organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
const returnUrl = `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`;
const checkoutUrl = await createSetupCheckoutSession(
organization.billing.stripeCustomerId,
subscriptionId,
returnUrl,
organizationId
);
ctx.auditLoggingCtx.newObject = { checkoutUrl };
return checkoutUrl;
})
);
const ZStartScaleTrialAction = z.object({
organizationId: ZId,
});
export const startScaleTrialAction = authenticatedActionClient
export const startHobbyAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
@@ -168,12 +218,46 @@ export const startScaleTrialAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
if (!organization.billing?.stripeCustomerId) {
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await createScaleTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "scale-trial");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
export const startProTrialAction = 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 createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});

View File

@@ -0,0 +1,52 @@
import Stripe from "stripe";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
/**
* Creates a Stripe Checkout Session in `setup` mode so the customer can enter
* a payment method, billing address, and tax ID — without creating a new subscription.
* After completion the webhook handler attaches the payment method to the existing
* trial subscription.
*/
export const createSetupCheckoutSession = async (
stripeCustomerId: string,
subscriptionId: string,
returnUrl: string,
organizationId: string
): Promise<string> => {
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currency = subscription.currency ?? "usd";
const session = await stripe.checkout.sessions.create({
mode: "setup",
customer: stripeCustomerId,
currency,
billing_address_collection: "required",
tax_id_collection: {
enabled: true,
required: "if_supported",
},
customer_update: {
address: "auto",
name: "auto",
},
success_url: `${returnUrl}?checkout_success=1`,
cancel_url: returnUrl,
metadata: {
organizationId,
subscriptionId,
},
});
if (!session.url) {
throw new Error("Stripe did not return a Checkout Session URL");
}
return session.url;
};

View File

@@ -16,6 +16,47 @@ const relevantEvents = new Set([
"entitlements.active_entitlement_summary.updated",
]);
/**
* When a setup-mode Checkout Session completes, the customer has just provided a
* payment method + billing address. We attach that payment method as the default
* on the customer (for future invoices) and on the trial subscription so Stripe
* can charge it when the trial ends.
*/
const handleSetupCheckoutCompleted = async (
session: Stripe.Checkout.Session,
stripe: Stripe
): Promise<void> => {
if (session.mode !== "setup" || !session.setup_intent) return;
const setupIntentId =
typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent.id;
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
const paymentMethodId =
typeof setupIntent.payment_method === "string"
? setupIntent.payment_method
: setupIntent.payment_method?.id;
if (!paymentMethodId) {
logger.warn({ sessionId: session.id }, "Setup checkout completed but no payment method found");
return;
}
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
if (customerId) {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
}
const subscriptionId = session.metadata?.subscriptionId;
if (subscriptionId) {
await stripe.subscriptions.update(subscriptionId, {
default_payment_method: paymentMethodId,
});
}
};
const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
if (!("metadata" in eventObject) || !eventObject.metadata) {
return null;
@@ -101,6 +142,10 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
}
try {
if (event.type === "checkout.session.completed") {
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,

View File

@@ -1,6 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Script from "next/script";
import { createElement, useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
@@ -12,10 +12,12 @@ import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
createPricingTableCustomerSessionAction,
createTrialPaymentCheckoutAction,
isSubscriptionCancelledAction,
manageSubscriptionAction,
retryStripeSetupAction,
} from "../actions";
import { TrialAlert } from "./trial-alert";
import { UsageCard } from "./usage-card";
const STRIPE_SUPPORTED_LOCALES = new Set([
@@ -92,6 +94,7 @@ interface PricingTableProps {
stripePublishableKey: string | null;
stripePricingTableId: string | null;
isStripeSetupIncomplete: boolean;
trialDaysRemaining: number | null;
}
const getCurrentCloudPlanLabel = (
@@ -118,9 +121,11 @@ export const PricingTable = ({
stripePublishableKey,
stripePricingTableId,
isStripeSetupIncomplete,
trialDaysRemaining,
}: PricingTableProps) => {
const { t, i18n } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
@@ -128,8 +133,9 @@ export const PricingTable = ({
>(null);
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
const isTrialing = currentSubscriptionStatus === "trialing";
const showPricingTable =
hasBillingRights && isUpgradeablePlan && !!stripePublishableKey && !!stripePricingTableId;
hasBillingRights && isUpgradeablePlan && !isTrialing && !!stripePublishableKey && !!stripePricingTableId;
const canManageSubscription =
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
const stripeLocaleOverride = useMemo(
@@ -161,6 +167,13 @@ export const PricingTable = ({
stripePublishableKey,
]);
useEffect(() => {
if (searchParams.get("checkout_success")) {
const timer = setTimeout(() => router.refresh(), 2500);
return () => clearTimeout(timer);
}
}, [searchParams, router]);
useEffect(() => {
const checkSubscriptionStatus = async () => {
if (!hasBillingRights || !canManageSubscription) {
@@ -213,6 +226,20 @@ export const PricingTable = ({
}
};
const openTrialPaymentCheckout = async () => {
try {
const response = await createTrialPaymentCheckoutAction({ environmentId });
if (response?.data && typeof response.data === "string") {
globalThis.location.href = response.data;
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
} catch (error) {
console.error("Failed to create checkout session:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
const retryStripeSetup = async () => {
setIsRetryingStripeSetup(true);
try {
@@ -246,6 +273,25 @@ export const PricingTable = ({
return (
<main>
<div className="flex max-w-4xl flex-col gap-4">
{trialDaysRemaining !== null &&
(organization.billing.stripe?.hasPaymentMethod ? (
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
<AlertDescription>
{t("environments.settings.billing.trial_payment_method_added_description")}
</AlertDescription>
</TrialAlert>
) : (
<TrialAlert trialDaysRemaining={trialDaysRemaining}>
<AlertDescription>
{t("environments.settings.billing.trial_alert_description")}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void openTrialPaymentCheckout()}>
{t("environments.settings.billing.add_payment_method")}
</AlertButton>
)}
</TrialAlert>
))}
{isStripeSetupIncomplete && hasBillingRights && (
<Alert variant="warning">
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
@@ -261,7 +307,7 @@ export const PricingTable = ({
title={t("environments.settings.billing.subscription")}
description={t("environments.settings.billing.subscription_description")}
buttonInfo={
canManageSubscription
canManageSubscription && currentSubscriptionStatus !== "trialing"
? {
text: t("environments.settings.billing.manage_subscription"),
onClick: () => void openCustomerPortal(),
@@ -324,7 +370,7 @@ export const PricingTable = ({
</div>
</SettingsCard>
{currentCloudPlan === "pro" && (
{currentCloudPlan === "pro" && !isTrialing && (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1.5">

View File

@@ -11,7 +11,8 @@ 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 { startHobbyAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button";
interface SelectPlanCardProps {
@@ -31,20 +32,25 @@ 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 = [
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") {
@@ -60,8 +66,20 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
}
};
const handleContinueFree = () => {
router.push(nextUrl);
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);
}
};
return (
@@ -94,7 +112,7 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
onClick={handleStartTrial}
className="mt-4 w-full"
loading={isStartingTrial}
disabled={isStartingTrial}>
disabled={isStartingTrial || isStartingHobby}>
{t("common.start_free_trial")}
</Button>
</div>
@@ -120,9 +138,10 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
</div>
<button
onClick={handleContinueFree}
onClick={handleContinueHobby}
disabled={isStartingTrial || isStartingHobby}
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
{t("environments.settings.billing.stay_on_hobby_plan")}
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
</button>
</div>
);

View File

@@ -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 (
<Alert variant={variant} size={size}>
<AlertTitle>{title}</AlertTitle>
{children}
</Alert>
);
};

View File

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

View File

@@ -10,6 +10,7 @@ export type TCloudBillingDisplayContext = {
organizationId: string;
currentCloudPlan: TCloudBillingDisplayPlan;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
trialDaysRemaining: number | null;
usageCycleStart: Date;
usageCycleEnd: Date;
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
@@ -27,6 +28,22 @@ const resolveCurrentSubscriptionStatus = (
return billing.stripe?.subscriptionStatus ?? null;
};
const MS_PER_DAY = 86_400_000;
const resolveTrialDaysRemaining = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): 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<TCloudBillingDisplayContext> => {
@@ -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,

View File

@@ -278,6 +278,40 @@ 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",
@@ -742,81 +776,50 @@ describe("organization-billing", () => {
expect(mocks.prismaOrganizationFindUnique).not.toHaveBeenCalled();
});
test("ensureCloudStripeSetupForOrganization provisions hobby subscription when org has no active subscription", async () => {
test("ensureCloudStripeSetupForOrganization creates customer and syncs billing without provisioning hobby", async () => {
mocks.prismaOrganizationFindUnique.mockResolvedValueOnce({
id: "org_1",
name: "Org 1",
});
// ensureStripeCustomerForOrganization no longer reads billing;
// reconcile and sync each read billing once
mocks.prismaOrganizationBillingFindUnique
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
mocks.prismaMembershipFindFirst.mockResolvedValue({
user: { email: "owner@example.com", name: "Owner Name" },
});
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
usageCycleAnchor: new Date(),
stripe: {},
})
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
},
usageCycleAnchor: new Date(),
stripe: {},
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
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" },
},
},
],
},
},
],
});
mocks.subscriptionsList.mockResolvedValue({ data: [] });
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.productsList).toHaveBeenCalledWith({
active: true,
limit: 100,
});
expect(mocks.pricesList).toHaveBeenCalledWith({
product: "prod_hobby",
active: true,
limit: 100,
});
expect(mocks.subscriptionsCreate).toHaveBeenCalledWith(
expect(mocks.customersCreate).toHaveBeenCalledWith(
{
customer: "cus_new",
items: [{ price: "price_hobby_1", quantity: 1 }],
metadata: { organizationId: "org_1" },
name: "Owner Name",
email: "owner@example.com",
metadata: { organizationId: "org_1", organizationName: "Org 1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
{ 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,
}),
}),
});
});
test("reconcileCloudStripeSubscriptionsForOrganization cancels hobby when paid subscription is active", async () => {

View File

@@ -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<boolean> => {
const hasEmailUsedProTrial = async (email: string, proProductId: string): Promise<boolean> => {
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<void> => {
@@ -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),
},
};
@@ -804,6 +807,5 @@ 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

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

View File

@@ -7,6 +7,7 @@ import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import {
getBasicSurveyMetadata,
getBrandColorForURL,
getMetadataBrandColor,
getNameForURL,
getSurveyOpenGraphMetadata,
} from "./metadata-utils";
@@ -253,6 +254,35 @@ describe("Metadata Utils", () => {
});
});
describe("getMetadataBrandColor", () => {
test("returns survey brand color when project allows override and survey overrides theme", () => {
const projectStyling = { allowStyleOverwrite: true, brandColor: { light: "#ff0000" } };
const surveyStyling = { overwriteThemeStyling: true, brandColor: { light: "#0000ff" } };
expect(getMetadataBrandColor(projectStyling, surveyStyling as any)).toBe("#0000ff");
});
test("returns project brand color when survey does not override theme", () => {
const projectStyling = { allowStyleOverwrite: true, brandColor: { light: "#ff0000" } };
const surveyStyling = { overwriteThemeStyling: false, brandColor: { light: "#0000ff" } };
expect(getMetadataBrandColor(projectStyling, surveyStyling as any)).toBe("#ff0000");
});
test("returns project brand color when project disallows style overwrite", () => {
const projectStyling = { allowStyleOverwrite: false, brandColor: { light: "#ff0000" } };
const surveyStyling = { overwriteThemeStyling: true, brandColor: { light: "#0000ff" } };
expect(getMetadataBrandColor(projectStyling, surveyStyling as any)).toBe("#ff0000");
});
test("returns project brand color when survey styling is null", () => {
const projectStyling = { allowStyleOverwrite: true, brandColor: { light: "#ff0000" } };
expect(getMetadataBrandColor(projectStyling, null)).toBe("#ff0000");
});
});
describe("getSurveyOpenGraphMetadata", () => {
test("generates correct OpenGraph metadata", () => {
const surveyId = "survey-123";

View File

@@ -17,6 +17,7 @@ import {
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
setIsHistoryPatched,
} from "@/lib/survey/no-code-action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
import { type TActionClassNoCodeConfig } from "@/types/survey";
@@ -638,6 +639,32 @@ describe("addPageUrlEventListeners additional cases", () => {
(window.addEventListener as Mock).mockRestore();
dispatchEventSpy.mockRestore();
});
test("patched history.replaceState dispatches a 'replacestate' event", () => {
const addEventListenerMock = vi.fn();
const removeEventListenerMock = vi.fn();
const originalReplaceState = vi.fn();
const dispatchEventMock = vi.fn();
vi.stubGlobal("window", {
addEventListener: addEventListenerMock,
removeEventListener: removeEventListenerMock,
dispatchEvent: dispatchEventMock,
});
vi.stubGlobal("history", { pushState: vi.fn(), replaceState: originalReplaceState });
// Reset patching state so addPageUrlEventListeners patches history fresh
setIsHistoryPatched(false);
removePageUrlEventListeners();
addPageUrlEventListeners();
// Call the patched replaceState
history.replaceState({}, "", "/replaced-url");
expect(originalReplaceState).toHaveBeenCalledWith({}, "", "/replaced-url");
expect(dispatchEventMock).toHaveBeenCalledWith(expect.objectContaining({ type: "replacestate" }));
(window.addEventListener as Mock).mockRestore();
});
});
describe("removePageUrlEventListeners additional cases", () => {

View File

@@ -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<typeof ZOrganizationStripeBilling>;