Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21da0e1b39 | |||
| b112559d5f | |||
| 2dc5c50f4d | |||
| bddcec0466 | |||
| 92677e1ec0 | |||
| b12228e305 | |||
| 91be2af30b | |||
| 84c668be86 | |||
| 4015c76f2b | |||
| a7b2ade4a9 | |||
| 75f44952c7 | |||
| 0df5e26381 | |||
| 89bb3bcd84 | |||
| 30fdb72c09 | |||
| cb58cf5825 | |||
| 99bd2ba256 |
@@ -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)
|
||||
|
||||
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { project } = useEnvironment();
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
|
||||
@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/upgrade_plan: 4fab76a3fc5d5c94e3248cd279cfdd14
|
||||
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
|
||||
@@ -913,8 +916,11 @@ checksums:
|
||||
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
|
||||
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
|
||||
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
|
||||
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
|
||||
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
|
||||
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2
|
||||
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
|
||||
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
|
||||
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
@@ -927,11 +933,28 @@ checksums:
|
||||
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
|
||||
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
|
||||
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
|
||||
environments/settings/billing/select_plan_header_subtitle: 8de6b4e3ce5726829829bd46582f343a
|
||||
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
|
||||
environments/settings/billing/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/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
|
||||
@@ -958,11 +981,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 +998,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 |
@@ -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(),
|
||||
@@ -165,6 +166,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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Oben links",
|
||||
"top_right": "Oben rechts",
|
||||
"trial_days_remaining": "Noch {count} Tage in deiner Testphase",
|
||||
"trial_expired": "Deine Testphase ist abgelaufen",
|
||||
"trial_one_day_remaining": "Noch 1 Tag in deiner Testphase",
|
||||
"try_again": "Versuch's nochmal",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Unbekannte Umfrage",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Zahlungsmethode hinzufügen",
|
||||
"cancelling": "Wird storniert",
|
||||
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Quotenverwaltung",
|
||||
"scale_feature_spam": "Spamschutz",
|
||||
"scale_feature_teams": "Teams & Zugriffsrollen",
|
||||
"select_plan_header_subtitle": "Keine Kreditkarte erforderlich, keine versteckten Bedingungen.",
|
||||
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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": "Upgrade plan",
|
||||
"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",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Add payment method",
|
||||
"cancelling": "Cancelling",
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"failed_to_start_trial": "Failed to start trial. Please try again.",
|
||||
"manage_subscription": "Manage subscription",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Quota Management",
|
||||
"scale_feature_spam": "Spam Protection",
|
||||
"scale_feature_teams": "Teams & Access Roles",
|
||||
"select_plan_header_subtitle": "No credit card required, no strings attached.",
|
||||
"select_plan_header_title": "Ship professional, unbranded surveys today!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Añadir método de pago",
|
||||
"cancelling": "Cancelando",
|
||||
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
|
||||
"manage_subscription": "Gestionar suscripción",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Gestión de cuota",
|
||||
"scale_feature_spam": "Protección contra spam",
|
||||
"scale_feature_teams": "Equipos y roles de acceso",
|
||||
"select_plan_header_subtitle": "Sin tarjeta de crédito, sin compromisos.",
|
||||
"select_plan_header_title": "¡Lanza encuestas profesionales sin marca hoy mismo!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titre",
|
||||
"top_left": "En haut à gauche",
|
||||
"top_right": "En haut à droite",
|
||||
"trial_days_remaining": "{count} jours restants dans votre période d'essai",
|
||||
"trial_expired": "Votre période d'essai a expiré",
|
||||
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Enquête inconnue",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Ajouter un moyen de paiement",
|
||||
"cancelling": "Annulation en cours",
|
||||
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"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": "Envoyez des sondages professionnels et personnalisés dès aujourd'hui !",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Cím",
|
||||
"top_left": "Balra fent",
|
||||
"top_right": "Jobbra fent",
|
||||
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból",
|
||||
"trial_expired": "A próbaidőszak lejárt",
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Fizetési mód hozzáadása",
|
||||
"cancelling": "Lemondás folyamatban",
|
||||
"manage_subscription": "Feliratkozás kezelése",
|
||||
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
|
||||
"manage_subscription": "Előfizetés kezelése",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Keretkezelés",
|
||||
"scale_feature_spam": "Spamvédelem",
|
||||
"scale_feature_teams": "Csapatok és hozzáférési szerepkörök",
|
||||
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
|
||||
"select_plan_header_title": "Küldjön professzionális, márkajelzés nélküli felméréseket még ma!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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": "不明なフォーム",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "支払い方法を追加",
|
||||
"cancelling": "キャンセル中",
|
||||
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
|
||||
"manage_subscription": "サブスクリプションを管理",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "クォータ管理",
|
||||
"scale_feature_spam": "スパム防止機能",
|
||||
"scale_feature_teams": "チーム&アクセス権限管理",
|
||||
"select_plan_header_subtitle": "クレジットカード不要、縛りなし。",
|
||||
"select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Hobbyプランを継続する",
|
||||
"stripe_setup_incomplete": "請求情報の設定が未完了",
|
||||
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
|
||||
"subscription": "サブスクリプション",
|
||||
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
|
||||
"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": "アップグレード",
|
||||
@@ -1017,11 +1040,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 +1057,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": "Linksboven",
|
||||
"top_right": "Rechtsboven",
|
||||
"trial_days_remaining": "{count} dagen over in je proefperiode",
|
||||
"trial_expired": "Je proefperiode is verlopen",
|
||||
"trial_one_day_remaining": "1 dag over in je proefperiode",
|
||||
"try_again": "Probeer het opnieuw",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Onbekende enquête",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Betaalmethode toevoegen",
|
||||
"cancelling": "Bezig met annuleren",
|
||||
"manage_subscription": "Beheer abonnement",
|
||||
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
|
||||
"manage_subscription": "Abonnement beheren",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Quotabeheer",
|
||||
"scale_feature_spam": "Spam-beveiliging",
|
||||
"scale_feature_teams": "Teams & toegangsrollen",
|
||||
"select_plan_header_subtitle": "Geen creditcard vereist, geen verplichtingen.",
|
||||
"select_plan_header_title": "Verstuur vandaag nog professionele, ongemerkte enquêtes!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Título",
|
||||
"top_left": "Canto superior esquerdo",
|
||||
"top_right": "Canto Superior Direito",
|
||||
"trial_days_remaining": "{count} dias restantes no seu período de teste",
|
||||
"trial_expired": "Seu período de teste expirou",
|
||||
"trial_one_day_remaining": "1 dia restante no seu período de teste",
|
||||
"try_again": "Tenta de novo",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Pesquisa desconhecida",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adicionar forma de pagamento",
|
||||
"cancelling": "Cancelando",
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
|
||||
"manage_subscription": "Gerenciar assinatura",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Gestão de cota",
|
||||
"scale_feature_spam": "Proteção contra spam",
|
||||
"scale_feature_teams": "Equipes e papéis de acesso",
|
||||
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
|
||||
"select_plan_header_title": "Envie pesquisas profissionais e sem marca hoje mesmo!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Título",
|
||||
"top_left": "Superior Esquerdo",
|
||||
"top_right": "Superior Direito",
|
||||
"trial_days_remaining": "{count} dias restantes no teu período de teste",
|
||||
"trial_expired": "O teu período de teste expirou",
|
||||
"trial_one_day_remaining": "1 dia restante no teu período de teste",
|
||||
"try_again": "Tente novamente",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Inquérito desconhecido",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adicionar método de pagamento",
|
||||
"cancelling": "A cancelar",
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
|
||||
"manage_subscription": "Gerir subscrição",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Gestão de quotas",
|
||||
"scale_feature_spam": "Proteção contra spam",
|
||||
"scale_feature_teams": "Equipas e papéis de acesso",
|
||||
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
|
||||
"select_plan_header_title": "Envia inquéritos profissionais sem marca hoje!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "Titlu",
|
||||
"top_left": "Stânga Sus",
|
||||
"top_right": "Dreapta Sus",
|
||||
"trial_days_remaining": "{count} zile rămase în perioada ta de probă",
|
||||
"trial_expired": "Perioada ta de probă a expirat",
|
||||
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
|
||||
"try_again": "Încearcă din nou",
|
||||
"type": "Tip",
|
||||
"unknown_survey": "Chestionar necunoscut",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adaugă o metodă de plată",
|
||||
"cancelling": "Anulare în curs",
|
||||
"manage_subscription": "Gestionați abonamentul",
|
||||
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
|
||||
"manage_subscription": "Gestionează abonamentul",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scală",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Gestionare cote",
|
||||
"scale_feature_spam": "Protecție anti-spam",
|
||||
"scale_feature_teams": "Echipe și roluri de acces",
|
||||
"select_plan_header_subtitle": "Nu este necesar card de credit, fără obligații.",
|
||||
"select_plan_header_title": "Lansează chestionare profesionale, fără branding, astăzi!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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": "Неизвестный опрос",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Добавить способ оплаты",
|
||||
"cancelling": "Отмена",
|
||||
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
|
||||
"manage_subscription": "Управление подпиской",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Хобби",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Scale",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "Управление квотами",
|
||||
"scale_feature_spam": "Защита от спама",
|
||||
"scale_feature_teams": "Команды и роли доступа",
|
||||
"select_plan_header_subtitle": "Кредитная карта не требуется, никаких обязательств.",
|
||||
"select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
|
||||
"status_trialing": "Пробный",
|
||||
"stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby",
|
||||
"stripe_setup_incomplete": "Настройка оплаты не завершена",
|
||||
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
|
||||
"subscription": "Подписка",
|
||||
"subscription_description": "Управляйте своим тарифом и следите за использованием",
|
||||
"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": "Обновить",
|
||||
@@ -1017,11 +1040,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 +1057,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",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Lägg till betalningsmetod",
|
||||
"cancelling": "Avbryter",
|
||||
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
|
||||
"manage_subscription": "Hantera prenumeration",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_pro": "Pro",
|
||||
"plan_scale": "Skala",
|
||||
@@ -982,11 +988,28 @@
|
||||
"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": "Skicka professionella undersökningar utan varumärke idag!",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1017,11 +1040,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 +1057,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.",
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
"title": "标题",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "试用期还剩 {count} 天",
|
||||
"trial_expired": "您的试用期已过期",
|
||||
"trial_one_day_remaining": "试用期还剩 1 天",
|
||||
"try_again": "再试一次",
|
||||
"type": "类型",
|
||||
"unknown_survey": "未知调查",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "添加支付方式",
|
||||
"cancelling": "正在取消",
|
||||
"manage_subscription": "管理 订阅",
|
||||
"failed_to_start_trial": "试用启动失败,请重试。",
|
||||
"manage_subscription": "管理订阅",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "兴趣版",
|
||||
"plan_pro": "专业版",
|
||||
"plan_scale": "规模版",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "额度管理",
|
||||
"scale_feature_spam": "垃圾防护",
|
||||
"scale_feature_teams": "团队与访问角色",
|
||||
"select_plan_header_subtitle": "无需信用卡,没有任何附加条件。",
|
||||
"select_plan_header_title": "立即发布专业的无品牌调查!",
|
||||
"status_trialing": "试用版",
|
||||
"stay_on_hobby_plan": "我想继续使用免费版计划",
|
||||
"stripe_setup_incomplete": "账单设置未完成",
|
||||
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
|
||||
"subscription": "订阅",
|
||||
"subscription_description": "管理你的订阅套餐并监控用量",
|
||||
"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": "升级",
|
||||
@@ -1017,11 +1040,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 +1057,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": "未知問卷",
|
||||
@@ -968,8 +971,11 @@
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "新增付款方式",
|
||||
"cancelling": "正在取消",
|
||||
"failed_to_start_trial": "無法開始試用。請再試一次。",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "興趣版",
|
||||
"plan_pro": "專業版",
|
||||
"plan_scale": "規模版",
|
||||
@@ -982,11 +988,28 @@
|
||||
"scale_feature_quota": "額度管理",
|
||||
"scale_feature_spam": "垃圾訊息防護",
|
||||
"scale_feature_teams": "團隊與存取權限",
|
||||
"select_plan_header_subtitle": "無需信用卡,完全沒有附加條件。",
|
||||
"select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
|
||||
"status_trialing": "試用版",
|
||||
"stay_on_hobby_plan": "我想繼續使用 Hobby 方案",
|
||||
"stripe_setup_incomplete": "帳單設定尚未完成",
|
||||
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
|
||||
"subscription": "訂閱",
|
||||
"subscription_description": "管理您的訂閱方案並監控用量",
|
||||
"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": "升級",
|
||||
@@ -1017,11 +1040,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 +1057,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": "授權伺服器無法連線,請稍後再試。",
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details with method and path", () => {
|
||||
test("logs API error details", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -228,7 +228,7 @@ describe("utils", () => {
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -238,11 +238,9 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "POST",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -277,8 +275,6 @@ describe("utils", () => {
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "",
|
||||
method: "GET",
|
||||
path: "/api/test",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -289,7 +285,7 @@ describe("utils", () => {
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
|
||||
test("log API error details with SENTRY_DSN set", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -299,23 +295,11 @@ describe("utils", () => {
|
||||
// Mock Sentry's captureException method
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
// Capture the scope mock for tag verification
|
||||
const scopeSetTagMock = vi.fn();
|
||||
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: scopeSetTagMock,
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -325,60 +309,20 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry scope tags include method and path
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("does not send to Sentry for non-internal_server_error types", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
vi.mocked(Sentry.captureException).mockClear();
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
|
||||
mockRequest.headers.set("x-request-id", "456");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify Sentry.captureException was NOT called for non-500 errors
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// But structured logging should still happen
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,13 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setTag("method", method);
|
||||
scope.setTag("path", path);
|
||||
scope.setLevel("error");
|
||||
|
||||
scope.setExtra("originalError", error);
|
||||
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
method,
|
||||
path,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
|
||||
@@ -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$%^&*()";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,8 +10,14 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
|
||||
import { createSetupCheckoutSession } from "@/modules/ee/billing/api/lib/create-setup-checkout-session";
|
||||
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import {
|
||||
createProTrialSubscription,
|
||||
ensureCloudStripeSetupForOrganization,
|
||||
reconcileCloudStripeSubscriptionsForOrganization,
|
||||
syncOrganizationBillingFromStripe,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
|
||||
|
||||
const ZManageSubscriptionAction = z.object({
|
||||
@@ -137,4 +143,86 @@ 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 AuthorizationError("You do not have an associated Stripe CustomerId");
|
||||
}
|
||||
|
||||
const subscriptionId = organization.billing.stripe?.subscriptionId;
|
||||
if (!subscriptionId) {
|
||||
throw new ResourceNotFoundError("subscription", organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const returnUrl = `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`;
|
||||
const checkoutUrl = await createSetupCheckoutSession(
|
||||
organization.billing.stripeCustomerId,
|
||||
subscriptionId,
|
||||
returnUrl,
|
||||
organizationId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = { checkoutUrl };
|
||||
return checkoutUrl;
|
||||
})
|
||||
);
|
||||
|
||||
const ZStartScaleTrialAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const 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);
|
||||
}
|
||||
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await createProTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.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;
|
||||
};
|
||||
@@ -16,6 +16,47 @@ const relevantEvents = new Set([
|
||||
"entitlements.active_entitlement_summary.updated",
|
||||
]);
|
||||
|
||||
/**
|
||||
* When a setup-mode Checkout Session completes, the customer has just provided a
|
||||
* payment method + billing address. We attach that payment method as the default
|
||||
* on the customer (for future invoices) and on the trial subscription so Stripe
|
||||
* can charge it when the trial ends.
|
||||
*/
|
||||
const handleSetupCheckoutCompleted = async (
|
||||
session: Stripe.Checkout.Session,
|
||||
stripe: Stripe
|
||||
): Promise<void> => {
|
||||
if (session.mode !== "setup" || !session.setup_intent) return;
|
||||
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent.id;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
logger.warn({ sessionId: session.id }, "Setup checkout completed but no payment method found");
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
|
||||
if (customerId) {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionId = session.metadata?.subscriptionId;
|
||||
if (subscriptionId) {
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
default_payment_method: paymentMethodId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
|
||||
if (!("metadata" in eventObject) || !eventObject.metadata) {
|
||||
return null;
|
||||
@@ -101,6 +142,10 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
await handleSetupCheckoutCompleted(event.data.object, stripe);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
|
||||
await syncOrganizationBillingFromStripe(organizationId, {
|
||||
id: event.id,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { createElement, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -12,10 +12,12 @@ import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
createPricingTableCustomerSessionAction,
|
||||
createTrialPaymentCheckoutAction,
|
||||
isSubscriptionCancelledAction,
|
||||
manageSubscriptionAction,
|
||||
retryStripeSetupAction,
|
||||
} from "../actions";
|
||||
import { TrialAlert } from "./trial-alert";
|
||||
import { UsageCard } from "./usage-card";
|
||||
|
||||
const STRIPE_SUPPORTED_LOCALES = new Set([
|
||||
@@ -87,20 +89,22 @@ interface PricingTableProps {
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
hasBillingRights: boolean;
|
||||
currentCloudPlan: "hobby" | "pro" | "scale" | "unknown";
|
||||
currentCloudPlan: "hobby" | "pro" | "scale" | "custom" | "unknown";
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
stripePublishableKey: string | null;
|
||||
stripePricingTableId: string | null;
|
||||
isStripeSetupIncomplete: boolean;
|
||||
trialDaysRemaining: number | null;
|
||||
}
|
||||
|
||||
const getCurrentCloudPlanLabel = (
|
||||
plan: "hobby" | "pro" | "scale" | "unknown",
|
||||
plan: "hobby" | "pro" | "scale" | "custom" | "unknown",
|
||||
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");
|
||||
};
|
||||
|
||||
@@ -117,9 +121,11 @@ export const PricingTable = ({
|
||||
stripePublishableKey,
|
||||
stripePricingTableId,
|
||||
isStripeSetupIncomplete,
|
||||
trialDaysRemaining,
|
||||
}: PricingTableProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
|
||||
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
|
||||
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
|
||||
@@ -127,8 +133,9 @@ export const PricingTable = ({
|
||||
>(null);
|
||||
|
||||
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
|
||||
const isTrialing = currentSubscriptionStatus === "trialing";
|
||||
const showPricingTable =
|
||||
hasBillingRights && isUpgradeablePlan && !!stripePublishableKey && !!stripePricingTableId;
|
||||
hasBillingRights && isUpgradeablePlan && !isTrialing && !!stripePublishableKey && !!stripePricingTableId;
|
||||
const canManageSubscription =
|
||||
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
|
||||
const stripeLocaleOverride = useMemo(
|
||||
@@ -160,6 +167,13 @@ export const PricingTable = ({
|
||||
stripePublishableKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("checkout_success")) {
|
||||
const timer = setTimeout(() => router.refresh(), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSubscriptionStatus = async () => {
|
||||
if (!hasBillingRights || !canManageSubscription) {
|
||||
@@ -212,6 +226,20 @@ export const PricingTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const openTrialPaymentCheckout = async () => {
|
||||
try {
|
||||
const response = await createTrialPaymentCheckoutAction({ environmentId });
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
globalThis.location.href = response.data;
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkout session:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
const retryStripeSetup = async () => {
|
||||
setIsRetryingStripeSetup(true);
|
||||
try {
|
||||
@@ -222,14 +250,14 @@ export const PricingTable = ({
|
||||
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 responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
@@ -244,7 +272,26 @@ export const PricingTable = ({
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex max-w-4xl flex-col gap-4">
|
||||
{trialDaysRemaining !== null &&
|
||||
(organization.billing.stripe?.hasPaymentMethod ? (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_payment_method_added_description")}
|
||||
</AlertDescription>
|
||||
</TrialAlert>
|
||||
) : (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining}>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_alert_description")}
|
||||
</AlertDescription>
|
||||
{hasBillingRights && (
|
||||
<AlertButton onClick={() => void openTrialPaymentCheckout()}>
|
||||
{t("environments.settings.billing.add_payment_method")}
|
||||
</AlertButton>
|
||||
)}
|
||||
</TrialAlert>
|
||||
))}
|
||||
{isStripeSetupIncomplete && hasBillingRights && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
|
||||
@@ -260,7 +307,8 @@ export const PricingTable = ({
|
||||
title={t("environments.settings.billing.subscription")}
|
||||
description={t("environments.settings.billing.subscription_description")}
|
||||
buttonInfo={
|
||||
canManageSubscription
|
||||
(canManageSubscription && currentSubscriptionStatus !== "trialing") ||
|
||||
(hasBillingRights && !!organization.billing.stripe?.hasPaymentMethod)
|
||||
? {
|
||||
text: t("environments.settings.billing.manage_subscription"),
|
||||
onClick: () => void openCustomerPortal(),
|
||||
@@ -300,19 +348,19 @@ export const PricingTable = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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")}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
|
||||
</p>
|
||||
</div>
|
||||
<UsageCard
|
||||
metric={t("common.workspaces")}
|
||||
currentCount={projectCount}
|
||||
@@ -323,7 +371,7 @@ export const PricingTable = ({
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{currentCloudPlan === "pro" && (
|
||||
{currentCloudPlan === "pro" && !isTrialing && (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"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 { 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 { 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 handleContinueFree = () => {
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
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}>
|
||||
{t("common.upgrade_plan")}
|
||||
</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={handleContinueFree}
|
||||
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
|
||||
{t("environments.settings.billing.stay_on_hobby_plan")}
|
||||
</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}>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,7 @@ describe("cloud-billing-display", () => {
|
||||
organizationId: "org_1",
|
||||
currentCloudPlan: "pro",
|
||||
currentSubscriptionStatus: null,
|
||||
trialDaysRemaining: null,
|
||||
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
|
||||
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
|
||||
billing,
|
||||
|
||||
@@ -4,12 +4,13 @@ import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/or
|
||||
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;
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
trialDaysRemaining: number | null;
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
|
||||
@@ -27,6 +28,22 @@ const resolveCurrentSubscriptionStatus = (
|
||||
return billing.stripe?.subscriptionStatus ?? null;
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
const resolveTrialDaysRemaining = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): number | null => {
|
||||
if (billing.stripe?.subscriptionStatus !== "trialing" || !billing.stripe.trialEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trialEndDate = new Date(billing.stripe.trialEnd);
|
||||
if (!Number.isFinite(trialEndDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return Math.ceil((trialEndDate.getTime() - Date.now()) / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export const getCloudBillingDisplayContext = async (
|
||||
organizationId: string
|
||||
): Promise<TCloudBillingDisplayContext> => {
|
||||
@@ -42,6 +59,7 @@ export const getCloudBillingDisplayContext = async (
|
||||
organizationId,
|
||||
currentCloudPlan: resolveCurrentCloudPlan(billing),
|
||||
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
|
||||
trialDaysRemaining: resolveTrialDaysRemaining(billing),
|
||||
usageCycleStart: usageCycleWindow.start,
|
||||
usageCycleEnd: usageCycleWindow.end,
|
||||
billing,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
syncOrganizationBillingFromStripe,
|
||||
} from "./organization-billing";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isCloud: true,
|
||||
getBillingCacheKey: vi.fn(),
|
||||
@@ -28,6 +30,12 @@ const mocks = vi.hoisted(() => ({
|
||||
subscriptionsCancel: vi.fn(),
|
||||
pricesList: vi.fn(),
|
||||
entitlementsList: vi.fn(),
|
||||
customersList: vi.fn(),
|
||||
customersRetrieve: vi.fn(),
|
||||
customersUpdate: vi.fn(),
|
||||
prismaMembershipFindFirst: vi.fn(),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
@@ -59,6 +67,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
upsert: mocks.prismaOrganizationBillingUpsert,
|
||||
update: mocks.prismaOrganizationBillingUpdate,
|
||||
},
|
||||
membership: {
|
||||
findFirst: mocks.prismaMembershipFindFirst,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -72,6 +83,8 @@ vi.mock("@/lib/cache", () => ({
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
warn: mocks.loggerWarn,
|
||||
info: mocks.loggerInfo,
|
||||
error: mocks.loggerError,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -85,7 +98,12 @@ vi.mock("./stripe-plan", async (importOriginal) => {
|
||||
|
||||
vi.mock("./stripe-client", () => ({
|
||||
stripeClient: {
|
||||
customers: { create: mocks.customersCreate },
|
||||
customers: {
|
||||
create: mocks.customersCreate,
|
||||
list: mocks.customersList,
|
||||
retrieve: mocks.customersRetrieve,
|
||||
update: mocks.customersUpdate,
|
||||
},
|
||||
products: {
|
||||
list: mocks.productsList,
|
||||
retrieve: mocks.productsRetrieve,
|
||||
@@ -111,6 +129,8 @@ describe("organization-billing", () => {
|
||||
mocks.getBillingCacheKey.mockReturnValue("billing-cache-key");
|
||||
mocks.getCloudPlanFromProduct.mockReturnValue("pro");
|
||||
mocks.subscriptionsList.mockResolvedValue({ data: [] });
|
||||
mocks.customersList.mockResolvedValue({ data: [] });
|
||||
mocks.prismaMembershipFindFirst.mockResolvedValue(null);
|
||||
mocks.productsList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -157,28 +177,34 @@ describe("organization-billing", () => {
|
||||
expect(mocks.customersCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ensureStripeCustomerForOrganization returns existing customer id", async () => {
|
||||
test("ensureStripeCustomerForOrganization reuses Stripe customer found by owner email", async () => {
|
||||
mocks.prismaOrganizationFindUnique.mockResolvedValue({
|
||||
id: "org_1",
|
||||
name: "Org 1",
|
||||
});
|
||||
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
|
||||
stripeCustomerId: "cus_existing",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
stripe: null,
|
||||
mocks.prismaMembershipFindFirst.mockResolvedValue({
|
||||
user: { email: "owner@example.com", name: "Owner Name" },
|
||||
});
|
||||
mocks.customersList.mockResolvedValue({
|
||||
data: [{ id: "cus_existing", deleted: false }],
|
||||
});
|
||||
mocks.customersUpdate.mockResolvedValue({ id: "cus_existing" });
|
||||
|
||||
const result = await ensureStripeCustomerForOrganization("org_1");
|
||||
|
||||
expect(result).toEqual({ customerId: "cus_existing" });
|
||||
expect(mocks.customersCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.prismaOrganizationBillingUpsert).not.toHaveBeenCalled();
|
||||
expect(mocks.customersUpdate).toHaveBeenCalledWith("cus_existing", {
|
||||
name: "Owner Name",
|
||||
metadata: { organizationId: "org_1", organizationName: "Org 1" },
|
||||
});
|
||||
expect(mocks.prismaOrganizationBillingUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { organizationId: "org_1" },
|
||||
create: expect.objectContaining({ stripeCustomerId: "cus_existing" }),
|
||||
update: expect.objectContaining({ stripeCustomerId: "cus_existing" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureStripeCustomerForOrganization creates and stores a Stripe customer", async () => {
|
||||
@@ -186,6 +212,9 @@ describe("organization-billing", () => {
|
||||
id: "org_1",
|
||||
name: "Org 1",
|
||||
});
|
||||
mocks.prismaMembershipFindFirst.mockResolvedValue({
|
||||
user: { email: "owner@example.com", name: "Owner Name" },
|
||||
});
|
||||
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
@@ -204,8 +233,9 @@ describe("organization-billing", () => {
|
||||
expect(result).toEqual({ customerId: "cus_new" });
|
||||
expect(mocks.customersCreate).toHaveBeenCalledWith(
|
||||
{
|
||||
name: "Org 1",
|
||||
metadata: { organizationId: "org_1" },
|
||||
name: "Owner Name",
|
||||
email: "owner@example.com",
|
||||
metadata: { organizationId: "org_1", organizationName: "Org 1" },
|
||||
},
|
||||
{ idempotencyKey: "ensure-customer-org_1" }
|
||||
);
|
||||
@@ -382,6 +412,156 @@ describe("organization-billing", () => {
|
||||
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
|
||||
});
|
||||
|
||||
test("syncOrganizationBillingFromStripe stores unlimited responses when entitlement is unlimited", async () => {
|
||||
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
|
||||
stripeCustomerId: "cus_1",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
stripe: { lastSyncedEventId: null },
|
||||
});
|
||||
mocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "sub_1",
|
||||
status: "active",
|
||||
billing_cycle_anchor: 1739923200,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: { id: "prod_scale" },
|
||||
recurring: { usage_type: "licensed", interval: "month" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.entitlementsList.mockResolvedValue({
|
||||
data: [
|
||||
{ id: "ent_0", lookup_key: "workspace-limit-5" },
|
||||
{ id: "ent_1", lookup_key: "responses-included-unlimited" },
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const result = await syncOrganizationBillingFromStripe("org_1", {
|
||||
id: "evt_unlimited",
|
||||
created: 1739923300,
|
||||
});
|
||||
|
||||
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
|
||||
where: { organizationId: "org_1" },
|
||||
data: expect.objectContaining({
|
||||
limits: {
|
||||
projects: 5,
|
||||
monthly: {
|
||||
responses: null,
|
||||
},
|
||||
},
|
||||
stripe: expect.objectContaining({
|
||||
features: ["workspace-limit-5", "responses-included-unlimited"],
|
||||
lastSyncedEventId: "evt_unlimited",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(result?.limits.monthly.responses).toBeNull();
|
||||
expect(result?.stripe?.features).toEqual(["workspace-limit-5", "responses-included-unlimited"]);
|
||||
});
|
||||
|
||||
test("syncOrganizationBillingFromStripe prefers unlimited responses over numeric response entitlements", async () => {
|
||||
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
|
||||
stripeCustomerId: "cus_1",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
stripe: {},
|
||||
});
|
||||
mocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "sub_1",
|
||||
status: "active",
|
||||
billing_cycle_anchor: 1739923200,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: { id: "prod_scale" },
|
||||
recurring: { usage_type: "licensed", interval: "month" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.entitlementsList.mockResolvedValue({
|
||||
data: [
|
||||
{ id: "ent_0", lookup_key: "responses-included-500" },
|
||||
{ id: "ent_1", lookup_key: "responses-included-unlimited" },
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const result = await syncOrganizationBillingFromStripe("org_1");
|
||||
|
||||
expect(result?.limits.monthly.responses).toBeNull();
|
||||
expect(result?.stripe?.features).toEqual(["responses-included-500", "responses-included-unlimited"]);
|
||||
});
|
||||
|
||||
test("syncOrganizationBillingFromStripe preserves previous response limit when no response entitlement exists", async () => {
|
||||
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
|
||||
stripeCustomerId: "cus_1",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 500,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
stripe: {},
|
||||
});
|
||||
mocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "sub_1",
|
||||
status: "active",
|
||||
billing_cycle_anchor: 1739923200,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: { id: "prod_pro" },
|
||||
recurring: { usage_type: "licensed", interval: "month" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.entitlementsList.mockResolvedValue({
|
||||
data: [{ id: "ent_0", lookup_key: "workspace-limit-5" }],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const result = await syncOrganizationBillingFromStripe("org_1");
|
||||
|
||||
expect(result?.limits.monthly.responses).toBe(500);
|
||||
expect(result?.stripe?.features).toEqual(["workspace-limit-5"]);
|
||||
});
|
||||
|
||||
test("syncOrganizationBillingFromStripe prefers higher-tier active subscription over hobby", async () => {
|
||||
mocks.getCloudPlanFromProduct.mockImplementation(
|
||||
(product: { metadata?: { formbricks_plan?: string } }) => {
|
||||
@@ -567,18 +747,9 @@ describe("organization-billing", () => {
|
||||
id: "org_1",
|
||||
name: "Org 1",
|
||||
});
|
||||
// ensureStripeCustomerForOrganization no longer reads billing;
|
||||
// reconcile and sync each read billing once
|
||||
mocks.prismaOrganizationBillingFindUnique
|
||||
.mockResolvedValueOnce({
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
},
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
stripe: {},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stripeCustomerId: "cus_new",
|
||||
limits: {
|
||||
@@ -602,26 +773,30 @@ describe("organization-billing", () => {
|
||||
stripe: {},
|
||||
});
|
||||
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
|
||||
mocks.subscriptionsList.mockResolvedValueOnce({ data: [] }).mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "sub_hobby",
|
||||
created: 1739923200,
|
||||
status: "active",
|
||||
billing_cycle_anchor: 1739923200,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: { id: "prod_hobby" },
|
||||
recurring: { usage_type: "licensed", interval: "month" },
|
||||
mocks.subscriptionsList
|
||||
.mockResolvedValueOnce({ data: [] }) // reconciliation initial list (status: "all")
|
||||
.mockResolvedValueOnce({ data: [] }) // fresh re-check before hobby creation (status: "active")
|
||||
.mockResolvedValueOnce({
|
||||
// sync reads subscriptions after hobby is created
|
||||
data: [
|
||||
{
|
||||
id: "sub_hobby",
|
||||
created: 1739923200,
|
||||
status: "active",
|
||||
billing_cycle_anchor: 1739923200,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: { id: "prod_hobby" },
|
||||
recurring: { usage_type: "licensed", interval: "month" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
],
|
||||
});
|
||||
|
||||
await ensureCloudStripeSetupForOrganization("org_1");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Stripe from "stripe";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
type TOrganizationBilling,
|
||||
type TOrganizationStripeSubscriptionStatus,
|
||||
@@ -33,7 +34,7 @@ export const invalidateOrganizationBillingCache = async (organizationId: string)
|
||||
await cache.del([getBillingCacheKey(organizationId)]);
|
||||
};
|
||||
|
||||
const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
|
||||
export const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
|
||||
limits: {
|
||||
projects: IS_FORMBRICKS_CLOUD ? 1 : 3,
|
||||
monthly: {
|
||||
@@ -92,16 +93,23 @@ const listAllActiveEntitlements = async (customerId: string): Promise<string[]>
|
||||
return [...new Set(featureLookupKeys)];
|
||||
};
|
||||
|
||||
const parseMaxNumericEntitlementLimit = (features: string[], prefix: string): number | null => {
|
||||
let maxValue: number | null = null;
|
||||
const parseEntitlementLimit = (features: string[], prefix: string): number | null | undefined => {
|
||||
let maxValue: number | null | undefined;
|
||||
|
||||
for (const feature of features) {
|
||||
if (!feature.startsWith(prefix)) continue;
|
||||
const rawValue = feature.slice(prefix.length);
|
||||
if (rawValue === "unlimited") {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d+$/.test(rawValue)) continue;
|
||||
const parsed = Number.parseInt(rawValue, 10);
|
||||
if (Number.isNaN(parsed)) continue;
|
||||
maxValue = maxValue === null ? parsed : Math.max(maxValue, parsed);
|
||||
if (maxValue == null) {
|
||||
maxValue = parsed;
|
||||
continue;
|
||||
}
|
||||
maxValue = Math.max(maxValue, parsed);
|
||||
}
|
||||
|
||||
return maxValue;
|
||||
@@ -291,6 +299,105 @@ const ensureHobbySubscription = async (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the given email has already used a Pro trial on any Stripe customer.
|
||||
* Searches all customers with that email and inspects their subscription history.
|
||||
*/
|
||||
const hasEmailUsedProTrial = async (email: string, proProductId: string): Promise<boolean> => {
|
||||
if (!stripeClient) return false;
|
||||
|
||||
const customers = await stripeClient.customers.list({
|
||||
email,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
for (const customer of customers.data) {
|
||||
const subscriptions = await stripeClient.subscriptions.list({
|
||||
customer: customer.id,
|
||||
status: "all",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const hadProTrial = subscriptions.data.some(
|
||||
(sub) =>
|
||||
sub.trial_start != null &&
|
||||
sub.items.data.some((item) => {
|
||||
const productId =
|
||||
typeof item.price.product === "string" ? item.price.product : item.price.product.id;
|
||||
return productId === proProductId;
|
||||
})
|
||||
);
|
||||
|
||||
if (hadProTrial) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createProTrialSubscription = async (
|
||||
organizationId: string,
|
||||
customerId: string
|
||||
): Promise<void> => {
|
||||
if (!stripeClient) return;
|
||||
|
||||
const products = await stripeClient.products.list({
|
||||
active: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const proProduct = products.data.find((product) => product.metadata.formbricks_plan === "pro");
|
||||
if (!proProduct) {
|
||||
throw new Error("Stripe product metadata formbricks_plan=pro not found");
|
||||
}
|
||||
|
||||
const customer = await stripeClient.customers.retrieve(customerId);
|
||||
if (!customer.deleted && customer.email) {
|
||||
const alreadyUsed = await hasEmailUsedProTrial(customer.email, proProduct.id);
|
||||
if (alreadyUsed) {
|
||||
throw new OperationNotAllowedError("trial_already_used");
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPrice =
|
||||
typeof proProduct.default_price === "string" ? null : (proProduct.default_price ?? null);
|
||||
|
||||
const fallbackPrices = await stripeClient.prices.list({
|
||||
product: proProduct.id,
|
||||
active: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const proPrice =
|
||||
defaultPrice ??
|
||||
fallbackPrices.data.find(
|
||||
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
|
||||
) ??
|
||||
fallbackPrices.data[0] ??
|
||||
null;
|
||||
|
||||
if (!proPrice) {
|
||||
throw new Error(`No active price found for Stripe pro product ${proProduct.id}`);
|
||||
}
|
||||
|
||||
await stripeClient.subscriptions.create(
|
||||
{
|
||||
customer: customerId,
|
||||
items: [{ price: proPrice.id, quantity: 1 }],
|
||||
trial_period_days: 14,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: "cancel",
|
||||
},
|
||||
},
|
||||
payment_settings: {
|
||||
save_default_payment_method: "on_subscription",
|
||||
},
|
||||
metadata: { organizationId },
|
||||
},
|
||||
{ idempotencyKey: `create-pro-trial-${organizationId}` }
|
||||
);
|
||||
};
|
||||
|
||||
const ensureOrganizationBillingRecord = async (
|
||||
organizationId: string
|
||||
): Promise<TOrganizationBilling | null> => {
|
||||
@@ -328,6 +435,40 @@ const ensureOrganizationBillingRecord = async (
|
||||
return mapBillingRecord(billing);
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the email of the organization owner by looking up the membership with role "owner"
|
||||
* and joining to the user table.
|
||||
*/
|
||||
const getOrganizationOwner = async (
|
||||
organizationId: string
|
||||
): Promise<{ email: string; name: string | null } | null> => {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: { organizationId, role: "owner" },
|
||||
select: { user: { select: { email: true, name: true } } },
|
||||
});
|
||||
if (!membership) return null;
|
||||
return { email: membership.user.email, name: membership.user.name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches Stripe for an existing non-deleted customer with the given email.
|
||||
* Returns the first match, or null if none found.
|
||||
*/
|
||||
const findStripeCustomerByEmail = async (email: string): Promise<Stripe.Customer | null> => {
|
||||
if (!stripeClient) return null;
|
||||
|
||||
const customers = await stripeClient.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const customer = customers.data[0];
|
||||
if (customer && !customer.deleted) {
|
||||
return customer;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ensureStripeCustomerForOrganization = async (
|
||||
organizationId: string
|
||||
): Promise<{ customerId: string | null }> => {
|
||||
@@ -344,40 +485,64 @@ export const ensureStripeCustomerForOrganization = async (
|
||||
return { customerId: null };
|
||||
}
|
||||
|
||||
const billing = await ensureOrganizationBillingRecord(organization.id);
|
||||
if (!billing) {
|
||||
// Look up the org owner's email/name and check if a Stripe customer already exists for it.
|
||||
// This reuses the old customer (and its trial history) when a user deletes their account
|
||||
// and signs up again with the same email.
|
||||
const owner = await getOrganizationOwner(organization.id);
|
||||
if (!owner) {
|
||||
logger.error({ organizationId }, "Cannot set up Stripe customer: organization has no owner");
|
||||
return { customerId: null };
|
||||
}
|
||||
|
||||
if (billing.stripeCustomerId) {
|
||||
return { customerId: billing.stripeCustomerId };
|
||||
const { email: ownerEmail, name: ownerName } = owner;
|
||||
let existingCustomer: Stripe.Customer | null = null;
|
||||
|
||||
if (ownerEmail) {
|
||||
const foundCustomer = await findStripeCustomerByEmail(ownerEmail);
|
||||
if (foundCustomer) {
|
||||
// Only reuse if this customer is not already linked to another org's billing record
|
||||
const existingBillingOwner = await findOrganizationIdByStripeCustomerId(foundCustomer.id);
|
||||
if (!existingBillingOwner || existingBillingOwner === organizationId) {
|
||||
existingCustomer = foundCustomer;
|
||||
await stripeClient.customers.update(existingCustomer.id, {
|
||||
name: ownerName ?? undefined,
|
||||
metadata: { organizationId: organization.id, organizationName: organization.name },
|
||||
});
|
||||
logger.info(
|
||||
{ organizationId, customerId: existingCustomer.id, email: ownerEmail },
|
||||
"Reusing existing Stripe customer for new organization"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customer = await stripeClient.customers.create(
|
||||
{
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
},
|
||||
{ idempotencyKey: `ensure-customer-${organization.id}` }
|
||||
);
|
||||
const customer =
|
||||
existingCustomer ??
|
||||
(await stripeClient.customers.create(
|
||||
{
|
||||
name: ownerName ?? undefined,
|
||||
email: ownerEmail,
|
||||
metadata: { organizationId: organization.id, organizationName: organization.name },
|
||||
},
|
||||
{ idempotencyKey: `ensure-customer-${organization.id}` }
|
||||
));
|
||||
|
||||
const updatedStripeSnapshot = {
|
||||
...billing.stripe,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
};
|
||||
const defaultBilling = getDefaultOrganizationBilling();
|
||||
|
||||
// Always create/update the billing record with the resolved Stripe customer ID.
|
||||
// Using upsert so the billing row is created if it doesn't exist yet.
|
||||
await prisma.organizationBilling.upsert({
|
||||
where: { organizationId: organization.id },
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
stripeCustomerId: customer.id,
|
||||
limits: billing.limits,
|
||||
usageCycleAnchor: billing.usageCycleAnchor,
|
||||
stripe: updatedStripeSnapshot,
|
||||
limits: defaultBilling.limits,
|
||||
usageCycleAnchor: defaultBilling.usageCycleAnchor,
|
||||
stripe: { lastSyncedAt: new Date().toISOString() },
|
||||
},
|
||||
update: {
|
||||
stripeCustomerId: customer.id,
|
||||
stripe: updatedStripeSnapshot,
|
||||
stripe: { lastSyncedAt: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -422,18 +587,15 @@ export const syncOrganizationBillingFromStripe = async (
|
||||
const subscriptionStatus = resolveSubscriptionStatus(subscription);
|
||||
const usageCycleAnchor = resolveUsageCycleAnchor(subscription);
|
||||
const previousLimits = billing.limits;
|
||||
const workspaceLimitFromEntitlements = parseMaxNumericEntitlementLimit(
|
||||
featureLookupKeys,
|
||||
"workspace-limit-"
|
||||
);
|
||||
const responsesIncludedFromEntitlements = parseMaxNumericEntitlementLimit(
|
||||
featureLookupKeys,
|
||||
"responses-included-"
|
||||
);
|
||||
const workspaceLimitFromEntitlements = parseEntitlementLimit(featureLookupKeys, "workspace-limit-");
|
||||
const responsesIncludedFromEntitlements = parseEntitlementLimit(featureLookupKeys, "responses-included-");
|
||||
|
||||
const projectsLimit = workspaceLimitFromEntitlements ?? previousLimits?.projects ?? null;
|
||||
const projectsLimit =
|
||||
workspaceLimitFromEntitlements === undefined
|
||||
? (previousLimits?.projects ?? null)
|
||||
: workspaceLimitFromEntitlements;
|
||||
|
||||
if (workspaceLimitFromEntitlements === null && previousLimits?.projects == null) {
|
||||
if (workspaceLimitFromEntitlements === undefined && previousLimits?.projects == null) {
|
||||
logger.warn(
|
||||
{ organizationId, customerId, cloudPlan, featureLookupKeys },
|
||||
"No workspace limit entitlement found in Stripe entitlements; preserving previous projects limit"
|
||||
@@ -441,9 +603,11 @@ export const syncOrganizationBillingFromStripe = async (
|
||||
}
|
||||
|
||||
const responsesIncludedLimit =
|
||||
responsesIncludedFromEntitlements ?? previousLimits?.monthly?.responses ?? null;
|
||||
responsesIncludedFromEntitlements === undefined
|
||||
? (previousLimits?.monthly?.responses ?? null)
|
||||
: responsesIncludedFromEntitlements;
|
||||
|
||||
if (responsesIncludedFromEntitlements === null && previousLimits?.monthly?.responses == null) {
|
||||
if (responsesIncludedFromEntitlements === undefined && previousLimits?.monthly?.responses == null) {
|
||||
logger.warn(
|
||||
{ organizationId, customerId, cloudPlan, featureLookupKeys },
|
||||
"No responses included entitlement found in Stripe entitlements; preserving previous responses limit"
|
||||
@@ -464,10 +628,14 @@ export const syncOrganizationBillingFromStripe = async (
|
||||
plan: cloudPlan,
|
||||
subscriptionStatus,
|
||||
subscriptionId: subscription?.id ?? null,
|
||||
hasPaymentMethod: subscription?.default_payment_method != null,
|
||||
features: featureLookupKeys,
|
||||
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
lastSyncedEventId: event?.id ?? existingStripeSnapshot?.lastSyncedEventId ?? null,
|
||||
trialEnd: subscription?.trial_end
|
||||
? new Date(subscription.trial_end * 1000).toISOString()
|
||||
: (existingStripeSnapshot?.trialEnd ?? null),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -528,9 +696,25 @@ export const getOrganizationBillingWithReadThroughSync = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStripeCustomer = async (stripeCustomerId: string): Promise<void> => {
|
||||
/**
|
||||
* Cleans up a Stripe customer after organization deletion by cancelling all active
|
||||
* subscriptions. The customer object is intentionally kept so that trial usage history
|
||||
* is preserved — this prevents the same email from claiming a free trial again.
|
||||
*/
|
||||
export const cleanupStripeCustomer = async (stripeCustomerId: string): Promise<void> => {
|
||||
if (!stripeClient) return;
|
||||
await stripeClient.customers.del(stripeCustomerId);
|
||||
|
||||
const subscriptions = await stripeClient.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
status: "all",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.data
|
||||
.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status))
|
||||
.map((sub) => stripeClient!.subscriptions.cancel(sub.id, { prorate: false }))
|
||||
);
|
||||
};
|
||||
|
||||
export const findOrganizationIdByStripeCustomerId = async (customerId: string): Promise<string | null> => {
|
||||
@@ -606,7 +790,17 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
}
|
||||
|
||||
if (subscriptionsWithPlanLevel.length === 0) {
|
||||
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
|
||||
// Re-check active subscriptions to guard against concurrent reconciliation calls
|
||||
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
|
||||
const freshSubscriptions = await client.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "active",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (freshSubscriptions.data.length === 0) {
|
||||
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -59,6 +59,7 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
|
||||
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
|
||||
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
|
||||
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
|
||||
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ export const ContactsPageLayout = async ({
|
||||
description={upgradePromptDescription ?? t("environments.contacts.unlock_contacts_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -97,13 +97,14 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
);
|
||||
|
||||
const ZUpdateSegmentAction = z.object({
|
||||
environmentId: ZId,
|
||||
segmentId: ZId,
|
||||
data: ZSegmentUpdateInput,
|
||||
});
|
||||
|
||||
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
|
||||
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
|
||||
@@ -75,6 +75,7 @@ export function SegmentSettings({
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
const data = await updateSegmentAction({
|
||||
environmentId,
|
||||
segmentId: segment.id,
|
||||
data: {
|
||||
title: segment.title,
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TargetingCard({
|
||||
};
|
||||
|
||||
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, data });
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
|
||||
return updatedSegment?.data as TSegment;
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export function TargetingCard({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZDeleteQuotaAction = z.object({
|
||||
quotaId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
const checkQuotasEnabled = async (organizationId: string) => {
|
||||
@@ -36,7 +37,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
|
||||
|
||||
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
|
||||
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -48,7 +49,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -71,7 +72,7 @@ const ZUpdateQuotaAction = z.object({
|
||||
|
||||
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
|
||||
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -83,7 +84,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,6 +85,7 @@ export const QuotasCard = ({
|
||||
setIsDeletingQuota(true);
|
||||
const deleteQuotaActionResult = await deleteQuotaAction({
|
||||
quotaId: quotaId,
|
||||
surveyId: localSurvey.id,
|
||||
});
|
||||
if (deleteQuotaActionResult?.data) {
|
||||
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
|
||||
@@ -173,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",
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
|
||||
@@ -32,6 +31,7 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
|
||||
|
||||
const ZUpdateInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
data: ZInviteUpdateInput,
|
||||
});
|
||||
|
||||
@@ -39,16 +39,17 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
|
||||
|
||||
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
|
||||
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
ctx.user.id,
|
||||
parsedInput.organizationId
|
||||
);
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
@@ -67,9 +68,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
|
||||
throw new OperationNotAllowedError("Managers can only invite members");
|
||||
}
|
||||
|
||||
await checkRoleManagementPermission(organizationId);
|
||||
await checkRoleManagementPermission(parsedInput.organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function EditMembershipRole({
|
||||
}
|
||||
|
||||
if (inviteId) {
|
||||
await updateInviteAction({ inviteId: inviteId, data: { role } });
|
||||
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
|
||||
@@ -37,7 +37,7 @@ export const TeamsView = async ({
|
||||
|
||||
const buttons: [ModalButton, ModalButton] = [
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
|
||||
|
||||
@@ -181,7 +181,7 @@ export const EmailCustomizationSettings = ({
|
||||
|
||||
const buttons: [ModalButton, ModalButton] = [
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -149,7 +149,7 @@ export const FaviconCustomizationSettings = ({
|
||||
|
||||
const buttons: [ModalButton, ModalButton] = [
|
||||
{
|
||||
text: t("common.start_free_trial"),
|
||||
text: t("common.upgrade_plan"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ export const BrandingSettingsCard = async ({
|
||||
|
||||
const buttons: [ModalButton, ModalButton] = [
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,15 +27,14 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
|
||||
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -43,7 +42,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
return await deleteInvite(parsedInput.inviteId);
|
||||
|
||||
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (!member && invite) {
|
||||
// This is an invite
|
||||
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id });
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
|
||||
@@ -193,7 +193,7 @@ export const IndividualInviteTab = ({
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license"
|
||||
}>
|
||||
{t("common.start_free_trial")}
|
||||
{t("common.upgrade_plan")}
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
|
||||
description={t("environments.surveys.edit.unlock_targeting_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||