Compare commits

...

8 Commits

Author SHA1 Message Date
Cursor Agent 207ad3c7bd fix: wrap custom script execution in try-catch to prevent ReferenceError
Fixes FORMBRICKS-TK

Custom scripts injected via CustomScriptsInjector can attempt to access
undefined global variables (like zp_token) before they're defined, causing
a ReferenceError that breaks the survey page.

This fix wraps inline script content in an IIFE with try-catch to gracefully
handle errors from undefined variables. Also adds error handlers for external
scripts that fail to load.

The survey page will now continue to function even when custom scripts
encounter runtime errors, with warnings logged to the console for debugging.
2026-03-17 18:30:40 +00:00
Dhruwang Jariwala 1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
Anshuman Pandey a51a006c26 fix: fixes data element i18n fixes (#7488) 2026-03-16 10:12:48 +00:00
Matti Nannt ce96cb0b89 feat: replace hosted stripe pricing table (#7486)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-16 10:11:40 +00:00
Matti Nannt fb265d9dba feat: add SAML telemetry reporting (#7461) 2026-03-16 09:41:33 +00:00
Matti Nannt e4c155b501 fix: defer hobby subscription creation (#7484) 2026-03-15 14:13:53 +00:00
90 changed files with 4563 additions and 802 deletions
-1
View File
@@ -150,7 +150,6 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -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`
@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
@@ -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",
@@ -50,6 +50,7 @@ vi.mock("@/lib/env", () => ({
RECAPTCHA_SITE_KEY: "site-key",
RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id",
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
},
}));
@@ -138,6 +139,7 @@ describe("sendTelemetryEvents", () => {
expect(payload.userCount).toBe(5);
expect(payload.integrations.notion).toBe(true);
expect(payload.sso.github).toBe(true);
expect(payload.sso.saml).toBe(true);
// Check cache update (no TTL parameter)
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
@@ -212,6 +212,7 @@ const sendTelemetry = async (lastSent: number) => {
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
saml: !!env.SAML_DATABASE_URL || ssoProviders.some((p) => p.provider === "saml"),
};
// Construct telemetry payload with usage statistics and configuration.
+41 -10
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/start_free_trial: e346e4ed7d138dcc873db187922369da
common/status: 4e1fcce15854d824919b4a582c697c90
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
@@ -417,6 +417,7 @@ checksums:
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
common/updated_at: 8fdb85248e591254973403755dcc3724
common/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
@@ -917,30 +918,57 @@ checksums:
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/add_payment_method_to_upgrade_tooltip: 977005ad38bfe0800a78c21edcd16e4d
environments/settings/billing/billing_interval_toggle: 62c76eb73507108fc6aefdf1ab86cc38
environments/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
environments/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
environments/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
environments/settings/billing/custom_plan_title: f3b71be0d1cd4f81a177ada040119f30
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
environments/settings/billing/keep_current_plan: 57ac15ffa2c29ac364dd405669eeb7f6
environments/settings/billing/manage_billing_details: 40448f0b5ed4b3bb1d864ba6e1bb6a3b
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
environments/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
environments/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
environments/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
environments/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
environments/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
environments/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
environments/settings/billing/plan_change_applied: d1e04599487247dd0e21a7d99785dc7a
environments/settings/billing/plan_change_scheduled: 16455d4aa9a152b156ee434d8c7e34d4
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2
environments/settings/billing/plan_feature_everything_in_hobby: 5417a498136fa29988c8215291e3fd8b
environments/settings/billing/plan_feature_everything_in_pro: 3f5129ff1f01eed4f051a8790ed62997
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
environments/settings/billing/plan_hobby_description: 1fa1cf69b42ec82727aebc5ef1ec24a2
environments/settings/billing/plan_hobby_feature_responses: d1e6c1d83f5e57cbae2a09e6a818a25d
environments/settings/billing/plan_hobby_feature_workspaces: 02a34669419ed7f30f728980f54d42ef
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
environments/settings/billing/plan_pro_description: 748c848ea0d8cf81a66704762edcd6f4
environments/settings/billing/plan_pro_feature_responses: e16ffe385051a16dba76538c13d97a5f
environments/settings/billing/plan_pro_feature_workspaces: 819874022b491209ca7f0f1ab1e3daea
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
environments/settings/billing/plan_scale_description: ef5c66e0b52686f56319e31388bd8409
environments/settings/billing/plan_scale_feature_responses: 0b74bf8d089c738ebb7f0867bdd7d7f1
environments/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
environments/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
environments/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0
environments/settings/billing/scale_banner_description: 79a9734c77ab0336d5d2fadb5f2151be
environments/settings/billing/scale_banner_title: a2a78f57ebcbf444ad881ece234b8f45
environments/settings/billing/scale_feature_api: 67231215e5452944b86edc2bc47d2a16
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
environments/settings/billing/select_plan_header_subtitle: 8de6b4e3ce5726829829bd46582f343a
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
environments/settings/billing/select_plan_header_title: b15a9d86b819a7fae8e956a50572184c
environments/settings/billing/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad
environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9
environments/settings/billing/switch_at_period_end: 9c91b2287886e077a0571efab8908623
environments/settings/billing/switch_plan_now: dad56622a1916fe5d1a2bda5b0393194
environments/settings/billing/this_includes: 127e0fe104f47886b54106a057a6b26f
environments/settings/billing/trial_alert_description: aba3076cc6814cc6128d425d3d1957e8
environments/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d
@@ -958,8 +986,11 @@ checksums:
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
-2
View File
@@ -85,7 +85,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_PRICING_TABLE_ID: z.string().optional(),
PUBLIC_URL: z
.url()
.refine(
@@ -203,7 +202,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
PUBLIC_URL: process.env.PUBLIC_URL,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Etwas ist schiefgelaufen",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
"sort_by": "Sortieren nach",
"start_free_trial": "Kostenlos starten",
"start_free_trial": "Kostenlose Testversion starten",
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
@@ -444,6 +444,7 @@
"update": "Aktualisierung",
"updated": "Aktualisiert",
"updated_at": "Aktualisiert am",
"upgrade_plan": "Plan upgraden",
"upload": "Hochladen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Zahlungsmethode hinzufügen",
"cancelling": "Wird storniert",
"add_payment_method_to_upgrade_tooltip": "Bitte füge oben eine Zahlungsmethode hinzu, um auf einen kostenpflichtigen Plan zu upgraden",
"billing_interval_toggle": "Abrechnungsintervall",
"current_plan_badge": "Aktuell",
"current_plan_cta": "Aktueller Tarif",
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardtarife unten wechseln.",
"custom_plan_title": "Individueller Tarif",
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
"manage_subscription": "Abonnement verwalten",
"keep_current_plan": "Aktuellen Tarif beibehalten",
"manage_billing_details": "Kartendaten & Rechnungen verwalten",
"monthly": "Monatlich",
"most_popular": "Am beliebtesten",
"pending_change_removed": "Geplante Tarifänderung entfernt.",
"pending_plan_badge": "Geplant",
"pending_plan_change_description": "Dein Tarif wechselt am {{date}} zu {{plan}}.",
"pending_plan_change_title": "Geplante Tarifänderung",
"pending_plan_cta": "Geplant",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_change_applied": "Tarif erfolgreich aktualisiert.",
"plan_change_scheduled": "Tarifänderung erfolgreich geplant.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Alles aus Hobby",
"plan_feature_everything_in_pro": "Alles aus Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Für Einzelpersonen und kleine Teams, die mit Formbricks Cloud starten.",
"plan_hobby_feature_responses": "250 Antworten / Monat",
"plan_hobby_feature_workspaces": "1 Arbeitsbereich",
"plan_pro": "Pro",
"plan_pro_description": "Für wachsende Teams, die höhere Limits, Automatisierungen und dynamische Überschreitungen benötigen.",
"plan_pro_feature_responses": "2.000 Antworten / Monat (dynamische Überschreitung)",
"plan_pro_feature_workspaces": "3 Arbeitsbereiche",
"plan_scale": "Scale",
"plan_scale_description": "Für größere Teams, die mehr Kapazität, stärkere Governance und höheres Antwortvolumen benötigen.",
"plan_scale_feature_responses": "5.000 Antworten / Monat (dynamische Mehrnutzung)",
"plan_scale_feature_workspaces": "5 Arbeitsbereiche",
"plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle dann direkt in Formbricks den Plan.",
"plan_selection_title": "Wähle deinen Plan",
"plan_unknown": "Unbekannt",
"remove_branding": "Branding entfernen",
"retry_setup": "Erneut einrichten",
"scale_banner_description": "Schalte höhere Limits, Teamzusammenarbeit und erweiterte Sicherheitsfunktionen mit dem Scale-Tarif frei.",
"scale_banner_title": "Bereit für den nächsten Schritt?",
"scale_feature_api": "Vollständiger API-Zugang",
"scale_feature_quota": "Quotenverwaltung",
"scale_feature_spam": "Spamschutz",
"scale_feature_teams": "Teams & Zugriffsrollen",
"select_plan_header_subtitle": "Keine Kreditkarte erforderlich, keine versteckten Bedingungen.",
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
"select_plan_header_title": "Nahtlos integrierte Umfragen, 100% deine Marke.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben",
"stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig",
"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",
"switch_at_period_end": "Am Ende der Periode wechseln",
"switch_plan_now": "Plan jetzt wechseln",
"this_includes": "Das beinhaltet",
"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": "API-Zugriff",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Unbegrenzte Antworten",
"unlimited_workspaces": "Unbegrenzte Projekte",
"upgrade": "Upgrade",
"upgrade_now": "Jetzt upgraden",
"usage_cycle": "Usage cycle",
"used": "verwendet",
"yearly": "Jährlich",
"yearly_checkout_unavailable": "Die jährliche Abrechnung ist noch nicht verfügbar. Füge zuerst eine Zahlungsmethode bei einem monatlichen Plan hinzu oder kontaktiere den Support.",
"your_plan": "Dein Tarif"
},
"domain": {
+41 -10
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": "Start free trial",
"status": "Status",
"step_by_step_manual": "Step by step manual",
"storage_not_configured": "File storage not set up, uploads will likely fail",
@@ -444,6 +444,7 @@
"update": "Update",
"updated": "Updated",
"updated_at": "Updated at",
"upgrade_plan": "Upgrade plan",
"upload": "Upload",
"upload_failed": "Upload failed. Please try again.",
"upload_input_description": "Click or drag to upload files.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Add payment method",
"cancelling": "Cancelling",
"add_payment_method_to_upgrade_tooltip": "Please add a payment method above to upgrade to a paid plan",
"billing_interval_toggle": "Billing interval",
"current_plan_badge": "Current",
"current_plan_cta": "Current plan",
"custom_plan_description": "Your organization is on a custom billing setup. You can still switch to one of the standard plans below.",
"custom_plan_title": "Custom plan",
"failed_to_start_trial": "Failed to start trial. Please try again.",
"manage_subscription": "Manage subscription",
"keep_current_plan": "Keep current plan",
"manage_billing_details": "Manage card details & invoices",
"monthly": "Monthly",
"most_popular": "Most popular",
"pending_change_removed": "Scheduled plan change removed.",
"pending_plan_badge": "Scheduled",
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
"pending_plan_change_title": "Scheduled plan change",
"pending_plan_cta": "Scheduled",
"per_month": "per month",
"per_year": "per year",
"plan_change_applied": "Plan updated successfully.",
"plan_change_scheduled": "Plan change scheduled successfully.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Everything in Hobby",
"plan_feature_everything_in_pro": "Everything in Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "For individuals and small teams getting started with Formbricks Cloud.",
"plan_hobby_feature_responses": "250 responses / month",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "For growing teams that need higher limits, automations, and dynamic overages.",
"plan_pro_feature_responses": "2,000 responses / month (dynamic overage)",
"plan_pro_feature_workspaces": "3 workspaces",
"plan_scale": "Scale",
"plan_scale_description": "For larger teams that need more capacity, stronger governance, and higher response volume.",
"plan_scale_feature_responses": "5,000 responses / month (dynamic overage)",
"plan_scale_feature_workspaces": "5 workspaces",
"plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.",
"plan_selection_title": "Choose your plan",
"plan_unknown": "Unknown",
"remove_branding": "Remove Branding",
"retry_setup": "Retry setup",
"scale_banner_description": "Unlock higher limits, team collaboration, and advanced security features with the Scale plan.",
"scale_banner_title": "Ready to scale up?",
"scale_feature_api": "Full API Access",
"scale_feature_quota": "Quota Management",
"scale_feature_spam": "Spam Protection",
"scale_feature_teams": "Teams & Access Roles",
"select_plan_header_subtitle": "No credit card required, no strings attached.",
"select_plan_header_title": "Ship professional, unbranded surveys today!",
"select_plan_header_title": "Seamlessly integrated surveys, 100% your brand.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "I want to stay on the Hobby plan",
"stripe_setup_incomplete": "Billing setup incomplete",
"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",
"switch_at_period_end": "Switch at period end",
"switch_plan_now": "Switch plan now",
"this_includes": "This includes",
"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": "API Access",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Unlimited Responses",
"unlimited_workspaces": "Unlimited Workspaces",
"upgrade": "Upgrade",
"upgrade_now": "Upgrade now",
"usage_cycle": "Usage cycle",
"used": "used",
"yearly": "Yearly",
"yearly_checkout_unavailable": "Yearly checkout is not available yet. Add a payment method on a monthly plan first or contact support.",
"your_plan": "Your plan"
},
"domain": {
+40 -9
View File
@@ -444,6 +444,7 @@
"update": "Actualizar",
"updated": "Actualizado",
"updated_at": "Actualizado el",
"upgrade_plan": "Mejorar plan",
"upload": "Subir",
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
"upload_input_description": "Haz clic o arrastra para subir archivos.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Añadir método de pago",
"cancelling": "Cancelando",
"add_payment_method_to_upgrade_tooltip": "Por favor, añade un método de pago arriba para mejorar a un plan de pago",
"billing_interval_toggle": "Intervalo de facturación",
"current_plan_badge": "Actual",
"current_plan_cta": "Plan actual",
"custom_plan_description": "Tu organización tiene una configuración de facturación personalizada. Aún puedes cambiar a uno de los planes estándar a continuación.",
"custom_plan_title": "Plan personalizado",
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
"manage_subscription": "Gestionar suscripción",
"keep_current_plan": "Mantener plan actual",
"manage_billing_details": "Gestionar datos de tarjeta y facturas",
"monthly": "Mensual",
"most_popular": "Más popular",
"pending_change_removed": "Cambio de plan programado eliminado.",
"pending_plan_badge": "Programado",
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
"pending_plan_change_title": "Cambio de plan programado",
"pending_plan_cta": "Programado",
"per_month": "por mes",
"per_year": "por año",
"plan_change_applied": "Plan actualizado correctamente.",
"plan_change_scheduled": "Cambio de plan programado correctamente.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Todo lo de Hobby",
"plan_feature_everything_in_pro": "Todo lo de Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Para individuos y equipos pequeños que comienzan con Formbricks Cloud.",
"plan_hobby_feature_responses": "250 respuestas / mes",
"plan_hobby_feature_workspaces": "1 espacio de trabajo",
"plan_pro": "Pro",
"plan_pro_description": "Para equipos en crecimiento que necesitan límites más altos, automatizaciones y excesos dinámicos.",
"plan_pro_feature_responses": "2.000 respuestas / mes (uso excedente dinámico)",
"plan_pro_feature_workspaces": "3 espacios de trabajo",
"plan_scale": "Scale",
"plan_scale_description": "Para equipos más grandes que necesitan mayor capacidad, gobernanza más sólida y mayor volumen de respuestas.",
"plan_scale_feature_responses": "5.000 respuestas/mes (excedente dinámico)",
"plan_scale_feature_workspaces": "5 espacios de trabajo",
"plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.",
"plan_selection_title": "Elige tu plan",
"plan_unknown": "Desconocido",
"remove_branding": "Eliminar marca",
"retry_setup": "Reintentar configuración",
"scale_banner_description": "Desbloquea límites superiores, colaboración en equipo y funciones de seguridad avanzadas con el plan Scale.",
"scale_banner_title": "¿Listo para crecer?",
"scale_feature_api": "Acceso completo a la API",
"scale_feature_quota": "Gestión de cuota",
"scale_feature_spam": "Protección contra spam",
"scale_feature_teams": "Equipos y roles de acceso",
"select_plan_header_subtitle": "Sin tarjeta de crédito, sin compromisos.",
"select_plan_header_title": "¡Lanza encuestas profesionales sin marca hoy mismo!",
"select_plan_header_title": "Encuestas perfectamente integradas, 100% tu marca.",
"status_trialing": "Prueba",
"stay_on_hobby_plan": "Quiero quedarme en el plan Hobby",
"stripe_setup_incomplete": "Configuración de facturación incompleta",
"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",
"switch_at_period_end": "Cambiar al final del período",
"switch_plan_now": "Cambiar de plan ahora",
"this_includes": "Esto incluye",
"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 a la API",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Respuestas ilimitadas",
"unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar",
"upgrade_now": "Actualizar ahora",
"usage_cycle": "Usage cycle",
"used": "usados",
"yearly": "Anual",
"yearly_checkout_unavailable": "El pago anual aún no está disponible. Primero añade un método de pago en un plan mensual o contacta con soporte.",
"your_plan": "Tu plan"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Quelque chose s'est mal passé.",
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
"sort_by": "Trier par",
"start_free_trial": "Essayer gratuitement",
"start_free_trial": "Commencer l'essai gratuit",
"status": "Statut",
"step_by_step_manual": "Manuel étape par étape",
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
@@ -444,6 +444,7 @@
"update": "Mise à jour",
"updated": "Mise à jour",
"updated_at": "Mis à jour à",
"upgrade_plan": "Améliorer le forfait",
"upload": "Télécharger",
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Ajouter un moyen de paiement",
"cancelling": "Annulation en cours",
"add_payment_method_to_upgrade_tooltip": "Veuillez ajouter un moyen de paiement ci-dessus pour passer à un forfait payant",
"billing_interval_toggle": "Intervalle de facturation",
"current_plan_badge": "Actuel",
"current_plan_cta": "Formule actuelle",
"custom_plan_description": "Votre organisation dispose d'une configuration de facturation personnalisée. Tu peux toujours basculer vers l'une des formules standard ci-dessous.",
"custom_plan_title": "Formule personnalisée",
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
"manage_subscription": "Gérer l'abonnement",
"keep_current_plan": "Conserver la formule actuelle",
"manage_billing_details": "Gérer les détails de la carte et les factures",
"monthly": "Mensuel",
"most_popular": "Le plus populaire",
"pending_change_removed": "Changement de formule programmé supprimé.",
"pending_plan_badge": "Programmé",
"pending_plan_change_description": "Ta formule passera à {{plan}} le {{date}}.",
"pending_plan_change_title": "Changement de formule programmé",
"pending_plan_cta": "Programmé",
"per_month": "par mois",
"per_year": "par an",
"plan_change_applied": "Formule mise à jour avec succès.",
"plan_change_scheduled": "Changement de formule programmé avec succès.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tout ce qui est inclus dans Hobby",
"plan_feature_everything_in_pro": "Tout ce qui est inclus dans Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Pour les particuliers et les petites équipes qui débutent avec Formbricks Cloud.",
"plan_hobby_feature_responses": "250 réponses / mois",
"plan_hobby_feature_workspaces": "1 espace de travail",
"plan_pro": "Pro",
"plan_pro_description": "Pour les équipes en croissance qui ont besoin de limites plus élevées, d'automatisations et de dépassements dynamiques.",
"plan_pro_feature_responses": "2 000 réponses / mois (dépassement dynamique)",
"plan_pro_feature_workspaces": "3 espaces de travail",
"plan_scale": "Scale",
"plan_scale_description": "Pour les grandes équipes qui ont besoin de plus de capacité, d'une meilleure gouvernance et d'un volume de réponses plus élevé.",
"plan_scale_feature_responses": "5 000 réponses / mois (dépassement dynamique)",
"plan_scale_feature_workspaces": "5 espaces de travail",
"plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.",
"plan_selection_title": "Choisis ta formule",
"plan_unknown": "Inconnu",
"remove_branding": "Suppression du logo",
"retry_setup": "Réessayer la configuration",
"scale_banner_description": "Débloque des limites plus élevées, la collaboration en équipe et des fonctionnalités de sécurité avancées avec loffre Scale.",
"scale_banner_title": "Prêt à passer à la vitesse supérieure ?",
"scale_feature_api": "Accès API complet",
"scale_feature_quota": "Gestion des quotas",
"scale_feature_spam": "Protection contre le spam",
"scale_feature_teams": "Équipes & rôles daccès",
"select_plan_header_subtitle": "Aucune carte bancaire requise, aucun engagement.",
"select_plan_header_title": "Envoyez des sondages professionnels et personnalisés dès aujourd'hui !",
"select_plan_header_title": "Sondages parfaitement intégrés, 100 % à ton image.",
"status_trialing": "Essai",
"stay_on_hobby_plan": "Je veux rester sur le plan Hobby",
"stripe_setup_incomplete": "Configuration de la facturation incomplète",
"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",
"switch_at_period_end": "Changer à la fin de la période",
"switch_plan_now": "Changer de formule maintenant",
"this_includes": "Cela inclut",
"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 API",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Réponses illimitées",
"unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau",
"upgrade_now": "Passer à la formule supérieure maintenant",
"usage_cycle": "Usage cycle",
"used": "utilisé(s)",
"yearly": "Annuel",
"yearly_checkout_unavailable": "Le paiement annuel n'est pas encore disponible. Ajoute d'abord un moyen de paiement sur un forfait mensuel ou contacte le support.",
"your_plan": "Ton offre"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Valami probléma történt",
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
"sort_by": "Rendezési sorrend",
"start_free_trial": "Ingyenes próba indítása",
"start_free_trial": "Ingyenes próbaverzió indítása",
"status": "Állapot",
"step_by_step_manual": "Lépésenkénti kézikönyv",
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
@@ -444,6 +444,7 @@
"update": "Frissítés",
"updated": "Frissítve",
"updated_at": "Frissítve",
"upgrade_plan": "Csomag frissítése",
"upload": "Feltöltés",
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Fizetési mód hozzáadása",
"cancelling": "Lemondás folyamatban",
"add_payment_method_to_upgrade_tooltip": "Kérjük, adjon hozzá egy fizetési módot fent a fizetős csomagra való frissítéshez",
"billing_interval_toggle": "Számlázási időszak",
"current_plan_badge": "Jelenlegi",
"current_plan_cta": "Jelenlegi csomag",
"custom_plan_description": "A szervezete egyedi számlázási beállítással rendelkezik. Továbbra is válthat az alábbi standard csomagok egyikére.",
"custom_plan_title": "Egyedi csomag",
"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",
"keep_current_plan": "Jelenlegi csomag megtartása",
"manage_billing_details": "Kártyaadatok és számlák kezelése",
"monthly": "Havi",
"most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{date}}-án átvált erre: {{plan}}.",
"pending_plan_change_title": "Ütemezett csomagváltás",
"pending_plan_cta": "Ütemezett",
"per_month": "havonta",
"per_year": "évente",
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltás sikeresen ütemezve.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Minden, ami a Hobby csomagban",
"plan_feature_everything_in_pro": "Minden, ami a Pro csomagban",
"plan_hobby": "Hobby",
"plan_hobby_description": "Magánszemélyek és kisebb csapatok számára, akik most kezdik a Formbricks Cloud használatát.",
"plan_hobby_feature_responses": "250 válasz / hó",
"plan_hobby_feature_workspaces": "1 munkaterület",
"plan_pro": "Pro",
"plan_pro_description": "Növekvő csapatok számára, amelyeknek magasabb korlátokra, automatizálásokra és dinamikus túlhasználatra van szükségük.",
"plan_pro_feature_responses": "2 000 válasz / hó (dinamikus túlhasználat)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Scale",
"plan_scale_description": "Nagyobb csapatok számára, amelyeknek nagyobb kapacitásra, erősebb irányításra és magasabb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz / hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hasonlítsa össze a Hobby, Pro és Scale csomagokat, majd váltson csomagot közvetlenül a Formbricks alkalmazásból.",
"plan_selection_title": "Válassza ki az Ön csomagját",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
"retry_setup": "Újrapróbálkozás a beállítással",
"scale_banner_description": "Nagyobb limitek, csapatmunka és fejlett biztonsági funkciók a Scale csomaggal.",
"scale_banner_title": "Készen áll a növekedésre?",
"scale_feature_api": "Teljes API hozzáférés",
"scale_feature_quota": "Keretkezelés",
"scale_feature_spam": "Spamvédelem",
"scale_feature_teams": "Csapatok és hozzáférési szerepkörök",
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
"select_plan_header_title": "Küldjön professzionális, márkajelzés nélküli felméréseket még ma!",
"select_plan_header_title": "Zökkenőmentesen integrált felmérések, 100%-ban az Ön márkája.",
"status_trialing": "Próbaverzió",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
"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",
"switch_at_period_end": "Váltás az időszak végén",
"switch_plan_now": "Csomag váltása most",
"this_includes": "Ez tartalmazza",
"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": "API-hozzáférés",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",
"upgrade_now": "Frissítés most",
"usage_cycle": "Usage cycle",
"used": "felhasználva",
"yearly": "Éves",
"yearly_checkout_unavailable": "Az éves fizetés még nem érhető el. Kérjük, adjon hozzá fizetési módot egy havi előfizetéshez, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"your_plan": "Az Ön csomagja"
},
"domain": {
+40 -9
View File
@@ -444,6 +444,7 @@
"update": "更新",
"updated": "更新済み",
"updated_at": "更新日時",
"upgrade_plan": "プランをアップグレード",
"upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "支払い方法を追加",
"cancelling": "キャンセル中",
"add_payment_method_to_upgrade_tooltip": "有料プランにアップグレードするには、上記で支払い方法を追加してください",
"billing_interval_toggle": "請求間隔",
"current_plan_badge": "現在のプラン",
"current_plan_cta": "現在のプラン",
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
"custom_plan_title": "カスタムプラン",
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
"manage_subscription": "サブスクリプションを管理",
"keep_current_plan": "現在のプランを継続",
"manage_billing_details": "カード情報と請求書を管理",
"monthly": "月払い",
"most_popular": "人気",
"pending_change_removed": "予定されていたプラン変更を取り消しました。",
"pending_plan_badge": "変更予定",
"pending_plan_change_description": "{{date}}に{{plan}}へ切り替わります。",
"pending_plan_change_title": "プラン変更の予定",
"pending_plan_cta": "変更予定",
"per_month": "/月",
"per_year": "/年",
"plan_change_applied": "プランを更新しました。",
"plan_change_scheduled": "プラン変更を予約しました。",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Hobbyプランの全機能",
"plan_feature_everything_in_pro": "Proプランの全機能",
"plan_hobby": "Hobby",
"plan_hobby_description": "Formbricks Cloudを始める個人や小規模チーム向けのプランです。",
"plan_hobby_feature_responses": "月250回の回答",
"plan_hobby_feature_workspaces": "1ワークスペース",
"plan_pro": "Pro",
"plan_pro_description": "より高い制限、自動化、動的なオーバーエージが必要な成長中のチーム向け。",
"plan_pro_feature_responses": "月2,000回の回答(超過分は従量制)",
"plan_pro_feature_workspaces": "3つのワークスペース",
"plan_scale": "Scale",
"plan_scale_description": "より多くの容量、強力なガバナンス、高いレスポンス量が必要な大規模チーム向け。",
"plan_scale_feature_responses": "月間5,000レスポンス(動的な超過課金)",
"plan_scale_feature_workspaces": "5つのワークスペース",
"plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。",
"plan_selection_title": "プランを選択",
"plan_unknown": "不明",
"remove_branding": "ブランディングを削除",
"retry_setup": "セットアップを再試行",
"scale_banner_description": "Scaleプランで、上限の引き上げ、チームでのコラボレーション、高度なセキュリティ機能を利用しましょう。",
"scale_banner_title": "スケールアップの準備はできていますか?",
"scale_feature_api": "APIフルアクセス",
"scale_feature_quota": "クォータ管理",
"scale_feature_spam": "スパム防止機能",
"scale_feature_teams": "チーム&アクセス権限管理",
"select_plan_header_subtitle": "クレジットカード不要、縛りなし。",
"select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
"select_plan_header_title": "シームレスに統合されたアンケート、100%あなたのブランド。",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Hobbyプランを継続する",
"stripe_setup_incomplete": "請求情報の設定が未完了",
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
"subscription": "サブスクリプション",
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
"switch_at_period_end": "期間終了時に切り替え",
"switch_plan_now": "今すぐプランを切り替え",
"this_includes": "これには以下が含まれます",
"trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
"trial_feature_api_access": "APIアクセス",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "無制限の回答",
"unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード",
"upgrade_now": "今すぐアップグレード",
"usage_cycle": "Usage cycle",
"used": "使用済み",
"yearly": "年間",
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
"your_plan": "ご利用プラン"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Er is iets misgegaan",
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
"sort_by": "Sorteer op",
"start_free_trial": "Gratis proefperiode starten",
"start_free_trial": "Start gratis proefperiode",
"status": "Status",
"step_by_step_manual": "Stap voor stap handleiding",
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
@@ -444,6 +444,7 @@
"update": "Update",
"updated": "Bijgewerkt",
"updated_at": "Bijgewerkt op",
"upgrade_plan": "Abonnement upgraden",
"upload": "Uploaden",
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Betaalmethode toevoegen",
"cancelling": "Bezig met annuleren",
"add_payment_method_to_upgrade_tooltip": "Voeg hierboven een betaalmethode toe om te upgraden naar een betaald abonnement",
"billing_interval_toggle": "Factureringsinterval",
"current_plan_badge": "Huidig",
"current_plan_cta": "Huidig abonnement",
"custom_plan_description": "Je organisatie heeft een aangepaste factureringsopzet. Je kunt nog steeds overstappen naar een van de standaard abonnementen hieronder.",
"custom_plan_title": "Aangepast abonnement",
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
"manage_subscription": "Abonnement beheren",
"keep_current_plan": "Huidig abonnement behouden",
"manage_billing_details": "Kaartgegevens en facturen beheren",
"monthly": "Maandelijks",
"most_popular": "Meest populair",
"pending_change_removed": "Geplande abonnementswijziging verwijderd.",
"pending_plan_badge": "Gepland",
"pending_plan_change_description": "Je abonnement wordt op {{date}} omgezet naar {{plan}}.",
"pending_plan_change_title": "Geplande abonnementswijziging",
"pending_plan_cta": "Gepland",
"per_month": "per maand",
"per_year": "per jaar",
"plan_change_applied": "Abonnement succesvol bijgewerkt.",
"plan_change_scheduled": "Abonnementswijziging succesvol ingepland.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Alles in Hobby",
"plan_feature_everything_in_pro": "Alles in Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Voor individuen en kleine teams die aan de slag gaan met Formbricks Cloud.",
"plan_hobby_feature_responses": "250 reacties / maand",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "Voor groeiende teams die hogere limieten, automatiseringen en dynamische overschrijdingen nodig hebben.",
"plan_pro_feature_responses": "2.000 reacties / maand (dynamische overschrijding)",
"plan_pro_feature_workspaces": "3 werkruimtes",
"plan_scale": "Scale",
"plan_scale_description": "Voor grotere teams die meer capaciteit, beter bestuur en een hoger responsvolume nodig hebben.",
"plan_scale_feature_responses": "5.000 reacties / maand (dynamische overbrugging)",
"plan_scale_feature_workspaces": "5 werkruimtes",
"plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.",
"plan_selection_title": "Kies je abonnement",
"plan_unknown": "Onbekend",
"remove_branding": "Branding verwijderen",
"retry_setup": "Opnieuw proberen",
"scale_banner_description": "Ontgrendel hogere limieten, team samenwerking, en geavanceerde beveiligingsfuncties met het Scale-abonnement.",
"scale_banner_title": "Klaar om op te schalen?",
"scale_feature_api": "Volledige API-toegang",
"scale_feature_quota": "Quotabeheer",
"scale_feature_spam": "Spam-beveiliging",
"scale_feature_teams": "Teams & toegangsrollen",
"select_plan_header_subtitle": "Geen creditcard vereist, geen verplichtingen.",
"select_plan_header_title": "Verstuur vandaag nog professionele, ongemerkte enquêtes!",
"select_plan_header_title": "Naadloos geïntegreerde enquêtes, 100% jouw merk.",
"status_trialing": "Proefperiode",
"stay_on_hobby_plan": "Ik wil op het Hobby-abonnement blijven",
"stripe_setup_incomplete": "Facturatie-instelling niet voltooid",
"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",
"switch_at_period_end": "Schakel aan het einde van de periode",
"switch_plan_now": "Schakel nu van abonnement",
"this_includes": "Dit omvat",
"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": "API-toegang",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Onbeperkte reacties",
"unlimited_workspaces": "Onbeperkt werkruimtes",
"upgrade": "Upgraden",
"upgrade_now": "Nu upgraden",
"usage_cycle": "Usage cycle",
"used": "gebruikt",
"yearly": "Jaarlijks",
"yearly_checkout_unavailable": "Jaarlijkse checkout is nog niet beschikbaar. Voeg eerst een betaalmethode toe bij een maandelijks abonnement of neem contact op met support.",
"your_plan": "Jouw abonnement"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Algo deu errado",
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
"sort_by": "Ordenar por",
"start_free_trial": "Iniciar Teste Grátis",
"start_free_trial": "Iniciar teste gratuito",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
@@ -444,6 +444,7 @@
"update": "atualizar",
"updated": "atualizado",
"updated_at": "Atualizado em",
"upgrade_plan": "Fazer upgrade do plano",
"upload": "Enviar",
"upload_failed": "Falha no upload. Tente novamente.",
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Adicionar forma de pagamento",
"cancelling": "Cancelando",
"add_payment_method_to_upgrade_tooltip": "Por favor, adicione uma forma de pagamento acima para fazer upgrade para um plano pago",
"billing_interval_toggle": "Intervalo de cobrança",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "Sua organização está em uma configuração de cobrança personalizada. Você ainda pode mudar para um dos planos padrão abaixo.",
"custom_plan_title": "Plano personalizado",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
"manage_subscription": "Gerenciar assinatura",
"keep_current_plan": "Manter plano atual",
"manage_billing_details": "Gerenciar detalhes do cartão e faturas",
"monthly": "Mensal",
"most_popular": "Mais popular",
"pending_change_removed": "Mudança de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "Seu plano mudará para {{plan}} em {{date}}.",
"pending_plan_change_title": "Mudança de plano agendada",
"pending_plan_cta": "Agendado",
"per_month": "por mês",
"per_year": "por ano",
"plan_change_applied": "Plano atualizado com sucesso.",
"plan_change_scheduled": "Mudança de plano agendada com sucesso.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tudo do Hobby",
"plan_feature_everything_in_pro": "Tudo do Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Para indivíduos e pequenas equipes começando com o Formbricks Cloud.",
"plan_hobby_feature_responses": "250 respostas / mês",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "Para equipes em crescimento que precisam de limites maiores, automações e excedentes dinâmicos.",
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
"plan_pro_feature_workspaces": "3 espaços de trabalho",
"plan_scale": "Scale",
"plan_scale_description": "Para equipes maiores que precisam de mais capacidade, governança mais forte e maior volume de respostas.",
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
"plan_scale_feature_workspaces": "5 espaços de trabalho",
"plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.",
"plan_selection_title": "Escolha seu plano",
"plan_unknown": "desconhecido",
"remove_branding": "Remover Marca",
"retry_setup": "Tentar novamente",
"scale_banner_description": "Desbloqueie limites maiores, colaboração em equipe e recursos avançados de segurança com o plano Scale.",
"scale_banner_title": "Pronto para expandir?",
"scale_feature_api": "Acesso completo à API",
"scale_feature_quota": "Gestão de cota",
"scale_feature_spam": "Proteção contra spam",
"scale_feature_teams": "Equipes e papéis de acesso",
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
"select_plan_header_title": "Envie pesquisas profissionais e sem marca hoje mesmo!",
"select_plan_header_title": "Pesquisas perfeitamente integradas, 100% sua marca.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Quero continuar no plano Hobby",
"stripe_setup_incomplete": "Configuração de cobrança incompleta",
"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",
"switch_at_period_end": "Mudar no final do período",
"switch_plan_now": "Mudar de plano agora",
"this_includes": "Isso inclui",
"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": "Acesso à API",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
"used": "usado",
"yearly": "Anual",
"yearly_checkout_unavailable": "O checkout anual ainda não está disponível. Adicione um método de pagamento em um plano mensal primeiro ou entre em contato com o suporte.",
"your_plan": "Seu plano"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Algo correu mal",
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
"sort_by": "Ordem",
"start_free_trial": "Iniciar Teste Grátis",
"start_free_trial": "Iniciar teste gratuito",
"status": "Estado",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
@@ -444,6 +444,7 @@
"update": "Atualizar",
"updated": "Atualizado",
"updated_at": "Atualizado em",
"upgrade_plan": "Fazer upgrade do plano",
"upload": "Carregar",
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Adicionar método de pagamento",
"cancelling": "A cancelar",
"add_payment_method_to_upgrade_tooltip": "Por favor, adiciona um método de pagamento acima para fazeres upgrade para um plano pago",
"billing_interval_toggle": "Intervalo de faturação",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "A tua organização tem uma configuração de faturação personalizada. Podes mudar para um dos planos padrão abaixo.",
"custom_plan_title": "Plano personalizado",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
"manage_subscription": "Gerir subscrição",
"keep_current_plan": "Manter plano atual",
"manage_billing_details": "Gerir detalhes do cartão e faturas",
"monthly": "Mensal",
"most_popular": "Mais popular",
"pending_change_removed": "Alteração de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "O teu plano mudará para {{plan}} em {{date}}.",
"pending_plan_change_title": "Alteração de plano agendada",
"pending_plan_cta": "Agendado",
"per_month": "por mês",
"per_year": "por ano",
"plan_change_applied": "Plano atualizado com sucesso.",
"plan_change_scheduled": "Alteração de plano agendada com sucesso.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tudo no Hobby",
"plan_feature_everything_in_pro": "Tudo no Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Para indivíduos e pequenas equipas que estão a começar com o Formbricks Cloud.",
"plan_hobby_feature_responses": "250 respostas / mês",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "Para equipas em crescimento que precisam de limites mais elevados, automatizações e excedentes dinâmicos.",
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
"plan_pro_feature_workspaces": "3 áreas de trabalho",
"plan_scale": "Scale",
"plan_scale_description": "Para equipas maiores que precisam de mais capacidade, maior controlo e um volume de respostas mais elevado.",
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
"plan_scale_feature_workspaces": "5 áreas de trabalho",
"plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.",
"plan_selection_title": "Escolhe o teu plano",
"plan_unknown": "Desconhecido",
"remove_branding": "Possibilidade de remover o logo",
"retry_setup": "Tentar novamente configurar",
"scale_banner_description": "Desbloqueia limites mais elevados, colaboração em equipa e funcionalidades avançadas de segurança com o plano Scale.",
"scale_banner_title": "Preparado para aumentar a escala?",
"scale_feature_api": "Acesso total à API",
"scale_feature_quota": "Gestão de quotas",
"scale_feature_spam": "Proteção contra spam",
"scale_feature_teams": "Equipas e papéis de acesso",
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
"select_plan_header_title": "Envia inquéritos profissionais sem marca hoje!",
"select_plan_header_title": "Inquéritos perfeitamente integrados, 100% da tua marca.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Quero manter o plano Hobby",
"stripe_setup_incomplete": "Configuração de faturação incompleta",
"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",
"switch_at_period_end": "Mudar no fim do período",
"switch_plan_now": "Mudar de plano agora",
"this_includes": "Isto inclui",
"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": "Acesso à API",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
"used": "utilizado",
"yearly": "Anual",
"yearly_checkout_unavailable": "O pagamento anual ainda não está disponível. Adiciona primeiro um método de pagamento num plano mensal ou contacta o suporte.",
"your_plan": "O teu plano"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "Ceva nu a mers bine",
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
"sort_by": "Sortare după",
"start_free_trial": "Începe perioada de testare gratuită",
"start_free_trial": "Începe perioada de probă gratuită",
"status": "Stare",
"step_by_step_manual": "Manual pas cu pas",
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
@@ -444,6 +444,7 @@
"update": "Actualizare",
"updated": "Actualizat",
"updated_at": "Actualizat la",
"upgrade_plan": "Actualizează planul",
"upload": "Încărcați",
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Adaugă o metodă de plată",
"cancelling": "Anulare în curs",
"add_payment_method_to_upgrade_tooltip": "Te rugăm să adaugi o metodă de plată mai sus pentru a trece la un plan plătit",
"billing_interval_toggle": "Interval de facturare",
"current_plan_badge": "Curent",
"current_plan_cta": "Plan curent",
"custom_plan_description": "Organizația ta folosește o configurație de facturare personalizată. Poți totuși să treci la unul dintre planurile standard de mai jos.",
"custom_plan_title": "Plan personalizat",
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
"manage_subscription": "Gestionează abonamentul",
"keep_current_plan": "Păstrează planul curent",
"manage_billing_details": "Gestionează detaliile cardului și facturile",
"monthly": "Lunar",
"most_popular": "Cel mai popular",
"pending_change_removed": "Schimbarea de plan programată a fost anulată.",
"pending_plan_badge": "Programat",
"pending_plan_change_description": "Planul tău va trece la {{plan}} pe {{date}}.",
"pending_plan_change_title": "Schimbare de plan programată",
"pending_plan_cta": "Programat",
"per_month": "pe lună",
"per_year": "pe an",
"plan_change_applied": "Planul a fost actualizat cu succes.",
"plan_change_scheduled": "Schimbarea de plan a fost programată cu succes.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tot ce include Hobby",
"plan_feature_everything_in_pro": "Tot ce include Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Pentru persoane individuale și echipe mici care încep să folosească Formbricks Cloud.",
"plan_hobby_feature_responses": "250 de răspunsuri / lună",
"plan_hobby_feature_workspaces": "1 spațiu de lucru",
"plan_pro": "Pro",
"plan_pro_description": "Pentru echipele în creștere care au nevoie de limite mai mari, automatizări și depășiri dinamice.",
"plan_pro_feature_responses": "2.000 de răspunsuri / lună (depășire dinamică)",
"plan_pro_feature_workspaces": "3 spații de lucru",
"plan_scale": "Scală",
"plan_scale_description": "Pentru echipe mai mari care au nevoie de mai multă capacitate, guvernanță mai puternică și volum mai mare de răspunsuri.",
"plan_scale_feature_responses": "5.000 răspunsuri / lună (suprataxă dinamică)",
"plan_scale_feature_workspaces": "5 spații de lucru",
"plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.",
"plan_selection_title": "Alege-ți planul",
"plan_unknown": "Necunoscut",
"remove_branding": "Eliminare branding",
"retry_setup": "Încearcă din nou configurarea",
"scale_banner_description": "Deblochează limite mai mari, colaborare în echipă și funcții avansate de securitate cu pachetul Scale.",
"scale_banner_title": "Gata să treci la nivelul următor?",
"scale_feature_api": "Acces complet API",
"scale_feature_quota": "Gestionare cote",
"scale_feature_spam": "Protecție anti-spam",
"scale_feature_teams": "Echipe și roluri de acces",
"select_plan_header_subtitle": "Nu este necesar card de credit, fără obligații.",
"select_plan_header_title": "Lansează chestionare profesionale, fără branding, astăzi!",
"select_plan_header_title": "Sondaje integrate perfect, 100% brandul tău.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Vreau să rămân pe planul Hobby",
"stripe_setup_incomplete": "Configurare facturare incompletă",
"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",
"switch_at_period_end": "Schimbă la sfârșitul perioadei",
"switch_plan_now": "Schimbă planul acum",
"this_includes": "Aceasta include",
"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": "Acces API",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Răspunsuri nelimitate",
"unlimited_workspaces": "Workspaces nelimitate",
"upgrade": "Actualizare",
"upgrade_now": "Actualizează acum",
"usage_cycle": "Usage cycle",
"used": "utilizat",
"yearly": "Anual",
"yearly_checkout_unavailable": "Plata anuală nu este disponibilă încă. Adaugă mai întâi o metodă de plată pe un abonament lunar sau contactează asistența.",
"your_plan": "Planul tău"
},
"domain": {
+40 -9
View File
@@ -444,6 +444,7 @@
"update": "Обновить",
"updated": "Обновлено",
"updated_at": "Обновлено",
"upgrade_plan": "Перейти на другой тариф",
"upload": "Загрузить",
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
"upload_input_description": "Кликните или перетащите файлы для загрузки.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Добавить способ оплаты",
"cancelling": "Отмена",
"add_payment_method_to_upgrade_tooltip": "Пожалуйста, добавьте способ оплаты выше, чтобы перейти на платный тариф",
"billing_interval_toggle": "Интервал выставления счетов",
"current_plan_badge": "Текущий",
"current_plan_cta": "Текущий тариф",
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
"custom_plan_title": "Индивидуальный тариф",
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
"manage_subscription": "Управление подпиской",
"keep_current_plan": "Оставить текущий тариф",
"manage_billing_details": "Управление данными карты и счетами",
"monthly": "Ежемесячно",
"most_popular": "Самый популярный",
"pending_change_removed": "Запланированное изменение тарифа отменено.",
"pending_plan_badge": "Запланирован",
"pending_plan_change_description": "Ваш тариф изменится на {{plan}} {{date}}.",
"pending_plan_change_title": "Запланированное изменение тарифа",
"pending_plan_cta": "Запланирован",
"per_month": "в месяц",
"per_year": "в год",
"plan_change_applied": "Тариф успешно обновлен.",
"plan_change_scheduled": "Изменение тарифа успешно запланировано.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Все возможности Hobby",
"plan_feature_everything_in_pro": "Все возможности Pro",
"plan_hobby": "Хобби",
"plan_hobby_description": "Для частных лиц и небольших команд, начинающих работу с Formbricks Cloud.",
"plan_hobby_feature_responses": "250 ответов в месяц",
"plan_hobby_feature_workspaces": "1 рабочее пространство",
"plan_pro": "Pro",
"plan_pro_description": "Для растущих команд, которым нужны более высокие лимиты, автоматизация и динамические дополнительные ресурсы.",
"plan_pro_feature_responses": "2 000 ответов в месяц (динамическое превышение)",
"plan_pro_feature_workspaces": "3 рабочих пространства",
"plan_scale": "Scale",
"plan_scale_description": "Для крупных команд, которым нужно больше возможностей, строгое управление и больший объем ответов.",
"plan_scale_feature_responses": "5 000 ответов / месяц (динамический перерасход)",
"plan_scale_feature_workspaces": "5 рабочих пространств",
"plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.",
"plan_selection_title": "Выбери свой план",
"plan_unknown": "Неизвестно",
"remove_branding": "Удалить брендинг",
"retry_setup": "Повторить настройку",
"scale_banner_description": "Откройте новые лимиты, командную работу и расширенные функции безопасности с тарифом Scale.",
"scale_banner_title": "Готовы развиваться?",
"scale_feature_api": "Полный доступ к API",
"scale_feature_quota": "Управление квотами",
"scale_feature_spam": "Защита от спама",
"scale_feature_teams": "Команды и роли доступа",
"select_plan_header_subtitle": "Кредитная карта не требуется, никаких обязательств.",
"select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
"select_plan_header_title": "Бесшовно интегрированные опросы, 100% ваш бренд.",
"status_trialing": "Пробный",
"stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby",
"stripe_setup_incomplete": "Настройка оплаты не завершена",
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
"subscription": "Подписка",
"subscription_description": "Управляйте своим тарифом и следите за использованием",
"switch_at_period_end": "Переключить в конце периода",
"switch_plan_now": "Переключить план сейчас",
"this_includes": "Это включает",
"trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
"trial_feature_api_access": "Доступ к API",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Неограниченное количество ответов",
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить",
"upgrade_now": "Обновить сейчас",
"usage_cycle": "Usage cycle",
"used": "использовано",
"yearly": "Годовой",
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
"your_plan": "Ваш тариф"
},
"domain": {
+40 -9
View File
@@ -444,6 +444,7 @@
"update": "Uppdatera",
"updated": "Uppdaterad",
"updated_at": "Uppdaterad",
"upgrade_plan": "Uppgradera plan",
"upload": "Ladda upp",
"upload_failed": "Uppladdning misslyckades. Vänligen försök igen.",
"upload_input_description": "Klicka eller dra för att ladda upp filer.",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "Lägg till betalningsmetod",
"cancelling": "Avbryter",
"add_payment_method_to_upgrade_tooltip": "Lägg till en betalningsmetod ovan för att uppgradera till en betald plan",
"billing_interval_toggle": "Faktureringsintervall",
"current_plan_badge": "Nuvarande",
"current_plan_cta": "Nuvarande abonnemang",
"custom_plan_description": "Din organisation har en anpassad faktureringslösning. Du kan fortfarande byta till något av standardabonnemangen nedan.",
"custom_plan_title": "Anpassat abonnemang",
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
"manage_subscription": "Hantera prenumeration",
"keep_current_plan": "Behåll nuvarande abonnemang",
"manage_billing_details": "Hantera kortuppgifter & fakturor",
"monthly": "Månatlig",
"most_popular": "Mest populär",
"pending_change_removed": "Schemalagd abonnemangsändring har tagits bort.",
"pending_plan_badge": "Schemalagd",
"pending_plan_change_description": "Ditt abonnemang kommer att ändras till {{plan}} den {{date}}.",
"pending_plan_change_title": "Schemalagd abonnemangsändring",
"pending_plan_cta": "Schemalagd",
"per_month": "per månad",
"per_year": "per år",
"plan_change_applied": "Abonnemanget har uppdaterats.",
"plan_change_scheduled": "Abonnemangsändring har schemalagts.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Allt i Hobby",
"plan_feature_everything_in_pro": "Allt i Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "För privatpersoner och små team som kommer igång med Formbricks Cloud.",
"plan_hobby_feature_responses": "250 svar / månad",
"plan_hobby_feature_workspaces": "1 arbetsyta",
"plan_pro": "Pro",
"plan_pro_description": "För växande team som behöver högre gränser, automationer och dynamiska överskott.",
"plan_pro_feature_responses": "2 000 svar / månad (dynamisk överförbrukning)",
"plan_pro_feature_workspaces": "3 arbetsytor",
"plan_scale": "Skala",
"plan_scale_description": "För större team som behöver mer kapacitet, starkare styrning och högre svarsvolym.",
"plan_scale_feature_responses": "5 000 svar / månad (dynamisk överförbrukning)",
"plan_scale_feature_workspaces": "5 arbetsytor",
"plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.",
"plan_selection_title": "Välj din plan",
"plan_unknown": "Okänd",
"remove_branding": "Ta bort varumärke",
"retry_setup": "Försök igen med inställningen",
"scale_banner_description": "Lås upp högre gränser, samarbete i team och avancerade säkerhetsfunktioner med Scale-planen.",
"scale_banner_title": "Redo att växla upp?",
"scale_feature_api": "Full API-åtkomst",
"scale_feature_quota": "Kvot­hantering",
"scale_feature_spam": "Spamskydd",
"scale_feature_teams": "Team & åtkomstroller",
"select_plan_header_subtitle": "Inget kreditkort krävs, inga villkor.",
"select_plan_header_title": "Skicka professionella undersökningar utan varumärke idag!",
"select_plan_header_title": "Sömlöst integrerade undersökningar, 100% ditt varumärke.",
"status_trialing": "Testperiod",
"stay_on_hobby_plan": "Jag vill behålla Hobby-planen",
"stripe_setup_incomplete": "Faktureringsinställningar ofullständiga",
"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",
"switch_at_period_end": "Byt vid periodens slut",
"switch_plan_now": "Byt plan nu",
"this_includes": "Detta inkluderar",
"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": "API-åtkomst",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "Obegränsade svar",
"unlimited_workspaces": "Obegränsat antal arbetsytor",
"upgrade": "Uppgradera",
"upgrade_now": "Uppgradera nu",
"usage_cycle": "Usage cycle",
"used": "använt",
"yearly": "Årligen",
"yearly_checkout_unavailable": "Årlig betalning är inte tillgänglig ännu. Lägg till en betalningsmetod på en månatlig plan först eller kontakta support.",
"your_plan": "Din plan"
},
"domain": {
+41 -10
View File
@@ -399,7 +399,7 @@
"something_went_wrong": "出错了",
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
"sort_by": "排序 依据",
"start_free_trial": "开始 免费试用",
"start_free_trial": "开始免费试用",
"status": "状态",
"step_by_step_manual": "分步 手册",
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
@@ -444,6 +444,7 @@
"update": "更新",
"updated": "已更新",
"updated_at": "更新 于",
"upgrade_plan": "升级套餐",
"upload": "上传",
"upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "添加支付方式",
"cancelling": "正在取消",
"add_payment_method_to_upgrade_tooltip": "请先在上方添加付款方式以升级到付费套餐",
"billing_interval_toggle": "账单周期",
"current_plan_badge": "当前",
"current_plan_cta": "当前方案",
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
"custom_plan_title": "自定义方案",
"failed_to_start_trial": "试用启动失败,请重试。",
"manage_subscription": "管理订阅",
"keep_current_plan": "保持当前方案",
"manage_billing_details": "管理卡片详情与发票",
"monthly": "按月",
"most_popular": "最受欢迎",
"pending_change_removed": "已取消预定的方案变更。",
"pending_plan_badge": "已预定",
"pending_plan_change_description": "您的方案将在 {{date}} 切换至 {{plan}}。",
"pending_plan_change_title": "预定的方案变更",
"pending_plan_cta": "已预定",
"per_month": "每月",
"per_year": "每年",
"plan_change_applied": "方案更新成功。",
"plan_change_scheduled": "方案变更预定成功。",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
"plan_hobby": "兴趣版",
"plan_hobby_description": "适合开始使用 Formbricks Cloud 的个人和小团队。",
"plan_hobby_feature_responses": "250 条回复 / 月",
"plan_hobby_feature_workspaces": "1 个工作区",
"plan_pro": "专业版",
"plan_pro_description": "适合需要更高限额、自动化功能和动态超额使用的成长型团队。",
"plan_pro_feature_responses": "2,000 条回复 / 月(动态超额)",
"plan_pro_feature_workspaces": "3 个工作区",
"plan_scale": "规模版",
"plan_scale_description": "适合需要更大容量、更强治理能力和更高响应量的大型团队。",
"plan_scale_feature_responses": "每月 5,000 次响应(动态超额)",
"plan_scale_feature_workspaces": "5 个工作区",
"plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。",
"plan_selection_title": "选择您的套餐",
"plan_unknown": "未知",
"remove_branding": "移除 品牌",
"retry_setup": "重试设置",
"scale_banner_description": "升级到 Scale 套餐,解锁更高额度、团队协作和高级安全功能。",
"scale_banner_title": "准备好扩容了吗?",
"scale_feature_api": "完整 API 访问权限",
"scale_feature_quota": "额度管理",
"scale_feature_spam": "垃圾防护",
"scale_feature_teams": "团队与访问角色",
"select_plan_header_subtitle": "无需信用卡,没有任何附加条件。",
"select_plan_header_title": "立即发布专业的无品牌调查!",
"select_plan_header_title": "无缝集成的调查问卷,100% 展现您的品牌。",
"status_trialing": "试用版",
"stay_on_hobby_plan": "我想继续使用免费版计划",
"stripe_setup_incomplete": "账单设置未完成",
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
"subscription": "订阅",
"subscription_description": "管理你的订阅套餐并监控用量",
"switch_at_period_end": "在周期结束时切换",
"switch_plan_now": "立即切换套餐",
"this_includes": "包含以下内容",
"trial_alert_description": "添加支付方式以继续使用所有功能。",
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
"trial_feature_api_access": "API 访问",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "无限反馈",
"unlimited_workspaces": "无限工作区",
"upgrade": "升级",
"upgrade_now": "立即升级",
"usage_cycle": "Usage cycle",
"used": "已用",
"yearly": "按年付费",
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
"your_plan": "你的套餐"
},
"domain": {
+40 -9
View File
@@ -444,6 +444,7 @@
"update": "更新",
"updated": "已更新",
"updated_at": "更新時間",
"upgrade_plan": "升級方案",
"upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。",
@@ -972,30 +973,57 @@
},
"billing": {
"add_payment_method": "新增付款方式",
"cancelling": "正在取消",
"add_payment_method_to_upgrade_tooltip": "請先在上方新增付款方式以升級至付費方案",
"billing_interval_toggle": "帳單週期",
"current_plan_badge": "目前",
"current_plan_cta": "目前方案",
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
"custom_plan_title": "自訂方案",
"failed_to_start_trial": "無法開始試用。請再試一次。",
"manage_subscription": "管理訂閱",
"keep_current_plan": "保留目前方案",
"manage_billing_details": "管理卡片資訊與發票",
"monthly": "每月",
"most_popular": "最受歡迎",
"pending_change_removed": "已取消預定的方案變更。",
"pending_plan_badge": "已排程",
"pending_plan_change_description": "您的方案將於 {{date}} 切換至 {{plan}}。",
"pending_plan_change_title": "已排程的方案變更",
"pending_plan_cta": "已排程",
"per_month": "每月",
"per_year": "每年",
"plan_change_applied": "方案更新成功。",
"plan_change_scheduled": "方案變更已成功排程。",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
"plan_hobby": "興趣版",
"plan_hobby_description": "適合個人與小型團隊開始使用 Formbricks Cloud。",
"plan_hobby_feature_responses": "每月 250 次回應",
"plan_hobby_feature_workspaces": "1 個工作區",
"plan_pro": "專業版",
"plan_pro_description": "適合需要更高限制、自動化功能和彈性超量使用的成長中團隊。",
"plan_pro_feature_responses": "每月 2,000 次回應(動態超量計費)",
"plan_pro_feature_workspaces": "3 個工作區",
"plan_scale": "規模版",
"plan_scale_description": "適合需要更大容量、更強管理機制和更高回應量的大型團隊。",
"plan_scale_feature_responses": "每月 5,000 則回應(動態超額計費)",
"plan_scale_feature_workspaces": "5 個工作區",
"plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。",
"plan_selection_title": "選擇您的方案",
"plan_unknown": "未知",
"remove_branding": "移除品牌",
"retry_setup": "重新設定",
"scale_banner_description": "加入 Scale 方案,解鎖更高限制、團隊協作和進階安全功能。",
"scale_banner_title": "準備好升級規模了嗎?",
"scale_feature_api": "完整 API 存取",
"scale_feature_quota": "額度管理",
"scale_feature_spam": "垃圾訊息防護",
"scale_feature_teams": "團隊與存取權限",
"select_plan_header_subtitle": "無需信用卡,完全沒有附加條件。",
"select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
"select_plan_header_title": "完美整合的問卷調查,100% 展現你的品牌。",
"status_trialing": "試用版",
"stay_on_hobby_plan": "我想繼續使用 Hobby 方案",
"stripe_setup_incomplete": "帳單設定尚未完成",
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
"subscription": "訂閱",
"subscription_description": "管理您的訂閱方案並監控用量",
"switch_at_period_end": "週期結束時切換",
"switch_plan_now": "立即切換方案",
"this_includes": "包含內容",
"trial_alert_description": "新增付款方式以繼續使用所有功能。",
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
"trial_feature_api_access": "API 存取",
@@ -1013,8 +1041,11 @@
"unlimited_responses": "無限回應",
"unlimited_workspaces": "無限工作區",
"upgrade": "升級",
"upgrade_now": "立即升級",
"usage_cycle": "Usage cycle",
"used": "已使用",
"yearly": "年繳",
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
"your_plan": "您的方案"
},
"domain": {
@@ -217,7 +217,7 @@ describe("utils", () => {
});
describe("logApiError", () => {
test("logs API error details", () => {
test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -238,9 +238,11 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
});
@@ -275,6 +277,8 @@ describe("utils", () => {
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "",
method: "GET",
path: "/api/test",
error,
});
@@ -285,7 +289,7 @@ describe("utils", () => {
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set", () => {
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -295,11 +299,23 @@ describe("utils", () => {
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -309,20 +325,60 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
mockRequest.headers.set("x-request-id", "456");
const error: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
});
+8 -1
View File
@@ -6,13 +6,18 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID as a tag for easy filtering
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
@@ -24,6 +29,8 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");
+1 -1
View File
@@ -135,7 +135,7 @@ describe("Auth Utils", () => {
expect(hashedComplex.length).toBe(60);
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
});
}, 15000);
test("should handle bcrypt errors gracefully and log warning", async () => {
// Save the original bcryptjs implementation
+172
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 });
});
});
+188 -65
View File
@@ -2,7 +2,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZCloudBillingInterval } from "@formbricks/types/organizations";
import { WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -11,14 +12,16 @@ 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 {
createPaidPlanCheckoutSession,
createProTrialSubscription,
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization,
switchOrganizationToCloudPlan,
syncOrganizationBillingFromStripe,
undoPendingOrganizationPlanChange,
} from "@/modules/ee/billing/lib/organization-billing";
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
const ZManageSubscriptionAction = z.object({
environmentId: ZId,
@@ -46,7 +49,7 @@ export const manageSubscriptionAction = authenticatedActionClient
}
if (!organization.billing.stripeCustomerId) {
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
@@ -54,75 +57,64 @@ export const manageSubscriptionAction = authenticatedActionClient
organization.billing.stripeCustomerId,
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
);
ctx.auditLoggingCtx.newObject = { portalSession: result };
ctx.auditLoggingCtx.newObject = { portalSessionCreated: true };
return result;
})
);
const ZIsSubscriptionCancelledAction = z.object({
organizationId: ZId,
});
export const isSubscriptionCancelledAction = authenticatedActionClient
.inputSchema(ZIsSubscriptionCancelledAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
return await isSubscriptionCancelled(parsedInput.organizationId);
});
const ZCreatePricingTableCustomerSessionAction = z.object({
const ZCreatePlanCheckoutAction = z.object({
environmentId: ZId,
targetPlan: z.enum(["pro", "scale"]),
targetInterval: ZCloudBillingInterval,
});
export const createPricingTableCustomerSessionAction = authenticatedActionClient
.inputSchema(ZCreatePricingTableCustomerSessionAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
export const createPlanCheckoutAction = authenticatedActionClient
.inputSchema(ZCreatePlanCheckoutAction)
.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);
}
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
if (!stripeClient) {
return { clientSecret: null };
}
if (organization.billing.stripe?.subscriptionId) {
throw new OperationNotAllowedError("paid_checkout_requires_no_existing_subscription");
}
const customerSession = await stripeClient.customerSessions.create({
customer: organization.billing.stripeCustomerId,
components: {
pricing_table: {
enabled: true,
},
},
});
const checkoutUrl = await createPaidPlanCheckoutSession({
organizationId,
customerId: organization.billing.stripeCustomerId,
environmentId: parsedInput.environmentId,
plan: parsedInput.targetPlan,
interval: parsedInput.targetInterval,
});
return { clientSecret: customerSession.client_secret ?? null };
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = {
checkoutCreated: true,
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
};
return checkoutUrl;
})
);
const ZRetryStripeSetupAction = z.object({
organizationId: ZId,
@@ -172,7 +164,7 @@ export const createTrialPaymentCheckoutAction = authenticatedActionClient
}
if (!organization.billing.stripeCustomerId) {
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const subscriptionId = organization.billing.stripe?.subscriptionId;
@@ -189,7 +181,7 @@ export const createTrialPaymentCheckoutAction = authenticatedActionClient
organizationId
);
ctx.auditLoggingCtx.newObject = { checkoutUrl };
ctx.auditLoggingCtx.newObject = { setupCheckoutCreated: true };
return checkoutUrl;
})
);
@@ -198,6 +190,37 @@ 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 }) => {
@@ -217,12 +240,112 @@ export const startProTrialAction = 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 createProTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
const ZChangeBillingPlanAction = z.discriminatedUnion("targetPlan", [
z.object({
environmentId: ZId,
targetPlan: z.literal("hobby"),
targetInterval: z.literal("monthly"),
}),
z.object({
environmentId: ZId,
targetPlan: z.enum(["pro", "scale"]),
targetInterval: ZCloudBillingInterval,
}),
]);
export const changeBillingPlanAction = authenticatedActionClient.inputSchema(ZChangeBillingPlanAction).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 ResourceNotFoundError("OrganizationBilling", organizationId);
}
const result = await switchOrganizationToCloudPlan({
organizationId,
customerId: organization.billing.stripeCustomerId,
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
});
if (result.mode === "immediate") {
await syncOrganizationBillingFromStripe(organizationId);
}
// Scheduled downgrades already persist the pending snapshot locally and
// the ensuing subscription_schedule webhook performs the full Stripe resync.
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = {
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
mode: result.mode,
};
return result;
})
);
const ZUndoPendingPlanChangeAction = z.object({
environmentId: ZId,
});
export const undoPendingPlanChangeAction = authenticatedActionClient
.inputSchema(ZUndoPendingPlanChangeAction)
.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 ResourceNotFoundError("OrganizationBilling", organizationId);
}
await undoPendingOrganizationPlanChange(organizationId, organization.billing.stripeCustomerId);
await syncOrganizationBillingFromStripe(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
return { success: true };
})
);
@@ -1,50 +0,0 @@
import { logger } from "@formbricks/logger";
import { getOrganization } from "@/lib/organization/service";
import { getStripeClient } from "./stripe-client";
export const isSubscriptionCancelled = async (
organizationId: string
): Promise<{
cancelled: boolean;
date: Date | null;
}> => {
try {
const stripe = getStripeClient();
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Team not found.");
let isNewTeam =
!organization.billing.stripeCustomerId ||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
if (!organization.billing.stripeCustomerId || isNewTeam) {
return {
cancelled: false,
date: null,
};
}
const subscriptions = await stripe.subscriptions.list({
customer: organization.billing.stripeCustomerId,
});
for (const subscription of subscriptions.data) {
if (subscription.cancel_at_period_end) {
const periodEndTimestamp = subscription.cancel_at ?? subscription.items.data[0]?.current_period_end;
return {
cancelled: true,
date: periodEndTimestamp ? new Date(periodEndTimestamp * 1000) : null,
};
}
}
return {
cancelled: false,
date: null,
};
} catch (err) {
logger.error(err, "Error checking if subscription is cancelled");
return {
cancelled: false,
date: null,
};
}
};
@@ -12,8 +12,12 @@ const relevantEvents = new Set([
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.finalized",
"entitlements.active_entitlement_summary.updated",
"subscription_schedule.created",
"subscription_schedule.updated",
"subscription_schedule.released",
"subscription_schedule.canceled",
"subscription_schedule.completed",
]);
/**
@@ -1,86 +1,39 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import Script from "next/script";
import { createElement, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization, TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import {
type TCloudBillingInterval,
type TOrganization,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import {
createPricingTableCustomerSessionAction,
changeBillingPlanAction,
createPlanCheckoutAction,
createTrialPaymentCheckoutAction,
isSubscriptionCancelledAction,
manageSubscriptionAction,
retryStripeSetupAction,
undoPendingPlanChangeAction,
} from "../actions";
import type { TStripeBillingCatalogDisplay } from "../lib/stripe-billing-catalog";
import { TrialAlert } from "./trial-alert";
import { UsageCard } from "./usage-card";
const STRIPE_SUPPORTED_LOCALES = new Set([
"bg",
"cs",
"da",
"de",
"el",
"en",
"en-GB",
"es",
"es-419",
"et",
"fi",
"fil",
"fr",
"fr-CA",
"hr",
"hu",
"id",
"it",
"ja",
"ko",
"lt",
"lv",
"ms",
"mt",
"nb",
"nl",
"pl",
"pt",
"pt-BR",
"ro",
"ru",
"sk",
"sl",
"sv",
"th",
"tr",
"vi",
"zh",
"zh-HK",
"zh-TW",
]);
const getStripeLocaleOverride = (locale?: string): string | undefined => {
if (!locale) return undefined;
const normalizedLocale = locale.trim();
if (STRIPE_SUPPORTED_LOCALES.has(normalizedLocale)) {
return normalizedLocale;
}
const baseLocale = normalizedLocale.split("-")[0];
if (STRIPE_SUPPORTED_LOCALES.has(baseLocale)) {
return baseLocale;
}
return undefined;
};
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
type TDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "unknown";
type TStandardPlan = "hobby" | "pro" | "scale";
interface PricingTableProps {
organization: TOrganization;
environmentId: string;
@@ -89,18 +42,22 @@ interface PricingTableProps {
usageCycleStart: Date;
usageCycleEnd: Date;
hasBillingRights: boolean;
currentCloudPlan: "hobby" | "pro" | "scale" | "custom" | "unknown";
currentCloudPlan: TDisplayPlan;
currentBillingInterval: TCloudBillingInterval | null;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
stripePublishableKey: string | null;
stripePricingTableId: string | null;
pendingChange: TOrganizationStripePendingChange | null;
isStripeSetupIncomplete: boolean;
trialDaysRemaining: number | null;
billingCatalog: TStripeBillingCatalogDisplay;
}
const getCurrentCloudPlanLabel = (
plan: "hobby" | "pro" | "scale" | "custom" | "unknown",
t: (key: string) => string
) => {
const STANDARD_PLAN_LEVEL: Record<TStandardPlan, number> = {
hobby: 0,
pro: 1,
scale: 2,
};
const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string) => {
if (plan === "hobby") return t("environments.settings.billing.plan_hobby");
if (plan === "pro") return t("environments.settings.billing.plan_pro");
if (plan === "scale") return t("environments.settings.billing.plan_scale");
@@ -108,6 +65,78 @@ const getCurrentCloudPlanLabel = (
return t("environments.settings.billing.plan_unknown");
};
const formatMoney = (currency: string, unitAmount: number | null, locale: string) => {
if (unitAmount == null) {
return "—";
}
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currency.toUpperCase(),
minimumFractionDigits: unitAmount % 100 === 0 ? 0 : 2,
}).format(unitAmount / 100);
};
const formatDate = (date: Date, locale: string) =>
date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
type TPlanCardData = {
plan: TStandardPlan;
interval: TCloudBillingInterval;
amount: string;
description: string;
features: string[];
};
const getPlanPeriodLabel = (
plan: TStandardPlan,
interval: TCloudBillingInterval,
t: (key: string) => string
) => {
if (plan === "hobby" || interval === "monthly") {
return t("environments.settings.billing.per_month");
}
return t("environments.settings.billing.per_year");
};
const getPlanChangePayload = (environmentId: string, plan: TStandardPlan, interval: TCloudBillingInterval) =>
plan === "hobby"
? {
environmentId,
targetPlan: "hobby" as const,
targetInterval: "monthly" as const,
}
: {
environmentId,
targetPlan: plan,
targetInterval: interval,
};
const getPlanChangeSuccessMessage = (
mode: "immediate" | "scheduled" | undefined,
t: (key: string) => string
) => {
if (mode === "scheduled") {
return t("environments.settings.billing.plan_change_scheduled");
}
return t("environments.settings.billing.plan_change_applied");
};
const getActionErrorMessage = (serverError: string, t: (key: string) => string) => {
if (serverError === "mixed_interval_checkout_unsupported") {
return t("environments.settings.billing.yearly_checkout_unavailable");
}
return t("common.something_went_wrong_please_try_again");
};
export const PricingTable = ({
environmentId,
organization,
@@ -117,55 +146,35 @@ export const PricingTable = ({
usageCycleEnd,
hasBillingRights,
currentCloudPlan,
currentBillingInterval,
currentSubscriptionStatus,
stripePublishableKey,
stripePricingTableId,
pendingChange,
isStripeSetupIncomplete,
trialDaysRemaining,
billingCatalog,
}: 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<
string | null
>(null);
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
const isTrialing = currentSubscriptionStatus === "trialing";
const showPricingTable =
hasBillingRights && isUpgradeablePlan && !isTrialing && !!stripePublishableKey && !!stripePricingTableId;
const canManageSubscription =
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
const stripeLocaleOverride = useMemo(
() => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
[i18n.language, i18n.resolvedLanguage]
const [isPlanActionPending, setIsPlanActionPending] = useState<string | null>(null);
const [selectedInterval, setSelectedInterval] = useState<TCloudBillingInterval>(
currentBillingInterval ?? "monthly"
);
const stripePricingTableProps = useMemo(() => {
const props: Record<string, string> = {
"pricing-table-id": stripePricingTableId ?? "",
"publishable-key": stripePublishableKey ?? "",
};
if (stripeLocaleOverride) {
props["__locale-override"] = stripeLocaleOverride;
}
if (pricingTableCustomerSessionClientSecret) {
props["customer-session-client-secret"] = pricingTableCustomerSessionClientSecret;
} else {
props["client-reference-id"] = organization.id;
}
return props;
}, [
organization.id,
pricingTableCustomerSessionClientSecret,
stripeLocaleOverride,
stripePricingTableId,
stripePublishableKey,
]);
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const isTrialing = currentSubscriptionStatus === "trialing";
const hasPaymentMethod = organization.billing.stripe?.hasPaymentMethod === true;
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const currentPlanLevel =
currentCloudPlan === "hobby" || currentCloudPlan === "pro" || currentCloudPlan === "scale"
? STANDARD_PLAN_LEVEL[currentCloudPlan]
: null;
useEffect(() => {
if (searchParams.get("checkout_success")) {
@@ -174,68 +183,96 @@ export const PricingTable = ({
}
}, [searchParams, router]);
useEffect(() => {
const checkSubscriptionStatus = async () => {
if (!hasBillingRights || !canManageSubscription) {
setCancellingOn(null);
return;
}
try {
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
organizationId: organization.id,
});
if (isSubscriptionCancelledResponse?.data) {
setCancellingOn(isSubscriptionCancelledResponse.data.date);
}
} catch {
// Ignore permission/network failures here and keep rendering billing UI.
}
};
checkSubscriptionStatus();
}, [canManageSubscription, hasBillingRights, organization.id]);
useEffect(() => {
if (!showPricingTable) {
setPricingTableCustomerSessionClientSecret(null);
return;
}
const planCards = useMemo<TPlanCardData[]>(() => {
return [
{
plan: "hobby",
interval: "monthly",
amount: formatMoney(
billingCatalog.hobby.monthly.currency,
billingCatalog.hobby.monthly.unitAmount,
locale
),
description: t("environments.settings.billing.plan_hobby_description"),
features: [
t("environments.settings.billing.plan_hobby_feature_workspaces"),
t("environments.settings.billing.plan_hobby_feature_responses"),
],
},
{
plan: "pro",
interval: selectedInterval,
amount: formatMoney(
billingCatalog.pro[selectedInterval].currency,
billingCatalog.pro[selectedInterval].unitAmount,
locale
),
description: t("environments.settings.billing.plan_pro_description"),
features: [
t("environments.settings.billing.plan_feature_everything_in_hobby"),
t("environments.settings.billing.plan_pro_feature_workspaces"),
t("environments.settings.billing.plan_pro_feature_responses"),
],
},
{
plan: "scale",
interval: selectedInterval,
amount: formatMoney(
billingCatalog.scale[selectedInterval].currency,
billingCatalog.scale[selectedInterval].unitAmount,
locale
),
description: t("environments.settings.billing.plan_scale_description"),
features: [
t("environments.settings.billing.plan_feature_everything_in_pro"),
t("environments.settings.billing.plan_scale_feature_workspaces"),
t("environments.settings.billing.plan_scale_feature_responses"),
],
},
];
}, [billingCatalog, locale, selectedInterval, t]);
const persistEnvironmentId = () => {
if (globalThis.window !== undefined) {
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
}
};
const loadPricingTableCustomerSession = async () => {
try {
const response = await createPricingTableCustomerSessionAction({ environmentId });
setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
} catch {
setPricingTableCustomerSessionClientSecret(null);
}
};
void loadPricingTableCustomerSession();
}, [environmentId, showPricingTable]);
const openCustomerPortal = async () => {
const manageSubscriptionResponse = await manageSubscriptionAction({
environmentId,
});
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
router.push(manageSubscriptionResponse.data);
const navigateToExternalUrl = (url: string) => {
if (globalThis.window !== undefined) {
globalThis.window.location.href = url;
}
};
const openBillingPortal = async () => {
const response = await manageSubscriptionAction({ environmentId });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data && typeof response.data === "string") {
router.push(response.data);
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
};
const openTrialPaymentCheckout = async () => {
try {
persistEnvironmentId();
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"));
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data && typeof response.data === "string") {
navigateToExternalUrl(response.data);
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
} catch (error) {
console.error("Failed to create checkout session:", error);
console.error("Failed to create setup checkout session:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
@@ -244,11 +281,15 @@ export const PricingTable = ({
setIsRetryingStripeSetup(true);
try {
const response = await retryStripeSetupAction({ organizationId: organization.id });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data) {
router.refresh();
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
@@ -256,25 +297,118 @@ export const PricingTable = ({
}
};
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})} - ${usageCycleEnd.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})}`;
const redirectToPlanCheckout = async (
plan: Exclude<TStandardPlan, "hobby">,
interval: TCloudBillingInterval
): Promise<void> => {
if (existingSubscriptionId) {
await openTrialPaymentCheckout();
return;
}
if (interval === "yearly") {
toast.error(t("environments.settings.billing.yearly_checkout_unavailable"));
return;
}
persistEnvironmentId();
const response = await createPlanCheckoutAction({
environmentId,
targetPlan: plan,
targetInterval: interval,
});
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data && typeof response.data === "string") {
navigateToExternalUrl(response.data);
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
};
const handlePlanAction = async (plan: TStandardPlan, interval: TCloudBillingInterval) => {
const actionKey = `${plan}-${interval}`;
setIsPlanActionPending(actionKey);
try {
if (!hasPaymentMethod && plan !== "hobby") {
await redirectToPlanCheckout(plan, interval);
return;
}
const response = await changeBillingPlanAction(getPlanChangePayload(environmentId, plan, interval));
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
toast.success(getPlanChangeSuccessMessage(response?.data?.mode, t));
router.refresh();
} catch (error) {
console.error("Failed to change billing plan:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsPlanActionPending(null);
}
};
const undoPendingChange = async () => {
setIsPlanActionPending("undo");
try {
const response = await undoPendingPlanChangeAction({ environmentId });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data) {
toast.success(t("environments.settings.billing.pending_change_removed"));
router.refresh();
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
} catch (error) {
console.error("Failed to undo pending plan change:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsPlanActionPending(null);
}
};
const getCtaLabel = (plan: TStandardPlan, interval: TCloudBillingInterval) => {
const isCurrentSelection =
currentCloudPlan === plan && (plan === "hobby" || currentBillingInterval === interval);
if (isCurrentSelection) {
return t("environments.settings.billing.current_plan_cta");
}
const isPendingSelection =
pendingChange?.targetPlan === plan && (plan === "hobby" || pendingChange.targetInterval === interval);
if (isPendingSelection) {
return t("environments.settings.billing.pending_plan_cta");
}
if (!hasPaymentMethod && plan !== "hobby") {
return t("environments.settings.billing.upgrade_now");
}
if (currentPlanLevel === null) {
return t("environments.settings.billing.switch_plan_now");
}
return STANDARD_PLAN_LEVEL[plan] > currentPlanLevel
? t("environments.settings.billing.upgrade_now")
: t("environments.settings.billing.switch_at_period_end");
};
return (
<main>
<div className="flex max-w-4xl flex-col gap-4">
<div className="flex max-w-6xl flex-col gap-4">
{trialDaysRemaining !== null &&
(organization.billing.stripe?.hasPaymentMethod ? (
(hasPaymentMethod ? (
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
<AlertDescription>
{t("environments.settings.billing.trial_payment_method_added_description")}
@@ -292,6 +426,23 @@ export const PricingTable = ({
)}
</TrialAlert>
))}
{pendingChange && (
<Alert variant="info" className="max-w-4xl">
<AlertTitle>{t("environments.settings.billing.pending_plan_change_title")}</AlertTitle>
<AlertDescription>
{t("environments.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
{t("environments.settings.billing.keep_current_plan")}
</AlertButton>
)}
</Alert>
)}
{isStripeSetupIncomplete && hasBillingRights && (
<Alert variant="warning">
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
@@ -303,14 +454,24 @@ export const PricingTable = ({
</AlertButton>
</Alert>
)}
{currentCloudPlan === "custom" && (
<Alert>
<AlertTitle>{t("environments.settings.billing.custom_plan_title")}</AlertTitle>
<AlertDescription>{t("environments.settings.billing.custom_plan_description")}</AlertDescription>
</Alert>
)}
<SettingsCard
title={t("environments.settings.billing.subscription")}
description={t("environments.settings.billing.subscription_description")}
buttonInfo={
canManageSubscription && currentSubscriptionStatus !== "trialing"
canShowSubscriptionButton
? {
text: t("environments.settings.billing.manage_subscription"),
onClick: () => void openCustomerPortal(),
text: hasPaymentMethod
? t("environments.settings.billing.manage_billing_details")
: t("environments.settings.billing.add_payment_method"),
onClick: () => void (hasPaymentMethod ? openBillingPortal() : openTrialPaymentCheckout()),
variant: "default",
}
: undefined
@@ -320,8 +481,19 @@ export const PricingTable = ({
<p className="text-sm font-semibold text-slate-700">
{t("environments.settings.billing.your_plan")}
</p>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge type="success" size="normal" text={getCurrentCloudPlanLabel(currentCloudPlan, t)} />
{currentCloudPlan !== "hobby" && currentBillingInterval && (
<Badge
type="gray"
size="normal"
text={
currentBillingInterval === "monthly"
? t("environments.settings.billing.monthly")
: t("environments.settings.billing.yearly")
}
/>
)}
{currentSubscriptionStatus === "trialing" && (
<Badge
type="warning"
@@ -329,24 +501,9 @@ export const PricingTable = ({
text={t("environments.settings.billing.status_trialing")}
/>
)}
{cancellingOn && (
<Badge
type="warning"
size="normal"
text={`${t("environments.settings.billing.cancelling")}: ${cancellingOn.toLocaleDateString(
"en-US",
{
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}
)}`}
/>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<UsageCard
metric={t("common.responses")}
@@ -355,11 +512,11 @@ export const PricingTable = ({
isUnlimited={responsesUnlimitedCheck}
unlimitedLabel={t("environments.settings.billing.unlimited_responses")}
/>
<p className="text-sm text-slate-500">
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
</p>
</div>
<UsageCard
metric={t("common.workspaces")}
currentCount={projectCount}
@@ -370,35 +527,136 @@ export const PricingTable = ({
</div>
</SettingsCard>
{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">
<h3 className="text-lg font-semibold text-white">
{t("environments.settings.billing.scale_banner_title")}
</h3>
<p className="text-sm text-slate-300">
{t("environments.settings.billing.scale_banner_description")}
</p>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-400">
<span>&#10003; {t("environments.settings.billing.scale_feature_teams")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_api")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_quota")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_spam")}</span>
</div>
{showPlanSelector && (
<SettingsCard
title={t("environments.settings.billing.plan_selection_title")}
description={t("environments.settings.billing.plan_selection_description")}>
<div className="flex flex-col gap-6">
<div
className="flex w-fit rounded-xl border border-slate-200 bg-slate-100 p-1"
role="tablist"
aria-label={t("environments.settings.billing.billing_interval_toggle")}>
{(["monthly", "yearly"] as const).map((interval) => (
<button
key={interval}
type="button"
role="tab"
aria-selected={selectedInterval === interval}
tabIndex={selectedInterval === interval ? 0 : -1}
onClick={() => setSelectedInterval(interval)}
className={cn(
"rounded-lg px-5 py-2 text-sm font-medium transition-colors",
selectedInterval === interval
? "bg-slate-900 text-white"
: "text-slate-600 hover:text-slate-900"
)}>
{interval === "monthly"
? t("environments.settings.billing.monthly")
: t("environments.settings.billing.yearly")}
</button>
))}
</div>
<Button variant="secondary" size="sm" onClick={openCustomerPortal} className="shrink-0">
{t("environments.settings.billing.upgrade")}
</Button>
</div>
</div>
)}
{showPricingTable && (
<div className="mb-12 w-full max-w-4xl">
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
{createElement("stripe-pricing-table", stripePricingTableProps)}
</div>
<div className="grid gap-4 lg:grid-cols-3">
{planCards.map((planCard) => {
const isCurrentSelection =
currentCloudPlan === planCard.plan &&
(planCard.plan === "hobby" || currentBillingInterval === planCard.interval);
const isPendingSelection =
pendingChange?.targetPlan === planCard.plan &&
(planCard.plan === "hobby" || pendingChange.targetInterval === planCard.interval);
const isMissingPaymentMethodUpgrade =
hasBillingRights &&
!isStripeSetupIncomplete &&
!isTrialing &&
!isCurrentSelection &&
!isPendingSelection &&
!hasPaymentMethod &&
planCard.plan !== "hobby";
const isDisabled =
!hasBillingRights ||
isCurrentSelection ||
isPendingSelection ||
isStripeSetupIncomplete ||
isMissingPaymentMethodUpgrade ||
(isTrialing && !hasPaymentMethod);
return (
<div
key={`${planCard.plan}-${planCard.interval}`}
className={cn(
"grid h-full grid-rows-[minmax(1.75rem,auto)_minmax(8rem,auto)_minmax(4.5rem,auto)_auto_1fr] rounded-2xl border bg-white p-6 shadow-sm",
planCard.plan === "pro" ? "border-slate-900/20" : "border-slate-200"
)}>
<div className="mb-4 flex min-h-7 items-start gap-2">
{planCard.plan === "pro" && (
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">
{t("environments.settings.billing.most_popular")}
</span>
)}
{isCurrentSelection && (
<span className="rounded-md bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">
{t("environments.settings.billing.current_plan_badge")}
</span>
)}
{isPendingSelection && (
<span className="rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
{t("environments.settings.billing.pending_plan_badge")}
</span>
)}
</div>
<div className="min-h-32">
<h3 className="text-3xl font-semibold text-slate-900">
{getCurrentCloudPlanLabel(planCard.plan, t)}
</h3>
<p className="mt-3 text-sm leading-6 text-slate-500">{planCard.description}</p>
</div>
<div className="mt-4 flex min-h-[3rem] items-end gap-2">
<span className="text-3xl font-normal tracking-tight text-slate-900">
{planCard.amount}
</span>
<span className="pb-1 text-sm text-slate-500">
{getPlanPeriodLabel(planCard.plan, planCard.interval, t)}
</span>
</div>
<TooltipRenderer
shouldRender={isMissingPaymentMethodUpgrade}
triggerClass="block w-full"
tooltipContent={t(
"environments.settings.billing.add_payment_method_to_upgrade_tooltip"
)}>
<Button
variant="secondary"
className="mt-4 w-full"
disabled={isDisabled}
loading={isPlanActionPending === `${planCard.plan}-${planCard.interval}`}
onClick={() => void handlePlanAction(planCard.plan, planCard.interval)}>
{getCtaLabel(planCard.plan, planCard.interval)}
</Button>
</TooltipRenderer>
<div className="mt-8 border-t border-slate-100 pt-6">
<p className="mb-4 text-sm font-semibold text-slate-900">
{t("environments.settings.billing.this_includes")}
</p>
<ul className="space-y-3">
{planCard.features.map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-slate-700">
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</div>
);
})}
</div>
</div>
</SettingsCard>
)}
</div>
</main>
@@ -12,6 +12,7 @@ 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 {
@@ -31,6 +32,7 @@ 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 = [
@@ -64,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 (
@@ -98,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>
@@ -124,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>
);
@@ -36,7 +36,7 @@ export const TrialAlert = ({
const variant = hasPaymentMethod ? "success" : getTrialVariant(trialDaysRemaining);
return (
<Alert variant={variant} size={size}>
<Alert variant={variant} size={size} className="max-w-4xl">
<AlertTitle>{title}</AlertTitle>
{children}
</Alert>
@@ -29,7 +29,9 @@ describe("cloud-billing-display", () => {
expect(result).toEqual({
organizationId: "org_1",
currentCloudPlan: "pro",
currentBillingInterval: null,
currentSubscriptionStatus: null,
pendingChange: null,
trialDaysRemaining: null,
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
@@ -1,6 +1,10 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import {
type TCloudBillingInterval,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
@@ -9,7 +13,9 @@ export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "u
export type TCloudBillingDisplayContext = {
organizationId: string;
currentCloudPlan: TCloudBillingDisplayPlan;
currentBillingInterval: TCloudBillingInterval | null;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
pendingChange: TOrganizationStripePendingChange | null;
trialDaysRemaining: number | null;
usageCycleStart: Date;
usageCycleEnd: Date;
@@ -28,6 +34,18 @@ const resolveCurrentSubscriptionStatus = (
return billing.stripe?.subscriptionStatus ?? null;
};
const resolveCurrentBillingInterval = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): TCloudBillingInterval | null => {
return billing.stripe?.interval ?? null;
};
const resolvePendingChange = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): TOrganizationStripePendingChange | null => {
return billing.stripe?.pendingChange ?? null;
};
const MS_PER_DAY = 86_400_000;
const resolveTrialDaysRemaining = (
@@ -58,7 +76,9 @@ export const getCloudBillingDisplayContext = async (
return {
organizationId,
currentCloudPlan: resolveCurrentCloudPlan(billing),
currentBillingInterval: resolveCurrentBillingInterval(billing),
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
pendingChange: resolvePendingChange(billing),
trialDaysRemaining: resolveTrialDaysRemaining(billing),
usageCycleStart: usageCycleWindow.start,
usageCycleEnd: usageCycleWindow.end,
File diff suppressed because it is too large Load Diff
@@ -4,13 +4,24 @@ import Stripe from "stripe";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
type TCloudBillingInterval,
type TCloudBillingPlan,
type TOrganizationBilling,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import {
type TStandardCloudPlan,
getCatalogItemForPlan,
getCatalogItemsForPlan,
getIntervalFromPrice,
getPlanFromPrice,
getPriceKindFromPrice,
} from "./stripe-billing-catalog";
import { stripeClient } from "./stripe-client";
import { CLOUD_PLAN_LEVEL, type TCloudStripePlan, getCloudPlanFromProduct } from "./stripe-plan";
@@ -67,6 +78,31 @@ const getDateFromBilling = (value: string | null | undefined): Date | null => {
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const updatePendingPlanChangeSnapshot = async (
organizationId: string,
pendingChange: TOrganizationStripePendingChange | null
): Promise<void> => {
const existingBilling = await ensureOrganizationBillingRecord(organizationId);
if (!existingBilling) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const nextStripeSnapshot = existingBilling.stripe ? { ...existingBilling.stripe } : {};
await prisma.organizationBilling.update({
where: { organizationId },
data: {
stripe: {
...nextStripeSnapshot,
pendingChange,
lastSyncedAt: new Date().toISOString(),
},
},
});
await invalidateOrganizationBillingCache(organizationId);
};
const listAllActiveEntitlements = async (customerId: string): Promise<string[]> => {
if (!stripeClient) return [];
@@ -171,6 +207,149 @@ const hydrateSubscriptionProducts = async <
}));
};
const hydratePrices = async <
TPriceContainer extends {
price: string | Stripe.Price | Stripe.DeletedPrice;
},
>(
items: TPriceContainer[]
): Promise<Array<TPriceContainer & { price: Stripe.Price }>> => {
if (!stripeClient || items.length === 0) {
return items.filter(
(item): item is TPriceContainer & { price: Stripe.Price } =>
typeof item.price !== "string" && !item.price.deleted
);
}
const client = stripeClient;
const missingPriceIds = [
...new Set(items.flatMap((item) => (typeof item.price === "string" ? [item.price] : []))),
];
const retrievedPrices = await Promise.all(
missingPriceIds.map(
async (priceId) =>
[
priceId,
await client.prices.retrieve(priceId, {
expand: ["product"],
}),
] as const
)
);
const pricesById = new Map(retrievedPrices);
return items.flatMap((item) => {
if (typeof item.price !== "string") {
if (item.price.deleted) {
return [];
}
return [{ ...item, price: item.price }];
}
const price = pricesById.get(item.price);
if (!price) {
return [];
}
return [{ ...item, price }];
});
};
const getBasePriceFromSubscription = (
subscription: {
items: {
data: Array<{
id?: string;
price: Stripe.Price;
}>;
};
} | null
): Stripe.Price | null => {
if (!subscription) {
return null;
}
return (
subscription.items.data.find((item) => {
const plan = getPlanFromPrice(item.price);
const kind = getPriceKindFromPrice(item.price);
return plan !== null && kind === "base";
})?.price ?? null
);
};
const resolveSubscriptionInterval = (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
): TCloudBillingInterval | null => {
return getIntervalFromPrice(getBasePriceFromSubscription(subscription));
};
const mapSubscriptionItemsToScheduleItems = (
items: Array<{
price: Stripe.Price;
quantity?: number | null;
}>
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.Item> => {
return items.map((item) => {
const scheduleItem: Stripe.SubscriptionScheduleUpdateParams.Phase.Item = {
price: item.price.id,
};
if (item.price.recurring?.usage_type !== "metered") {
scheduleItem.quantity = item.quantity ?? 1;
}
return scheduleItem;
});
};
const getPendingPlanChangeFromSchedule = async (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
): Promise<TOrganizationStripePendingChange | null> => {
if (!stripeClient || !subscription?.schedule) {
return null;
}
const scheduleId =
typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule.id;
const schedule = await stripeClient.subscriptionSchedules.retrieve(scheduleId);
const currentPhaseEnd = schedule.current_phase?.end_date ?? null;
if (!currentPhaseEnd) {
return null;
}
const nextPhase = schedule.phases.find((phase) => phase.start_date >= currentPhaseEnd);
if (!nextPhase) {
return null;
}
const phaseItems = await hydratePrices(
nextPhase.items.map((item) => ({
price: item.price,
quantity: item.quantity,
}))
);
const basePrice = phaseItems.find((item) => getPriceKindFromPrice(item.price) === "base")?.price ?? null;
const targetPlan = getPlanFromPrice(basePrice);
if (!targetPlan) {
return null;
}
return {
type: "plan_change",
targetPlan,
targetInterval: getIntervalFromPrice(basePrice),
effectiveAt: new Date(nextPhase.start_date * 1000).toISOString(),
};
};
const getSubscriptionTopPlanLevel = (
subscription: {
items: {
@@ -201,6 +380,7 @@ const resolveCurrentSubscription = async (customerId: string) => {
customer: customerId,
status: "all",
limit: 20,
expand: ["data.schedule"],
});
const subscriptionsWithProducts = await hydrateSubscriptionProducts(subscriptions.data);
@@ -251,48 +431,42 @@ const resolveUsageCycleAnchor = (
return new Date(subscription.billing_cycle_anchor * 1000);
};
const resolvePendingChangeEffectiveAt = (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
): string | null => {
if (!subscription) {
return null;
}
if (subscription.cancel_at) {
return new Date(subscription.cancel_at * 1000).toISOString();
}
const currentPeriodEnd = subscription.items.data.reduce<number | null>((latestEnd, item) => {
const itemPeriodEnd = item.current_period_end ?? null;
if (itemPeriodEnd == null) {
return latestEnd;
}
return latestEnd == null ? itemPeriodEnd : Math.max(latestEnd, itemPeriodEnd);
}, null);
return currentPeriodEnd ? new Date(currentPeriodEnd * 1000).toISOString() : null;
};
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const products = await stripeClient.products.list({
active: true,
limit: 100,
});
const hobbyProduct = products.data.find((product) => product.metadata.formbricks_plan === "hobby");
if (!hobbyProduct) {
throw new Error("Stripe product metadata formbricks_plan=hobby not found");
}
const defaultPrice =
typeof hobbyProduct.default_price === "string" ? null : (hobbyProduct.default_price ?? null);
const fallbackPrices = await stripeClient.prices.list({
product: hobbyProduct.id,
active: true,
limit: 100,
});
const hobbyPrice =
defaultPrice ??
fallbackPrices.data.find(
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
) ??
fallbackPrices.data[0] ??
null;
if (!hobbyPrice) {
throw new Error(`No active price found for Stripe hobby product ${hobbyProduct.id}`);
}
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
await stripeClient.subscriptions.create(
{
customer: customerId,
items: [{ price: hobbyPrice.id, quantity: 1 }],
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
@@ -339,50 +513,24 @@ export const createProTrialSubscription = async (
customerId: string
): Promise<void> => {
if (!stripeClient) return;
const products = await stripeClient.products.list({
active: true,
limit: 100,
});
const proProduct = products.data.find((product) => product.metadata.formbricks_plan === "pro");
if (!proProduct) {
throw new Error("Stripe product metadata formbricks_plan=pro not found");
}
const proCatalogItem = await getCatalogItemForPlan("pro", "monthly");
const proProductId =
typeof proCatalogItem.basePrice.product === "string"
? proCatalogItem.basePrice.product
: proCatalogItem.basePrice.product.id;
const customer = await stripeClient.customers.retrieve(customerId);
if (!customer.deleted && customer.email) {
const alreadyUsed = await hasEmailUsedProTrial(customer.email, proProduct.id);
const alreadyUsed = await hasEmailUsedProTrial(customer.email, proProductId);
if (alreadyUsed) {
throw new OperationNotAllowedError("trial_already_used");
}
}
const defaultPrice =
typeof proProduct.default_price === "string" ? null : (proProduct.default_price ?? null);
const fallbackPrices = await stripeClient.prices.list({
product: proProduct.id,
active: true,
limit: 100,
});
const proPrice =
defaultPrice ??
fallbackPrices.data.find(
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
) ??
fallbackPrices.data[0] ??
null;
if (!proPrice) {
throw new Error(`No active price found for Stripe pro product ${proProduct.id}`);
}
await stripeClient.subscriptions.create(
{
customer: customerId,
items: [{ price: proPrice.id, quantity: 1 }],
items: await getCatalogItemsForPlan("pro", "monthly"),
trial_period_days: 14,
trial_settings: {
end_behavior: {
@@ -398,6 +546,380 @@ export const createProTrialSubscription = async (
);
};
export const createPaidPlanCheckoutSession = async (input: {
organizationId: string;
customerId: string;
environmentId: string;
plan: Exclude<TStandardCloudPlan, "hobby">;
interval: TCloudBillingInterval;
}): Promise<string> => {
if (!stripeClient) {
throw new Error("Stripe is not configured");
}
const catalogItem = await getCatalogItemForPlan(input.plan, input.interval);
const checkoutIntervals = new Set<Stripe.Price.Recurring.Interval>(
[catalogItem.basePrice.recurring?.interval, catalogItem.responsePrice?.recurring?.interval].filter(
(interval): interval is Stripe.Price.Recurring.Interval => interval != null
)
);
if (checkoutIntervals.size > 1) {
throw new OperationNotAllowedError("mixed_interval_checkout_unsupported");
}
const items = await getCatalogItemsForPlan(input.plan, input.interval);
const session = await stripeClient.checkout.sessions.create({
mode: "subscription",
customer: input.customerId,
line_items: items,
client_reference_id: input.organizationId,
billing_address_collection: "required",
tax_id_collection: {
enabled: true,
required: "if_supported",
},
customer_update: {
address: "auto",
name: "auto",
},
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${input.environmentId}&checkout_success=1`,
cancel_url: `${WEBAPP_URL}/environments/${input.environmentId}/settings/billing`,
metadata: {
organizationId: input.organizationId,
targetPlan: input.plan,
targetInterval: input.interval,
},
subscription_data: {
metadata: {
organizationId: input.organizationId,
},
},
});
if (!session.url) {
throw new Error("Stripe did not return a Checkout Session URL");
}
return session.url;
};
const getRequiredActiveSubscription = async (
organizationId: string,
customerId: string
): Promise<NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>> => {
const subscription = await resolveCurrentSubscription(customerId);
if (!subscription) {
throw new ResourceNotFoundError("subscription", organizationId);
}
return subscription;
};
const clearPendingPlanState = async (
organizationId: string,
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>
): Promise<void> => {
if (!stripeClient) {
return;
}
if (subscription.cancel_at_period_end) {
await stripeClient.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
});
}
if (subscription.schedule) {
const scheduleId =
typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule.id;
await stripeClient.subscriptionSchedules.release(scheduleId, {
preserve_cancel_date: false,
});
}
await updatePendingPlanChangeSnapshot(organizationId, null);
};
const updateSubscriptionItemsImmediately = async (
organizationId: string,
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>,
targetPlan: TStandardCloudPlan,
targetInterval: TCloudBillingInterval
): Promise<void> => {
if (!stripeClient) {
return;
}
const targetItems = await getCatalogItemsForPlan(targetPlan, targetInterval);
const existingDeletions = subscription.items.data.map((item) => ({
id: item.id,
deleted: true as const,
}));
await stripeClient.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
items: [...existingDeletions, ...targetItems],
proration_behavior: "always_invoice",
// We don't grant the upgraded plan until Stripe can actually collect the prorated invoice.
payment_behavior: "error_if_incomplete",
...(subscription.trial_end ? { trial_end: subscription.trial_end } : {}),
metadata: {
organizationId,
},
});
};
const getScheduleItemsForPlanChange = async (
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>,
targetPlan: TStandardCloudPlan,
targetInterval: TCloudBillingInterval
) => {
const currentItems = mapSubscriptionItemsToScheduleItems(subscription.items.data);
const targetCatalogItem = await getCatalogItemForPlan(targetPlan, targetInterval);
const targetItems = mapSubscriptionItemsToScheduleItems([
{ price: targetCatalogItem.basePrice, quantity: 1 },
...(targetCatalogItem.responsePrice ? [{ price: targetCatalogItem.responsePrice }] : []),
]);
return { currentItems, targetItems };
};
const getOrCreatePlanChangeSchedule = async (
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>
) => {
if (!stripeClient) {
throw new Error("Stripe is not configured");
}
const existingScheduleId =
typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
if (existingScheduleId) {
return {
schedule: await stripeClient.subscriptionSchedules.retrieve(existingScheduleId),
createdSchedule: false,
};
}
return {
schedule: await stripeClient.subscriptionSchedules.create({
// Stripe rejects metadata when cloning from an existing subscription.
from_subscription: subscription.id,
}),
createdSchedule: true,
};
};
const getCurrentSchedulePhase = (schedule: Stripe.SubscriptionSchedule) => {
const currentPhase = schedule.current_phase;
if (!currentPhase) {
throw new Error(`Stripe subscription schedule ${schedule.id} has no current phase`);
}
if (!currentPhase.end_date) {
throw new Error(
`Stripe subscription schedule ${schedule.id} current phase has no end date; cannot schedule a plan change`
);
}
return currentPhase;
};
const buildPlanChangePhases = (input: {
currentPhase: { start_date: number; end_date: number };
currentItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[];
targetItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[];
organizationId: string;
targetPlan: TStandardCloudPlan;
targetInterval: TCloudBillingInterval;
}) => {
const { currentPhase, currentItems, targetItems, organizationId, targetPlan, targetInterval } = input;
return [
{
start_date: currentPhase.start_date,
end_date: currentPhase.end_date,
items: currentItems,
},
{
start_date: currentPhase.end_date,
items: targetItems,
metadata: {
organizationId,
targetPlan,
targetInterval,
},
},
];
};
const rollbackFailedPlanChangeScheduleUpdate = async (input: {
organizationId: string;
subscriptionId: string;
scheduleId: string;
createdSchedule: boolean;
hadCancelAtPeriodEnd: boolean;
}) => {
const { organizationId, subscriptionId, scheduleId, createdSchedule, hadCancelAtPeriodEnd } = input;
if (!stripeClient) {
return;
}
if (createdSchedule) {
try {
await stripeClient.subscriptionSchedules.release(scheduleId, {
preserve_cancel_date: false,
});
} catch (releaseError) {
logger.error(
{ error: releaseError, organizationId, scheduleId },
"Failed to release newly created Stripe schedule after plan change update error"
);
}
}
if (!hadCancelAtPeriodEnd) {
return;
}
try {
await stripeClient.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
} catch (restoreError) {
logger.error(
{ error: restoreError, organizationId, subscriptionId },
"Failed to restore Stripe cancel_at_period_end after plan change scheduling error"
);
}
};
const scheduleSubscriptionPlanChange = async (
organizationId: string,
subscription: NonNullable<Awaited<ReturnType<typeof resolveCurrentSubscription>>>,
targetPlan: TStandardCloudPlan,
targetInterval: TCloudBillingInterval
): Promise<TOrganizationStripePendingChange> => {
if (!stripeClient) {
throw new Error("Stripe is not configured");
}
const hadCancelAtPeriodEnd = subscription.cancel_at_period_end;
if (hadCancelAtPeriodEnd) {
await stripeClient.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
});
}
const { currentItems, targetItems } = await getScheduleItemsForPlanChange(
subscription,
targetPlan,
targetInterval
);
const { schedule, createdSchedule } = await getOrCreatePlanChangeSchedule(subscription);
const currentPhase = getCurrentSchedulePhase(schedule);
let updatedSchedule: Stripe.SubscriptionSchedule;
try {
updatedSchedule = await stripeClient.subscriptionSchedules.update(schedule.id, {
end_behavior: "release",
metadata: {
organizationId,
},
proration_behavior: "none",
phases: buildPlanChangePhases({
currentPhase,
currentItems,
targetItems,
organizationId,
targetPlan,
targetInterval,
}),
});
} catch (error) {
await rollbackFailedPlanChangeScheduleUpdate({
organizationId,
subscriptionId: subscription.id,
scheduleId: schedule.id,
createdSchedule,
hadCancelAtPeriodEnd,
});
throw error;
}
const nextPhase = updatedSchedule.phases.find((phase) => phase.start_date >= currentPhase.end_date);
if (!nextPhase) {
throw new Error(`Stripe subscription schedule ${updatedSchedule.id} has no next phase`);
}
const pendingChange: TOrganizationStripePendingChange = {
type: "plan_change",
targetPlan,
targetInterval: targetPlan === "hobby" ? "monthly" : targetInterval,
effectiveAt: new Date(nextPhase.start_date * 1000).toISOString(),
};
await updatePendingPlanChangeSnapshot(organizationId, pendingChange);
return pendingChange;
};
export const switchOrganizationToCloudPlan = async (input: {
organizationId: string;
customerId: string;
targetPlan: TStandardCloudPlan;
targetInterval: TCloudBillingInterval;
}): Promise<{ mode: "immediate" | "scheduled"; pendingChange: TOrganizationStripePendingChange | null }> => {
const subscription = await getRequiredActiveSubscription(input.organizationId, input.customerId);
const currentPlan = resolveCloudPlanFromSubscription(subscription);
const currentInterval = resolveSubscriptionInterval(subscription);
const isImmediateUpgrade = CLOUD_PLAN_LEVEL[input.targetPlan] > CLOUD_PLAN_LEVEL[currentPlan];
const isSameSelection = currentPlan === input.targetPlan && currentInterval === input.targetInterval;
if (isSameSelection) {
return { mode: "immediate", pendingChange: null };
}
if (isImmediateUpgrade) {
await updateSubscriptionItemsImmediately(
input.organizationId,
subscription,
input.targetPlan,
input.targetInterval
);
if (subscription.schedule) {
await clearPendingPlanState(input.organizationId, subscription);
}
return { mode: "immediate", pendingChange: null };
}
const pendingChange = await scheduleSubscriptionPlanChange(
input.organizationId,
subscription,
input.targetPlan,
input.targetInterval
);
return { mode: "scheduled", pendingChange };
};
export const undoPendingOrganizationPlanChange = async (
organizationId: string,
customerId: string
): Promise<void> => {
const subscription = await getRequiredActiveSubscription(organizationId, customerId);
await clearPendingPlanState(organizationId, subscription);
};
const ensureOrganizationBillingRecord = async (
organizationId: string
): Promise<TOrganizationBilling | null> => {
@@ -450,25 +972,6 @@ const getOrganizationOwner = async (
return { email: membership.user.email, name: membership.user.name };
};
/**
* Searches Stripe for an existing non-deleted customer with the given email.
* Returns the first match, or null if none found.
*/
const findStripeCustomerByEmail = async (email: string): Promise<Stripe.Customer | null> => {
if (!stripeClient) return null;
const customers = await stripeClient.customers.list({
email,
limit: 1,
});
const customer = customers.data[0];
if (customer && !customer.deleted) {
return customer;
}
return null;
};
export const ensureStripeCustomerForOrganization = async (
organizationId: string
): Promise<{ customerId: string | null }> => {
@@ -485,9 +988,6 @@ export const ensureStripeCustomerForOrganization = async (
return { customerId: null };
}
// Look up the org owner's email/name and check if a Stripe customer already exists for it.
// This reuses the old customer (and its trial history) when a user deletes their account
// and signs up again with the same email.
const owner = await getOrganizationOwner(organization.id);
if (!owner) {
logger.error({ organizationId }, "Cannot set up Stripe customer: organization has no owner");
@@ -495,37 +995,14 @@ export const ensureStripeCustomerForOrganization = async (
}
const { email: ownerEmail, name: ownerName } = owner;
let existingCustomer: Stripe.Customer | null = null;
if (ownerEmail) {
const foundCustomer = await findStripeCustomerByEmail(ownerEmail);
if (foundCustomer) {
// Only reuse if this customer is not already linked to another org's billing record
const existingBillingOwner = await findOrganizationIdByStripeCustomerId(foundCustomer.id);
if (!existingBillingOwner || existingBillingOwner === organizationId) {
existingCustomer = foundCustomer;
await stripeClient.customers.update(existingCustomer.id, {
name: ownerName ?? undefined,
metadata: { organizationId: organization.id, organizationName: organization.name },
});
logger.info(
{ organizationId, customerId: existingCustomer.id, email: ownerEmail },
"Reusing existing Stripe customer for new organization"
);
}
}
}
const customer =
existingCustomer ??
(await stripeClient.customers.create(
{
name: ownerName ?? undefined,
email: ownerEmail,
metadata: { organizationId: organization.id, organizationName: organization.name },
},
{ idempotencyKey: `ensure-customer-${organization.id}` }
));
const customer = await stripeClient.customers.create(
{
name: ownerName ?? undefined,
email: ownerEmail,
metadata: { organizationId: organization.id, organizationName: organization.name },
},
{ idempotencyKey: `ensure-customer-${organization.id}` }
);
const defaultBilling = getDefaultOrganizationBilling();
@@ -538,11 +1015,11 @@ export const ensureStripeCustomerForOrganization = async (
stripeCustomerId: customer.id,
limits: defaultBilling.limits,
usageCycleAnchor: defaultBilling.usageCycleAnchor,
stripe: { lastSyncedAt: new Date().toISOString() },
stripe: { plan: "hobby", lastSyncedAt: new Date().toISOString() },
},
update: {
stripeCustomerId: customer.id,
stripe: { lastSyncedAt: new Date().toISOString() },
stripe: { plan: "hobby", lastSyncedAt: new Date().toISOString() },
},
});
@@ -550,43 +1027,31 @@ export const ensureStripeCustomerForOrganization = async (
return { customerId: customer.id };
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
const shouldSkipStripeSyncForEvent = (
existingStripeSnapshot: TOrganizationBilling["stripe"],
event?: { id: string; created: number }
): Promise<TOrganizationBilling | null> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
return null;
}
const billing = await ensureOrganizationBillingRecord(organizationId);
if (!billing) {
return null;
}
const customerId = billing.stripeCustomerId;
if (!customerId) return billing;
const existingStripeSnapshot = billing.stripe;
) => {
const previousEventDate = getDateFromBilling(existingStripeSnapshot?.lastStripeEventCreatedAt ?? null);
const incomingEventDate = event ? new Date(event.created * 1000) : null;
if (event?.id && existingStripeSnapshot?.lastSyncedEventId === event.id) {
return billing;
return { shouldSkip: true as const, previousEventDate, incomingEventDate };
}
if (incomingEventDate && previousEventDate && incomingEventDate < previousEventDate) {
return billing;
return { shouldSkip: true as const, previousEventDate, incomingEventDate };
}
const [subscription, featureLookupKeys] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
]);
return { shouldSkip: false as const, previousEventDate, incomingEventDate };
};
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
const subscriptionStatus = resolveSubscriptionStatus(subscription);
const usageCycleAnchor = resolveUsageCycleAnchor(subscription);
const previousLimits = billing.limits;
const resolveEntitlementDrivenLimits = (
organizationId: string,
customerId: string,
cloudPlan: TCloudBillingPlan,
featureLookupKeys: string[],
previousLimits: TOrganizationBilling["limits"]
) => {
const workspaceLimitFromEntitlements = parseEntitlementLimit(featureLookupKeys, "workspace-limit-");
const responsesIncludedFromEntitlements = parseEntitlementLimit(featureLookupKeys, "responses-included-");
@@ -614,22 +1079,90 @@ export const syncOrganizationBillingFromStripe = async (
);
}
return {
projects: projectsLimit,
monthly: {
responses: responsesIncludedLimit,
},
};
};
const resolvePendingPlanChange = async (subscription: Stripe.Subscription | null) => {
const pendingChangeEffectiveAt = resolvePendingChangeEffectiveAt(subscription);
const scheduledPlanChange = await getPendingPlanChangeFromSchedule(subscription);
if (scheduledPlanChange) {
return scheduledPlanChange;
}
if (subscription?.cancel_at_period_end && pendingChangeEffectiveAt) {
return {
type: "plan_change" as const,
targetPlan: "hobby" as const,
targetInterval: "monthly" as const,
effectiveAt: pendingChangeEffectiveAt,
};
}
return null;
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
event?: { id: string; created: number }
): Promise<TOrganizationBilling | null> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
return null;
}
const billing = await ensureOrganizationBillingRecord(organizationId);
if (!billing) {
return null;
}
const customerId = billing.stripeCustomerId;
if (!customerId) return billing;
const existingStripeSnapshot = billing.stripe;
const { shouldSkip, previousEventDate, incomingEventDate } = shouldSkipStripeSyncForEvent(
existingStripeSnapshot,
event
);
if (shouldSkip) {
return billing;
}
const [subscription, featureLookupKeys] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
]);
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
const billingInterval = resolveSubscriptionInterval(subscription);
const subscriptionStatus = resolveSubscriptionStatus(subscription);
const usageCycleAnchor = resolveUsageCycleAnchor(subscription);
const pendingChange = await resolvePendingPlanChange(subscription);
const limits = resolveEntitlementDrivenLimits(
organizationId,
customerId,
cloudPlan,
featureLookupKeys,
billing.limits
);
const updatedBilling: TOrganizationBilling = {
stripeCustomerId: customerId,
limits: {
projects: projectsLimit,
monthly: {
responses: responsesIncludedLimit,
},
},
limits,
usageCycleAnchor,
stripe: {
...billing.stripe,
plan: cloudPlan,
interval: billingInterval,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
hasPaymentMethod: subscription?.default_payment_method != null,
features: featureLookupKeys,
pendingChange,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),
lastSyncedAt: new Date().toISOString(),
lastSyncedEventId: event?.id ?? existingStripeSnapshot?.lastSyncedEventId ?? null,
@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mocks = vi.hoisted(() => ({
pricesList: vi.fn(),
cacheWithCache: vi.fn(),
}));
vi.mock("./stripe-client", () => ({
stripeClient: {
prices: {
list: mocks.pricesList,
},
},
}));
const cacheStore = vi.hoisted(() => new Map<string, unknown>());
vi.mock("@/lib/cache", () => ({
cache: {
withCache: mocks.cacheWithCache,
},
}));
const createPrice = ({
id,
plan,
kind,
interval,
}: {
id: string;
plan: "hobby" | "pro" | "scale";
kind: "base" | "responses";
interval: "monthly" | "yearly";
}) => ({
id,
active: true,
currency: "usd",
unit_amount: kind === "responses" ? 0 : interval === "monthly" ? 1000 : 10000,
metadata: {
formbricks_plan: plan,
formbricks_price_kind: kind,
formbricks_interval: interval,
},
recurring: {
usage_type: kind === "base" ? "licensed" : "metered",
interval: interval === "monthly" ? "month" : "year",
},
product: {
id: `prod_${plan}`,
active: true,
metadata: {
formbricks_plan: plan,
},
},
});
describe("stripe-billing-catalog", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
cacheStore.clear();
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>, key: string) => {
if (cacheStore.has(key)) {
return cacheStore.get(key);
}
const value = await fn();
cacheStore.set(key, value);
return value;
});
});
test("resolves the metadata-backed billing catalog", async () => {
mocks.pricesList.mockResolvedValue({
data: [
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
],
has_more: false,
});
const { getCatalogItemsForPlan, getStripeBillingCatalogDisplay } =
await import("./stripe-billing-catalog");
await expect(getCatalogItemsForPlan("hobby", "monthly")).resolves.toEqual([
{ price: "price_hobby_monthly", quantity: 1 },
]);
await expect(getCatalogItemsForPlan("pro", "yearly")).resolves.toEqual([
{ price: "price_pro_yearly", quantity: 1 },
{ price: "price_pro_responses" },
]);
await expect(getStripeBillingCatalogDisplay()).resolves.toEqual({
hobby: {
monthly: {
plan: "hobby",
interval: "monthly",
currency: "usd",
unitAmount: 1000,
},
},
pro: {
monthly: {
plan: "pro",
interval: "monthly",
currency: "usd",
unitAmount: 1000,
},
yearly: {
plan: "pro",
interval: "yearly",
currency: "usd",
unitAmount: 10000,
},
},
scale: {
monthly: {
plan: "scale",
interval: "monthly",
currency: "usd",
unitAmount: 1000,
},
yearly: {
plan: "scale",
interval: "yearly",
currency: "usd",
unitAmount: 10000,
},
},
});
});
test("fails fast when the catalog is incomplete", async () => {
mocks.pricesList.mockResolvedValue({
data: [createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" })],
has_more: false,
});
const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog");
await expect(getCatalogItemsForPlan("pro", "monthly")).rejects.toThrow(
"Expected exactly one Stripe price for pro/base/monthly, but found 0"
);
});
test("reuses the shared cached catalog across module reloads", async () => {
mocks.pricesList.mockResolvedValue({
data: [
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
],
has_more: false,
});
const firstModule = await import("./stripe-billing-catalog");
await firstModule.getStripeBillingCatalogDisplay();
vi.resetModules();
const secondModule = await import("./stripe-billing-catalog");
await secondModule.getStripeBillingCatalogDisplay();
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
expect(mocks.cacheWithCache).toHaveBeenCalledTimes(2);
});
test("falls back to direct Stripe fetch when shared cache is unavailable", async () => {
mocks.pricesList.mockResolvedValue({
data: [
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
],
has_more: false,
});
mocks.cacheWithCache.mockImplementationOnce(async (fn: () => Promise<unknown>) => await fn());
const { getStripeBillingCatalogDisplay } = await import("./stripe-billing-catalog");
await expect(getStripeBillingCatalogDisplay()).resolves.toMatchObject({
hobby: {
monthly: {
plan: "hobby",
},
},
});
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,337 @@
import "server-only";
import { cache as reactCache } from "react";
import Stripe from "stripe";
import { createCacheKey } from "@formbricks/cache";
import type { TCloudBillingInterval } from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { stripeClient } from "./stripe-client";
export type TStandardCloudPlan = "hobby" | "pro" | "scale";
type TStripePriceKind = "base" | "responses";
type TStripeCatalogPrice = Stripe.Price & {
product: Stripe.Product | Stripe.DeletedProduct;
};
export type TStripeBillingCatalogItem = {
plan: TStandardCloudPlan;
interval: TCloudBillingInterval;
basePrice: TStripeCatalogPrice;
responsePrice: TStripeCatalogPrice | null;
};
export type TStripeBillingCatalog = {
hobby: {
monthly: TStripeBillingCatalogItem;
};
pro: {
monthly: TStripeBillingCatalogItem;
yearly: TStripeBillingCatalogItem;
};
scale: {
monthly: TStripeBillingCatalogItem;
yearly: TStripeBillingCatalogItem;
};
};
export type TStripeBillingCatalogDisplayItem = {
plan: TStandardCloudPlan;
interval: TCloudBillingInterval;
currency: string;
unitAmount: number | null;
};
export type TStripeBillingCatalogDisplay = {
hobby: {
monthly: TStripeBillingCatalogDisplayItem;
};
pro: {
monthly: TStripeBillingCatalogDisplayItem;
yearly: TStripeBillingCatalogDisplayItem;
};
scale: {
monthly: TStripeBillingCatalogDisplayItem;
yearly: TStripeBillingCatalogDisplayItem;
};
};
const STANDARD_CLOUD_PLANS = new Set<TStandardCloudPlan>(["hobby", "pro", "scale"]);
const STRIPE_BILLING_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
const STRIPE_BILLING_CATALOG_CACHE_VERSION = "v1";
const getStripeBillingCatalogCacheKey = () =>
createCacheKey.custom(
"billing",
"stripe_catalog",
`${hashString(env.STRIPE_SECRET_KEY ?? "stripe-unconfigured")}-${STRIPE_BILLING_CATALOG_CACHE_VERSION}`
);
const getPriceProduct = (price: Stripe.Price): Stripe.Product | Stripe.DeletedProduct | null => {
if (typeof price.product === "string") {
return null;
}
return price.product;
};
const getPricePlan = (price: Stripe.Price): TStandardCloudPlan | null => {
const product = getPriceProduct(price);
const plan =
price.metadata?.formbricks_plan ??
(!product || product.deleted ? undefined : product.metadata?.formbricks_plan);
if (!plan || !STANDARD_CLOUD_PLANS.has(plan as TStandardCloudPlan)) {
return null;
}
return plan as TStandardCloudPlan;
};
const normalizeInterval = (interval: string | null | undefined): TCloudBillingInterval | null => {
if (interval === "month" || interval === "monthly") return "monthly";
if (interval === "year" || interval === "yearly") return "yearly";
return null;
};
const getPriceInterval = (price: Stripe.Price): TCloudBillingInterval | null => {
const metadataInterval = normalizeInterval(price.metadata?.formbricks_interval);
if (metadataInterval) {
return metadataInterval;
}
return normalizeInterval(price.recurring?.interval);
};
const getPriceKind = (price: Stripe.Price): TStripePriceKind | null => {
const metadataKind = price.metadata?.formbricks_price_kind;
if (metadataKind === "base" || metadataKind === "responses") {
return metadataKind;
}
if (price.recurring?.usage_type === "licensed") {
return "base";
}
if (price.recurring?.usage_type === "metered") {
return "responses";
}
return null;
};
const isCatalogCandidate = (price: Stripe.Price): price is TStripeCatalogPrice => {
if (!price.active || !price.recurring) {
return false;
}
const product = getPriceProduct(price);
if (!product || product.deleted || !product.active) {
return false;
}
return getPricePlan(price) !== null && getPriceKind(price) !== null && getPriceInterval(price) !== null;
};
const listAllActivePrices = async (): Promise<TStripeCatalogPrice[]> => {
if (!stripeClient) {
return [];
}
const prices: TStripeCatalogPrice[] = [];
let startingAfter: string | undefined;
do {
const result = await stripeClient.prices.list({
active: true,
limit: 100,
expand: ["data.product"],
...(startingAfter ? { starting_after: startingAfter } : {}),
});
for (const price of result.data) {
if (isCatalogCandidate(price)) {
prices.push(price);
}
}
const lastItem = result.data.at(-1);
startingAfter = result.has_more && lastItem ? lastItem.id : undefined;
} while (startingAfter);
return prices;
};
const getSinglePrice = (
prices: TStripeCatalogPrice[],
plan: TStandardCloudPlan,
kind: TStripePriceKind,
interval: TCloudBillingInterval
): TStripeCatalogPrice => {
const matches = prices.filter(
(price) =>
getPricePlan(price) === plan && getPriceKind(price) === kind && getPriceInterval(price) === interval
);
if (matches.length !== 1) {
throw new Error(
`Expected exactly one Stripe price for ${plan}/${kind}/${interval}, but found ${matches.length}`
);
}
return matches[0];
};
const fetchStripeBillingCatalog = async (): Promise<TStripeBillingCatalog> => {
if (!stripeClient) {
throw new Error("Stripe is not configured");
}
const prices = await listAllActivePrices();
if (prices.length === 0) {
throw new Error("No active Stripe billing catalog prices found");
}
return {
hobby: {
monthly: {
plan: "hobby",
interval: "monthly",
basePrice: getSinglePrice(prices, "hobby", "base", "monthly"),
responsePrice: null,
},
},
pro: {
monthly: {
plan: "pro",
interval: "monthly",
basePrice: getSinglePrice(prices, "pro", "base", "monthly"),
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
},
yearly: {
plan: "pro",
interval: "yearly",
basePrice: getSinglePrice(prices, "pro", "base", "yearly"),
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
},
},
scale: {
monthly: {
plan: "scale",
interval: "monthly",
basePrice: getSinglePrice(prices, "scale", "base", "monthly"),
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
},
yearly: {
plan: "scale",
interval: "yearly",
basePrice: getSinglePrice(prices, "scale", "base", "yearly"),
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
},
},
};
};
export const getStripeBillingCatalog = reactCache(async (): Promise<TStripeBillingCatalog> => {
return await cache.withCache(
fetchStripeBillingCatalog,
getStripeBillingCatalogCacheKey(),
STRIPE_BILLING_CATALOG_CACHE_TTL_MS
);
});
export const getStripeBillingCatalogDisplay = reactCache(async (): Promise<TStripeBillingCatalogDisplay> => {
const catalog = await getStripeBillingCatalog();
return {
hobby: {
monthly: {
plan: "hobby",
interval: "monthly",
currency: catalog.hobby.monthly.basePrice.currency,
unitAmount: catalog.hobby.monthly.basePrice.unit_amount,
},
},
pro: {
monthly: {
plan: "pro",
interval: "monthly",
currency: catalog.pro.monthly.basePrice.currency,
unitAmount: catalog.pro.monthly.basePrice.unit_amount,
},
yearly: {
plan: "pro",
interval: "yearly",
currency: catalog.pro.yearly.basePrice.currency,
unitAmount: catalog.pro.yearly.basePrice.unit_amount,
},
},
scale: {
monthly: {
plan: "scale",
interval: "monthly",
currency: catalog.scale.monthly.basePrice.currency,
unitAmount: catalog.scale.monthly.basePrice.unit_amount,
},
yearly: {
plan: "scale",
interval: "yearly",
currency: catalog.scale.yearly.basePrice.currency,
unitAmount: catalog.scale.yearly.basePrice.unit_amount,
},
},
};
});
export const getCatalogItemForPlan = async (
plan: TStandardCloudPlan,
interval: TCloudBillingInterval
): Promise<TStripeBillingCatalogItem> => {
const catalog = await getStripeBillingCatalog();
if (plan === "hobby") {
return catalog.hobby.monthly;
}
return catalog[plan][interval];
};
export const getCatalogItemsForPlan = async (
plan: TStandardCloudPlan,
interval: TCloudBillingInterval
): Promise<Array<{ price: string; quantity?: number }>> => {
const item = await getCatalogItemForPlan(plan, interval);
return [
{ price: item.basePrice.id, quantity: 1 },
...(item.responsePrice ? [{ price: item.responsePrice.id }] : []),
];
};
export const getIntervalFromPrice = (
price: Stripe.Price | null | undefined
): TCloudBillingInterval | null => {
if (!price) {
return null;
}
return getPriceInterval(price);
};
export const getPlanFromPrice = (price: Stripe.Price | null | undefined): TStandardCloudPlan | null => {
if (!price) {
return null;
}
return getPricePlan(price);
};
export const getPriceKindFromPrice = (price: Stripe.Price | null | undefined): TStripePriceKind | null => {
if (!price) {
return null;
}
return getPriceKind(price);
};
+8 -4
View File
@@ -1,11 +1,11 @@
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { env } from "@/lib/env";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getStripeBillingCatalogDisplay } from "@/modules/ee/billing/lib/stripe-billing-catalog";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -21,7 +21,10 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
notFound();
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([
getCloudBillingDisplayContext(organization.id),
getStripeBillingCatalogDisplay(),
]);
const organizationWithSyncedBilling = {
...organization,
@@ -53,13 +56,14 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
projectCount={projectCount}
hasBillingRights={hasBillingRights}
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
pendingChange={cloudBillingDisplayContext.pendingChange}
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
billingCatalog={billingCatalog}
/>
</PageContentWrapper>
);
@@ -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",
@@ -97,14 +97,13 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
);
const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId,
data: ZSegmentUpdateInput,
});
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -75,7 +75,6 @@ export function SegmentSettings({
try {
setIsUpdatingSegment(true);
const data = await updateSegmentAction({
environmentId,
segmentId: segment.id,
data: {
title: segment.title,
@@ -124,7 +124,7 @@ export function TargetingCard({
};
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
const updatedSegment = await updateSegmentAction({ segmentId, data });
return updatedSegment?.data as TSegment;
};
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
const result = await updateSegmentAction({ segmentId: segment.id, data });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
+4 -5
View File
@@ -21,7 +21,6 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZDeleteQuotaAction = z.object({
quotaId: ZId,
surveyId: ZId,
});
const checkQuotasEnabled = async (organizationId: string) => {
@@ -37,7 +36,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -49,7 +48,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],
@@ -72,7 +71,7 @@ const ZUpdateQuotaAction = z.object({
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -84,7 +83,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],
@@ -85,7 +85,6 @@ export const QuotasCard = ({
setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId,
surveyId: localSurvey.id,
});
if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
@@ -174,9 +173,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",
@@ -10,6 +10,7 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
@@ -31,7 +32,6 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
const ZUpdateInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
data: ZInviteUpdateInput,
});
@@ -39,17 +39,16 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
data: parsedInput.data,
@@ -68,9 +67,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
throw new OperationNotAllowedError("Managers can only invite members");
}
await checkRoleManagementPermission(parsedInput.organizationId);
await checkRoleManagementPermission(organizationId);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
@@ -65,7 +65,7 @@ export function EditMembershipRole({
}
if (inviteId) {
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
await updateInviteAction({ inviteId: inviteId, data: { role } });
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
@@ -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",
@@ -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",
@@ -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`,
},
{
@@ -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",
@@ -27,14 +27,15 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -42,7 +43,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
return await deleteInvite(parsedInput.inviteId);
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) {
// This is an invite
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
const result = await deleteInviteAction({ inviteId: invite?.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
@@ -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>
@@ -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",
@@ -56,11 +56,26 @@ export const CustomScriptsInjector = ({
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content
// Copy inline script content with error handling
if (script.textContent) {
newScript.textContent = script.textContent;
// Wrap inline scripts in try-catch to prevent undefined variable errors
const wrappedContent = `
(function() {
try {
${script.textContent}
} catch (error) {
console.warn("[Formbricks] Custom script error:", error);
}
})();
`;
newScript.textContent = wrappedContent;
}
// Add error handler for external scripts
newScript.onerror = (error) => {
console.warn("[Formbricks] Custom script failed to load:", error);
};
document.head.appendChild(newScript);
});
+2
View File
@@ -94,7 +94,9 @@ describe("@formbricks/cache types/keys", () => {
test("should include expected namespaces", () => {
// Type test - this will fail at compile time if types don't match
const analyticsNamespace: CustomCacheNamespace = "analytics";
const billingNamespace: CustomCacheNamespace = "billing";
expect(analyticsNamespace).toBe("analytics");
expect(billingNamespace).toBe("billing");
});
test("should be usable in cache key construction", () => {
+1 -1
View File
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
* Possible namespaces for custom cache keys
* Add new namespaces here as they are introduced
*/
export type CustomCacheNamespace = "analytics";
export type CustomCacheNamespace = "analytics" | "billing";
+12
View File
@@ -28,6 +28,7 @@ export const ZOrganizationBilling = z.object({
stripe: z
.object({
plan: z.enum(["hobby", "pro", "scale", "custom", "unknown"]).optional(),
interval: z.enum(["monthly", "yearly"]).nullable().optional(),
subscriptionStatus: z
.enum([
"trialing",
@@ -42,10 +43,21 @@ export const ZOrganizationBilling = z.object({
.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(),
pendingChange: z
.object({
type: z.literal("plan_change"),
targetPlan: z.enum(["hobby", "pro", "scale"]),
targetInterval: z.enum(["monthly", "yearly"]).nullable(),
effectiveAt: z.string(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
@@ -43,6 +43,17 @@ vi.mock("@/lib/common/utils", () => ({
handleHiddenFields: vi.fn(),
}));
const mockUpdateQueue = {
hasPendingWork: vi.fn().mockReturnValue(false),
waitForPendingWork: vi.fn().mockResolvedValue(true),
};
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => mockUpdateQueue),
},
}));
describe("widget-file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
@@ -249,4 +260,265 @@ describe("widget-file", () => {
widget.removeWidgetContainer();
expect(document.getElementById("formbricks-container")).toBeFalsy();
});
test("renderWidget waits for pending identification before rendering", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
);
vi.useRealTimers();
});
test("renderWidget does not wait when no identification is pending", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
test("renderWidget reads contactId after identification wait completes", async () => {
let callCount = 0;
const mockConfigValue = {
get: vi.fn().mockImplementation(() => {
callCount++;
return {
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
// Simulate contactId becoming available after identification
userId: "user_abc",
contactId: callCount > 2 ? "contact_after_identification" : undefined,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
};
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
);
vi.useRealTimers();
});
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: null,
contactId: null,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: undefined,
} as unknown as TEnvironmentStateSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed but survey has no segment filters. Proceeding."
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
+19
View File
@@ -11,6 +11,7 @@ import {
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
@@ -60,6 +61,24 @@ export const renderWidget = async (
setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
}
@@ -169,4 +169,104 @@ describe("UpdateQueue", () => {
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
);
});
test("hasPendingWork returns false when no updates and no flush in flight", () => {
expect(updateQueue.hasPendingWork()).toBe(false);
});
test("hasPendingWork returns true when updates are queued", () => {
updateQueue.updateUserId(mockUserId1);
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// Start processing but don't await — the debounce means the flush is in-flight
void updateQueue.processUpdates();
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("waitForPendingWork returns true immediately when no pending work", async () => {
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
});
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
expect(updateQueue.hasPendingWork()).toBe(false);
expect(sendUpdates).toHaveBeenCalled();
});
test("waitForPendingWork returns false when processUpdates rejects", async () => {
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
updateQueue.updateUserId(mockUserId1);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
const processPromise = updateQueue.processUpdates().catch(() => {});
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(false);
await processPromise;
});
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
vi.useFakeTimers();
// sendUpdates returns a promise that never resolves, simulating a network hang
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const resultPromise = updateQueue.waitForPendingWork();
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
await vi.advanceTimersByTimeAsync(500);
// Advance past the pending work timeout (5000ms)
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toBe(false);
vi.useRealTimers();
});
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// First call creates the flush promise
const firstPromise = updateQueue.processUpdates();
// Second call while first is still pending should not create a new flush
updateQueue.updateAttributes({ name: mockAttributes.name });
const secondPromise = updateQueue.processUpdates();
// Both promises should resolve (second is not orphaned)
await Promise.all([firstPromise, secondPromise]);
expect(updateQueue.hasPendingWork()).toBe(false);
});
});
+36 -1
View File
@@ -8,7 +8,9 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {}
@@ -63,17 +65,45 @@ export class UpdateQueue {
return !this.updates;
}
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> {
const logger = Logger.getInstance();
if (!this.updates) {
return;
}
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
return new Promise((resolve, reject) => {
const flushPromise = new Promise<void>((resolve, reject) => {
const handler = async (): Promise<void> => {
try {
let currentUpdates = { ...this.updates };
@@ -147,8 +177,10 @@ export class UpdateQueue {
}
this.clearUpdates();
this.pendingFlush = null;
resolve();
} catch (error: unknown) {
this.pendingFlush = null;
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
@@ -158,5 +190,8 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
});
this.pendingFlush = flushPromise;
return flushPromise;
}
}
@@ -55,6 +55,8 @@ interface FileUploadProps {
imageAltText?: string;
/** Placeholder text for the file upload */
placeholderText?: string;
/** Text to display while uploading */
uploadingText?: string;
}
interface UploadedFileItemProps {
@@ -230,6 +232,7 @@ function FileUpload({
videoUrl,
imageAltText,
placeholderText = "Click or drag to upload files",
uploadingText = "Uploading...",
}: Readonly<FileUploadProps>): React.JSX.Element {
const fileInputRef = React.useRef<HTMLInputElement>(null);
@@ -306,7 +309,7 @@ function FileUpload({
<p
className="text-muted-foreground font-medium"
style={{ fontSize: "var(--fb-input-font-size)" }}>
Uploading...
{uploadingText}
</p>
</div>
) : null}
@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import * as React from "react";
import { cn, stripInlineStyles } from "@/lib/utils";
@@ -39,7 +39,7 @@ function Label({
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml =
isHtml && strippedContent
? DOMPurify.sanitize(strippedContent, {
? sanitize(strippedContent, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"],
})
+9 -7
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
@@ -27,14 +27,16 @@ export function cn(...inputs: ClassValue[]): string {
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return sanitize(preStripped, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};
+1
View File
@@ -4,6 +4,7 @@
"baseUrl": ".",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
+2
View File
@@ -41,7 +41,9 @@ checksums:
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
errors/file_input/placeholder_text: 15b61e390b6c5501d3e3b9da9f6c7930
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
errors/file_input/uploading: baef62e2015a34d6747ed6e4192a27b1
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
errors/invalid_device_error/title: ea7dbb9970c717e4d466f8e1211bd461
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "يجب أن يكون حجم الملف أقل من {maxSizeInMB} ميجابايت",
"no_valid_file_types_selected": "لم يتم اختيار أنواع ملفات صالحة. يرجى اختيار نوع ملف صالح.",
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
"placeholder_text": "انقر أو اسحب لرفع الملفات",
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
"uploading": "جارٍ الرفع...",
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Filen skal være mindre end {maxSizeInMB} MB",
"no_valid_file_types_selected": "Ingen gyldige filtyper valgt. Vælg venligst en gyldig filtype.",
"only_one_file_can_be_uploaded_at_a_time": "Du kan kun uploade én fil ad gangen.",
"placeholder_text": "Klik eller træk for at uploade filer",
"upload_failed": "Upload mislykkedes! Prøv venligst igen.",
"uploading": "Uploader...",
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
"placeholder_text": "Klicke oder ziehe Dateien hierher zum Hochladen",
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
"uploading": "Wird hochgeladen...",
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
"placeholder_text": "Click or drag to upload files",
"upload_failed": "Upload failed! Please try again.",
"uploading": "Uploading...",
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "El archivo debe ser menor de {maxSizeInMB} MB",
"no_valid_file_types_selected": "No se han seleccionado tipos de archivo válidos. Por favor, selecciona un tipo de archivo válido.",
"only_one_file_can_be_uploaded_at_a_time": "Solo se puede subir un archivo a la vez.",
"placeholder_text": "Haz clic o arrastra para subir archivos",
"upload_failed": "¡Subida fallida! Por favor, inténtalo de nuevo.",
"uploading": "Subiendo...",
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Le fichier doit être inférieur à {maxSizeInMB} Mo",
"no_valid_file_types_selected": "Aucun type de fichier valide sélectionné. Veuillez sélectionner un type de fichier valide.",
"only_one_file_can_be_uploaded_at_a_time": "Un seul fichier peut être téléchargé à la fois.",
"placeholder_text": "Cliquez ou glissez pour télécharger des fichiers",
"upload_failed": "Échec du téléchargement ! Veuillez réessayer.",
"uploading": "Téléchargement en cours...",
"you_can_only_upload_a_maximum_of_files": "Vous ne pouvez télécharger qu'un maximum de {FILE_LIMIT} fichiers."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "फ़ाइल {maxSizeInMB} MB से कम होनी चाहिए",
"no_valid_file_types_selected": "कोई मान्य फ़ाइल प्रकार नहीं चुना गया है। कृपया एक मान्य फ़ाइल प्रकार चुनें।",
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
"uploading": "अपलोड हो रहा है...",
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.",
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
"placeholder_text": "Kattints vagy húzd ide a fájlokat a feltöltéshez",
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
"uploading": "Feltöltés...",
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Il file deve essere inferiore a {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nessun tipo di file valido selezionato. Seleziona un tipo di file valido.",
"only_one_file_can_be_uploaded_at_a_time": "È possibile caricare solo un file alla volta.",
"placeholder_text": "Clicca o trascina per caricare i file",
"upload_failed": "Caricamento fallito! Riprova.",
"uploading": "Caricamento in corso...",
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "ファイルは{maxSizeInMB}MB未満である必要があります",
"no_valid_file_types_selected": "有効なファイルタイプが選択されていません。有効なファイルタイプを選択してください。",
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
"uploading": "アップロード中...",
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Het bestand moet kleiner zijn dan {maxSizeInMB} MB",
"no_valid_file_types_selected": "Geen geldige bestandstypen geselecteerd. Selecteer een geldig bestandstype.",
"only_one_file_can_be_uploaded_at_a_time": "Er kan slechts één bestand tegelijk worden geüpload.",
"placeholder_text": "Klik of sleep bestanden om te uploaden",
"upload_failed": "Uploaden mislukt! Probeer het opnieuw.",
"uploading": "Uploaden...",
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "O arquivo deve ter menos de {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nenhum tipo de arquivo válido selecionado. Por favor, selecione um tipo de arquivo válido.",
"only_one_file_can_be_uploaded_at_a_time": "Apenas um arquivo pode ser carregado de cada vez.",
"placeholder_text": "Clique ou arraste para enviar ficheiros",
"upload_failed": "Falha no carregamento! Por favor, tente novamente.",
"uploading": "A enviar...",
"you_can_only_upload_a_maximum_of_files": "Você só pode carregar um máximo de {FILE_LIMIT} arquivos."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Fișierul trebuie să fie mai mic de {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nu au fost selectate tipuri de fișiere valide. Te rugăm să selectezi un tip de fișier valid.",
"only_one_file_can_be_uploaded_at_a_time": "Poți încărca doar un singur fișier odată.",
"placeholder_text": "Apasă sau trage pentru a încărca fișiere",
"upload_failed": "Încărcarea a eșuat! Te rugăm să încerci din nou.",
"uploading": "Se încarcă...",
"you_can_only_upload_a_maximum_of_files": "Poți încărca un număr maxim de {FILE_LIMIT} fișiere."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Файл должен быть меньше {maxSizeInMB} МБ",
"no_valid_file_types_selected": "Не выбраны допустимые типы файлов. Пожалуйста, выберите допустимый тип файла.",
"only_one_file_can_be_uploaded_at_a_time": "Можно загрузить только один файл за раз.",
"placeholder_text": "Нажмите или перетащите файлы для загрузки",
"upload_failed": "Ошибка загрузки! Пожалуйста, попробуйте снова.",
"uploading": "Загрузка...",
"you_can_only_upload_a_maximum_of_files": "Вы можете загрузить максимум {FILE_LIMIT} файлов."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
"placeholder_text": "Klicka eller dra för att ladda upp filer",
"upload_failed": "Uppladdning misslyckades! Försök igen.",
"uploading": "Laddar upp...",
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "Fayl hajmi {maxSizeInMB} MB dan kam bo'lishi kerak",
"no_valid_file_types_selected": "Hech qanday yaroqli fayl turi tanlanmadi. Iltimos, yaroqli fayl turini tanlang.",
"only_one_file_can_be_uploaded_at_a_time": "Bir vaqtning o'zida faqat bitta fayl yuklanishi mumkin.",
"placeholder_text": "Fayllarni yuklash uchun bosing yoki sudrab olib keling",
"upload_failed": "Yuklash muvaffaqiyatsiz tugadi! Iltimos, qayta urinib ko'ring.",
"uploading": "Yuklanmoqda...",
"you_can_only_upload_a_maximum_of_files": "Siz faqat {FILE_LIMIT} ta faylni maksimal yuklashingiz mumkin."
},
"invalid_device_error": {
+2
View File
@@ -43,7 +43,9 @@
"file_size_exceeded_alert": "文件应小于 {maxSizeInMB} MB",
"no_valid_file_types_selected": "未选择有效的文件类型。请选择有效的文件类型。",
"only_one_file_can_be_uploaded_at_a_time": "一次只能上传一个文件。",
"placeholder_text": "点击或拖拽上传文件",
"upload_failed": "上传失败!请重试。",
"uploading": "上传中...",
"you_can_only_upload_a_maximum_of_files": "您最多只能上传 {FILE_LIMIT} 个文件。"
},
"invalid_device_error": {
@@ -357,6 +357,8 @@ export function FileUploadElement({
isUploading={isUploading}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
placeholderText={t("errors.file_input.placeholder_text")}
uploadingText={t("errors.file_input.uploading")}
/>
</form>
);
+8 -6
View File
@@ -10,14 +10,16 @@ import DOMPurify from "isomorphic-dompurify";
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return DOMPurify.sanitize(preStripped, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};
+12
View File
@@ -3,6 +3,8 @@ import { ZStorageUrl } from "./common";
export const ZCloudBillingPlan = z.enum(["hobby", "pro", "scale", "custom", "unknown"]);
export type TCloudBillingPlan = z.infer<typeof ZCloudBillingPlan>;
export const ZCloudBillingInterval = z.enum(["monthly", "yearly"]);
export type TCloudBillingInterval = z.infer<typeof ZCloudBillingInterval>;
export const ZOrganizationStripeSubscriptionStatus = z.enum([
"trialing",
"active",
@@ -15,8 +17,17 @@ export const ZOrganizationStripeSubscriptionStatus = z.enum([
]);
export type TOrganizationStripeSubscriptionStatus = z.infer<typeof ZOrganizationStripeSubscriptionStatus>;
export const ZOrganizationStripePendingChange = z.object({
type: z.literal("plan_change"),
targetPlan: z.enum(["hobby", "pro", "scale"]),
targetInterval: ZCloudBillingInterval.nullable(),
effectiveAt: z.string(),
});
export type TOrganizationStripePendingChange = z.infer<typeof ZOrganizationStripePendingChange>;
export const ZOrganizationStripeBilling = z.object({
plan: ZCloudBillingPlan.optional(),
interval: ZCloudBillingInterval.nullable().optional(),
subscriptionStatus: ZOrganizationStripeSubscriptionStatus.nullable().optional(),
subscriptionId: z.string().nullable().optional(),
hasPaymentMethod: z.boolean().optional(),
@@ -25,6 +36,7 @@ export const ZOrganizationStripeBilling = z.object({
lastSyncedAt: z.string().nullable().optional(),
lastSyncedEventId: z.string().nullable().optional(),
trialEnd: z.string().nullable().optional(),
pendingChange: ZOrganizationStripePendingChange.nullable().optional(),
});
export type TOrganizationStripeBilling = z.infer<typeof ZOrganizationStripeBilling>;
-1
View File
@@ -226,7 +226,6 @@
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_PRICING_TABLE_ID",
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"PUBLIC_URL",