Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac7369b3c | |||
| 913ab98d62 | |||
| 717a172ce0 | |||
| 8c935f20c2 | |||
| a10404ba1d | |||
| 39788ce0e1 | |||
| a51a006c26 | |||
| ce96cb0b89 | |||
| fb265d9dba | |||
| e4c155b501 | |||
| 2dc5c50f4d | |||
| bddcec0466 | |||
| 92677e1ec0 | |||
| b12228e305 | |||
| 91be2af30b | |||
| 84c668be86 | |||
| 4015c76f2b | |||
| a7b2ade4a9 | |||
| 75f44952c7 | |||
| 0df5e26381 | |||
| 89bb3bcd84 | |||
| 30fdb72c09 | |||
| cb58cf5825 | |||
| 99bd2ba256 |
@@ -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=
|
||||
|
||||
@@ -285,12 +285,14 @@ runs:
|
||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||
posthog_key=${{ env.POSTHOG_KEY }}
|
||||
env:
|
||||
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
||||
|
||||
- name: Sign GHCR image (GHCR only)
|
||||
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
||||
|
||||
@@ -92,3 +92,4 @@ jobs:
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.15",
|
||||
"@storybook/addon-links": "10.2.15",
|
||||
"@storybook/addon-onboarding": "10.2.15",
|
||||
"@storybook/react-vite": "10.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@storybook/addon-a11y": "10.2.17",
|
||||
"@storybook/addon-links": "10.2.17",
|
||||
"@storybook/addon-onboarding": "10.2.17",
|
||||
"@storybook/react-vite": "10.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.14",
|
||||
"storybook": "10.2.15",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.15"
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=redis_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
--mount=type=secret,id=posthog_key \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
#
|
||||
@@ -121,8 +122,11 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
||||
RUN chmod -R 755 ./node_modules/uuid
|
||||
# Runtime migrations import uuid v7 from the database package, so copy the
|
||||
# database package's resolved install instead of the repo-root hoisted version.
|
||||
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
|
||||
RUN chmod -R 755 ./node_modules/uuid \
|
||||
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
|
||||
|
||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
@@ -165,4 +169,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
|
||||
@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
|
||||
},
|
||||
});
|
||||
|
||||
const projectTeams = teams.map((team) => ({
|
||||
return teams.map((team: TOrganizationTeam) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
}));
|
||||
|
||||
return projectTeams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface SelectPlanOnboardingProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
|
||||
const t = await getTranslate();
|
||||
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
||||
<Header
|
||||
title={t("environments.settings.billing.select_plan_header_title")}
|
||||
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||
|
||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
|
||||
interface PlanPageProps {
|
||||
params: Promise<{
|
||||
organizationId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: PlanPageProps) => {
|
||||
const params = await props.params;
|
||||
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
const { session } = await getOrganizationAuth(params.organizationId);
|
||||
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Users with an existing paid/trial subscription should not be shown the trial page.
|
||||
// Redirect them directly to the next onboarding step.
|
||||
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
|
||||
const currentPlan = billing?.stripe?.plan;
|
||||
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
|
||||
|
||||
if (hasExistingSubscription) {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -6,16 +6,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
interface ConfirmationPageProps {
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
|
||||
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||
export const ConfirmationPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState(environmentId ?? null);
|
||||
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
@@ -24,19 +20,13 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
|
||||
setResolvedEnvironmentId(environmentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
||||
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
||||
);
|
||||
if (storedEnvironmentId) {
|
||||
setResolvedEnvironmentId(storedEnvironmentId);
|
||||
}
|
||||
}, [environmentId]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
||||
@@ -3,17 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const Page = async (props: { searchParams: Promise<{ environmentId: string }> }) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const { environmentId } = searchParams;
|
||||
|
||||
if (!environmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Page = async () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<ConfirmationPage environmentId={environmentId} />
|
||||
<ConfirmationPage />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -167,6 +168,20 @@ export const MainNavigation = ({
|
||||
if (isOwnerOrManager) loadReleases();
|
||||
}, [isOwnerOrManager]);
|
||||
|
||||
const trialDaysRemaining = useMemo(() => {
|
||||
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
||||
const trialEnd = organization.billing.stripe.trialEnd;
|
||||
if (!trialEnd) return null;
|
||||
const ts = new Date(trialEnd).getTime();
|
||||
if (!Number.isFinite(ts)) return null;
|
||||
const msPerDay = 86_400_000;
|
||||
return Math.ceil((ts - Date.now()) / msPerDay);
|
||||
}, [
|
||||
isFormbricksCloud,
|
||||
organization.billing?.stripe?.subscriptionStatus,
|
||||
organization.billing?.stripe?.trialEnd,
|
||||
]);
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
return (
|
||||
@@ -241,6 +256,13 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -7,21 +7,20 @@ import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { SettingsCard } from "../../../components/SettingsCard";
|
||||
|
||||
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
|
||||
|
||||
interface EnterpriseLicenseStatusProps {
|
||||
status: LicenseStatus;
|
||||
status: TLicenseStatus;
|
||||
gracePeriodEnd?: Date;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const getBadgeConfig = (
|
||||
status: LicenseStatus,
|
||||
status: TLicenseStatus,
|
||||
t: TFunction
|
||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||
switch (status) {
|
||||
@@ -29,6 +28,11 @@ const getBadgeConfig = (
|
||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||
case "expired":
|
||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||
case "instance_mismatch":
|
||||
return {
|
||||
type: "error",
|
||||
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
||||
};
|
||||
case "unreachable":
|
||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||
case "invalid_license":
|
||||
@@ -59,6 +63,8 @@ export const EnterpriseLicenseStatus = ({
|
||||
if (result?.data) {
|
||||
if (result.data.status === "unreachable") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
||||
} else if (result.data.status === "instance_mismatch") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
|
||||
} else if (result.data.status === "invalid_license") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||
} else {
|
||||
@@ -128,6 +134,13 @@ export const EnterpriseLicenseStatus = ({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{status === "instance_mismatch" && (
|
||||
<Alert variant="error" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("environments.settings.enterprise.license_instance_mismatch_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
|
||||
@@ -94,7 +94,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</PageHeader>
|
||||
{hasLicense ? (
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
|
||||
status={licenseState.status}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
|
||||
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
|
||||
import { PostHogIdentify } from "@/app/posthog/PostHogIdentify";
|
||||
import {
|
||||
CHATWOOT_BASE_URL,
|
||||
CHATWOOT_WEBSITE_TOKEN,
|
||||
IS_CHATWOOT_CONFIGURED,
|
||||
POSTHOG_KEY,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
@@ -19,6 +25,9 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
{POSTHOG_KEY && user && (
|
||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||
)}
|
||||
{IS_CHATWOOT_CONFIGURED && (
|
||||
<ChatwootWidget
|
||||
userEmail={user?.email}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -190,7 +190,7 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
|
||||
if (featureCheckResult) {
|
||||
return {
|
||||
response: featureCheckResult,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCreateInputWithEnvironmentId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { checkFeaturePermissions } from "./utils";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -24,6 +26,14 @@ vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
|
||||
getSurveyFollowUpsPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/permission", () => ({
|
||||
getExternalUrlsPermission: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
|
||||
}));
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org",
|
||||
name: "Test Organization",
|
||||
@@ -98,6 +108,13 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
|
||||
};
|
||||
|
||||
describe("checkFeaturePermissions", () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("should return null if no restricted features are used", async () => {
|
||||
const surveyData = { ...baseSurveyData };
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
@@ -197,4 +214,315 @@ describe("checkFeaturePermissions", () => {
|
||||
);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
|
||||
});
|
||||
|
||||
// External URLs - ending card button link tests
|
||||
test("should return forbiddenResponse when adding new ending with buttonLink without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://example.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
||||
);
|
||||
});
|
||||
|
||||
test("should return forbiddenResponse when changing ending buttonLink without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://new-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://old-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
|
||||
test("should allow keeping existing ending buttonLink without permission (grandfathering)", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://existing-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://existing-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should allow ending buttonLink when permission is granted", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://example.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// External URLs - CTA external button tests
|
||||
test("should return forbiddenResponse when adding CTA with external button without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
||||
);
|
||||
});
|
||||
|
||||
test("should return forbiddenResponse when changing CTA external button URL without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://new-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://old-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
|
||||
test("should allow keeping existing CTA external button without permission (grandfathering)", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://existing-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://existing-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should allow CTA external button when permission is granted", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return forbiddenResponse when switching CTA from internal to external without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
|
||||
export const checkFeaturePermissions = async (
|
||||
surveyData: TSurveyCreateInputWithEnvironmentId,
|
||||
organization: TOrganization
|
||||
organization: TOrganization,
|
||||
oldSurvey?: TSurvey
|
||||
): Promise<Response | null> => {
|
||||
if (surveyData.recaptcha?.enabled) {
|
||||
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
|
||||
@@ -22,5 +25,46 @@ export const checkFeaturePermissions = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isExternalUrlsAllowed = await getExternalUrlsPermission(organization.id);
|
||||
if (!isExternalUrlsAllowed) {
|
||||
// Check ending cards for new/changed button links
|
||||
if (surveyData.endings) {
|
||||
for (const newEnding of surveyData.endings) {
|
||||
const oldEnding = oldSurvey?.endings.find((e) => e.id === newEnding.id);
|
||||
|
||||
if (newEnding.type === "endScreen" && newEnding.buttonLink) {
|
||||
if (!oldEnding || oldEnding.type !== "endScreen" || oldEnding.buttonLink !== newEnding.buttonLink) {
|
||||
return responses.forbiddenResponse(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check CTA elements for new/changed external button URLs
|
||||
if (surveyData.blocks) {
|
||||
const newElements = getElementsFromBlocks(surveyData.blocks);
|
||||
const oldElements = oldSurvey?.blocks ? getElementsFromBlocks(oldSurvey.blocks) : [];
|
||||
|
||||
for (const newElement of newElements) {
|
||||
const oldElement = oldElements.find((e) => e.id === newElement.id);
|
||||
|
||||
if (newElement.type === "cta" && newElement.buttonExternal) {
|
||||
if (
|
||||
!oldElement ||
|
||||
oldElement.type !== "cta" ||
|
||||
!oldElement.buttonExternal ||
|
||||
oldElement.buttonUrl !== newElement.buttonUrl
|
||||
) {
|
||||
return responses.forbiddenResponse(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -66,6 +67,9 @@ const Page = async () => {
|
||||
|
||||
if (!firstProductionEnvironmentId) {
|
||||
if (isOwner || isManager) {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/plan`);
|
||||
}
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/mode`);
|
||||
} else {
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/landing`);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface PostHogIdentifyProps {
|
||||
posthogKey: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export const PostHogIdentify = ({ posthogKey, userId, email, name }: PostHogIdentifyProps) => {
|
||||
const lastIdentifiedUserId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!posthog.__loaded) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: "/ingest",
|
||||
ui_host: "https://eu.i.posthog.com",
|
||||
defaults: "2026-01-30",
|
||||
capture_exceptions: true,
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
}
|
||||
|
||||
if (lastIdentifiedUserId.current && lastIdentifiedUserId.current !== userId) {
|
||||
posthog.reset();
|
||||
}
|
||||
|
||||
posthog.identify(userId, { email, name });
|
||||
lastIdentifiedUserId.current = userId;
|
||||
}, [posthogKey, userId, email, name]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const ZCreateOrganizationAction = z.object({
|
||||
@@ -33,6 +36,16 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
// Stripe setup must run AFTER membership is created so the owner email is available
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
ensureCloudStripeSetupForOrganization(newOrganization.id).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId: newOrganization.id },
|
||||
"Stripe setup failed after organization creation"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -407,6 +407,9 @@ checksums:
|
||||
common/title: 344e64395eaff6822a57d18623853e1a
|
||||
common/top_left: aa61bb29b56df3e046b6d68d89ee8986
|
||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||
common/trial_days_remaining: 914ff3132895e410bf0f862433ccb49e
|
||||
common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07
|
||||
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
@@ -414,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
|
||||
@@ -913,30 +917,80 @@ checksums:
|
||||
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
|
||||
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
|
||||
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
|
||||
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/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: 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
|
||||
environments/settings/billing/trial_feature_attribute_segmentation: 90087da973ae48e32ec6d863516fc8c9
|
||||
environments/settings/billing/trial_feature_contact_segment_management: 27f17a039ebed6413811ab3a461db2f4
|
||||
environments/settings/billing/trial_feature_email_followups: 0cc02dc14aa28ce94ca6153c306924e5
|
||||
environments/settings/billing/trial_feature_hide_branding: b8dbcb24e50e0eb4aeb0c97891cac61d
|
||||
environments/settings/billing/trial_feature_mobile_sdks: 0963480a27df49657c1b7507adec9a06
|
||||
environments/settings/billing/trial_feature_respondent_identification: a82e24ab4c27c5e485326678d9b7bd79
|
||||
environments/settings/billing/trial_feature_unlimited_seats: a3257d5b6a23bfbc4b7fd1108087a823
|
||||
environments/settings/billing/trial_feature_webhooks: 5ead39fba97fbd37835a476ee67fdd94
|
||||
environments/settings/billing/trial_no_credit_card: 01c70aa6e1001815a9a11951394923ca
|
||||
environments/settings/billing/trial_payment_method_added_description: 872a5c557f56bafc9b7ec4895f9c33e8
|
||||
environments/settings/billing/trial_title: f2c3791c1fb2970617ec0f2d243a931b
|
||||
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
|
||||
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
|
||||
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
|
||||
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
|
||||
@@ -958,11 +1012,13 @@ checksums:
|
||||
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
|
||||
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
|
||||
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
|
||||
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
|
||||
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
|
||||
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
|
||||
environments/settings/enterprise/license_status_active: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
environments/settings/enterprise/license_status_description: 828e4527f606471cd8cf58b55ff824f6
|
||||
environments/settings/enterprise/license_status_expired: 63b27cccba4ab2143e0f5f3d46e4168a
|
||||
environments/settings/enterprise/license_status_instance_mismatch: 2c85ca34eef67c5ca34477dc1eda68c0
|
||||
environments/settings/enterprise/license_status_invalid: a4bfd3787fc0bf0a38db61745bd25cec
|
||||
environments/settings/enterprise/license_status_unreachable: 202b110dab099f1167b13c326349e570
|
||||
environments/settings/enterprise/license_unreachable_grace_period: c0587c9d79ac55ff2035fb8b8eec4433
|
||||
@@ -973,6 +1029,7 @@ checksums:
|
||||
environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7
|
||||
environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa
|
||||
environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c
|
||||
environments/settings/enterprise/recheck_license_instance_mismatch: 655cd1cce2f25b100439d8725c1e72f2
|
||||
environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8
|
||||
environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78
|
||||
environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="101" height="22" viewBox="0 0 101 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.0582 20.817C4.32115 20.817 0 16.2763 0 10.6704C0 5.04589 4.1005 0.467773 10.0582 0.467773C13.2209 0.467773 15.409 1.43945 17.1191 3.66311L14.3609 5.96151C13.2025 4.72822 11.805 4.11158 10.0582 4.11158C6.17833 4.11158 4.04533 7.08268 4.04533 10.6704C4.04533 14.2582 6.38059 17.1732 10.0582 17.1732C11.7866 17.1732 13.2577 16.5566 14.4161 15.3233L17.1375 17.7151C15.501 19.8453 13.2577 20.817 10.0582 20.817Z" fill="#292929"/>
|
||||
<path d="M29.0161 5.88601H32.7304V20.4612H29.0161V18.331C28.2438 19.8446 26.9566 20.8536 24.4927 20.8536C20.5577 20.8536 17.4133 17.4341 17.4133 13.2297C17.4133 9.02528 20.5577 5.60571 24.4927 5.60571C26.9383 5.60571 28.2438 6.61477 29.0161 8.12835V5.88601ZM29.1264 13.2297C29.1264 10.95 27.5634 9.06266 25.0995 9.06266C22.7274 9.06266 21.1828 10.9686 21.1828 13.2297C21.1828 15.4346 22.7274 17.3967 25.0995 17.3967C27.5451 17.3967 29.1264 15.4907 29.1264 13.2297Z" fill="#292929"/>
|
||||
<path d="M35.3599 0H39.0742V20.4427H35.3599V0Z" fill="#292929"/>
|
||||
<path d="M40.7291 18.5182C40.7291 17.3223 41.6853 16.3132 42.9908 16.3132C44.2964 16.3132 45.2158 17.3223 45.2158 18.5182C45.2158 19.7515 44.278 20.7605 42.9908 20.7605C41.7037 20.7605 40.7291 19.7515 40.7291 18.5182Z" fill="#292929"/>
|
||||
<path d="M59.4296 18.1068C58.0505 19.7885 55.9543 20.8536 53.4719 20.8536C49.0404 20.8536 45.7858 17.4341 45.7858 13.2297C45.7858 9.02528 49.0404 5.60571 53.4719 5.60571C55.8623 5.60571 57.9402 6.61477 59.3193 8.20309L56.4508 10.6136C55.7336 9.71667 54.7958 9.04397 53.4719 9.04397C51.0999 9.04397 49.5553 10.95 49.5553 13.211C49.5553 15.472 51.0999 17.378 53.4719 17.378C54.9062 17.378 55.8991 16.6306 56.6346 15.6215L59.4296 18.1068Z" fill="#292929"/>
|
||||
<path d="M59.7422 13.2297C59.7422 9.02528 62.9968 5.60571 67.4283 5.60571C71.8598 5.60571 75.1144 9.02528 75.1144 13.2297C75.1144 17.4341 71.8598 20.8536 67.4283 20.8536C62.9968 20.8349 59.7422 17.4341 59.7422 13.2297ZM71.3449 13.2297C71.3449 10.95 69.8003 9.06266 67.4283 9.06266C65.0563 9.04397 63.5117 10.95 63.5117 13.2297C63.5117 15.4907 65.0563 17.3967 67.4283 17.3967C69.8003 17.3967 71.3449 15.4907 71.3449 13.2297Z" fill="#292929"/>
|
||||
<path d="M100.232 11.5482V20.4428H96.518V12.4638C96.518 9.94119 95.3412 8.85739 93.576 8.85739C91.921 8.85739 90.7442 9.67958 90.7442 12.4638V20.4428H87.0299V12.4638C87.0299 9.94119 85.8346 8.85739 84.0878 8.85739C82.4329 8.85739 80.9802 9.67958 80.9802 12.4638V20.4428H77.2659V5.8676H80.9802V7.88571C81.7525 6.31607 83.15 5.53125 85.3014 5.53125C87.3425 5.53125 89.0525 6.5403 89.9903 8.24074C90.9281 6.50293 92.3072 5.53125 94.8079 5.53125C97.8603 5.54994 100.232 7.86702 100.232 11.5482Z" fill="#292929"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 948 299.3" style="enable-background:new 0 0 948 299.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#73D700;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g id="BG">
|
||||
<rect class="st0" width="948" height="299.3"/>
|
||||
</g>
|
||||
<g id="Logo">
|
||||
<rect x="81.7" y="149.4" class="st1" width="2.5" height="0.8"/>
|
||||
<g>
|
||||
<path class="st1" d="M94.7,82c-7.2,0-13,5.8-13,13v122.5h34.7v-53.2h49.1c7.1,0,13-5.9,13-13v-14.7h-62.1v-20.4
|
||||
c0-3.6,2.9-6.5,6.5-6.5h58.8c7.1,0,13-5.8,13-13V82L94.7,82L94.7,82z"/>
|
||||
<path class="st1" d="M250.7,189.6c-3.6,0-6.5-2.9-6.5-6.5V95.1c0-7.2-5.9-13.1-13.1-13.1h-21.8v122.4c0,7.2,5.9,13.1,13.1,13.1
|
||||
h71.3c7.2,0,13.1-5.9,13.1-13.1v-14.8H250.7L250.7,189.6z"/>
|
||||
<path class="st1" d="M356.7,217.3H322v-70.7c0-7.2,5.9-13,13-13h21.7L356.7,217.3L356.7,217.3z"/>
|
||||
<path class="st1" d="M343.7,121.1H322V95.1c0-7.2,5.9-13,13-13h8.7c7.2,0,13,5.9,13,13v13C356.7,115.2,350.8,121.1,343.7,121.1"/>
|
||||
<path class="st1" d="M580.4,195.4h-23.9c-6.9,0-12.5-5.6-12.5-12.5v-22.7h36.2c9.5,0,17.2,7.9,17.2,17.6S589.8,195.4,580.4,195.4
|
||||
M543.9,104h32.5c8.6,0,15.5,7,15.5,15.7s-6.8,15.6-15.3,15.7h-21.5c-6.2,0-11.2-5-11.2-11.2L543.9,104L543.9,104z M617.5,150.7
|
||||
c-0.8-0.7-2.9-2.4-3.4-2.8c6.5-6.6,9.2-15.4,9.2-26.6c0-24.7-16-39.3-40.7-39.3h-53.4c-7.1,0-13,5.8-13,13v109.4
|
||||
c0,7.1,5.8,13,13,13h59.5c24.7,0,39.7-14.4,39.7-39.1C628.3,166.7,624.3,157.4,617.5,150.7"/>
|
||||
<path class="st1" d="M752.5,82.1H737c-7.1,0-13,5.8-13,13V175c0,11.7-8,19.5-22,19.5h-6.4c-13.9,0-22-7.8-22-19.5V82.1h-15.5
|
||||
c-7.1,0-13,5.8-13,13v84.2c0,24.2,16.6,40.3,45.3,40.3h16.6c28.7,0,45.3-16.1,45.3-40.3L752.5,82.1L752.5,82.1z"/>
|
||||
<path class="st1" d="M810.1,109.8h43.8c7.1,0,13-5.8,13-13V82h-56.8c-22.7,0.2-41,18.7-41,41.4s18,39.6,40.4,40.1l0,0l17.9,0h0
|
||||
c7.2,0.1,13,5.9,13,13.1s-5.8,13-12.9,13.1h-56.7v14.7c0,7.1,5.8,13,13,13h44c22.5-0.4,40.7-18.8,40.7-41.4s-17.8-39.4-40.1-40.1
|
||||
v0h-18.2c-7.2-0.1-13-5.9-13-13.1S802.9,109.8,810.1,109.8"/>
|
||||
<path class="st1" d="M489,193.8l-23.7-32.6l-20.4,28.1l17.4,23.9c5.2,7.2,15.5,8.9,22.7,3.6l0.4-0.3
|
||||
C492.6,211.2,494.2,201,489,193.8"/>
|
||||
<path class="st1" d="M457.1,149.9l-20.4-28.1l-25.6-35.2c-5.2-7.2-15.5-8.8-22.7-3.6l-0.4,0.3c-7.2,5.2-8.9,15.5-3.6,22.7
|
||||
l31.8,43.8l-31.8,43.9c-5.3,7.3-3.7,17.5,3.5,22.8l0.4,0.3c7.2,5.2,17.5,3.6,22.7-3.6l18.8-25.8L457.1,149.9L457.1,149.9z"/>
|
||||
<path class="st1" d="M485.4,83.3L485,83c-7.2-5.2-17.5-3.6-22.7,3.6l-17.4,23.9l20.4,28.1L489,106
|
||||
C494.2,98.8,492.6,88.6,485.4,83.3"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -1,7 +1,19 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type Instrumentation } from "next";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
const [error] = args;
|
||||
|
||||
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
|
||||
// These are handled gracefully in the UI and don't need server-side Sentry reporting
|
||||
if (error instanceof Error && isExpectedError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
|
||||
@@ -186,6 +186,8 @@ export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
|
||||
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
|
||||
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
|
||||
|
||||
export const POSTHOG_KEY = env.POSTHOG_KEY;
|
||||
|
||||
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Crypto Utils", () => {
|
||||
// But both should verify correctly
|
||||
expect(await verifySecret(secret, hash1)).toBe(true);
|
||||
expect(await verifySecret(secret, hash2)).toBe(true);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test("should use custom cost factor", async () => {
|
||||
const secret = "test-secret-123";
|
||||
|
||||
@@ -42,6 +42,7 @@ export const env = createEnv({
|
||||
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
|
||||
CHATWOOT_BASE_URL: z.url().optional(),
|
||||
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
|
||||
POSTHOG_KEY: z.string().optional(),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
|
||||
MAIL_FROM: z.email().optional(),
|
||||
NEXTAUTH_URL: z.url().optional(),
|
||||
@@ -84,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(
|
||||
@@ -165,6 +165,7 @@ export const env = createEnv({
|
||||
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
|
||||
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
|
||||
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
|
||||
POSTHOG_KEY: process.env.POSTHOG_KEY,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
|
||||
@@ -201,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,
|
||||
|
||||
@@ -4,9 +4,13 @@ import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import {
|
||||
cleanupStripeCustomer,
|
||||
ensureCloudStripeSetupForOrganization,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
import {
|
||||
createOrganization,
|
||||
deleteOrganization,
|
||||
getOrganization,
|
||||
getOrganizationsByUserId,
|
||||
select as organizationSelect,
|
||||
@@ -22,6 +26,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
organizationBilling: {
|
||||
upsert: vi.fn(),
|
||||
@@ -38,6 +43,7 @@ vi.mock("@/lib/user/service", () => ({
|
||||
|
||||
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
ensureCloudStripeSetupForOrganization: vi.fn().mockResolvedValue(undefined),
|
||||
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("Organization Service", () => {
|
||||
@@ -197,48 +203,8 @@ describe("Organization Service", () => {
|
||||
},
|
||||
select: organizationSelect,
|
||||
});
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
expect(ensureCloudStripeSetupForOrganization).toHaveBeenCalledWith("org1");
|
||||
} else {
|
||||
expect(ensureCloudStripeSetupForOrganization).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("should still return organization when Stripe setup fails", async () => {
|
||||
const expectedBilling = {
|
||||
limits: {
|
||||
projects: IS_FORMBRICKS_CLOUD ? 1 : 3,
|
||||
monthly: {
|
||||
responses: IS_FORMBRICKS_CLOUD ? 250 : 1500,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: null,
|
||||
};
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: expectedBilling,
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.organization.create).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(ensureCloudStripeSetupForOrganization).mockRejectedValueOnce(
|
||||
new Error("stripe temporarily unavailable")
|
||||
);
|
||||
|
||||
const result = await createOrganization({ name: "Test Org" });
|
||||
|
||||
expect(result).toEqual(mockOrganization);
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
expect(ensureCloudStripeSetupForOrganization).toHaveBeenCalledWith("org1");
|
||||
} else {
|
||||
expect(ensureCloudStripeSetupForOrganization).not.toHaveBeenCalled();
|
||||
}
|
||||
// Stripe setup is now handled by the caller after membership creation
|
||||
expect(ensureCloudStripeSetupForOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on prisma error", async () => {
|
||||
@@ -375,4 +341,22 @@ describe("Organization Service", () => {
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrganization", () => {
|
||||
test("should call cleanupStripeCustomer when cloud and stripeCustomerId exists", async () => {
|
||||
vi.mocked(prisma.organization.delete).mockResolvedValue({
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
billing: { stripeCustomerId: "cus_123" },
|
||||
memberships: [],
|
||||
projects: [],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,7 @@ import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { getProjects } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
|
||||
import {
|
||||
deleteStripeCustomer,
|
||||
ensureCloudStripeSetupForOrganization,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const select = {
|
||||
@@ -183,15 +180,6 @@ export const createOrganization = async (
|
||||
select,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
ensureCloudStripeSetupForOrganization(organization.id).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId: organization.id },
|
||||
"Stripe setup failed after organization creation"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return mapOrganization(organization);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -320,12 +308,7 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
|
||||
const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId;
|
||||
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
|
||||
deleteStripeCustomer(stripeCustomerId).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId, stripeCustomerId },
|
||||
"Failed to delete Stripe customer after organization deletion"
|
||||
);
|
||||
});
|
||||
await cleanupStripeCustomer(stripeCustomerId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Oben links",
|
||||
"top_right": "Oben rechts",
|
||||
"trial_days_remaining": "Noch {count} Tage in deiner Testphase",
|
||||
"trial_expired": "Deine Testphase ist abgelaufen",
|
||||
"trial_one_day_remaining": "Noch 1 Tag in deiner Testphase",
|
||||
"try_again": "Versuch's nochmal",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Unbekannte Umfrage",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Wird storniert",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"add_payment_method": "Zahlungsmethode hinzufügen",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Attributbasierte Segmentierung",
|
||||
"trial_feature_contact_segment_management": "Kontakt- & Segmentverwaltung",
|
||||
"trial_feature_email_followups": "E-Mail-Nachfassaktionen",
|
||||
"trial_feature_hide_branding": "Formbricks-Branding ausblenden",
|
||||
"trial_feature_mobile_sdks": "iOS & Android SDKs",
|
||||
"trial_feature_respondent_identification": "Befragten-Identifikation",
|
||||
"trial_feature_unlimited_seats": "Unbegrenzte Benutzerplätze",
|
||||
"trial_feature_webhooks": "Individuelle Webhooks",
|
||||
"trial_no_credit_card": "14 Tage Testversion, keine Kreditkarte erforderlich",
|
||||
"trial_payment_method_added_description": "Alles bereit! Dein Pro-Tarif läuft nach Ende der Testphase automatisch weiter.",
|
||||
"trial_title": "Hol dir Formbricks Pro kostenlos!",
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
"unlimited_workspaces": "Unbegrenzte Projekte",
|
||||
"upgrade": "Upgrade",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Unternehmensfunktionen",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
|
||||
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
|
||||
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
|
||||
"license_status": "Lizenzstatus",
|
||||
"license_status_active": "Aktiv",
|
||||
"license_status_description": "Status deiner Enterprise-Lizenz.",
|
||||
"license_status_expired": "Abgelaufen",
|
||||
"license_status_instance_mismatch": "An andere Instanz gebunden",
|
||||
"license_status_invalid": "Ungültige Lizenz",
|
||||
"license_status_unreachable": "Nicht erreichbar",
|
||||
"license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Fragen? Bitte melde Dich bei",
|
||||
"recheck_license": "Lizenz erneut prüfen",
|
||||
"recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.",
|
||||
"recheck_license_instance_mismatch": "Diese Lizenz ist an eine andere Formbricks-Instanz gebunden. Bitte den Formbricks-Support, die vorherige Bindung zu entfernen.",
|
||||
"recheck_license_invalid": "Der Lizenzschlüssel ist ungültig. Bitte überprüfe deinen ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Lizenzprüfung erfolgreich",
|
||||
"recheck_license_unreachable": "Lizenzserver ist nicht erreichbar. Bitte versuche es später erneut.",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Title",
|
||||
"top_left": "Top Left",
|
||||
"top_right": "Top Right",
|
||||
"trial_days_remaining": "{count} days left in your trial",
|
||||
"trial_expired": "Your trial has expired",
|
||||
"trial_one_day_remaining": "1 day left in your trial",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Unknown survey",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Cancelling",
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"add_payment_method": "Add payment method",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Attribute-based Segmentation",
|
||||
"trial_feature_contact_segment_management": "Contact & Segment Management",
|
||||
"trial_feature_email_followups": "Email Follow-ups",
|
||||
"trial_feature_hide_branding": "Hide Formbricks Branding",
|
||||
"trial_feature_mobile_sdks": "iOS & Android SDKs",
|
||||
"trial_feature_respondent_identification": "Respondent Identification",
|
||||
"trial_feature_unlimited_seats": "Unlimited Seats",
|
||||
"trial_feature_webhooks": "Custom Webhooks",
|
||||
"trial_no_credit_card": "14 days trial, no credit card required",
|
||||
"trial_payment_method_added_description": "You're all set! Your Pro plan will continue automatically after the trial ends.",
|
||||
"trial_title": "Get Formbricks Pro for free!",
|
||||
"unlimited_responses": "Unlimited Responses",
|
||||
"unlimited_workspaces": "Unlimited Workspaces",
|
||||
"upgrade": "Upgrade",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Enterprise Features",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
|
||||
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_status": "License Status",
|
||||
"license_status_active": "Active",
|
||||
"license_status_description": "Status of your enterprise license.",
|
||||
"license_status_expired": "Expired",
|
||||
"license_status_instance_mismatch": "Bound to Another Instance",
|
||||
"license_status_invalid": "Invalid License",
|
||||
"license_status_unreachable": "Unreachable",
|
||||
"license_unreachable_grace_period": "License server cannot be reached. Your enterprise features remain active during a 3-day grace period ending {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Questions? Please reach out to",
|
||||
"recheck_license": "Recheck license",
|
||||
"recheck_license_failed": "License check failed. The license server may be unreachable.",
|
||||
"recheck_license_instance_mismatch": "This license is bound to a different Formbricks instance. Ask Formbricks support to disconnect the previous binding.",
|
||||
"recheck_license_invalid": "The license key is invalid. Please verify your ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "License check successful",
|
||||
"recheck_license_unreachable": "License server is unreachable. Please try again later.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Título",
|
||||
"top_left": "Superior izquierda",
|
||||
"top_right": "Superior derecha",
|
||||
"trial_days_remaining": "{count} días restantes en tu prueba",
|
||||
"trial_expired": "Tu prueba ha expirado",
|
||||
"trial_one_day_remaining": "1 día restante en tu prueba",
|
||||
"try_again": "Intentar de nuevo",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Encuesta desconocida",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Cancelando",
|
||||
"manage_subscription": "Gestionar suscripción",
|
||||
"add_payment_method": "Añadir método de pago",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Segmentación basada en atributos",
|
||||
"trial_feature_contact_segment_management": "Gestión de contactos y segmentos",
|
||||
"trial_feature_email_followups": "Seguimientos por correo electrónico",
|
||||
"trial_feature_hide_branding": "Ocultar la marca Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs para iOS y Android",
|
||||
"trial_feature_respondent_identification": "Identificación de encuestados",
|
||||
"trial_feature_unlimited_seats": "Asientos ilimitados",
|
||||
"trial_feature_webhooks": "Webhooks personalizados",
|
||||
"trial_no_credit_card": "Prueba de 14 días, sin tarjeta de crédito",
|
||||
"trial_payment_method_added_description": "¡Todo listo! Tu plan Pro continuará automáticamente cuando termine el periodo de prueba.",
|
||||
"trial_title": "¡Consigue Formbricks Pro gratis!",
|
||||
"unlimited_responses": "Respuestas ilimitadas",
|
||||
"unlimited_workspaces": "Proyectos ilimitados",
|
||||
"upgrade": "Actualizar",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Características empresariales",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
|
||||
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
|
||||
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
|
||||
"license_status": "Estado de la licencia",
|
||||
"license_status_active": "Activa",
|
||||
"license_status_description": "Estado de tu licencia enterprise.",
|
||||
"license_status_expired": "Caducada",
|
||||
"license_status_instance_mismatch": "Vinculada a Otra Instancia",
|
||||
"license_status_invalid": "Licencia no válida",
|
||||
"license_status_unreachable": "Inaccesible",
|
||||
"license_unreachable_grace_period": "No se puede acceder al servidor de licencias. Tus funciones empresariales permanecen activas durante un período de gracia de 3 días que finaliza el {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con",
|
||||
"recheck_license": "Volver a comprobar licencia",
|
||||
"recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.",
|
||||
"recheck_license_instance_mismatch": "Esta licencia está vinculada a una instancia diferente de Formbricks. Solicita al soporte de Formbricks que desconecte la vinculación anterior.",
|
||||
"recheck_license_invalid": "La clave de licencia no es válida. Por favor, verifica tu ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Comprobación de licencia correcta",
|
||||
"recheck_license_unreachable": "El servidor de licencias no está disponible. Inténtalo de nuevo más tarde.",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titre",
|
||||
"top_left": "En haut à gauche",
|
||||
"top_right": "En haut à droite",
|
||||
"trial_days_remaining": "{count} jours restants dans votre période d'essai",
|
||||
"trial_expired": "Votre période d'essai a expiré",
|
||||
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Enquête inconnue",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Annulation en cours",
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"add_payment_method": "Ajouter un moyen de paiement",
|
||||
"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.",
|
||||
"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 l’offre 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 d’accès",
|
||||
"select_plan_header_subtitle": "Aucune carte bancaire requise, aucun engagement.",
|
||||
"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 n’a 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",
|
||||
"trial_feature_attribute_segmentation": "Segmentation basée sur les attributs",
|
||||
"trial_feature_contact_segment_management": "Gestion des contacts et segments",
|
||||
"trial_feature_email_followups": "Relances par e-mail",
|
||||
"trial_feature_hide_branding": "Masquer l'image de marque Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs iOS et Android",
|
||||
"trial_feature_respondent_identification": "Identification des répondants",
|
||||
"trial_feature_unlimited_seats": "Places illimitées",
|
||||
"trial_feature_webhooks": "Webhooks personnalisés",
|
||||
"trial_no_credit_card": "Essai de 14 jours, aucune carte bancaire requise",
|
||||
"trial_payment_method_added_description": "Tout est prêt ! Votre abonnement Pro se poursuivra automatiquement après la fin de la période d'essai.",
|
||||
"trial_title": "Obtenez Formbricks Pro gratuitement !",
|
||||
"unlimited_responses": "Réponses illimitées",
|
||||
"unlimited_workspaces": "Projets illimités",
|
||||
"upgrade": "Mise à niveau",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Fonctionnalités d'entreprise",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
|
||||
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
|
||||
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
|
||||
"license_status": "Statut de la licence",
|
||||
"license_status_active": "Active",
|
||||
"license_status_description": "Statut de votre licence entreprise.",
|
||||
"license_status_expired": "Expirée",
|
||||
"license_status_instance_mismatch": "Liée à une autre instance",
|
||||
"license_status_invalid": "Licence invalide",
|
||||
"license_status_unreachable": "Inaccessible",
|
||||
"license_unreachable_grace_period": "Le serveur de licence est injoignable. Vos fonctionnalités entreprise restent actives pendant une période de grâce de 3 jours se terminant le {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Des questions ? Veuillez contacter",
|
||||
"recheck_license": "Revérifier la licence",
|
||||
"recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.",
|
||||
"recheck_license_instance_mismatch": "Cette licence est liée à une autre instance Formbricks. Demande au support Formbricks de déconnecter la liaison précédente.",
|
||||
"recheck_license_invalid": "La clé de licence est invalide. Veuillez vérifier votre ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Vérification de la licence réussie",
|
||||
"recheck_license_unreachable": "Le serveur de licences est inaccessible. Veuillez réessayer plus tard.",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Cím",
|
||||
"top_left": "Balra fent",
|
||||
"top_right": "Jobbra fent",
|
||||
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból",
|
||||
"trial_expired": "A próbaidőszak lejárt",
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Lemondás folyamatban",
|
||||
"manage_subscription": "Feliratkozás kezelése",
|
||||
"add_payment_method": "Fizetési mód hozzáadása",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Attribútumalapú szegmentálás",
|
||||
"trial_feature_contact_segment_management": "Kapcsolat- és szegmenskezelés",
|
||||
"trial_feature_email_followups": "E-mail követések",
|
||||
"trial_feature_hide_branding": "Formbricks márkajelzés elrejtése",
|
||||
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
|
||||
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
|
||||
"trial_feature_unlimited_seats": "Korlátlan számú felhasználói hely",
|
||||
"trial_feature_webhooks": "Egyéni webhookok",
|
||||
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
|
||||
"trial_payment_method_added_description": "Minden rendben! A Pro csomag automatikusan folytatódik a próbaidőszak lejárta után.",
|
||||
"trial_title": "Szerezze meg a Formbricks Pro-t ingyen!",
|
||||
"unlimited_responses": "Korlátlan válaszok",
|
||||
"unlimited_workspaces": "Korlátlan munkaterület",
|
||||
"upgrade": "Frissítés",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Vállalati funkciók",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
|
||||
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks példányhoz van kötve. Amennyiben ez a telepítés újra lett építve vagy áthelyezésre került, kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző példány kötését.",
|
||||
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
|
||||
"license_status": "Licencállapot",
|
||||
"license_status_active": "Aktív",
|
||||
"license_status_description": "A vállalati licenc állapota.",
|
||||
"license_status_expired": "Lejárt",
|
||||
"license_status_instance_mismatch": "Másik Példányhoz Kötve",
|
||||
"license_status_invalid": "Érvénytelen licenc",
|
||||
"license_status_unreachable": "Nem érhető el",
|
||||
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
|
||||
"recheck_license": "Licenc újraellenőrzése",
|
||||
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
|
||||
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks példányhoz van kötve. Kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző kötést.",
|
||||
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
|
||||
"recheck_license_success": "A licencellenőrzés sikeres",
|
||||
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "タイトル",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "トライアル期間の残り{count}日",
|
||||
"trial_expired": "トライアル期間が終了しました",
|
||||
"trial_one_day_remaining": "トライアル期間の残り1日",
|
||||
"try_again": "もう一度お試しください",
|
||||
"type": "種類",
|
||||
"unknown_survey": "不明なフォーム",
|
||||
@@ -441,6 +444,7 @@
|
||||
"update": "更新",
|
||||
"updated": "更新済み",
|
||||
"updated_at": "更新日時",
|
||||
"upgrade_plan": "プランをアップグレード",
|
||||
"upload": "アップロード",
|
||||
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
|
||||
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "キャンセル中",
|
||||
"manage_subscription": "サブスクリプションを管理",
|
||||
"add_payment_method": "支払い方法を追加",
|
||||
"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": "トライアルの開始に失敗しました。もう一度お試しください。",
|
||||
"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": "シームレスに統合されたアンケート、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アクセス",
|
||||
"trial_feature_attribute_segmentation": "属性ベースのセグメンテーション",
|
||||
"trial_feature_contact_segment_management": "連絡先とセグメントの管理",
|
||||
"trial_feature_email_followups": "メールフォローアップ",
|
||||
"trial_feature_hide_branding": "Formbricksブランディングを非表示",
|
||||
"trial_feature_mobile_sdks": "iOS & Android SDK",
|
||||
"trial_feature_respondent_identification": "回答者の識別",
|
||||
"trial_feature_unlimited_seats": "無制限のシート数",
|
||||
"trial_feature_webhooks": "カスタムWebhook",
|
||||
"trial_no_credit_card": "14日間トライアル、クレジットカード不要",
|
||||
"trial_payment_method_added_description": "準備完了です!トライアル終了後、Proプランが自動的に継続されます。",
|
||||
"trial_title": "Formbricks Proを無料で入手しよう!",
|
||||
"unlimited_responses": "無制限の回答",
|
||||
"unlimited_workspaces": "無制限ワークスペース",
|
||||
"upgrade": "アップグレード",
|
||||
"upgrade_now": "今すぐアップグレード",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "使用済み",
|
||||
"yearly": "年間",
|
||||
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
|
||||
"your_plan": "ご利用プラン"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "エンタープライズ機能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
|
||||
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
|
||||
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
|
||||
"license_status": "ライセンスステータス",
|
||||
"license_status_active": "有効",
|
||||
"license_status_description": "エンタープライズライセンスのステータス。",
|
||||
"license_status_expired": "期限切れ",
|
||||
"license_status_instance_mismatch": "別のインスタンスに紐付け済み",
|
||||
"license_status_invalid": "無効なライセンス",
|
||||
"license_status_unreachable": "接続不可",
|
||||
"license_unreachable_grace_period": "ライセンスサーバーに接続できません。エンタープライズ機能は{gracePeriodEnd}までの3日間の猶予期間中は引き続き利用できます。",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください",
|
||||
"recheck_license": "ライセンスを再確認",
|
||||
"recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。",
|
||||
"recheck_license_instance_mismatch": "このライセンスは別のFormbricksインスタンスに紐付けられています。Formbricksサポートに連絡して、以前の紐付けを解除してもらってください。",
|
||||
"recheck_license_invalid": "ライセンスキーが無効です。ENTERPRISE_LICENSE_KEYを確認してください。",
|
||||
"recheck_license_success": "ライセンスの確認に成功しました",
|
||||
"recheck_license_unreachable": "ライセンスサーバーに接続できません。後ほど再度お試しください。",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Linksboven",
|
||||
"top_right": "Rechtsboven",
|
||||
"trial_days_remaining": "{count} dagen over in je proefperiode",
|
||||
"trial_expired": "Je proefperiode is verlopen",
|
||||
"trial_one_day_remaining": "1 dag over in je proefperiode",
|
||||
"try_again": "Probeer het opnieuw",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Onbekende enquête",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Bezig met annuleren",
|
||||
"manage_subscription": "Beheer abonnement",
|
||||
"add_payment_method": "Betaalmethode toevoegen",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Segmentatie op basis van attributen",
|
||||
"trial_feature_contact_segment_management": "Contact- en segmentbeheer",
|
||||
"trial_feature_email_followups": "E-mail follow-ups",
|
||||
"trial_feature_hide_branding": "Verberg Formbricks-branding",
|
||||
"trial_feature_mobile_sdks": "iOS- en Android-SDK's",
|
||||
"trial_feature_respondent_identification": "Identificatie van respondenten",
|
||||
"trial_feature_unlimited_seats": "Onbeperkt aantal gebruikers",
|
||||
"trial_feature_webhooks": "Aangepaste webhooks",
|
||||
"trial_no_credit_card": "14 dagen proefperiode, geen creditcard vereist",
|
||||
"trial_payment_method_added_description": "Je bent helemaal klaar! Je Pro-abonnement wordt automatisch voortgezet na afloop van de proefperiode.",
|
||||
"trial_title": "Krijg Formbricks Pro gratis!",
|
||||
"unlimited_responses": "Onbeperkte reacties",
|
||||
"unlimited_workspaces": "Onbeperkt werkruimtes",
|
||||
"upgrade": "Upgraden",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Enterprise-functies",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
|
||||
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
|
||||
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
|
||||
"license_status": "Licentiestatus",
|
||||
"license_status_active": "Actief",
|
||||
"license_status_description": "Status van je enterprise-licentie.",
|
||||
"license_status_expired": "Verlopen",
|
||||
"license_status_instance_mismatch": "Gekoppeld aan Andere Instantie",
|
||||
"license_status_invalid": "Ongeldige licentie",
|
||||
"license_status_unreachable": "Niet bereikbaar",
|
||||
"license_unreachable_grace_period": "Licentieserver is niet bereikbaar. Je enterprise functies blijven actief tijdens een respijtperiode van 3 dagen die eindigt op {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Vragen? Neem contact op met",
|
||||
"recheck_license": "Licentie opnieuw controleren",
|
||||
"recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.",
|
||||
"recheck_license_instance_mismatch": "Deze licentie is gekoppeld aan een andere Formbricks-instantie. Vraag Formbricks-support om de vorige koppeling te verbreken.",
|
||||
"recheck_license_invalid": "De licentiesleutel is ongeldig. Controleer je ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Licentiecontrole geslaagd",
|
||||
"recheck_license_unreachable": "Licentieserver is niet bereikbaar. Probeer het later opnieuw.",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Título",
|
||||
"top_left": "Canto superior esquerdo",
|
||||
"top_right": "Canto Superior Direito",
|
||||
"trial_days_remaining": "{count} dias restantes no seu período de teste",
|
||||
"trial_expired": "Seu período de teste expirou",
|
||||
"trial_one_day_remaining": "1 dia restante no seu período de teste",
|
||||
"try_again": "Tenta de novo",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Pesquisa desconhecida",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Cancelando",
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"add_payment_method": "Adicionar forma de pagamento",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
|
||||
"trial_feature_contact_segment_management": "Gerenciamento de Contatos e Segmentos",
|
||||
"trial_feature_email_followups": "Follow-ups por E-mail",
|
||||
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
|
||||
"trial_feature_respondent_identification": "Identificação de Respondentes",
|
||||
"trial_feature_unlimited_seats": "Assentos Ilimitados",
|
||||
"trial_feature_webhooks": "Webhooks Personalizados",
|
||||
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
|
||||
"trial_payment_method_added_description": "Tudo pronto! Seu plano Pro continuará automaticamente após o término do período de teste.",
|
||||
"trial_title": "Ganhe o Formbricks Pro gratuitamente!",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Recursos Empresariais",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
|
||||
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
|
||||
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
|
||||
"license_status": "Status da licença",
|
||||
"license_status_active": "Ativa",
|
||||
"license_status_description": "Status da sua licença enterprise.",
|
||||
"license_status_expired": "Expirada",
|
||||
"license_status_instance_mismatch": "Vinculada a Outra Instância",
|
||||
"license_status_invalid": "Licença inválida",
|
||||
"license_status_unreachable": "Inacessível",
|
||||
"license_unreachable_grace_period": "O servidor de licenças não pode ser alcançado. Seus recursos empresariais permanecem ativos durante um período de carência de 3 dias que termina em {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Perguntas? Entre em contato com",
|
||||
"recheck_license": "Verificar licença novamente",
|
||||
"recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.",
|
||||
"recheck_license_instance_mismatch": "Esta licença está vinculada a uma instância diferente do Formbricks. Peça ao suporte do Formbricks para desconectar a vinculação anterior.",
|
||||
"recheck_license_invalid": "A chave de licença é inválida. Verifique sua ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Verificação da licença bem-sucedida",
|
||||
"recheck_license_unreachable": "Servidor de licenças inacessível. Por favor, tente novamente mais tarde.",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Título",
|
||||
"top_left": "Superior Esquerdo",
|
||||
"top_right": "Superior Direito",
|
||||
"trial_days_remaining": "{count} dias restantes no teu período de teste",
|
||||
"trial_expired": "O teu período de teste expirou",
|
||||
"trial_one_day_remaining": "1 dia restante no teu período de teste",
|
||||
"try_again": "Tente novamente",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Inquérito desconhecido",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "A cancelar",
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"add_payment_method": "Adicionar método de pagamento",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
|
||||
"trial_feature_contact_segment_management": "Gestão de Contactos e Segmentos",
|
||||
"trial_feature_email_followups": "Seguimentos por E-mail",
|
||||
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
|
||||
"trial_feature_respondent_identification": "Identificação de Inquiridos",
|
||||
"trial_feature_unlimited_seats": "Lugares Ilimitados",
|
||||
"trial_feature_webhooks": "Webhooks Personalizados",
|
||||
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
|
||||
"trial_payment_method_added_description": "Está tudo pronto! O teu plano Pro continuará automaticamente após o fim do período experimental.",
|
||||
"trial_title": "Obtém o Formbricks Pro gratuitamente!",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Funcionalidades da Empresa",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
|
||||
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
|
||||
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
|
||||
"license_status": "Estado da licença",
|
||||
"license_status_active": "Ativa",
|
||||
"license_status_description": "Estado da sua licença empresarial.",
|
||||
"license_status_expired": "Expirada",
|
||||
"license_status_instance_mismatch": "Associada a Outra Instância",
|
||||
"license_status_invalid": "Licença inválida",
|
||||
"license_status_unreachable": "Inacessível",
|
||||
"license_unreachable_grace_period": "Não é possível contactar o servidor de licenças. As suas funcionalidades empresariais permanecem ativas durante um período de tolerância de 3 dias que termina a {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Questões? Por favor entre em contacto com",
|
||||
"recheck_license": "Verificar licença novamente",
|
||||
"recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.",
|
||||
"recheck_license_instance_mismatch": "Esta licença está associada a uma instância Formbricks diferente. Pede ao suporte da Formbricks para desconectar a associação anterior.",
|
||||
"recheck_license_invalid": "A chave de licença é inválida. Por favor, verifique a sua ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Verificação da licença bem-sucedida",
|
||||
"recheck_license_unreachable": "O servidor de licenças está inacessível. Por favor, tenta novamente mais tarde.",
|
||||
|
||||
@@ -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",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titlu",
|
||||
"top_left": "Stânga Sus",
|
||||
"top_right": "Dreapta Sus",
|
||||
"trial_days_remaining": "{count} zile rămase în perioada ta de probă",
|
||||
"trial_expired": "Perioada ta de probă a expirat",
|
||||
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
|
||||
"try_again": "Încearcă din nou",
|
||||
"type": "Tip",
|
||||
"unknown_survey": "Chestionar necunoscut",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Anulare în curs",
|
||||
"manage_subscription": "Gestionați abonamentul",
|
||||
"add_payment_method": "Adaugă o metodă de plată",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"trial_feature_attribute_segmentation": "Segmentare bazată pe atribute",
|
||||
"trial_feature_contact_segment_management": "Gestionare contacte și segmente",
|
||||
"trial_feature_email_followups": "Urmăriri prin email",
|
||||
"trial_feature_hide_branding": "Ascunde branding-ul Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDK-uri iOS și Android",
|
||||
"trial_feature_respondent_identification": "Identificarea respondenților",
|
||||
"trial_feature_unlimited_seats": "Locuri nelimitate",
|
||||
"trial_feature_webhooks": "Webhook-uri personalizate",
|
||||
"trial_no_credit_card": "14 zile de probă, fără card necesar",
|
||||
"trial_payment_method_added_description": "Totul este pregătit! Planul tău Pro va continua automat după ce se încheie perioada de probă.",
|
||||
"trial_title": "Obține Formbricks Pro gratuit!",
|
||||
"unlimited_responses": "Răspunsuri nelimitate",
|
||||
"unlimited_workspaces": "Workspaces nelimitate",
|
||||
"upgrade": "Actualizare",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Funcții Enterprise",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
|
||||
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
|
||||
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
|
||||
"license_status": "Stare licență",
|
||||
"license_status_active": "Activă",
|
||||
"license_status_description": "Starea licenței tale enterprise.",
|
||||
"license_status_expired": "Expirată",
|
||||
"license_status_instance_mismatch": "Asociată cu Altă Instanță",
|
||||
"license_status_invalid": "Licență invalidă",
|
||||
"license_status_unreachable": "Indisponibilă",
|
||||
"license_unreachable_grace_period": "Serverul de licențe nu poate fi contactat. Funcționalitățile enterprise rămân active timp de 3 zile, până la data de {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Întrebări? Vă rugăm să trimiteți mesaj către",
|
||||
"recheck_license": "Verifică din nou licența",
|
||||
"recheck_license_failed": "Verificarea licenței a eșuat. Serverul de licențe poate fi indisponibil.",
|
||||
"recheck_license_instance_mismatch": "Această licență este asociată cu o altă instanță Formbricks. Solicită echipei de suport Formbricks să deconecteze asocierea anterioară.",
|
||||
"recheck_license_invalid": "Cheia de licență este invalidă. Te rugăm să verifici variabila ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Licența a fost verificată cu succes",
|
||||
"recheck_license_unreachable": "Serverul de licențe este indisponibil. Te rugăm să încerci din nou mai târziu.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Заголовок",
|
||||
"top_left": "Вверху слева",
|
||||
"top_right": "Вверху справа",
|
||||
"trial_days_remaining": "{count, plural, one {Остался # день пробного периода} few {Осталось # дня пробного периода} many {Осталось # дней пробного периода} other {Осталось # дней пробного периода}}",
|
||||
"trial_expired": "Пробный период истёк",
|
||||
"trial_one_day_remaining": "Остался 1 день пробного периода",
|
||||
"try_again": "Попробуйте ещё раз",
|
||||
"type": "Тип",
|
||||
"unknown_survey": "Неизвестный опрос",
|
||||
@@ -441,6 +444,7 @@
|
||||
"update": "Обновить",
|
||||
"updated": "Обновлено",
|
||||
"updated_at": "Обновлено",
|
||||
"upgrade_plan": "Перейти на другой тариф",
|
||||
"upload": "Загрузить",
|
||||
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
|
||||
"upload_input_description": "Кликните или перетащите файлы для загрузки.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Отмена",
|
||||
"manage_subscription": "Управление подпиской",
|
||||
"add_payment_method": "Добавить способ оплаты",
|
||||
"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": "Не удалось запустить пробный период. Попробуйте снова.",
|
||||
"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": "Бесшовно интегрированные опросы, 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",
|
||||
"trial_feature_attribute_segmentation": "Сегментация на основе атрибутов",
|
||||
"trial_feature_contact_segment_management": "Управление контактами и сегментами",
|
||||
"trial_feature_email_followups": "Email-уведомления",
|
||||
"trial_feature_hide_branding": "Скрыть брендинг Formbricks",
|
||||
"trial_feature_mobile_sdks": "iOS и Android SDK",
|
||||
"trial_feature_respondent_identification": "Идентификация респондентов",
|
||||
"trial_feature_unlimited_seats": "Неограниченное количество мест",
|
||||
"trial_feature_webhooks": "Пользовательские вебхуки",
|
||||
"trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется",
|
||||
"trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.",
|
||||
"trial_title": "Получите Formbricks Pro бесплатно!",
|
||||
"unlimited_responses": "Неограниченное количество ответов",
|
||||
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
|
||||
"upgrade": "Обновить",
|
||||
"upgrade_now": "Обновить сейчас",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "использовано",
|
||||
"yearly": "Годовой",
|
||||
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
|
||||
"your_plan": "Ваш тариф"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Функции для предприятий",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
|
||||
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
|
||||
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
|
||||
"license_status": "Статус лицензии",
|
||||
"license_status_active": "Активна",
|
||||
"license_status_description": "Статус вашей корпоративной лицензии.",
|
||||
"license_status_expired": "Срок действия истёк",
|
||||
"license_status_instance_mismatch": "Привязана к другому экземпляру",
|
||||
"license_status_invalid": "Недействительная лицензия",
|
||||
"license_status_unreachable": "Недоступна",
|
||||
"license_unreachable_grace_period": "Не удаётся подключиться к серверу лицензий. Корпоративные функции останутся активными в течение 3-дневного льготного периода, который закончится {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Вопросы? Свяжитесь с",
|
||||
"recheck_license": "Проверить лицензию ещё раз",
|
||||
"recheck_license_failed": "Не удалось проверить лицензию. Сервер лицензий может быть недоступен.",
|
||||
"recheck_license_instance_mismatch": "Эта лицензия привязана к другому экземпляру Formbricks. Обратитесь в службу поддержки Formbricks для отключения предыдущей привязки.",
|
||||
"recheck_license_invalid": "Ключ лицензии недействителен. Пожалуйста, проверь свою переменную ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Проверка лицензии прошла успешно",
|
||||
"recheck_license_unreachable": "Сервер лицензий недоступен. Пожалуйста, попробуй позже.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Övre vänster",
|
||||
"top_right": "Övre höger",
|
||||
"trial_days_remaining": "{count} dagar kvar av din provperiod",
|
||||
"trial_expired": "Din provperiod har gått ut",
|
||||
"trial_one_day_remaining": "1 dag kvar av din provperiod",
|
||||
"try_again": "Försök igen",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Okänd enkät",
|
||||
@@ -441,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.",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "Avbryter",
|
||||
"manage_subscription": "Hantera prenumeration",
|
||||
"add_payment_method": "Lägg till betalningsmetod",
|
||||
"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.",
|
||||
"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": "Kvothantering",
|
||||
"scale_feature_spam": "Spamskydd",
|
||||
"scale_feature_teams": "Team & åtkomstroller",
|
||||
"select_plan_header_subtitle": "Inget kreditkort krävs, inga villkor.",
|
||||
"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",
|
||||
"trial_feature_attribute_segmentation": "Attributbaserad segmentering",
|
||||
"trial_feature_contact_segment_management": "Kontakt- och segmenthantering",
|
||||
"trial_feature_email_followups": "E-postuppföljningar",
|
||||
"trial_feature_hide_branding": "Dölj Formbricks-branding",
|
||||
"trial_feature_mobile_sdks": "iOS- och Android-SDK:er",
|
||||
"trial_feature_respondent_identification": "Respondentidentifiering",
|
||||
"trial_feature_unlimited_seats": "Obegränsade platser",
|
||||
"trial_feature_webhooks": "Anpassade webhooks",
|
||||
"trial_no_credit_card": "14 dagars provperiod, inget kreditkort krävs",
|
||||
"trial_payment_method_added_description": "Du är redo! Din Pro-plan kommer att fortsätta automatiskt efter att provperioden slutar.",
|
||||
"trial_title": "Få Formbricks Pro gratis!",
|
||||
"unlimited_responses": "Obegränsade svar",
|
||||
"unlimited_workspaces": "Obegränsat antal arbetsytor",
|
||||
"upgrade": "Uppgradera",
|
||||
"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": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "Enterprise-funktioner",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
|
||||
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
|
||||
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
|
||||
"license_status": "Licensstatus",
|
||||
"license_status_active": "Aktiv",
|
||||
"license_status_description": "Status för din företagslicens.",
|
||||
"license_status_expired": "Utgången",
|
||||
"license_status_instance_mismatch": "Kopplad till en annan instans",
|
||||
"license_status_invalid": "Ogiltig licens",
|
||||
"license_status_unreachable": "Otillgänglig",
|
||||
"license_unreachable_grace_period": "Licensservern kan inte nås. Dina enterprise-funktioner är aktiva under en 3-dagars respitperiod som slutar {gracePeriodEnd}.",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "Frågor? Kontakta",
|
||||
"recheck_license": "Kontrollera licensen igen",
|
||||
"recheck_license_failed": "Licenskontrollen misslyckades. Licensservern kan vara otillgänglig.",
|
||||
"recheck_license_instance_mismatch": "Den här licensen är kopplad till en annan Formbricks-instans. Be Formbricks support att koppla bort den tidigare bindningen.",
|
||||
"recheck_license_invalid": "Licensnyckeln är ogiltig. Kontrollera din ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Licenskontrollen lyckades",
|
||||
"recheck_license_unreachable": "Licensservern är otillgänglig. Försök igen senare.",
|
||||
|
||||
@@ -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": "文件存储 未设置,上传 可能 失败",
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "标题",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "试用期还剩 {count} 天",
|
||||
"trial_expired": "您的试用期已过期",
|
||||
"trial_one_day_remaining": "试用期还剩 1 天",
|
||||
"try_again": "再试一次",
|
||||
"type": "类型",
|
||||
"unknown_survey": "未知调查",
|
||||
@@ -441,6 +444,7 @@
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新 于",
|
||||
"upgrade_plan": "升级套餐",
|
||||
"upload": "上传",
|
||||
"upload_failed": "上传失败,请重试。",
|
||||
"upload_input_description": "点击 或 拖动 上传 文件",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "正在取消",
|
||||
"manage_subscription": "管理 订阅",
|
||||
"add_payment_method": "添加支付方式",
|
||||
"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": "试用启动失败,请重试。",
|
||||
"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": "无缝集成的调查问卷,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 访问",
|
||||
"trial_feature_attribute_segmentation": "基于属性的细分",
|
||||
"trial_feature_contact_segment_management": "联系人和细分管理",
|
||||
"trial_feature_email_followups": "电子邮件跟进",
|
||||
"trial_feature_hide_branding": "隐藏 Formbricks 品牌标识",
|
||||
"trial_feature_mobile_sdks": "iOS 和 Android SDK",
|
||||
"trial_feature_respondent_identification": "受访者识别",
|
||||
"trial_feature_unlimited_seats": "无限席位",
|
||||
"trial_feature_webhooks": "自定义 Webhook",
|
||||
"trial_no_credit_card": "14 天试用,无需信用卡",
|
||||
"trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。",
|
||||
"trial_title": "免费获取 Formbricks Pro!",
|
||||
"unlimited_responses": "无限反馈",
|
||||
"unlimited_workspaces": "无限工作区",
|
||||
"upgrade": "升级",
|
||||
"upgrade_now": "立即升级",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已用",
|
||||
"yearly": "按年付费",
|
||||
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
|
||||
"your_plan": "你的套餐"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "企业 功能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
|
||||
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
|
||||
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
|
||||
"license_status": "许可证状态",
|
||||
"license_status_active": "已激活",
|
||||
"license_status_description": "你的企业许可证状态。",
|
||||
"license_status_expired": "已过期",
|
||||
"license_status_instance_mismatch": "已绑定到其他实例",
|
||||
"license_status_invalid": "许可证无效",
|
||||
"license_status_unreachable": "无法访问",
|
||||
"license_unreachable_grace_period": "无法连接到许可证服务器。在为期 3 天的宽限期内,你的企业功能仍然可用,宽限期将于 {gracePeriodEnd} 结束。",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "问题 ? 请 联系",
|
||||
"recheck_license": "重新检查许可证",
|
||||
"recheck_license_failed": "许可证检查失败。许可证服务器可能无法访问。",
|
||||
"recheck_license_instance_mismatch": "此许可证已绑定到另一个 Formbricks 实例。请联系 Formbricks 支持团队解除先前的绑定。",
|
||||
"recheck_license_invalid": "许可证密钥无效。请确认你的 ENTERPRISE_LICENSE_KEY。",
|
||||
"recheck_license_success": "许可证检查成功",
|
||||
"recheck_license_unreachable": "许可证服务器无法访问,请稍后再试。",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "標題",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "試用期剩餘 {count} 天",
|
||||
"trial_expired": "您的試用期已結束",
|
||||
"trial_one_day_remaining": "試用期剩餘 1 天",
|
||||
"try_again": "再試一次",
|
||||
"type": "類型",
|
||||
"unknown_survey": "未知問卷",
|
||||
@@ -441,6 +444,7 @@
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新時間",
|
||||
"upgrade_plan": "升級方案",
|
||||
"upload": "上傳",
|
||||
"upload_failed": "上傳失敗。請再試一次。",
|
||||
"upload_input_description": "點擊或拖曳以上傳檔案。",
|
||||
@@ -968,30 +972,80 @@
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"cancelling": "正在取消",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"add_payment_method": "新增付款方式",
|
||||
"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": "無法開始試用。請再試一次。",
|
||||
"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": "完美整合的問卷調查,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 存取",
|
||||
"trial_feature_attribute_segmentation": "基於屬性的分群",
|
||||
"trial_feature_contact_segment_management": "聯絡人與分群管理",
|
||||
"trial_feature_email_followups": "電子郵件追蹤",
|
||||
"trial_feature_hide_branding": "隱藏 Formbricks 品牌標識",
|
||||
"trial_feature_mobile_sdks": "iOS 與 Android SDK",
|
||||
"trial_feature_respondent_identification": "受訪者識別",
|
||||
"trial_feature_unlimited_seats": "無限座位數",
|
||||
"trial_feature_webhooks": "自訂 Webhook",
|
||||
"trial_no_credit_card": "14 天試用,無需信用卡",
|
||||
"trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。",
|
||||
"trial_title": "免費獲得 Formbricks Pro!",
|
||||
"unlimited_responses": "無限回應",
|
||||
"unlimited_workspaces": "無限工作區",
|
||||
"upgrade": "升級",
|
||||
"upgrade_now": "立即升級",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已使用",
|
||||
"yearly": "年繳",
|
||||
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
|
||||
"your_plan": "您的方案"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1017,11 +1071,13 @@
|
||||
"enterprise_features": "企業版功能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
|
||||
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
|
||||
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
|
||||
"license_status": "授權狀態",
|
||||
"license_status_active": "有效",
|
||||
"license_status_description": "你的企業授權狀態。",
|
||||
"license_status_expired": "已過期",
|
||||
"license_status_instance_mismatch": "已綁定至其他執行個體",
|
||||
"license_status_invalid": "授權無效",
|
||||
"license_status_unreachable": "無法連線",
|
||||
"license_unreachable_grace_period": "無法連線至授權伺服器。在 3 天的寬限期內,你的企業功能仍可使用,寬限期將於 {gracePeriodEnd} 結束。",
|
||||
@@ -1032,6 +1088,7 @@
|
||||
"questions_please_reach_out_to": "有任何問題?請聯絡",
|
||||
"recheck_license": "重新檢查授權",
|
||||
"recheck_license_failed": "授權檢查失敗。授權伺服器可能無法連線。",
|
||||
"recheck_license_instance_mismatch": "此授權已綁定至不同的 Formbricks 執行個體。請聯繫 Formbricks 支援以解除先前的綁定。",
|
||||
"recheck_license_invalid": "授權金鑰無效。請確認你的 ENTERPRISE_LICENSE_KEY。",
|
||||
"recheck_license_success": "授權檢查成功",
|
||||
"recheck_license_unreachable": "授權伺服器無法連線,請稍後再試。",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<TContactLinkParams> }) =>
|
||||
@@ -47,6 +49,17 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
});
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(params.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return handleApiError(request, {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const surveyResult = await getSurvey(params.surveyId);
|
||||
|
||||
if (!surveyResult.ok) {
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("Auth Utils", () => {
|
||||
expect(hash1).not.toBe(hash2);
|
||||
expect(await verifyPassword(password, hash1)).toBe(true);
|
||||
expect(await verifyPassword(password, hash2)).toBe(true);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test("should hash complex passwords correctly", async () => {
|
||||
const complexPassword = "MyC0mpl3x!P@ssw0rd#2024$%^&*()";
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
|
||||
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants";
|
||||
import { verifyInviteToken } from "@/lib/jwt";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
@@ -17,6 +18,7 @@ import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
@@ -148,6 +150,16 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
// Stripe setup must run AFTER membership is created so the owner email is available
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
ensureCloudStripeSetupForOrganization(organization.id).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId: organization.id },
|
||||
"Stripe setup failed after organization creation"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -10,9 +11,17 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
|
||||
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
|
||||
import { createSetupCheckoutSession } from "@/modules/ee/billing/api/lib/create-setup-checkout-session";
|
||||
import {
|
||||
createPaidPlanCheckoutSession,
|
||||
createProTrialSubscription,
|
||||
ensureCloudStripeSetupForOrganization,
|
||||
ensureStripeCustomerForOrganization,
|
||||
reconcileCloudStripeSubscriptionsForOrganization,
|
||||
switchOrganizationToCloudPlan,
|
||||
syncOrganizationBillingFromStripe,
|
||||
undoPendingOrganizationPlanChange,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
|
||||
const ZManageSubscriptionAction = z.object({
|
||||
environmentId: ZId,
|
||||
@@ -40,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;
|
||||
@@ -48,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,
|
||||
@@ -137,4 +135,217 @@ export const retryStripeSetupAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
await ensureCloudStripeSetupForOrganization(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const ZCreateTrialPaymentCheckoutAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const createTrialPaymentCheckoutAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateTrialPaymentCheckoutAction)
|
||||
.action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
const subscriptionId = organization.billing.stripe?.subscriptionId;
|
||||
if (!subscriptionId) {
|
||||
throw new ResourceNotFoundError("subscription", organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const returnUrl = `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`;
|
||||
const checkoutUrl = await createSetupCheckoutSession(
|
||||
organization.billing.stripeCustomerId,
|
||||
subscriptionId,
|
||||
returnUrl,
|
||||
organizationId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = { setupCheckoutCreated: true };
|
||||
return checkoutUrl;
|
||||
})
|
||||
);
|
||||
|
||||
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 }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(parsedInput.organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
organization.billing?.stripeCustomerId ??
|
||||
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
|
||||
if (!customerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await createProTrialSubscription(parsedInput.organizationId, customerId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
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 };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Stripe from "stripe";
|
||||
import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Creates a Stripe Checkout Session in `setup` mode so the customer can enter
|
||||
* a payment method, billing address, and tax ID — without creating a new subscription.
|
||||
* After completion the webhook handler attaches the payment method to the existing
|
||||
* trial subscription.
|
||||
*/
|
||||
export const createSetupCheckoutSession = async (
|
||||
stripeCustomerId: string,
|
||||
subscriptionId: string,
|
||||
returnUrl: string,
|
||||
organizationId: string
|
||||
): Promise<string> => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
|
||||
});
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const currency = subscription.currency ?? "usd";
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "setup",
|
||||
customer: stripeCustomerId,
|
||||
currency,
|
||||
billing_address_collection: "required",
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
required: "if_supported",
|
||||
},
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
name: "auto",
|
||||
},
|
||||
success_url: `${returnUrl}?checkout_success=1`,
|
||||
cancel_url: returnUrl,
|
||||
metadata: {
|
||||
organizationId,
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error("Stripe did not return a Checkout Session URL");
|
||||
}
|
||||
|
||||
return session.url;
|
||||
};
|
||||
@@ -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,10 +12,55 @@ 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",
|
||||
]);
|
||||
|
||||
/**
|
||||
* When a setup-mode Checkout Session completes, the customer has just provided a
|
||||
* payment method + billing address. We attach that payment method as the default
|
||||
* on the customer (for future invoices) and on the trial subscription so Stripe
|
||||
* can charge it when the trial ends.
|
||||
*/
|
||||
const handleSetupCheckoutCompleted = async (
|
||||
session: Stripe.Checkout.Session,
|
||||
stripe: Stripe
|
||||
): Promise<void> => {
|
||||
if (session.mode !== "setup" || !session.setup_intent) return;
|
||||
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent.id;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
logger.warn({ sessionId: session.id }, "Setup checkout completed but no payment method found");
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
|
||||
if (customerId) {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionId = session.metadata?.subscriptionId;
|
||||
if (subscriptionId) {
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
default_payment_method: paymentMethodId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
|
||||
if (!("metadata" in eventObject) || !eventObject.metadata) {
|
||||
return null;
|
||||
@@ -101,6 +146,10 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
await handleSetupCheckoutCompleted(event.data.object, stripe);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
|
||||
await syncOrganizationBillingFromStripe(organizationId, {
|
||||
id: event.id,
|
||||
|
||||
@@ -15,7 +15,10 @@ export const BillingSlider = ({ className, value, max }: BillingSliderProps) =>
|
||||
<div className={cn("relative h-2 w-full overflow-hidden rounded-full bg-slate-200", className)}>
|
||||
<div
|
||||
style={{ width: `${percentage}%` }}
|
||||
className={cn("h-full rounded-full transition-all", percentage >= 90 ? "bg-red-500" : "bg-slate-800")}
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
percentage >= 90 ? "bg-amber-600" : "bg-slate-800"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,84 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { createElement, useEffect, useMemo, useState } from "react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
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,
|
||||
isSubscriptionCancelledAction,
|
||||
changeBillingPlanAction,
|
||||
createPlanCheckoutAction,
|
||||
createTrialPaymentCheckoutAction,
|
||||
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;
|
||||
@@ -87,23 +42,101 @@ interface PricingTableProps {
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
hasBillingRights: boolean;
|
||||
currentCloudPlan: "hobby" | "pro" | "scale" | "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" | "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");
|
||||
if (plan === "custom") return t("environments.settings.billing.plan_custom");
|
||||
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,
|
||||
@@ -113,102 +146,134 @@ 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 showPricingTable =
|
||||
hasBillingRights && isUpgradeablePlan && !!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(() => {
|
||||
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;
|
||||
if (searchParams.get("checkout_success")) {
|
||||
const timer = setTimeout(() => router.refresh(), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
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);
|
||||
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?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
void loadPricingTableCustomerSession();
|
||||
}, [environmentId, showPricingTable]);
|
||||
|
||||
const openCustomerPortal = async () => {
|
||||
const manageSubscriptionResponse = await manageSubscriptionAction({
|
||||
environmentId,
|
||||
});
|
||||
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
|
||||
router.push(manageSubscriptionResponse.data);
|
||||
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 setup checkout session:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,35 +281,168 @@ 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 {
|
||||
setIsRetryingStripeSetup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const responsesUnlimitedCheck =
|
||||
currentCloudPlan === "scale" && organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck =
|
||||
currentCloudPlan === "scale" && 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 flex-col gap-4">
|
||||
<div className="flex max-w-6xl flex-col gap-4">
|
||||
{trialDaysRemaining !== null &&
|
||||
(hasPaymentMethod ? (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_payment_method_added_description")}
|
||||
</AlertDescription>
|
||||
</TrialAlert>
|
||||
) : (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining}>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_alert_description")}
|
||||
</AlertDescription>
|
||||
{hasBillingRights && (
|
||||
<AlertButton onClick={() => void openTrialPaymentCheckout()}>
|
||||
{t("environments.settings.billing.add_payment_method")}
|
||||
</AlertButton>
|
||||
)}
|
||||
</TrialAlert>
|
||||
))}
|
||||
|
||||
{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>
|
||||
@@ -256,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
|
||||
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
|
||||
@@ -273,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"
|
||||
@@ -282,36 +501,21 @@ 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>
|
||||
|
||||
<UsageCard
|
||||
metric={t("common.responses")}
|
||||
currentCount={responseCount}
|
||||
limit={organization.billing.limits.monthly.responses}
|
||||
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 className="flex flex-col gap-2">
|
||||
<UsageCard
|
||||
metric={t("common.responses")}
|
||||
currentCount={responseCount}
|
||||
limit={organization.billing.limits.monthly.responses}
|
||||
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")}
|
||||
@@ -323,35 +527,136 @@ export const PricingTable = ({
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{currentCloudPlan === "pro" && (
|
||||
<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>✓ {t("environments.settings.billing.scale_feature_teams")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_api")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_quota")}</span>
|
||||
<span>✓ {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>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, GiftIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import calLogo from "@/images/customer-logos/cal-logo-light.svg";
|
||||
import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
|
||||
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
|
||||
import githubLogo from "@/images/customer-logos/github-logo.png";
|
||||
import siemensLogo from "@/images/customer-logos/siemens.png";
|
||||
import { startProTrialAction } from "@/modules/ee/billing/actions";
|
||||
import { startHobbyAction } from "@/modules/ee/billing/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SelectPlanCardProps {
|
||||
/** URL to redirect after starting trial or continuing with free */
|
||||
nextUrl: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const CUSTOMER_LOGOS = [
|
||||
{ src: siemensLogo, alt: "Siemens" },
|
||||
{ src: calLogo, alt: "Cal.com" },
|
||||
{ src: flixbusLogo, alt: "FlixBus" },
|
||||
{ src: githubLogo, alt: "GitHub" },
|
||||
{ src: ethereumLogo, alt: "Ethereum" },
|
||||
];
|
||||
|
||||
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
|
||||
const router = useRouter();
|
||||
const [isStartingTrial, setIsStartingTrial] = useState(false);
|
||||
const [isStartingHobby, setIsStartingHobby] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const TRIAL_FEATURE_KEYS = [
|
||||
t("environments.settings.billing.trial_feature_unlimited_seats"),
|
||||
t("environments.settings.billing.trial_feature_hide_branding"),
|
||||
t("environments.settings.billing.trial_feature_respondent_identification"),
|
||||
t("environments.settings.billing.trial_feature_contact_segment_management"),
|
||||
t("environments.settings.billing.trial_feature_attribute_segmentation"),
|
||||
t("environments.settings.billing.trial_feature_mobile_sdks"),
|
||||
t("environments.settings.billing.trial_feature_email_followups"),
|
||||
t("environments.settings.billing.trial_feature_webhooks"),
|
||||
t("environments.settings.billing.trial_feature_api_access"),
|
||||
] as const;
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
setIsStartingTrial(true);
|
||||
try {
|
||||
const result = await startProTrialAction({ organizationId });
|
||||
if (result?.data) {
|
||||
router.push(nextUrl);
|
||||
} else if (result?.serverError === "trial_already_used") {
|
||||
toast.error(t("environments.settings.billing.trial_already_used"));
|
||||
setIsStartingTrial(false);
|
||||
} else {
|
||||
toast.error(t("environments.settings.billing.failed_to_start_trial"));
|
||||
setIsStartingTrial(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.settings.billing.failed_to_start_trial"));
|
||||
setIsStartingTrial(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex w-full max-w-md flex-col items-center space-y-6">
|
||||
{/* Trial Card */}
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div className="flex flex-col items-center space-y-6 p-8">
|
||||
<div className="rounded-full bg-slate-100 p-4">
|
||||
<GiftIcon className="h-10 w-10 text-slate-600" />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold text-slate-800">
|
||||
{t("environments.settings.billing.trial_title")}
|
||||
</h3>
|
||||
<p className="mt-2 text-slate-600">{t("environments.settings.billing.trial_no_credit_card")}</p>
|
||||
</div>
|
||||
|
||||
<ul className="w-full space-y-3 text-left">
|
||||
{TRIAL_FEATURE_KEYS.map((key) => (
|
||||
<li key={key} className="flex items-center gap-3 text-slate-700">
|
||||
<CheckIcon className="h-5 w-5 flex-shrink-0 text-slate-900" />
|
||||
<span>{key}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleStartTrial}
|
||||
className="mt-4 w-full"
|
||||
loading={isStartingTrial}
|
||||
disabled={isStartingTrial || isStartingHobby}>
|
||||
{t("common.start_free_trial")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logo Carousel */}
|
||||
<div className="w-full overflow-hidden border-t border-slate-100 bg-slate-50 py-4">
|
||||
<div className="flex w-max animate-logo-scroll gap-12 hover:[animation-play-state:paused]">
|
||||
{[...CUSTOMER_LOGOS, ...CUSTOMER_LOGOS].map((logo, index) => (
|
||||
<div
|
||||
key={`${logo.alt}-${index}`}
|
||||
className="flex h-5 items-center opacity-50 grayscale transition-all duration-200 hover:opacity-100 hover:grayscale-0">
|
||||
<Image
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
height={20}
|
||||
width={100}
|
||||
className="h-5 w-auto max-w-[100px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleContinueHobby}
|
||||
disabled={isStartingTrial || isStartingHobby}
|
||||
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
|
||||
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
|
||||
type TrialAlertVariant = "error" | "warning" | "info" | "success";
|
||||
|
||||
const getTrialVariant = (daysRemaining: number): TrialAlertVariant => {
|
||||
if (daysRemaining <= 3) return "error";
|
||||
if (daysRemaining <= 7) return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
interface TrialAlertProps {
|
||||
trialDaysRemaining: number;
|
||||
size?: "small";
|
||||
hasPaymentMethod?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TrialAlert = ({
|
||||
trialDaysRemaining,
|
||||
size,
|
||||
hasPaymentMethod = false,
|
||||
children,
|
||||
}: TrialAlertProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (trialDaysRemaining <= 0) return t("common.trial_expired");
|
||||
if (trialDaysRemaining === 1) return t("common.trial_one_day_remaining");
|
||||
return t("common.trial_days_remaining", { count: trialDaysRemaining });
|
||||
}, [trialDaysRemaining, t]);
|
||||
|
||||
const variant = hasPaymentMethod ? "success" : getTrialVariant(trialDaysRemaining);
|
||||
|
||||
return (
|
||||
<Alert variant={variant} size={size} className="max-w-4xl">
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -29,7 +29,10 @@ 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"),
|
||||
billing,
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
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";
|
||||
|
||||
export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "unknown";
|
||||
export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "unknown";
|
||||
|
||||
export type TCloudBillingDisplayContext = {
|
||||
organizationId: string;
|
||||
currentCloudPlan: TCloudBillingDisplayPlan;
|
||||
currentBillingInterval: TCloudBillingInterval | null;
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
pendingChange: TOrganizationStripePendingChange | null;
|
||||
trialDaysRemaining: number | null;
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
|
||||
@@ -27,6 +34,34 @@ 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 = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): number | null => {
|
||||
if (billing.stripe?.subscriptionStatus !== "trialing" || !billing.stripe.trialEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trialEndDate = new Date(billing.stripe.trialEnd);
|
||||
if (!Number.isFinite(trialEndDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return Math.ceil((trialEndDate.getTime() - Date.now()) / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export const getCloudBillingDisplayContext = async (
|
||||
organizationId: string
|
||||
): Promise<TCloudBillingDisplayContext> => {
|
||||
@@ -41,7 +76,10 @@ 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,
|
||||
billing,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -16,6 +16,9 @@ describe("stripe-plan", () => {
|
||||
expect(
|
||||
getCloudPlanFromProduct(product({ id: "prod_scale", metadata: { formbricks_plan: "scale" } }))
|
||||
).toBe("scale");
|
||||
expect(
|
||||
getCloudPlanFromProduct(product({ id: "prod_custom", metadata: { formbricks_plan: "custom" } }))
|
||||
).toBe("custom");
|
||||
});
|
||||
|
||||
test("falls back to unknown for missing or unknown products", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CLOUD_PLAN_LEVEL = {
|
||||
hobby: 0,
|
||||
pro: 1,
|
||||
scale: 2,
|
||||
custom: 3,
|
||||
unknown: -1,
|
||||
} as const;
|
||||
|
||||
@@ -14,6 +15,7 @@ const CLOUD_PRODUCT_METADATA_TO_PLAN = {
|
||||
hobby: "hobby",
|
||||
pro: "pro",
|
||||
scale: "scale",
|
||||
custom: "custom",
|
||||
} as const satisfies Record<string, Exclude<TCloudStripePlan, "unknown">>;
|
||||
|
||||
const getProductPlanMetadata = (
|
||||
|
||||
@@ -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,12 +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",
|
||||
|
||||
@@ -75,9 +75,13 @@ export const recheckLicenseAction = authenticatedActionClient
|
||||
try {
|
||||
freshLicense = await fetchLicenseFresh();
|
||||
} catch (error) {
|
||||
// 400 = invalid license key — return directly so the UI shows the correct message
|
||||
if (error instanceof LicenseApiError && error.status === 400) {
|
||||
return { active: false, status: "invalid_license" as const };
|
||||
// 400 = invalid license key, 403 = license bound to another instance.
|
||||
// Return directly so the UI shows the correct message.
|
||||
if (error instanceof LicenseApiError && (error.status === 400 || error.status === 403)) {
|
||||
return {
|
||||
active: false,
|
||||
status: error.status === 400 ? ("invalid_license" as const) : ("instance_mismatch" as const),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -462,6 +462,37 @@ describe("License Core Logic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should return instance_mismatch when API returns 403", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
fetch.mockResolvedValueOnce({ ok: false, status: 403 } as any);
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: expect.objectContaining({ projects: 3 }),
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "instance_mismatch" as const,
|
||||
});
|
||||
});
|
||||
|
||||
test("should skip polling and fetch directly when Redis is unavailable (tryLock error)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getInstanceId } from "@/lib/instance";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
TEnterpriseLicenseStatusReturn,
|
||||
TLicenseStatus,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
|
||||
// Configuration
|
||||
@@ -52,7 +52,7 @@ type TEnterpriseLicenseResult = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
status: TEnterpriseLicenseStatusReturn;
|
||||
status: TLicenseStatus;
|
||||
};
|
||||
|
||||
type TPreviousResult = {
|
||||
@@ -407,8 +407,9 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
return fetchLicenseFromServerInternal(retryCount + 1);
|
||||
}
|
||||
|
||||
// 400 = invalid license key — propagate so callers can distinguish from unreachable
|
||||
if (res.status === 400) {
|
||||
// 400 = invalid license key, 403 = license bound to another instance.
|
||||
// Propagate both so callers can distinguish them from unreachable.
|
||||
if (res.status === 400 || res.status === 403) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -585,7 +586,7 @@ const computeLicenseState = async (
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
||||
status: liveLicenseDetails?.status ?? "unreachable",
|
||||
};
|
||||
memoryCache = { data: graceResult, timestamp: Date.now() };
|
||||
return graceResult;
|
||||
@@ -632,14 +633,15 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
try {
|
||||
liveLicenseDetails = await fetchLicense();
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseApiError && error.status === 400) {
|
||||
if (error instanceof LicenseApiError && (error.status === 400 || error.status === 403)) {
|
||||
const status = error.status === 400 ? "invalid_license" : "instance_mismatch";
|
||||
const invalidResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "invalid_license" as const,
|
||||
status,
|
||||
};
|
||||
memoryCache = { data: invalidResult, timestamp: Date.now() };
|
||||
return invalidResult;
|
||||
|
||||
@@ -29,9 +29,10 @@ export const ZEnterpriseLicenseDetails = z.object({
|
||||
|
||||
export type TEnterpriseLicenseDetails = z.infer<typeof ZEnterpriseLicenseDetails>;
|
||||
|
||||
export type TEnterpriseLicenseStatusReturn =
|
||||
export type TLicenseStatus =
|
||||
| "active"
|
||||
| "expired"
|
||||
| "instance_mismatch"
|
||||
| "unreachable"
|
||||
| "invalid_license"
|
||||
| "no-license";
|
||||
|
||||
@@ -174,9 +174,7 @@ export const QuotasCard = ({
|
||||
description={t("common.quotas_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud
|
||||
? t("common.start_free_trial")
|
||||
: t("common.request_trial_license"),
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -31,6 +31,7 @@ const baseContext: TOrganizationEntitlementsContext = {
|
||||
licenseStatus: "no-license",
|
||||
licenseFeatures: null,
|
||||
stripeCustomerId: "cus_1",
|
||||
subscriptionStatus: null,
|
||||
usageCycleAnchor: null,
|
||||
};
|
||||
|
||||
@@ -61,6 +62,33 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for trial-restricted follow-ups while trialing", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["follow-ups"],
|
||||
subscriptionStatus: "trialing",
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "follow-ups")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for trial-restricted custom links while trialing", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["custom-links-in-surveys"],
|
||||
subscriptionStatus: "trialing",
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "custom-links-in-surveys")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for trial-restricted custom redirect while trialing", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["custom-redirect-url"],
|
||||
subscriptionStatus: "trialing",
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "custom-redirect-url")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when feature not present", async () => {
|
||||
mockGetContext.mockResolvedValue(baseContext);
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "hide-branding")).toBe(false);
|
||||
@@ -102,10 +130,20 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
|
||||
...baseContext,
|
||||
features: ["custom-redirect-url"],
|
||||
licenseStatus: "active",
|
||||
subscriptionStatus: "active",
|
||||
licenseFeatures: {} as TOrganizationEntitlementsContext["licenseFeatures"],
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "custom-redirect-url")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not affect unrelated features while trialing", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["rbac"],
|
||||
subscriptionStatus: "trialing",
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationEntitlementLimits", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import "server-only";
|
||||
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
|
||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { getOrganizationEntitlementsContext } from "./provider";
|
||||
import { isEntitlementFeature } from "./types";
|
||||
import { type TEntitlementFeature, isEntitlementFeature } from "./types";
|
||||
|
||||
const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLicenseFeatures>> = {
|
||||
"hide-branding": "removeBranding",
|
||||
@@ -11,6 +12,15 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLice
|
||||
contacts: "contacts",
|
||||
};
|
||||
|
||||
const TRIAL_RESTRICTED_ENTITLEMENT_KEYS = [
|
||||
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.FOLLOW_UPS,
|
||||
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CUSTOM_LINKS_IN_SURVEYS,
|
||||
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CUSTOM_REDIRECT_URL,
|
||||
] as const satisfies readonly TEntitlementFeature[];
|
||||
|
||||
const isTrialRestrictedEntitlement = (featureLookupKey: TEntitlementFeature): boolean =>
|
||||
(TRIAL_RESTRICTED_ENTITLEMENT_KEYS as readonly TEntitlementFeature[]).includes(featureLookupKey);
|
||||
|
||||
export const hasOrganizationEntitlement = async (
|
||||
organizationId: string,
|
||||
featureLookupKey: string
|
||||
@@ -37,6 +47,14 @@ export const hasOrganizationEntitlementWithLicenseGuard = async (
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
context.source === "cloud_stripe" &&
|
||||
context.subscriptionStatus === "trialing" &&
|
||||
isTrialRestrictedEntitlement(featureLookupKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.licenseStatus === "no-license") {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import type { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getCloudOrganizationEntitlementsContext } from "./cloud-provider";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { warn: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
getOrganizationBillingWithReadThroughSync: vi.fn(),
|
||||
getDefaultOrganizationBilling: () => ({
|
||||
limits: { projects: 1, monthly: { responses: 250 } },
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
@@ -17,26 +26,52 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
const mockGetBilling = vi.mocked(getOrganizationBillingWithReadThroughSync);
|
||||
const mockGetLicense = vi.mocked(getEnterpriseLicense);
|
||||
|
||||
const createBillingFixture = (overrides: Partial<TOrganizationBilling> = {}): TOrganizationBilling => ({
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: null,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getCloudOrganizationEntitlementsContext", () => {
|
||||
test("throws ResourceNotFoundError when billing is null", async () => {
|
||||
test("returns default entitlements when billing is null", async () => {
|
||||
mockGetBilling.mockResolvedValue(null);
|
||||
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
|
||||
|
||||
await expect(getCloudOrganizationEntitlementsContext("org1")).rejects.toThrow(ResourceNotFoundError);
|
||||
const result = await getCloudOrganizationEntitlementsContext("org1");
|
||||
|
||||
expect(result).toEqual({
|
||||
organizationId: "org1",
|
||||
source: "cloud_stripe",
|
||||
features: [],
|
||||
limits: { projects: 1, monthlyResponses: 250 },
|
||||
licenseStatus: "no-license",
|
||||
licenseFeatures: null,
|
||||
stripeCustomerId: null,
|
||||
subscriptionStatus: null,
|
||||
usageCycleAnchor: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns context with billing data", async () => {
|
||||
const usageCycleAnchor = new Date("2025-01-01");
|
||||
mockGetBilling.mockResolvedValue({
|
||||
stripeCustomerId: "cus_1",
|
||||
limits: { projects: 5, monthly: { responses: 1000 } },
|
||||
usageCycleAnchor,
|
||||
stripe: { features: ["rbac", "spam-protection"], plan: "pro" },
|
||||
} as any);
|
||||
mockGetBilling.mockResolvedValue(
|
||||
createBillingFixture({
|
||||
stripeCustomerId: "cus_1",
|
||||
limits: { projects: 5, monthly: { responses: 1000 } },
|
||||
usageCycleAnchor,
|
||||
stripe: { features: ["rbac", "spam-protection"], plan: "pro" },
|
||||
})
|
||||
);
|
||||
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
|
||||
|
||||
const result = await getCloudOrganizationEntitlementsContext("org1");
|
||||
@@ -49,17 +84,13 @@ describe("getCloudOrganizationEntitlementsContext", () => {
|
||||
licenseStatus: "no-license",
|
||||
licenseFeatures: null,
|
||||
stripeCustomerId: "cus_1",
|
||||
subscriptionStatus: null,
|
||||
usageCycleAnchor,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles missing stripe features and limits gracefully", async () => {
|
||||
mockGetBilling.mockResolvedValue({
|
||||
stripeCustomerId: null,
|
||||
limits: {},
|
||||
usageCycleAnchor: null,
|
||||
stripe: null,
|
||||
} as any);
|
||||
mockGetBilling.mockResolvedValue(createBillingFixture({ stripe: null }));
|
||||
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
|
||||
|
||||
const result = await getCloudOrganizationEntitlementsContext("org1");
|
||||
@@ -67,16 +98,17 @@ describe("getCloudOrganizationEntitlementsContext", () => {
|
||||
expect(result.features).toEqual([]);
|
||||
expect(result.limits).toEqual({ projects: null, monthlyResponses: null });
|
||||
expect(result.stripeCustomerId).toBeNull();
|
||||
expect(result.subscriptionStatus).toBeNull();
|
||||
expect(result.usageCycleAnchor).toBeNull();
|
||||
});
|
||||
|
||||
test("parses string usageCycleAnchor to Date", async () => {
|
||||
mockGetBilling.mockResolvedValue({
|
||||
stripeCustomerId: null,
|
||||
limits: {},
|
||||
usageCycleAnchor: "2025-06-15T00:00:00.000Z",
|
||||
stripe: null,
|
||||
} as any);
|
||||
mockGetBilling.mockResolvedValue(
|
||||
createBillingFixture({
|
||||
usageCycleAnchor: "2025-06-15T00:00:00.000Z",
|
||||
stripe: null,
|
||||
})
|
||||
);
|
||||
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
|
||||
|
||||
const result = await getCloudOrganizationEntitlementsContext("org1");
|
||||
@@ -85,16 +117,30 @@ describe("getCloudOrganizationEntitlementsContext", () => {
|
||||
});
|
||||
|
||||
test("filters out invalid feature keys from stripe", async () => {
|
||||
mockGetBilling.mockResolvedValue({
|
||||
stripeCustomerId: null,
|
||||
limits: {},
|
||||
usageCycleAnchor: null,
|
||||
stripe: { features: ["rbac", "invalid-feature-xyz"] },
|
||||
} as any);
|
||||
mockGetBilling.mockResolvedValue(
|
||||
createBillingFixture({
|
||||
stripe: { features: ["rbac", "invalid-feature-xyz"] },
|
||||
})
|
||||
);
|
||||
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
|
||||
|
||||
const result = await getCloudOrganizationEntitlementsContext("org1");
|
||||
|
||||
expect(result.features).toEqual(["rbac"]);
|
||||
});
|
||||
|
||||
test("exposes subscription status from billing stripe snapshot", async () => {
|
||||
mockGetBilling.mockResolvedValue(
|
||||
createBillingFixture({
|
||||
stripeCustomerId: "cus_1",
|
||||
limits: { projects: 5, monthly: { responses: 1000 } },
|
||||
stripe: { features: ["follow-ups"], subscriptionStatus: "trialing" },
|
||||
})
|
||||
);
|
||||
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
|
||||
|
||||
const result = await getCloudOrganizationEntitlementsContext("org1");
|
||||
|
||||
expect(result.subscriptionStatus).toBe("trialing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import "server-only";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
getDefaultOrganizationBilling,
|
||||
getOrganizationBillingWithReadThroughSync,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { type TOrganizationEntitlementsContext, isEntitlementFeature } from "./types";
|
||||
|
||||
@@ -19,7 +22,23 @@ export const getCloudOrganizationEntitlementsContext = async (
|
||||
]);
|
||||
|
||||
if (!billing) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
logger.warn({ organizationId }, "Organization billing not found, using default entitlements");
|
||||
const defaultBilling = getDefaultOrganizationBilling();
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
source: "cloud_stripe",
|
||||
features: [],
|
||||
limits: {
|
||||
projects: defaultBilling.limits?.projects ?? null,
|
||||
monthlyResponses: defaultBilling.limits?.monthly?.responses ?? null,
|
||||
},
|
||||
licenseStatus: license.status,
|
||||
licenseFeatures: license.features,
|
||||
stripeCustomerId: null,
|
||||
subscriptionStatus: null,
|
||||
usageCycleAnchor: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -33,6 +52,7 @@ export const getCloudOrganizationEntitlementsContext = async (
|
||||
licenseStatus: license.status,
|
||||
licenseFeatures: license.features,
|
||||
stripeCustomerId: billing.stripeCustomerId ?? null,
|
||||
subscriptionStatus: billing.stripe?.subscriptionStatus ?? null,
|
||||
usageCycleAnchor: toDateOrNull(billing.usageCycleAnchor),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
|
||||
licenseStatus: "no-license",
|
||||
licenseFeatures: null,
|
||||
stripeCustomerId: null,
|
||||
subscriptionStatus: null,
|
||||
usageCycleAnchor: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ export const getSelfHostedOrganizationEntitlementsContext = async (
|
||||
licenseStatus: license.status,
|
||||
licenseFeatures: license.features,
|
||||
stripeCustomerId: null,
|
||||
subscriptionStatus: null,
|
||||
usageCycleAnchor: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
|
||||
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
|
||||
import type {
|
||||
TEnterpriseLicenseFeatures,
|
||||
TEnterpriseLicenseStatusReturn,
|
||||
TLicenseStatus,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
|
||||
export type TEntitlementSource = "cloud_stripe" | "self_hosted_license";
|
||||
@@ -32,8 +33,9 @@ export type TOrganizationEntitlementsContext = {
|
||||
source: TEntitlementSource;
|
||||
features: TEntitlementFeature[];
|
||||
limits: TEntitlementLimits;
|
||||
licenseStatus: TEnterpriseLicenseStatusReturn;
|
||||
licenseStatus: TLicenseStatus;
|
||||
licenseFeatures: TEnterpriseLicenseFeatures | null;
|
||||
stripeCustomerId: string | null;
|
||||
subscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
usageCycleAnchor: Date | null;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject, ZProject } from "@formbricks/types/project";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import {
|
||||
TEnterpriseLicenseFeatures,
|
||||
TLicenseStatus,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
// Type for the enterprise license returned by getEnterpriseLicense()
|
||||
@@ -15,7 +18,7 @@ type TEnterpriseLicense = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
status: "active" | "expired" | "unreachable" | "no-license" | "invalid_license";
|
||||
status: TLicenseStatus;
|
||||
};
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
|
||||
@@ -34,6 +37,16 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
// Stripe setup must run AFTER membership is created so the owner email is available
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
ensureCloudStripeSetupForOrganization(newOrganization.id).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId: newOrganization.id },
|
||||
"Stripe setup failed after organization creation"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await createProject(newOrganization.id, {
|
||||
name: "My Project",
|
||||
});
|
||||
|
||||
@@ -34,9 +34,9 @@ export const CreateOrganizationModal = ({ open, setOpen }: CreateOrganizationMod
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const { register, handleSubmit, watch } = useForm<FormValues>();
|
||||
const organizationName = watch("name", "");
|
||||
const isOrganizationNameValid = organizationName.trim() !== "";
|
||||
const { register, handleSubmit } = useForm<FormValues>();
|
||||
|
||||
const submitOrganization = async (data: FormValues) => {
|
||||
data.name = data.name.trim();
|
||||
@@ -75,8 +75,6 @@ export const CreateOrganizationModal = ({ open, setOpen }: CreateOrganizationMod
|
||||
autoFocus
|
||||
placeholder={t("environments.settings.general.organization_name_placeholder")}
|
||||
{...register("name", { required: true })}
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<ProjectConfigNavigation activeId="tags" />
|
||||
<ProjectConfigNavigation activeId="tags" loading />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.workspace.tags.manage_tags")}
|
||||
|
||||
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
|
||||
if (url?.[0]) {
|
||||
if (url?.length) {
|
||||
updateSurvey({ fileUrl: url[0] });
|
||||
} else {
|
||||
updateSurvey({ fileUrl: undefined });
|
||||
}
|
||||
}}
|
||||
fileUrl={localSurvey?.welcomeCard?.fileUrl}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -94,6 +94,17 @@ export const ValidationRulesEditor = ({
|
||||
|
||||
const handleDisable = () => {
|
||||
onUpdateValidation({ rules: [], logic: validationLogic });
|
||||
// Reset inputType to "text" when disabling validation for OpenText elements.
|
||||
// Without this, the HTML input keeps its type (e.g. "url"), which still enforces
|
||||
// browser-native format validation even though the user toggled validation off.
|
||||
if (
|
||||
elementType === TSurveyElementTypeEnum.OpenText &&
|
||||
onUpdateInputType &&
|
||||
inputType !== undefined &&
|
||||
inputType !== "text"
|
||||
) {
|
||||
onUpdateInputType("text");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = (insertAfterIndex: number) => {
|
||||
|
||||
@@ -48,7 +48,9 @@ export const FollowUpsView = ({
|
||||
description={t("environments.surveys.edit.follow_ups_empty_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: isFormbricksCloud
|
||||
? t("environments.settings.billing.upgrade")
|
||||
: t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${localSurvey.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/docs/self-hosting/license",
|
||||
|
||||
@@ -10,7 +10,11 @@ import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
|
||||
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "@/modules/survey/link/lib/metadata-utils";
|
||||
import {
|
||||
getBasicSurveyMetadata,
|
||||
getMetadataBrandColor,
|
||||
getSurveyOpenGraphMetadata,
|
||||
} from "@/modules/survey/link/lib/metadata-utils";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
|
||||
interface ContactSurveyPageProps {
|
||||
@@ -48,9 +52,8 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
|
||||
const customFaviconUrl = environmentContext.organizationWhitelabel?.faviconUrl;
|
||||
|
||||
// Get OpenGraph metadata
|
||||
const surveyBrandColor = survey.styling?.brandColor?.light;
|
||||
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
|
||||
const brandColor = getMetadataBrandColor(environmentContext.project.styling, survey.styling);
|
||||
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, brandColor);
|
||||
|
||||
// Override with the custom image URL
|
||||
if (baseMetadata.openGraph) {
|
||||
|
||||