resolve conflict

This commit is contained in:
Balázs Úr
2026-03-10 22:18:28 +01:00
174 changed files with 4970 additions and 3419 deletions
+3 -1
View File
@@ -150,6 +150,8 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -230,4 +232,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here
@@ -28,8 +28,10 @@ const OnboardingLayout = async (props) => {
throw new Error(t("common.organization_not_found"));
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`);
@@ -42,7 +42,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -7,15 +7,36 @@ import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
interface ConfirmationPageProps {
environmentId: string;
environmentId?: string;
}
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState(environmentId ?? null);
useEffect(() => {
setShowConfetti(true);
}, []);
if (globalThis.window === undefined) {
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">
@@ -30,7 +51,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link href={`/environments/${environmentId}/settings/billing`}>
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>
@@ -53,7 +53,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
@@ -61,7 +61,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
@@ -29,7 +29,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
@@ -38,7 +37,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
@@ -52,7 +51,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount}
/>
)}
@@ -28,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
@@ -26,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -27,7 +27,7 @@ const Page = async (props) => {
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
@@ -53,7 +53,7 @@ const Page = async (props) => {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -154,7 +154,8 @@ const ZGeneratePersonalLinksAction = z.object({
export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
@@ -40,10 +40,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) {
throw new Error(t("common.user_not_found"));
}
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
@@ -51,7 +52,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
@@ -88,7 +88,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Organization", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
@@ -114,7 +114,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
@@ -34,23 +34,27 @@ export const SurveyStatusDropdown = ({
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
toast.success(
status === "inProgress"
? t("common.survey_live")
: status === "paused"
? t("common.survey_paused")
: status === "completed"
? t("common.survey_completed")
: ""
);
const resultingStatus = updateSurveyActionResponse.data.status;
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
inProgress: t("common.survey_live"),
paused: t("common.survey_paused"),
completed: t("common.survey_completed"),
};
const toastMessage = statusToToastMessage[resultingStatus];
if (toastMessage) {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage);
}
if (updateLocalSurveyStatus) updateLocalSurveyStatus(status);
};
return (
@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
@@ -27,11 +28,13 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={organization.billing.plan}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
@@ -6,7 +6,12 @@ import { logger } from "@formbricks/logger";
import { sendTelemetryEvents } from "./telemetry";
// Mock dependencies
vi.mock("@formbricks/cache");
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
createCacheKey: {
custom: vi.fn((_namespace: string, key: string) => key),
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
@@ -1,5 +1,5 @@
import { IntegrationType } from "@prisma/client";
import { type CacheKey, getCacheService } from "@formbricks/cache";
import { createCacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
@@ -7,8 +7,8 @@ import { getInstanceInfo } from "@/lib/instance";
import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const TELEMETRY_LOCK_KEY = "telemetry_lock" as CacheKey;
const TELEMETRY_LAST_SENT_KEY = "telemetry_last_sent_ts" as CacheKey;
const TELEMETRY_LOCK_KEY = createCacheKey.custom("analytics", "telemetry_lock");
const TELEMETRY_LAST_SENT_KEY = createCacheKey.custom("analytics", "telemetry_last_sent_ts");
/**
* In-memory timestamp for the next telemetry check.
@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
@@ -290,6 +291,14 @@ export const POST = async (request: Request) => {
});
}
if (event === "responseCreated") {
recordResponseCreatedMeterEvent({
stripeCustomerId: organization.billing.stripeCustomerId,
responseId: response.id,
createdAt: response.createdAt,
}).catch((error) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Send telemetry events
await sendTelemetryEvents();
}
@@ -5,6 +5,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -44,7 +45,8 @@ export const POST = withV1ApiWrapper({
}
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
@@ -38,13 +38,6 @@ const mockEnvironmentData = {
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
@@ -114,13 +107,6 @@ describe("getEnvironmentStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
@@ -154,18 +140,6 @@ describe("getEnvironmentStateData", () => {
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
@@ -283,32 +257,11 @@ describe("getEnvironmentStateData", () => {
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
test("should not include organization in result", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
expect(result).not.toHaveProperty("organization");
});
});
@@ -25,10 +25,6 @@ export interface EnvironmentStateData {
appSetupCompleted: boolean;
project: TJsEnvironmentStateProject;
};
organization: {
id: string;
billing: any;
};
surveys: TJsEnvironmentStateSurvey[];
actionClasses: TJsEnvironmentStateActionClass[];
}
@@ -59,13 +55,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
placement: true,
inAppSurveyBranding: true,
styling: true,
// Organization data (nested select for efficiency)
organization: {
select: {
id: true,
billing: true,
},
},
},
},
// Action classes (optimized for environment state)
@@ -157,10 +146,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("project", null);
}
if (!environmentData.project.organization) {
throw new ResourceNotFoundError("organization", null);
}
// Transform surveys using existing utility
const transformedSurveys = environmentData.surveys.map((survey) =>
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
@@ -181,10 +166,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
styling: resolveStorageUrlsInObject(environmentData.project.styling),
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
@@ -7,12 +7,10 @@ import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/typ
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(),
@@ -70,17 +68,14 @@ const mockOrganization: TOrganization = {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
stripeCustomerId: null,
period: "monthly",
limits: {
projects: 1,
monthly: {
responses: 100,
miu: 1000,
},
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
};
@@ -162,7 +157,6 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
});
afterEach(() => {
@@ -182,7 +176,6 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
});
test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -221,24 +214,6 @@ describe("getEnvironmentState", () => {
expect(result.data).toBeDefined();
});
test("should return empty surveys if monthly response limit reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
});
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(99); // Below limit
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
});
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
const result = await getEnvironmentState(environmentId);
@@ -255,32 +230,6 @@ describe("getEnvironmentState", () => {
);
});
test("should handle null response limit correctly (unlimited)", async () => {
const unlimitedOrgData = {
...mockEnvironmentStateData,
organization: {
...mockEnvironmentStateData.organization,
billing: {
...mockOrganization.billing,
limits: {
...mockOrganization.billing.limits,
monthly: {
...mockOrganization.billing.limits.monthly,
responses: null, // Unlimited
},
},
},
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(unlimitedOrgData);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(999999); // High count
const result = await getEnvironmentState(environmentId);
// Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys);
});
test("should propagate database update errors", async () => {
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
@@ -3,8 +3,7 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getEnvironmentStateData } from "./data";
/**
@@ -22,8 +21,7 @@ export const getEnvironmentState = async (
return cache.withCache(
async () => {
// Single optimized database call replacing multiple service calls
const { environment, organization, surveys, actionClasses } =
await getEnvironmentStateData(environmentId);
const { environment, surveys, actionClasses } = await getEnvironmentStateData(environmentId);
// Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration
@@ -34,18 +32,9 @@ export const getEnvironmentState = async (
});
}
// Check monthly response limits for Formbricks Cloud
let isMonthlyResponsesLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
}
// Build the response data
const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
surveys,
actionClasses,
project: environment.project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
@@ -54,6 +43,6 @@ export const getEnvironmentState = async (
return { data };
},
createCacheKey.environment.state(environmentId),
60 * 1000 // 1 minutes in milliseconds
60 * 1000 // 1 minute in milliseconds
);
};
@@ -63,7 +63,6 @@ const mockOrganization = {
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
@@ -13,6 +13,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -108,7 +109,8 @@ export const POST = withV1ApiWrapper({
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
@@ -96,7 +96,7 @@ export const POST = withV1ApiWrapper({
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
: MAX_FILE_UPLOAD_SIZES.standard;
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service";
@@ -24,7 +25,11 @@ const mockOrganization = {
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "free", limits: { monthly: { responses: null } } } as any, // Default no limit
billing: {
stripeCustomerId: null,
limits: { projects: 3, monthly: { responses: null } },
usageCycleAnchor: new Date(),
} as TOrganizationBilling, // Default no limit
} as unknown as Organization;
const mockResponseInput: TResponseInput = {
@@ -107,6 +112,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
STRIPE_API_VERSION: "2026-01-28.clover",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
@@ -30,17 +30,14 @@ const mockOrganization: TOrganization = {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
stripeCustomerId: null,
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
};
@@ -9,14 +9,14 @@ export const checkFeaturePermissions = async (
organization: TOrganization
): Promise<Response | null> => {
if (surveyData.recaptcha?.enabled) {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.billing.plan);
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
if (!isSpamProtectionEnabled) {
return responses.forbiddenResponse("Spam protection is not enabled for this organization");
}
}
if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.id);
if (!isSurveyFollowUpsEnabled) {
return responses.forbiddenResponse("Survey follow ups are not allowed for this organization");
}
@@ -3,6 +3,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -39,7 +40,8 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
if (inputValidation.data.contactId) {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
@@ -1,7 +1,7 @@
import { Organization } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { getOrganizationBillingByEnvironmentId } from "./organization";
vi.mock("@formbricks/database", () => ({
@@ -19,19 +19,17 @@ vi.mock("@formbricks/logger", () => ({
describe("getOrganizationBillingByEnvironmentId", () => {
const environmentId = "env-123";
const mockBillingData: Organization["billing"] = {
const mockBillingData: TOrganizationBilling = {
limits: {
monthly: { miu: 0, responses: 0 },
monthly: { responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id",
};
test("returns billing when organization is found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData });
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData } as any);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toEqual(mockBillingData);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
@@ -47,7 +45,14 @@ describe("getOrganizationBillingByEnvironmentId", () => {
},
},
select: {
billing: true,
billing: {
select: {
stripeCustomerId: true,
limits: true,
usageCycleAnchor: true,
stripe: true,
},
},
},
});
});
@@ -1,10 +1,10 @@
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TOrganizationBilling } from "@formbricks/types/organizations";
export const getOrganizationBillingByEnvironmentId = reactCache(
async (environmentId: string): Promise<Organization["billing"] | null> => {
async (environmentId: string): Promise<TOrganizationBilling | null> => {
try {
const organization = await prisma.organization.findFirst({
where: {
@@ -19,15 +19,29 @@ export const getOrganizationBillingByEnvironmentId = reactCache(
},
},
select: {
billing: true,
billing: {
select: {
stripeCustomerId: true,
limits: true,
usageCycleAnchor: true,
stripe: true,
},
},
},
});
if (!organization) {
if (!organization?.billing) {
return null;
}
return organization.billing;
return {
stripeCustomerId: organization.billing.stripeCustomerId,
limits: organization.billing.limits as TOrganizationBilling["limits"],
usageCycleAnchor: organization.billing.usageCycleAnchor,
...(organization.billing.stripe === null
? {}
: { stripe: organization.billing.stripe as TOrganizationBilling["stripe"] }),
};
} catch (error) {
logger.error(error, "Failed to get organization billing by environment ID");
return null;
@@ -41,6 +41,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
STRIPE_API_VERSION: "2026-01-28.clover",
}));
vi.mock("@/lib/organization/service");
@@ -69,7 +70,6 @@ const mockOrganization = {
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
@@ -8,6 +8,7 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
vi.mock("@/lib/i18n/utils", () => ({
@@ -35,6 +36,10 @@ vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () =>
getOrganizationBillingByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
@@ -94,20 +99,19 @@ const mockResponseInput: TResponseInputV2 = {
meta: {},
};
const mockBillingData: Organization["billing"] = {
const mockBillingData: TOrganizationBilling = {
limits: {
monthly: { miu: 0, responses: 0 },
monthly: { responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id",
};
describe("checkSurveyValidity", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("cm8f4x9mm0001gx9h5b7d7h3q");
});
test("should return badRequestResponse if survey environmentId does not match", async () => {
@@ -6,6 +6,7 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed";
@@ -92,7 +93,8 @@ export const checkSurveyValidity = async (
return responses.notFoundResponse("Organization", null);
}
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(billing.plan);
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organizationId);
if (!isSpamProtectionEnabled) {
logger.error("Spam protection is not enabled for this organization");
@@ -11,6 +11,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -76,7 +77,8 @@ export const POST = async (request: Request, context: Context): Promise<Response
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
+23 -53
View File
@@ -107,7 +107,6 @@ checksums:
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
common/api_keys: f961b547cd312cc8b9b79f0c9e0b2cc3
common/app: 77e32ac4e5a1e01bc9a6a15fdfef9bf8
@@ -336,7 +335,6 @@ checksums:
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/replace: 98b2268975b1a737b2e4ad837df96703
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
@@ -454,7 +452,6 @@ checksums:
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
common/you_have_reached_your_monthly_miu_limit_of: ded62fc6842c707f62622386ca34f71a
common/you_have_reached_your_monthly_response_limit_of: 3824db23ecc3dcd2b1787b98ccfdd5f9
common/you_will_be_downgraded_to_the_community_edition_on_date: bff35b54c13e2c205dc4c19056261cc0
common/your_license_has_expired_please_renew: 3f21ae4a7deab351b143b407ece58254
@@ -913,57 +910,31 @@ 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/1000_monthly_responses: e8625042bed54209770add138c27b3a5
environments/settings/billing/1_workspace: 4528d3e5277b172ef7a42fee7e881781
environments/settings/billing/2000_contacts: af340a4c9297a61b255af65c2a4ee688
environments/settings/billing/3_workspaces: 3f76b2f62e426297db3c2942a10c22f8
environments/settings/billing/5000_monthly_responses: c2f4bf25658af916bbd8331d4bb045b3
environments/settings/billing/7500_contacts: eb1344ca1ba3338101087deda33e3383
environments/settings/billing/all_integrations: a607324ae2bd67640bf579f9e4092610
environments/settings/billing/annually: 33d3ab83ba770949c0261b0d6dab3a75
environments/settings/billing/api_webhooks: 00e10a3efa54e3a227913ffbf9898243
environments/settings/billing/app_surveys: 659ee04ce36f1b1c84810e5f6f4f823f
environments/settings/billing/attribute_based_targeting: 15fd8606a5881d1e1838c28fd059e03c
environments/settings/billing/current: 27f172f76ac28e72cb062f80002b0ad5
environments/settings/billing/current_plan: 7497746eb3b4897ff953b1aa8c7c9f63
environments/settings/billing/current_tier_limit: a6875905f376953b12fdf5ae8fc7e051
environments/settings/billing/custom: fee41bfbe59e71721d8648e7a95ec9c5
environments/settings/billing/custom_contacts_limit: c9c10a51c470d9b5661d47317eb8f94e
environments/settings/billing/custom_response_limit: 96ef34d587001a7b479f3f6f7c9e66dc
environments/settings/billing/custom_workspace_limit: 3f6f7f901dfc245028ce938e3d9aa2c6
environments/settings/billing/email_embedded_surveys: bb1f558f9061287666041c08384ad1d4
environments/settings/billing/email_follow_ups: 0cc02dc14aa28ce94ca6153c306924e5
environments/settings/billing/enterprise_description: 56832155245afde5f8366fbc97beefaa
environments/settings/billing/everybody_has_the_free_plan_by_default: 59372bc25ddcfeff0fea7e7da9912b15
environments/settings/billing/everything_in_free: 937aec5d29ce25da7765e491fb5a588c
environments/settings/billing/everything_in_startup: 739b608db1ccba9a131f0cdeb3c51c3b
environments/settings/billing/free: 0326365539c004f6088656f692602078
environments/settings/billing/free_description: 8f7b8278c48c1a145ee09e78abf7f5b9
environments/settings/billing/get_2_months_free: 50d996b1d3ce850012caba14fdeb1cdb
environments/settings/billing/hosted_in_frankfurt: e2b7598cf13bc0d53b325f49fc904bac
environments/settings/billing/ios_android_sdks: c7f4048b07a56592567bda04f10b6aae
environments/settings/billing/link_surveys: 4dc5ef440001c5e9d45864635bf5cf47
environments/settings/billing/logic_jumps_hidden_fields_recurring_surveys: f58485b1bbf76e3805d6105b5e8294e6
environments/settings/billing/manage_card_details: 8d9e61ee37cada980edcdd16ffd7b2a0
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55
environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37
environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
environments/settings/billing/startup: 4c4ac5a0b9dc62100bca6c6465f31c4c
environments/settings/billing/startup_description: 964fcb2c77f49b80266c94606e3f4506
environments/settings/billing/switch_plan: fb3e1941051a4273ca29224803570f4b
environments/settings/billing/team_access_roles: 1cc4af14e589f6c09ab92a4f21958049
environments/settings/billing/unable_to_upgrade_plan: 50fc725609411d139e534c85eeb2879e
environments/settings/billing/unlimited_miu: 29c3f5bd01c2a09fdf1d3601665ce90f
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0
environments/settings/billing/scale_banner_description: 79a9734c77ab0336d5d2fadb5f2151be
environments/settings/billing/scale_banner_title: a2a78f57ebcbf444ad881ece234b8f45
environments/settings/billing/scale_feature_api: 67231215e5452944b86edc2bc47d2a16
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
environments/settings/billing/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
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/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
environments/settings/billing/unlimited_surveys: 7d5766ee0b7ef632a8b36c90b121c0bd
environments/settings/billing/unlimited_team_members: b73b1b6ec747f4c2f9cfcca9e883527d
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/uptime_sla_99: 25ca4060e575e1a7eee47fceb5576d7c
environments/settings/billing/website_surveys: f4d176cc66ffcc2abf44c0d5da1642e3
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
environments/settings/domain/description: f0b4d8c96da816f793cf1f4fdfaade34
@@ -1329,7 +1300,7 @@ checksums:
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
environments/surveys/edit/external_urls_paywall_tooltip: a8860ff0a2ad5f283bc0becba374cd54
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 70d0e1bdd6336b2cfb92c0121416c8d4
@@ -1382,7 +1353,6 @@ checksums:
environments/surveys/edit/follow_ups_modal_trigger_type_response: 8b0e49e76ba09241f512201871bef0f2
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
@@ -2870,7 +2840,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
@@ -3123,7 +3093,7 @@ checksums:
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
+2 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { CacheKey } from "@formbricks/cache";
type CacheKey = string;
// Create mocks
const mockCacheService = {
+26 -7
View File
@@ -1,6 +1,19 @@
import "server-only";
import { type CacheKey, type CacheService, getCacheService } from "@formbricks/cache";
import { getCacheService } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import type { RedisClientType } from "redis";
type CacheResult<T, E = { code: string }> = { ok: true; data: T } | { ok: false; error: E };
type CacheService = {
get<T>(key: string): Promise<CacheResult<T | null>>;
exists(key: string): Promise<CacheResult<boolean>>;
set(key: string, value: unknown, ttlMs?: number): Promise<CacheResult<void>>;
del(keys: string[]): Promise<CacheResult<void>>;
tryLock(key: string, value: string, ttlMs: number): Promise<CacheResult<boolean>>;
withCache<T>(fn: () => Promise<T>, key: string, ttlMs: number): Promise<T>;
getRedisClient(): RedisClientType | null;
};
// Expose an async-leaning service to reflect lazy init for sync members like getRedisClient
type AsyncCacheService = Omit<CacheService, "getRedisClient"> & {
@@ -18,7 +31,7 @@ export const cache = new Proxy({} as AsyncCacheService, {
get(_target, prop: keyof CacheService) {
// Special-case: withCache must never fail; fall back to direct fn on init failure.
if (prop === "withCache") {
return async <T>(fn: () => Promise<T>, ...rest: [CacheKey, number]) => {
return async <T>(fn: () => Promise<T>, ...rest: [string, number]) => {
try {
const cacheServiceResult = await getCacheService();
@@ -26,7 +39,8 @@ export const cache = new Proxy({} as AsyncCacheService, {
return await fn();
}
return cacheServiceResult.data.withCache(fn, ...rest);
const cacheService = cacheServiceResult.data as CacheService;
return cacheService.withCache(fn, ...rest);
} catch (error) {
logger.warn({ error }, "Cache unavailable; executing function directly");
return await fn();
@@ -40,7 +54,8 @@ export const cache = new Proxy({} as AsyncCacheService, {
if (!cacheServiceResult.ok) {
return null;
}
return cacheServiceResult.data.getRedisClient();
const cacheService = cacheServiceResult.data as CacheService;
return cacheService.getRedisClient();
};
}
@@ -49,11 +64,15 @@ export const cache = new Proxy({} as AsyncCacheService, {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return { ok: false, error: cacheServiceResult.error };
return {
ok: false,
error: cacheServiceResult.error,
} as unknown as ReturnType<CacheService[typeof prop]>;
}
const method = cacheServiceResult.data[prop];
const cacheService = cacheServiceResult.data as CacheService;
const method = cacheService[prop];
return await method.apply(cacheServiceResult.data, args);
return await method.apply(cacheService, args);
};
},
});
-36
View File
@@ -182,42 +182,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"zh-Hant-TW",
];
// Billing constants
export enum PROJECT_FEATURE_KEYS {
FREE = "free",
STARTUP = "startup",
CUSTOM = "custom",
}
export enum STRIPE_PROJECT_NAMES {
STARTUP = "Formbricks Startup",
CUSTOM = "Formbricks Custom",
}
export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
}
export const BILLING_LIMITS = {
FREE: {
PROJECTS: 3,
RESPONSES: 1500,
MIU: 2000,
},
STARTUP: {
PROJECTS: 3,
RESPONSES: 5000,
MIU: 7500,
},
CUSTOM: {
PROJECTS: null,
RESPONSES: null,
MIU: null,
},
} as const;
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);
+4
View File
@@ -83,6 +83,8 @@ export const env = createEnv({
SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_PRICING_TABLE_ID: z.string().optional(),
PUBLIC_URL: z
.url()
.refine(
@@ -198,6 +200,8 @@ export const env = createEnv({
SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
PUBLIC_URL: process.env.PUBLIC_URL,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
+1 -4
View File
@@ -29,16 +29,13 @@ describe("auth", () => {
name: "Org 1",
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
},
+104 -51
View File
@@ -1,25 +1,31 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
import {
createOrganization,
getOrganization,
getOrganizationsByUserId,
select as organizationSelect,
subscribeOrganizationMembersToSurveyResponses,
updateOrganization,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
organization: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
organizationBilling: {
upsert: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
@@ -30,7 +36,15 @@ vi.mock("@/lib/user/service", () => ({
updateUser: vi.fn(),
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
ensureCloudStripeSetupForOrganization: vi.fn().mockResolvedValue(undefined),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
@@ -43,17 +57,14 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
projects: 3,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
responses: 1500,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
whitelabel: false,
@@ -98,17 +109,14 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
projects: 3,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
responses: 1500,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
whitelabel: false,
@@ -145,24 +153,23 @@ describe("Organization Service", () => {
describe("createOrganization", () => {
test("should create organization with default billing settings", 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: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
},
billing: expectedBilling,
isAIEnabled: false,
whitelabel: false,
};
@@ -176,21 +183,62 @@ describe("Organization Service", () => {
data: {
name: "Test Org",
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
create: {
limits: {
projects: IS_FORMBRICKS_CLOUD ? 1 : 3,
monthly: {
responses: IS_FORMBRICKS_CLOUD ? 250 : 1500,
},
},
stripeCustomerId: null,
usageCycleAnchor: null,
},
stripeCustomerId: null,
periodStart: expect.any(Date),
period: "monthly",
},
},
select: expect.any(Object),
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();
}
});
test("should throw DatabaseError on prisma error", async () => {
@@ -212,17 +260,14 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
projects: 3,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
responses: 1500,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
whitelabel: false,
@@ -235,26 +280,35 @@ describe("Organization Service", () => {
};
vi.mocked(prisma.organization.update).mockResolvedValue(mockOrganization);
vi.mocked(prisma.$transaction).mockImplementation(
async (fn: any) =>
await fn({
organization: {
update: prisma.organization.update,
findUnique: vi.fn().mockResolvedValue(mockOrganization),
},
organizationBilling: {
upsert: prisma.organizationBilling.upsert,
},
})
);
const result = await updateOrganization("org1", { name: "Updated Org" });
expect(result).toEqual({
expect(result).toMatchObject({
id: "org1",
name: "Updated Org",
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
projects: 3,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
responses: 1500,
},
},
stripeCustomerId: null,
periodStart: expect.any(Date),
period: "monthly",
usageCycleAnchor: expect.any(Date),
},
isAIEnabled: false,
whitelabel: false,
@@ -262,7 +316,6 @@ describe("Organization Service", () => {
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "org1" },
data: { name: "Updated Org" },
select: expect.any(Object),
});
});
});
+149 -40
View File
@@ -8,27 +8,77 @@ import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TOrganization,
TOrganizationBilling,
TOrganizationCreateInput,
TOrganizationUpdateInput,
ZOrganizationCreateInput,
} from "@formbricks/types/organizations";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { BILLING_LIMITS, ITEMS_PER_PAGE, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import {
deleteStripeCustomer,
ensureCloudStripeSetupForOrganization,
} from "@/modules/ee/billing/lib/organization-billing";
import { validateInputs } from "../utils/validate";
export const select: Prisma.OrganizationSelect = {
export const select = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
billing: true,
billing: {
select: {
stripeCustomerId: true,
limits: true,
usageCycleAnchor: true,
stripe: true,
},
},
isAIEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
type TOrganizationWithBilling = Prisma.OrganizationGetPayload<{ select: typeof select }>;
const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
limits: {
projects: IS_FORMBRICKS_CLOUD ? 1 : 3,
monthly: {
responses: IS_FORMBRICKS_CLOUD ? 250 : 1500,
},
},
stripeCustomerId: null,
usageCycleAnchor: null,
});
const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): TOrganizationBilling => {
const defaultBilling = getDefaultOrganizationBilling();
if (!billing) {
return defaultBilling;
}
return {
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
};
};
const mapOrganization = (organization: TOrganizationWithBilling): TOrganization => ({
id: organization.id,
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAIEnabled: organization.isAIEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
export const getOrganizationsTag = (organizationId: string) => `organizations-${organizationId}`;
export const getOrganizationsByUserIdCacheTag = (userId: string) => `users-${userId}-organizations`;
export const getOrganizationByEnvironmentIdCacheTag = (environmentId: string) =>
@@ -54,7 +104,7 @@ export const getOrganizationsByUserId = reactCache(
if (!organizations) {
throw new ResourceNotFoundError("Organizations by UserId", userId);
}
return organizations;
return organizations.map(mapOrganization);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -85,7 +135,7 @@ export const getOrganizationByEnvironmentId = reactCache(
select: { ...select, memberships: true }, // include memberships
});
return organization;
return organization ? mapOrganization(organization) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting organization by environment id");
@@ -107,7 +157,7 @@ export const getOrganization = reactCache(async (organizationId: string): Promis
},
select,
});
return organization;
return organization ? mapOrganization(organization) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -127,23 +177,22 @@ export const createOrganization = async (
data: {
...organizationInput,
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly",
create: getDefaultOrganizationBilling(),
},
},
select,
});
return organization;
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) {
throw new DatabaseError(error.message);
@@ -158,16 +207,68 @@ export const updateOrganization = async (
data: Partial<TOrganizationUpdateInput>
): Promise<TOrganization> => {
try {
const updatedOrganization = await prisma.organization.update({
where: {
id: organizationId,
},
data,
select: { ...select, memberships: true, projects: { select: { environments: true } } }, // include memberships & environments
const { billing, ...organizationData } = data;
const updatedOrganization = await prisma.$transaction(async (tx) => {
const existingOrganization = await tx.organization.findUnique({
where: {
id: organizationId,
},
select: {
id: true,
},
});
if (!existingOrganization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
if (Object.keys(organizationData).length > 0) {
await tx.organization.update({
where: {
id: organizationId,
},
data: organizationData,
});
}
if (billing) {
const fallbackBilling = getDefaultOrganizationBilling();
await tx.organizationBilling.upsert({
where: {
organizationId,
},
create: {
organizationId,
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
},
update: {
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits ?? fallbackBilling.limits,
usageCycleAnchor: billing.usageCycleAnchor ?? fallbackBilling.usageCycleAnchor,
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
},
});
}
return tx.organization.findUnique({
where: {
id: organizationId,
},
select: { ...select, memberships: true, projects: { select: { environments: true } } }, // include memberships & environments
});
});
if (!updatedOrganization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const organization = {
...updatedOrganization,
...mapOrganization(updatedOrganization),
memberships: undefined,
projects: undefined,
};
@@ -187,13 +288,18 @@ export const updateOrganization = async (
export const deleteOrganization = async (organizationId: string) => {
validateInputs([organizationId, ZId]);
try {
await prisma.organization.delete({
const deletedOrganization = await prisma.organization.delete({
where: {
id: organizationId,
},
select: {
id: true,
name: true,
billing: {
select: {
stripeCustomerId: true,
},
},
memberships: {
select: {
userId: true,
@@ -211,6 +317,16 @@ 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"
);
});
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -220,15 +336,6 @@ export const deleteOrganization = async (organizationId: string) => {
}
};
export const getMonthlyActiveOrganizationPeopleCount = reactCache(
async (organizationId: string): Promise<number> => {
validateInputs([organizationId, ZId]);
// temporary solution until we have a better way to track active users
return 0;
}
);
export const getMonthlyOrganizationResponseCount = reactCache(
async (organizationId: string): Promise<number> => {
validateInputs([organizationId, ZId]);
@@ -239,8 +346,7 @@ export const getMonthlyOrganizationResponseCount = reactCache(
throw new ResourceNotFoundError("Organization", organizationId);
}
// Use the utility function to calculate the start date
const startDate = getBillingPeriodStartDate(organization.billing);
const usageCycleWindow = getBillingUsageCycleWindow(organization.billing);
// Get all environment IDs for the organization
const projects = await getProjects(organizationId);
@@ -252,7 +358,10 @@ export const getMonthlyOrganizationResponseCount = reactCache(
id: true,
},
where: {
AND: [{ survey: { environmentId: { in: environmentIds } } }, { createdAt: { gte: startDate } }],
AND: [
{ survey: { environmentId: { in: environmentIds } } },
{ createdAt: { gte: usageCycleWindow.start, lt: usageCycleWindow.end } },
],
},
});
@@ -331,7 +440,7 @@ export const getOrganizationsWhereUserIsSingleOwner = reactCache(
const filteredOrgs = orgs
.filter((org) => org.memberships.length === 1)
.map((org) => ({
...org,
...mapOrganization(org),
memberships: undefined, // Remove memberships from the return object to match TOrganization type
}));
+2 -2
View File
@@ -384,9 +384,9 @@ export const getResponseDownloadFile = async (
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization billing not found");
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const headers = [
"No.",
@@ -42,6 +42,13 @@ const expectedResponseWithoutPerson: TResponse = {
tags: mockTags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
const mockOrganizationBillingRecord = {
stripeCustomerId: mockOrganizationOutput.billing.stripeCustomerId,
limits: mockOrganizationOutput.billing.limits,
usageCycleAnchor: mockOrganizationOutput.billing.usageCycleAnchor,
stripe: null,
};
beforeEach(() => {
// @ts-expect-error
prisma.response.create.mockImplementation(async (args) => {
@@ -86,6 +93,7 @@ beforeEach(() => {
prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput as unknown as any);
prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput as unknown as any);
prisma.organizationBilling.findUnique.mockResolvedValue(mockOrganizationBillingRecord as unknown as any);
prisma.project.findMany.mockResolvedValue([]);
// @ts-expect-error
prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } });
+1 -4
View File
@@ -235,16 +235,13 @@ export const mockOrganizationOutput: TOrganization = {
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: currentDate,
usageCycleAnchor: currentDate,
},
};
+2 -8
View File
@@ -62,16 +62,13 @@ describe("User Service", () => {
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
},
@@ -82,16 +79,13 @@ describe("User Service", () => {
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
},
+53 -164
View File
@@ -1,176 +1,65 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getBillingPeriodStartDate } from "./billing";
import { describe, expect, test } from "vitest";
import { getBillingUsageCycleWindow, getMonthlyUsageCycleWindow } from "./billing";
describe("getBillingPeriodStartDate", () => {
let originalDate: DateConstructor;
describe("getMonthlyUsageCycleWindow", () => {
test("derives the current monthly window from a regular anchor day", () => {
const result = getMonthlyUsageCycleWindow(
new Date("2026-01-15T10:30:00.000Z"),
new Date("2026-03-09T12:00:00.000Z")
);
beforeEach(() => {
// Store the original Date constructor
originalDate = global.Date;
expect(result).toEqual({
start: new Date("2026-02-15T10:30:00.000Z"),
end: new Date("2026-03-15T10:30:00.000Z"),
});
});
afterEach(() => {
// Restore the original Date constructor
global.Date = originalDate;
vi.useRealTimers();
test("keeps monthly windows for yearly subscriptions anchored on the original day", () => {
const result = getMonthlyUsageCycleWindow(
new Date("2026-01-15T00:00:00.000Z"),
new Date("2026-11-20T12:00:00.000Z")
);
expect(result).toEqual({
start: new Date("2026-11-15T00:00:00.000Z"),
end: new Date("2026-12-15T00:00:00.000Z"),
});
});
test("returns first day of month for free plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
test("clamps short months for anchors on the 31st", () => {
const result = getMonthlyUsageCycleWindow(
new Date("2026-01-31T08:00:00.000Z"),
new Date("2026-02-28T12:00:00.000Z")
);
const organization = {
billing: {
plan: "free",
periodStart: new Date("2023-01-15"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For free plans, should return first day of current month
expect(result).toEqual(new Date(2023, 2, 1));
expect(result).toEqual({
start: new Date("2026-02-28T08:00:00.000Z"),
end: new Date("2026-03-31T08:00:00.000Z"),
});
});
test("returns correct date for monthly plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
test("falls back to the current UTC calendar month when no anchor exists", () => {
const result = getMonthlyUsageCycleWindow(null, new Date("2026-03-09T12:00:00.000Z"));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-02-10"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For monthly plans, should return periodStart directly
expect(result).toEqual(new Date("2023-02-10"));
});
test("returns current month's subscription day for yearly plans when today is after subscription day", () => {
// Mock the current date to be March 20, 2023
vi.setSystemTime(new Date(2023, 2, 20));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 15, 2023 (same day in current month)
expect(result).toEqual(new Date(2023, 2, 15));
});
test("returns previous month's subscription day for yearly plans when today is before subscription day", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 15, 2023 (same day in previous month)
expect(result).toEqual(new Date(2023, 1, 15));
});
test("handles subscription day that doesn't exist in current month (February edge case)", () => {
// Mock the current date to be February 15, 2023
vi.setSystemTime(new Date(2023, 1, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return January 31, 2023 (previous month's subscription day)
// since today (Feb 15) is less than the subscription day (31st)
expect(result).toEqual(new Date(2023, 0, 31));
});
test("handles subscription day that doesn't exist in previous month (February to March transition)", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 28, 2023 (last day of February)
// since February 2023 doesn't have a 30th day
expect(result).toEqual(new Date(2023, 1, 28));
});
test("handles subscription day that doesn't exist in previous month (leap year)", () => {
// Mock the current date to be March 10, 2024 (leap year)
vi.setSystemTime(new Date(2024, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 29, 2024 (last day of February in leap year)
expect(result).toEqual(new Date(2024, 1, 29));
});
test("handles current month with fewer days than subscription day", () => {
// Mock the current date to be April 25, 2023 (April has 30 days)
vi.setSystemTime(new Date(2023, 3, 25));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 31, 2023 (since today is before April's adjusted subscription day)
expect(result).toEqual(new Date(2023, 2, 31));
});
test("throws error when periodStart is not set for non-free plans", () => {
const organization = {
billing: {
plan: "scale",
periodStart: null,
period: "monthly",
},
};
expect(() => {
getBillingPeriodStartDate(organization.billing);
}).toThrow("billing period start is not set");
expect(result).toEqual({
start: new Date("2026-03-01T00:00:00.000Z"),
end: new Date("2026-04-01T00:00:00.000Z"),
});
});
});
describe("getBillingUsageCycleWindow", () => {
test("uses the billing usageCycleAnchor", () => {
const result = getBillingUsageCycleWindow(
{
usageCycleAnchor: new Date("2026-02-10T00:00:00.000Z"),
},
new Date("2026-03-09T12:00:00.000Z")
);
expect(result).toEqual({
start: new Date("2026-02-10T00:00:00.000Z"),
end: new Date("2026-03-10T00:00:00.000Z"),
});
});
});
+80 -50
View File
@@ -1,54 +1,84 @@
import { TOrganizationBilling } from "@formbricks/types/organizations";
// Function to calculate billing period start date based on organization plan and billing period
export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date => {
const now = new Date();
if (billing.plan === "free") {
// For free plans, use the first day of the current calendar month
return new Date(now.getFullYear(), now.getMonth(), 1);
} else if (billing.period === "yearly" && billing.periodStart) {
// For yearly plans, use the same day of the month as the original subscription date
const periodStart = new Date(billing.periodStart);
// Use UTC to avoid timezone-offset shifting when parsing ISO date-only strings
const subscriptionDay = periodStart.getUTCDate();
type TBillingInput = Pick<TOrganizationBilling, "usageCycleAnchor">;
// Helper function to get the last day of a specific month
const getLastDayOfMonth = (year: number, month: number): number => {
// Create a date for the first day of the next month, then subtract one day
return new Date(year, month + 1, 0).getDate();
};
// Calculate the adjusted day for the current month
const lastDayOfCurrentMonth = getLastDayOfMonth(now.getFullYear(), now.getMonth());
const adjustedCurrentMonthDay = Math.min(subscriptionDay, lastDayOfCurrentMonth);
// Calculate the current month's adjusted subscription date
const currentMonthSubscriptionDate = new Date(now.getFullYear(), now.getMonth(), adjustedCurrentMonthDay);
// If today is before the subscription day in the current month (or its adjusted equivalent),
// we should use the previous month's subscription day as our start date
if (now.getDate() < adjustedCurrentMonthDay) {
// Calculate previous month and year
const prevMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
// Calculate the adjusted day for the previous month
const lastDayOfPreviousMonth = getLastDayOfMonth(prevYear, prevMonth);
const adjustedPreviousMonthDay = Math.min(subscriptionDay, lastDayOfPreviousMonth);
// Return the adjusted previous month date
return new Date(prevYear, prevMonth, adjustedPreviousMonthDay);
} else {
return currentMonthSubscriptionDate;
}
} else if (billing.period === "monthly" && billing.periodStart) {
// For monthly plans with a periodStart, use that date
return new Date(billing.periodStart);
} else {
// For other plans, use the periodStart from billing
if (!billing.periodStart) {
throw new Error("billing period start is not set");
}
return new Date(billing.periodStart);
}
export type TUsageCycleWindow = {
start: Date;
end: Date;
};
const getUtcMonthDate = (
year: number,
monthIndex: number,
anchorDay: number,
hours: number,
minutes: number,
seconds: number,
milliseconds: number
): Date => {
const lastDayOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
const clampedDay = Math.min(anchorDay, lastDayOfMonth);
return new Date(Date.UTC(year, monthIndex, clampedDay, hours, minutes, seconds, milliseconds));
};
const addUtcMonthsFromAnchor = (anchor: Date, monthOffset: number): Date => {
const anchorYear = anchor.getUTCFullYear();
const anchorMonth = anchor.getUTCMonth();
const anchorDay = anchor.getUTCDate();
const targetMonthIndex = anchorMonth + monthOffset;
const targetYear = anchorYear + Math.floor(targetMonthIndex / 12);
const normalizedMonthIndex = ((targetMonthIndex % 12) + 12) % 12;
return getUtcMonthDate(
targetYear,
normalizedMonthIndex,
anchorDay,
anchor.getUTCHours(),
anchor.getUTCMinutes(),
anchor.getUTCSeconds(),
anchor.getUTCMilliseconds()
);
};
const getCalendarMonthWindow = (now: Date): TUsageCycleWindow => {
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
return { start, end };
};
export const getMonthlyUsageCycleWindow = (
usageCycleAnchor: Date | null,
now = new Date()
): TUsageCycleWindow => {
if (!usageCycleAnchor) {
return getCalendarMonthWindow(now);
}
const anchor = new Date(usageCycleAnchor);
let monthOffset =
(now.getUTCFullYear() - anchor.getUTCFullYear()) * 12 + (now.getUTCMonth() - anchor.getUTCMonth());
let start = addUtcMonthsFromAnchor(anchor, monthOffset);
while (start > now) {
monthOffset -= 1;
start = addUtcMonthsFromAnchor(anchor, monthOffset);
}
let end = addUtcMonthsFromAnchor(anchor, monthOffset + 1);
while (end <= now) {
monthOffset += 1;
start = addUtcMonthsFromAnchor(anchor, monthOffset);
end = addUtcMonthsFromAnchor(anchor, monthOffset + 1);
}
return { start, end };
};
export const getBillingUsageCycleWindow = (billing: TBillingInput, now = new Date()): TUsageCycleWindow => {
return getMonthlyUsageCycleWindow(billing.usageCycleAnchor, now);
};
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
"api_keys": "API-Schlüssel",
"app": "App",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"replace": "Ersetzen",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
"you_have_reached_your_monthly_miu_limit_of": "Du hast dein monatliches MIU-Limit erreicht",
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"1000_monthly_responses": "1,000 monatliche Antworten",
"1_workspace": "1 Projekt",
"2000_contacts": "2,000 Kontakte",
"3_workspaces": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten",
"7500_contacts": "7,500 Kontakte",
"all_integrations": "Alle Integrationen",
"annually": "Jährlich",
"api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen",
"attribute_based_targeting": "Attributbasiertes Targeting",
"current": "aktuell",
"current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit",
"custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"custom_workspace_limit": "Individuelles Projektlimit",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_follow_ups": "E-Mail Follow-ups",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''",
"everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten",
"cancelling": "Wird storniert",
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Unbekannt",
"remove_branding": "Branding entfernen",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"team_access_roles": "Rollen für Teammitglieder",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_miu": "Unbegrenzte MIU",
"retry_setup": "Erneut einrichten",
"scale_banner_description": "Schalte höhere Limits, Teamzusammenarbeit und erweiterte Sicherheitsfunktionen mit dem Scale-Tarif frei.",
"scale_banner_title": "Bereit für den nächsten Schritt?",
"scale_feature_api": "Vollständiger API-Zugang",
"scale_feature_quota": "Quotenverwaltung",
"scale_feature_spam": "Spamschutz",
"scale_feature_teams": "Teams & Zugriffsrollen",
"status_trialing": "Trial",
"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",
"unlimited_responses": "Unbegrenzte Antworten",
"unlimited_surveys": "Unbegrenzte Umfragen",
"unlimited_team_members": "Unbegrenzte Teammitglieder",
"unlimited_workspaces": "Unbegrenzte Projekte",
"upgrade": "Upgrade",
"uptime_sla_99": "Betriebszeit SLA (99%)",
"website_surveys": "Website-Umfragen"
"usage_cycle": "Usage cycle",
"used": "verwendet",
"your_plan": "Dein Tarif"
},
"domain": {
"customize_favicon_description": "Lade ein individuelles Favicon hoch, um deine Link-Umfrage zu personalisieren und deine Markenpräsenz zu stärken.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder",
"expand_preview": "Vorschau erweitern",
"external_urls_paywall_tooltip": "Bitte führen sie ein upgrade auf den Startup-plan durch, um externe URLs anzupassen. Dies hilft uns, phishing zu verhindern.",
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
"four_points": "4 Punkte",
"heading": "Überschrift",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
"api_keys": "API Keys",
"app": "App",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Reorder and hide columns",
"replace": "Replace",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"response": "Response",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
"you_have_reached_your_monthly_miu_limit_of": "You have reached your monthly MIU limit of",
"you_have_reached_your_monthly_response_limit_of": "You have reached your monthly response limit of",
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"1000_monthly_responses": "Monthly 1,000 Responses",
"1_workspace": "1 Workspace",
"2000_contacts": "2,000 Contacts",
"3_workspaces": "3 Workspaces",
"5000_monthly_responses": "5,000 Monthly Responses",
"7500_contacts": "7,500 Contacts",
"all_integrations": "All Integrations",
"annually": "Annually",
"api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys",
"attribute_based_targeting": "Attribute-based Targeting",
"current": "Current",
"current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit",
"custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contact Limit",
"custom_response_limit": "Custom Response Limit",
"custom_workspace_limit": "Custom Workspace Limit",
"email_embedded_surveys": "Email Embedded Surveys",
"email_follow_ups": "Email Follow-ups",
"enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free",
"everything_in_startup": "Everything in Startup",
"free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)",
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details",
"cancelling": "Cancelling",
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Unknown",
"remove_branding": "Remove Branding",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"team_access_roles": "Team Access Roles",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_miu": "Unlimited MIU",
"retry_setup": "Retry setup",
"scale_banner_description": "Unlock higher limits, team collaboration, and advanced security features with the Scale plan.",
"scale_banner_title": "Ready to scale up?",
"scale_feature_api": "Full API Access",
"scale_feature_quota": "Quota Management",
"scale_feature_spam": "Spam Protection",
"scale_feature_teams": "Teams & Access Roles",
"status_trialing": "Trial",
"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",
"unlimited_responses": "Unlimited Responses",
"unlimited_surveys": "Unlimited Surveys",
"unlimited_team_members": "Unlimited Team Members",
"unlimited_workspaces": "Unlimited Workspaces",
"upgrade": "Upgrade",
"uptime_sla_99": "Uptime SLA (99%)",
"website_surveys": "Website Surveys"
"usage_cycle": "Usage cycle",
"used": "used",
"your_plan": "Your plan"
},
"domain": {
"customize_favicon_description": "Upload a custom favicon to personalize your link survey experience and strengthen your brand presence.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone",
"expand_preview": "Expand Preview",
"external_urls_paywall_tooltip": "Please upgrade to Startup plan to customize external URLs. This helps us prevent phishing.",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field “{fieldId}” is being used in “{quotaName}” quota",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"formbricks_sdk_is_not_connected": "Formbricks SDK is not connected",
"four_points": "4 points",
"heading": "Heading",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
"preview_survey_question_open_text_headline": "Anything else you would like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you'd like to add?",
"follow_up_label": "Is there anything else you would like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
+21 -51
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",
"api_keys": "Claves API",
"app": "Aplicación",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"replace": "Reemplazar",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"response": "Respuesta",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
"you_have_reached_your_monthly_miu_limit_of": "Has alcanzado tu límite mensual de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Has alcanzado tu límite mensual de respuestas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
},
"billing": {
"1000_monthly_responses": "1.000 respuestas mensuales",
"1_workspace": "1 proyecto",
"2000_contacts": "2.000 contactos",
"3_workspaces": "3 proyectos",
"5000_monthly_responses": "5.000 respuestas mensuales",
"7500_contacts": "7.500 contactos",
"all_integrations": "Todas las integraciones",
"annually": "Anualmente",
"api_webhooks": "API y webhooks",
"app_surveys": "Encuestas de aplicaciones",
"attribute_based_targeting": "Segmentación basada en atributos",
"current": "Actual",
"current_plan": "Plan actual",
"current_tier_limit": "Límite del nivel actual",
"custom": "Personalizado y escalable",
"custom_contacts_limit": "Límite de contactos personalizado",
"custom_response_limit": "Límite de respuestas personalizado",
"custom_workspace_limit": "Límite de proyectos personalizado",
"email_embedded_surveys": "Encuestas integradas en correo electrónico",
"email_follow_ups": "Seguimientos por correo electrónico",
"enterprise_description": "Soporte premium y límites personalizados.",
"everybody_has_the_free_plan_by_default": "¡Todo el mundo tiene el plan gratuito por defecto!",
"everything_in_free": "Todo lo incluido en Gratuito",
"everything_in_startup": "Todo lo incluido en Startup",
"free": "Gratuito",
"free_description": "Encuestas ilimitadas, miembros de equipo y más.",
"get_2_months_free": "Obtén 2 meses gratis",
"hosted_in_frankfurt": "Alojado en Frankfurt",
"ios_android_sdks": "SDK para iOS y Android para encuestas móviles",
"link_surveys": "Encuestas con enlace (compartibles)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltos lógicos, campos ocultos, encuestas recurrentes, etc.",
"manage_card_details": "Gestionar detalles de tarjeta",
"cancelling": "Cancelando",
"manage_subscription": "Gestionar suscripción",
"monthly": "Mensual",
"monthly_identified_users": "Usuarios identificados mensuales",
"plan_upgraded_successfully": "Plan actualizado con éxito",
"premium_support_with_slas": "Soporte premium con SLA",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Desconocido",
"remove_branding": "Eliminar marca",
"startup": "Startup",
"startup_description": "Todo lo incluido en Gratuito con funciones adicionales.",
"switch_plan": "Cambiar plan",
"team_access_roles": "Roles de acceso de equipo",
"unable_to_upgrade_plan": "No se puede actualizar el plan",
"unlimited_miu": "MIU ilimitados",
"retry_setup": "Reintentar configuración",
"scale_banner_description": "Desbloquea límites superiores, colaboración en equipo y funciones de seguridad avanzadas con el plan Scale.",
"scale_banner_title": "¿Listo para crecer?",
"scale_feature_api": "Acceso completo a la API",
"scale_feature_quota": "Gestión de cuota",
"scale_feature_spam": "Protección contra spam",
"scale_feature_teams": "Equipos y roles de acceso",
"status_trialing": "Prueba",
"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",
"unlimited_responses": "Respuestas ilimitadas",
"unlimited_surveys": "Encuestas ilimitadas",
"unlimited_team_members": "Miembros de equipo ilimitados",
"unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar",
"uptime_sla_99": "Acuerdo de nivel de servicio de tiempo de actividad (99 %)",
"website_surveys": "Encuestas de sitio web"
"usage_cycle": "Usage cycle",
"used": "usados",
"your_plan": "Tu plan"
},
"domain": {
"customize_favicon_description": "Sube un favicon personalizado para personalizar la experiencia de tu encuesta por enlace y fortalecer la presencia de tu marca.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos",
"expand_preview": "Expandir vista previa",
"external_urls_paywall_tooltip": "Por favor, actualiza al plan Startup para personalizar URLs externos. Esto nos ayuda a prevenir el phishing.",
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
"fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "El campo oculto \"{fieldId}\" se está utilizando en la cuota \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "El encuestado completa la encuesta",
"follow_ups_modal_updated_successfull_toast": "Seguimiento actualizado y se guardará cuando guardes la encuesta.",
"follow_ups_new": "Nuevo seguimiento",
"follow_ups_upgrade_button_text": "Actualiza para habilitar seguimientos",
"formbricks_sdk_is_not_connected": "El SDK de Formbricks no está conectado",
"four_points": "4 puntos",
"heading": "Encabezado",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
"api_keys": "Clés API",
"app": "Application",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"replace": "Remplacer",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"response": "Réponse",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
"you_have_reached_your_monthly_miu_limit_of": "Vous avez atteint votre limite mensuelle de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Vous avez atteint votre limite de réponses mensuelle de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
},
"billing": {
"1000_monthly_responses": "1 000 réponses mensuelles",
"1_workspace": "1 projet",
"2000_contacts": "2 000 contacts",
"3_workspaces": "3 projets",
"5000_monthly_responses": "5 000 réponses mensuelles",
"7500_contacts": "7 500 contacts",
"all_integrations": "Tout type d'intégration",
"annually": "Annuel",
"api_webhooks": "API et webhooks",
"app_surveys": "Enquêtes d'application",
"attribute_based_targeting": "Ciblage basé sur les attributs",
"current": "Actuel",
"current_plan": "Forfait actuel",
"current_tier_limit": "Limite de niveau actuel",
"custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisée",
"custom_response_limit": "Personnalisation des réponses",
"custom_workspace_limit": "Limite de projets personnalisée",
"email_embedded_surveys": "Sondages intégrés à des e-mails",
"email_follow_ups": "Relances par e-mail",
"enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Toutes les options du forfait Gratuit",
"everything_in_startup": "Toutes les options du forfait Initial",
"free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits",
"hosted_in_frankfurt": "Hébergement à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les enquêtes sur mobile",
"link_surveys": "Enquêtes par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte",
"cancelling": "Annulation en cours",
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs mensuels identifiés",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Assistance premium avec accord de niveau de service",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Inconnu",
"remove_branding": "Suppression du logo",
"startup": "Initial",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"team_access_roles": "Gestion des accès",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_miu": "MIU Illimité",
"retry_setup": "Réessayer la configuration",
"scale_banner_description": "Débloque des limites plus élevées, la collaboration en équipe et des fonctionnalités de sécurité avancées avec loffre Scale.",
"scale_banner_title": "Prêt à passer à la vitesse supérieure ?",
"scale_feature_api": "Accès API complet",
"scale_feature_quota": "Gestion des quotas",
"scale_feature_spam": "Protection contre le spam",
"scale_feature_teams": "Équipes & rôles daccès",
"status_trialing": "Essai",
"stripe_setup_incomplete": "Configuration de la facturation incomplète",
"stripe_setup_incomplete_description": "La configuration de la facturation na pas abouti. Merci de réessayer pour activer ton abonnement.",
"subscription": "Abonnement",
"subscription_description": "Gère ton abonnement et surveille ta consommation",
"unlimited_responses": "Réponses illimitées",
"unlimited_surveys": "Enquêtes illimitées",
"unlimited_team_members": "Membres d'équipe illimités",
"unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau",
"uptime_sla_99": "Disponibilité de 99 %",
"website_surveys": "Sondages de site web"
"usage_cycle": "Usage cycle",
"used": "utilisé(s)",
"your_plan": "Ton offre"
},
"domain": {
"customize_favicon_description": "Chargez un favicon personnalisé pour personnaliser l'expérience de vos enquêtes par lien et renforcer la présence de votre marque.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"everyone": "Tout le monde",
"expand_preview": "Agrandir l'aperçu",
"external_urls_paywall_tooltip": "Veuillez passer au forfait Startup pour personnaliser les URL externes. Cela nous aide à prévenir le phishing.",
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté",
"four_points": "4 points",
"heading": "En-tête",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
"preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager?",
"preview_survey_question_open_text_headline": "Souhaitez-vous partager autre chose ?",
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
"preview_survey_welcome_card_headline": "Bienvenue !",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?",
"follow_up_label": "Souhaitez-vous ajouter quelque chose ?",
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
"generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
"and": "És",
"and_response_limit_of": "és kérdéskorlátja ennek:",
"anonymous": "Névtelen",
"api_keys": "API-kulcsok",
"app": "Alkalmazás",
@@ -295,7 +294,7 @@
"new": "Új",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
"next": "Következő",
"no_actions_found": "Nem található művelet",
"no_actions_found": "Nem találhatók műveletek",
"no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
"replace": "Csere",
"report_survey": "Kérdőív jelentése",
"request_pricing": "Árazás kérése",
"request_trial_license": "Próbalicenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"response": "Válasz",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
"you_have_reached_your_limit_of_workspace_limit": "Elérte a(z) {projectLimit} munkaterületből álló korlátot.",
"you_have_reached_your_monthly_miu_limit_of": "Elérte a havi MIU-korlátját ennek:",
"you_have_reached_your_monthly_response_limit_of": "Elérte a havi válaszkorlátját ennek:",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.",
"your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat."
@@ -538,7 +535,7 @@
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
"text_variable": "Szöveg változó",
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
"verification_email_heading": "Már majdnem megvagyunk!",
"verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
@@ -968,57 +965,31 @@
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
},
"billing": {
"1000_monthly_responses": "Havi 1000 válasz",
"1_workspace": "1 munkaterület",
"2000_contacts": "2000 partner",
"3_workspaces": "3 munkaterület",
"5000_monthly_responses": "Havi 5000 válasz",
"7500_contacts": "7500 partner",
"all_integrations": "Összes integráció",
"annually": "Évente",
"api_webhooks": "API és webhorgok",
"app_surveys": "Alkalmazás-kérdőívek",
"attribute_based_targeting": "Attribútumalapú célzás",
"current": "Jelenlegi",
"current_plan": "Jelenlegi csomag",
"current_tier_limit": "Jelenlegi szintkorlát",
"custom": "Egyéni és méretezés",
"custom_contacts_limit": "Egyéni partnerkorlát",
"custom_response_limit": "Egyéni válaszkorlát",
"custom_workspace_limit": "Egyéni munkaterület-korlát",
"email_embedded_surveys": "E-mailbe beágyazott kérdőívek",
"email_follow_ups": "E-mail követések",
"enterprise_description": "Prémium támogatás és egyéni korlátok.",
"everybody_has_the_free_plan_by_default": "Alapértelmezetten mindenki az ingyenes csomaggal rendelkezik!",
"everything_in_free": "Minden az Ingyenesben",
"everything_in_startup": "Minden a Kezdőben",
"free": "Ingyenes",
"free_description": "Korlátlan kérdőívek, csapattagok és egyebek.",
"get_2_months_free": "Szerezzen 2 hónapot ingyen",
"hosted_in_frankfurt": "Frankfurtból kiszolgálva",
"ios_android_sdks": "iOS és Android SDK a mobil kérdőívekhez",
"link_surveys": "Hivatkozás-kérdőívek (megosztható)",
"logic_jumps_hidden_fields_recurring_surveys": "Logikai ugrások, rejtett mezők, ismétlődő kérdőívek stb.",
"manage_card_details": "Kártya részleteinek kezelése",
"cancelling": "Megszakítás",
"manage_subscription": "Feliratkozás kezelése",
"monthly": "Havonta",
"monthly_identified_users": "Havonta azonosított felhasználók",
"plan_upgraded_successfully": "A csomag sikeresen magasabbra váltva",
"premium_support_with_slas": "Prémium támogatás SLA-kkal",
"plan_hobby": "Hobbi",
"plan_pro": "Pro",
"plan_scale": "Méretezés",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
"startup": "Kezdő",
"startup_description": "Minden az Ingyenes csomagban további funkciókkal.",
"switch_plan": "Csomag váltása",
"team_access_roles": "Csapathozzáférés szerepei",
"unable_to_upgrade_plan": "Nem lehet magasabb csomagra váltani",
"unlimited_miu": "Korlátlan MIU",
"retry_setup": "Beállítás újrapróbálása",
"scale_banner_description": "Magasabb korlátok, csapat-együttműködés és speciális biztonsági funkciók feloldása a Méretezés csomaggal.",
"scale_banner_title": "Készen áll a méretnövelésre?",
"scale_feature_api": "Teljes API-hozzáférés",
"scale_feature_quota": "Kvótakezelés",
"scale_feature_spam": "Szemét elleni védekezés",
"scale_feature_teams": "Csapat- és hozzáférésszerepek",
"status_trialing": "Próba",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"subscription": "Előfizetés",
"subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete",
"unlimited_responses": "Korlátlan válaszok",
"unlimited_surveys": "Korlátlan kérdőívek",
"unlimited_team_members": "Korlátlan csapattagok",
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",
"uptime_sla_99": "Működési idő SLA (99%)",
"website_surveys": "Weboldal-kérdőívek"
"usage_cycle": "Használati ciklus",
"used": "használva",
"your_plan": "A csomagja"
},
"domain": {
"customize_favicon_description": "Egyéni böngészőikon feltöltése a hivatkozás-kérdőív élményének személyre szabásához és a márka jelenlétének erősítéséhez.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
"everyone": "Mindenki",
"expand_preview": "Előnézet kinyitása",
"external_urls_paywall_tooltip": "Váltson a magasabb Kezdő csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
"fallback_missing": "Tartalék hiányzik",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új követés",
"follow_ups_upgrade_button_text": "Magasabb csomagra váltás a követések engedélyezéséhez",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont",
"heading": "Címsor",
+22 -52
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",
"api_keys": "APIキー",
"app": "アプリ",
@@ -175,7 +174,7 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"count_attributes": "{count, plural, other {{count} の属性}}",
"count_attributes": "{count, plural, other {{count} の属性}}",
"count_contacts": "{count, plural, other {{count} 件の連絡先}}",
"count_members": "{count, plural, other {{count} 名のメンバー}}",
"count_questions": "{count, plural, other {# 件の質問}}",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "列の並び替えと非表示",
"replace": "置き換え",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
"request_trial_license": "トライアルライセンスをリクエスト",
"reset_to_default": "デフォルトにリセット",
"response": "回答",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
"you_have_reached_your_monthly_miu_limit_of": "月間MIU(月間アクティブユーザー)の上限に達しました",
"you_have_reached_your_monthly_response_limit_of": "月間回答数の上限に達しました",
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
},
"billing": {
"1000_monthly_responses": "月間1,000回答",
"1_workspace": "1ワークスペース",
"2000_contacts": "2,000連絡先",
"3_workspaces": "3ワークスペース",
"5000_monthly_responses": "月間5,000回答",
"7500_contacts": "7,500連絡先",
"all_integrations": "すべての連携",
"annually": "年間",
"api_webhooks": "API & Webhooks",
"app_surveys": "アプリ内フォーム",
"attribute_based_targeting": "属性ベースのターゲティング",
"current": "現在",
"current_plan": "現在のプラン",
"current_tier_limit": "現在のティア制限",
"custom": "カスタム&スケール",
"custom_contacts_limit": "カスタム連絡先制限",
"custom_response_limit": "カスタム回答制限",
"custom_workspace_limit": "カスタムワークスペース上限",
"email_embedded_surveys": "メール埋め込みフォーム",
"email_follow_ups": "メールフォローアップ",
"enterprise_description": "プレミアムサポートとカスタム制限。",
"everybody_has_the_free_plan_by_default": "デフォルトで誰もが無料プランを持っています!",
"everything_in_free": "無料プランのすべて",
"everything_in_startup": "スタートアッププランのすべて",
"free": "無料",
"free_description": "無制限のフォーム、チームメンバー、その他多数。",
"get_2_months_free": "2ヶ月間無料",
"hosted_in_frankfurt": "フランクフルトでホスト",
"ios_android_sdks": "モバイルフォーム用iOS & Android SDK",
"link_surveys": "リンクフォーム(共有可能)",
"logic_jumps_hidden_fields_recurring_surveys": "ロジックジャンプ、非表示フィールド、定期フォームなど。",
"manage_card_details": "カード詳細を管理",
"cancelling": "キャンセル中",
"manage_subscription": "サブスクリプションを管理",
"monthly": "月間",
"monthly_identified_users": "月間識別ユーザー数",
"plan_upgraded_successfully": "プランを正常にアップグレードしました",
"premium_support_with_slas": "SLA付きプレミアムサポート",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "不明",
"remove_branding": "ブランディングを削除",
"startup": "スタートアップ",
"startup_description": "無料プランのすべての機能に追加機能。",
"switch_plan": "プランを切り替え",
"team_access_roles": "チームアクセスロール",
"unable_to_upgrade_plan": "プランをアップグレードできません",
"unlimited_miu": "無制限のMIU",
"retry_setup": "セットアップを再試行",
"scale_banner_description": "Scaleプランで、上限の引き上げ、チームでのコラボレーション、高度なセキュリティ機能を利用しましょう。",
"scale_banner_title": "スケールアップの準備はできていますか?",
"scale_feature_api": "APIフルアクセス",
"scale_feature_quota": "クォータ管理",
"scale_feature_spam": "スパム防止機能",
"scale_feature_teams": "チーム&アクセス権限管理",
"status_trialing": "Trial",
"stripe_setup_incomplete": "請求情報の設定が未完了",
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
"subscription": "サブスクリプション",
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
"unlimited_responses": "無制限の回答",
"unlimited_surveys": "無制限のフォーム",
"unlimited_team_members": "無制限のチームメンバー",
"unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード",
"uptime_sla_99": "稼働率SLA (99%)",
"website_surveys": "ウェブサイトフォーム"
"usage_cycle": "Usage cycle",
"used": "使用済み",
"your_plan": "ご利用プラン"
},
"domain": {
"customize_favicon_description": "カスタムファビコンをアップロードして、リンク調査の体験をパーソナライズし、ブランドプレゼンスを強化します。",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
"everyone": "全員",
"expand_preview": "プレビューを展開",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには、スタートアッププランへのアップグレードが必要です。これによりフィッシング詐欺を防止することができます。",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき",
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
"formbricks_sdk_is_not_connected": "Formbricks SDKが接続されていません",
"four_points": "4点",
"heading": "見出し",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",
"api_keys": "API-sleutels",
"app": "App",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"replace": "Vervangen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"response": "Antwoord",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
"you_have_reached_your_monthly_miu_limit_of": "U heeft uw maandelijkse MIU-limiet van bereikt",
"you_have_reached_your_monthly_response_limit_of": "U heeft uw maandelijkse responslimiet bereikt van",
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
},
"billing": {
"1000_monthly_responses": "Maandelijks 1.000 reacties",
"1_workspace": "1 werkruimte",
"2000_contacts": "2.000 contacten",
"3_workspaces": "3 werkruimtes",
"5000_monthly_responses": "5.000 maandelijkse reacties",
"7500_contacts": "7.500 contacten",
"all_integrations": "Alle integraties",
"annually": "Jaarlijks",
"api_webhooks": "API en webhooks",
"app_surveys": "App-enquêtes",
"attribute_based_targeting": "Op kenmerken gebaseerde targeting",
"current": "Huidig",
"current_plan": "Huidig abonnement",
"current_tier_limit": "Huidige niveaulimiet",
"custom": "Aangepast en schaal",
"custom_contacts_limit": "Aangepaste contactenlimiet",
"custom_response_limit": "Aangepaste reactielimiet",
"custom_workspace_limit": "Aangepaste werkruimtelimiet",
"email_embedded_surveys": "Ingebedde enquêtes per e-mail",
"email_follow_ups": "E-mailopvolgingen",
"enterprise_description": "Premium-ondersteuning en aangepaste limieten.",
"everybody_has_the_free_plan_by_default": "Iedereen heeft standaard het gratis abonnement!",
"everything_in_free": "Alles in Gratis",
"everything_in_startup": "Alles in Opstarten",
"free": "Vrij",
"free_description": "Onbeperkte enquêtes, teamleden en meer.",
"get_2_months_free": "Ontvang 2 maanden gratis",
"hosted_in_frankfurt": "Gehost in Frankfort",
"ios_android_sdks": "iOS- en Android-SDK voor mobiele enquêtes",
"link_surveys": "Enquêtes koppelen (deelbaar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logische sprongen, verborgen velden, terugkerende enquêtes, enz.",
"manage_card_details": "Beheer kaartgegevens",
"cancelling": "Bezig met annuleren",
"manage_subscription": "Beheer abonnement",
"monthly": "Maandelijks",
"monthly_identified_users": "Maandelijks geïdentificeerde gebruikers",
"plan_upgraded_successfully": "Plan geüpgraded",
"premium_support_with_slas": "Premium ondersteuning met SLA's",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Onbekend",
"remove_branding": "Branding verwijderen",
"startup": "Opstarten",
"startup_description": "Alles gratis met extra functies.",
"switch_plan": "Schakelplan",
"team_access_roles": "Teamtoegangsrollen",
"unable_to_upgrade_plan": "Kan het abonnement niet upgraden",
"unlimited_miu": "Onbeperkte MIU",
"retry_setup": "Opnieuw proberen",
"scale_banner_description": "Ontgrendel hogere limieten, team samenwerking, en geavanceerde beveiligingsfuncties met het Scale-abonnement.",
"scale_banner_title": "Klaar om op te schalen?",
"scale_feature_api": "Volledige API-toegang",
"scale_feature_quota": "Quotabeheer",
"scale_feature_spam": "Spam-beveiliging",
"scale_feature_teams": "Teams & toegangsrollen",
"status_trialing": "Proefperiode",
"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",
"unlimited_responses": "Onbeperkte reacties",
"unlimited_surveys": "Onbeperkte enquêtes",
"unlimited_team_members": "Onbeperkte teamleden",
"unlimited_workspaces": "Onbeperkt werkruimtes",
"upgrade": "Upgraden",
"uptime_sla_99": "Uptime-SLA (99%)",
"website_surveys": "Website-enquêtes"
"usage_cycle": "Usage cycle",
"used": "gebruikt",
"your_plan": "Jouw abonnement"
},
"domain": {
"customize_favicon_description": "Upload een aangepaste favicon om je linkenquête-ervaring te personaliseren en je merkpresentie te versterken.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
"everyone": "Iedereen",
"expand_preview": "Voorbeeld uitvouwen",
"external_urls_paywall_tooltip": "Upgrade naar het Startup-abonnement om externe URL's aan te passen. Dit helpt ons phishing te voorkomen.",
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
"fallback_missing": "Terugval ontbreekt",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verborgen veld \"{fieldId}\" wordt gebruikt in het \"{quotaName}\" quotum",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Respondent vult enquête in",
"follow_ups_modal_updated_successfull_toast": "Follow-up bijgewerkt en wordt opgeslagen zodra u de enquête opslaat.",
"follow_ups_new": "Nieuw vervolg",
"follow_ups_upgrade_button_text": "Upgrade om follow-ups mogelijk te maken",
"formbricks_sdk_is_not_connected": "Formbricks SDK is niet verbonden",
"four_points": "4 punten",
"heading": "Rubriek",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Nee, dank je!",
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
"preview_survey_question_open_text_headline": "Wil je nog iets delen?",
"preview_survey_question_open_text_headline": "Wilt u nog iets anders delen?",
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
"preview_survey_welcome_card_headline": "Welkom!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat je wilt toevoegen?",
"follow_up_label": "Is er nog iets dat u wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
"generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
"api_keys": "Chaves de API",
"app": "app",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"replace": "Substituir",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"response": "Resposta",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
"you_have_reached_your_monthly_miu_limit_of": "Você atingiu o seu limite mensal de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Você atingiu o limite mensal de respostas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"1000_monthly_responses": "1000 Respostas Mensais",
"1_workspace": "1 projeto",
"2000_contacts": "2.000 Contatos",
"3_workspaces": "3 projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"7500_contacts": "7.500 Contatos",
"all_integrations": "Todas as Integrações",
"annually": "anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite personalizado de contatos",
"custom_response_limit": "Limite de Resposta Personalizado",
"custom_workspace_limit": "Limite personalizado de projetos",
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça",
"everything_in_startup": "Tudo em Startup",
"free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão",
"cancelling": "Cancelando",
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "desconhecido",
"remove_branding": "Remover Marca",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"team_access_roles": "Funções de Acesso da Equipe",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado",
"retry_setup": "Tentar novamente",
"scale_banner_description": "Desbloqueie limites maiores, colaboração em equipe e recursos avançados de segurança com o plano Scale.",
"scale_banner_title": "Pronto para expandir?",
"scale_feature_api": "Acesso completo à API",
"scale_feature_quota": "Gestão de cota",
"scale_feature_spam": "Proteção contra spam",
"scale_feature_teams": "Equipes e papéis de acesso",
"status_trialing": "Trial",
"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",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_surveys": "Pesquisas Ilimitadas",
"unlimited_team_members": "Membros Ilimitados na Equipe",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"uptime_sla_99": "Tempo de atividade SLA (99%)",
"website_surveys": "Pesquisas de Site"
"usage_cycle": "Usage cycle",
"used": "usado",
"your_plan": "Seu plano"
},
"domain": {
"customize_favicon_description": "Faça o upload de um favicon personalizado para personalizar a experiência da sua pesquisa por link e fortalecer a presença da sua marca.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"everyone": "Todo mundo",
"expand_preview": "Expandir prévia",
"external_urls_paywall_tooltip": "Por favor, faça upgrade para o plano Startup para personalizar URLs externos. Isso nos ajuda a prevenir phishing.",
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Título",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?",
"preview_survey_question_open_text_headline": "Há algo mais que você gostaria de compartilhar?",
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de adicionar?",
"follow_up_label": "Há algo mais que você gostaria de acrescentar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
"api_keys": "Chaves API",
"app": "Aplicação",
@@ -180,7 +179,7 @@
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
"count_responses": "{count, plural, one {{count} resposta} other {{count} respostas}}",
"count_selections": "{count, plural, one {{count} selecção} other {{count} selecções}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"replace": "Substituir",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"response": "Resposta",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
"you_have_reached_your_monthly_miu_limit_of": "Atingiu o seu limite mensal de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Atingiu o seu limite mensal de respostas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"1000_monthly_responses": "1000 Respostas Mensais",
"1_workspace": "1 projeto",
"2000_contacts": "2000 Contactos",
"3_workspaces": "3 projetos",
"5000_monthly_responses": "5000 Respostas Mensais",
"7500_contacts": "7500 Contactos",
"all_integrations": "Todas as Integrações",
"annually": "Anual",
"api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos (app)",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "Atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível",
"custom": "Personalizado",
"custom_contacts_limit": "Limite personalizado de contactos",
"custom_response_limit": "Limite de Resposta Personalizado",
"custom_workspace_limit": "Limite de projetos personalizado",
"email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo incluído no Plano Grátis",
"everything_in_startup": "Tudo incluído no Plano Para começar",
"free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Inquéritos por link (partilháveis)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltar Perguntas, Campos Ocultos, Inquéritos Regulares, etc.",
"manage_card_details": "Gerir Detalhes do Cartão",
"cancelling": "A cancelar",
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Desconhecido",
"remove_branding": "Possibilidade de remover o logo",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"team_access_roles": "Funções de Acesso da Equipa",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado",
"retry_setup": "Tentar novamente configurar",
"scale_banner_description": "Desbloqueia limites mais elevados, colaboração em equipa e funcionalidades avançadas de segurança com o plano Scale.",
"scale_banner_title": "Preparado para aumentar a escala?",
"scale_feature_api": "Acesso total à API",
"scale_feature_quota": "Gestão de quotas",
"scale_feature_spam": "Proteção contra spam",
"scale_feature_teams": "Equipas e papéis de acesso",
"status_trialing": "Trial",
"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",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_surveys": "Inquéritos Ilimitados",
"unlimited_team_members": "Membros da Equipa Ilimitados",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"uptime_sla_99": "SLA de Tempo de Atividade (99%)",
"website_surveys": "Inquéritos (site)"
"usage_cycle": "Usage cycle",
"used": "utilizado",
"your_plan": "O teu plano"
},
"domain": {
"customize_favicon_description": "Carregue um favicon personalizado para personalizar a experiência do seu inquérito por link e reforçar a presença da sua marca.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"everyone": "Todos",
"expand_preview": "Expandir pré-visualização",
"external_urls_paywall_tooltip": "Por favor, atualize para o plano Startup para personalizar URLs externos. Isto ajuda-nos a prevenir o phishing.",
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Cabeçalho",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_headline": "Há mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",
"api_keys": "Chei API",
"app": "Aplicație",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"replace": "Înlocuiește",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
"request_trial_license": "Solicitați o licență de încercare",
"reset_to_default": "Revino la implicit",
"response": "Răspuns",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
"you_have_reached_your_monthly_miu_limit_of": "Ați atins limita lunară MIU de",
"you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
},
"billing": {
"1000_monthly_responses": "1.000 Răspunsuri Lunare",
"1_workspace": "1 workspace",
"2000_contacts": "2.000 Contacte",
"3_workspaces": "3 workspaces",
"5000_monthly_responses": "5.000 Răspunsuri Lunare",
"7500_contacts": "7.500 Contacte",
"all_integrations": "Toate integrațiile",
"annually": "Anual",
"api_webhooks": "API & Webhook-uri",
"app_surveys": "Sondaje în aplicație",
"attribute_based_targeting": "Targetare bazată pe atribute",
"current": "Curent",
"current_plan": "Plan curent",
"current_tier_limit": "Limită curentă a nivelului",
"custom": "Personalizat & Scalare",
"custom_contacts_limit": "Limită personalizată de contacte",
"custom_response_limit": "Limit Personalizat Răspunsuri",
"custom_workspace_limit": "Limită personalizată de workspaces",
"email_embedded_surveys": "Sondaje încorporate în email",
"email_follow_ups": "Email follow-up",
"enterprise_description": "Suport Premium și limite personalizate.",
"everybody_has_the_free_plan_by_default": "Toată lumea are planul gratuit implicit!",
"everything_in_free": "Totul în Gratuit",
"everything_in_startup": "Totul în Startup",
"free": "Gratuit",
"free_description": "Sondaje nelimitate, membri în echipă și altele.",
"get_2_months_free": "Primește 2 luni gratuite",
"hosted_in_frankfurt": "Găzduit în Frankfurt",
"ios_android_sdks": "SDK iOS & Android pentru sondaje mobile",
"link_surveys": "Sondaje Link (Distribuibil)",
"logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.",
"manage_card_details": "Gestionați detaliile cardului",
"cancelling": "Anulare în curs",
"manage_subscription": "Gestionați abonamentul",
"monthly": "Lunar",
"monthly_identified_users": "Utilizatori identificați lunar",
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scală",
"plan_unknown": "Necunoscut",
"remove_branding": "Eliminare branding",
"startup": "Pornire",
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
"switch_plan": "Schimbă planul",
"team_access_roles": "Roluri acces echipă",
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
"unlimited_miu": "MIU Nelimitat",
"retry_setup": "Încearcă din nou configurarea",
"scale_banner_description": "Deblochează limite mai mari, colaborare în echipă și funcții avansate de securitate cu pachetul Scale.",
"scale_banner_title": "Gata să treci la nivelul următor?",
"scale_feature_api": "Acces complet API",
"scale_feature_quota": "Gestionare cote",
"scale_feature_spam": "Protecție anti-spam",
"scale_feature_teams": "Echipe și roluri de acces",
"status_trialing": "Trial",
"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",
"unlimited_responses": "Răspunsuri nelimitate",
"unlimited_surveys": "Sondaje nelimitate",
"unlimited_team_members": "Membri nelimitați în echipă",
"unlimited_workspaces": "Workspaces nelimitate",
"upgrade": "Actualizare",
"uptime_sla_99": "Disponibilitate SLA (99%)",
"website_surveys": "Sondaje ale site-ului"
"usage_cycle": "Usage cycle",
"used": "utilizat",
"your_plan": "Planul tău"
},
"domain": {
"customize_favicon_description": "Încărcați un favicon personalizat pentru a oferi o experiență unică sondajului de linkuri și pentru a consolida prezența brandului.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"everyone": "Toată lumea",
"expand_preview": "Extinde previzualizarea",
"external_urls_paywall_tooltip": " rugăm să treci la planul Startup pentru a personaliza URL-urile externe. Acest lucru ne ajută să prevenim phishing-ul.",
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat",
"four_points": "4 puncte",
"heading": "Titlu",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
"preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?",
"preview_survey_question_open_text_headline": "Mai aveți ceva de adăugat?",
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
"preview_survey_welcome_card_headline": "Bun venit!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
"follow_up_label": "Mai este ceva ce ați dori să adăugi?",
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
"generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?",
+24 -54
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",
"api_keys": "API-ключи",
"app": "Приложение",
@@ -180,7 +179,7 @@
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
"count_questions": "{count, plural, one {{count} вопрос} few {{count} вопроса} many {{count} вопросов} other {{count} вопросов}}",
"count_responses": "{count, plural, one {{count} ответ} few {{count} ответа} many {{count} ответов} other {{count} ответа}}",
"count_selections": "{count, plural, one {{count} вариант} few {{count} варианта} many {{count} вариантов} other {{count} варианта}}",
"count_selections": "{count, plural, one {{count} выбор} few {{count} выбора} many {{count} выборов} other {{count} выбора}}",
"create_new_organization": "Создать новую организацию",
"create_segment": "Создать сегмент",
"create_survey": "Создать опрос",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
"replace": "Заменить",
"report_survey": "Пожаловаться на опрос",
"request_pricing": "Запросить стоимость",
"request_trial_license": "Запросить пробную лицензию",
"reset_to_default": "Сбросить по умолчанию",
"response": "Ответ",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
"you_have_reached_your_monthly_miu_limit_of": "Вы достигли месячного лимита MIU:",
"you_have_reached_your_monthly_response_limit_of": "Вы достигли месячного лимита ответов:",
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
},
"billing": {
"1000_monthly_responses": "1 000 ответов в месяц",
"1_workspace": "1 рабочее пространство",
"2000_contacts": "2 000 контактов",
"3_workspaces": "3 рабочих пространства",
"5000_monthly_responses": "5 000 ответов в месяц",
"7500_contacts": "7 500 контактов",
"all_integrations": "Все интеграции",
"annually": "Ежегодно",
"api_webhooks": "API и вебхуки",
"app_surveys": "Опросы в приложении",
"attribute_based_targeting": "Таргетинг по атрибутам",
"current": "Текущий",
"current_plan": "Текущий план",
"current_tier_limit": "Текущий лимит тарифа",
"custom": "Индивидуально и масштабируемо",
"custom_contacts_limit": "Лимит пользовательских контактов",
"custom_response_limit": "Индивидуальный лимит ответов",
"custom_workspace_limit": "Пользовательский лимит рабочих пространств",
"email_embedded_surveys": "Встроенные в email опросы",
"email_follow_ups": "Email-напоминания",
"enterprise_description": "Премиум-поддержка и индивидуальные лимиты.",
"everybody_has_the_free_plan_by_default": "По умолчанию у всех бесплатный тариф!",
"everything_in_free": "Всё, что есть в тарифе Free",
"everything_in_startup": "Всё, что есть в тарифе Startup",
"free": "Free",
"free_description": "Неограниченное количество опросов, участников команды и многое другое.",
"get_2_months_free": "Получите 2 месяца бесплатно",
"hosted_in_frankfurt": "Хостинг во Франкфурте",
"ios_android_sdks": "SDK для iOS и Android для мобильных опросов",
"link_surveys": "Ссылочные опросы (можно делиться)",
"logic_jumps_hidden_fields_recurring_surveys": "Переходы по логике, скрытые поля, повторяющиеся опросы и др.",
"manage_card_details": "Управление данными карты",
"cancelling": "Отмена",
"manage_subscription": "Управление подпиской",
"monthly": "Ежемесячно",
"monthly_identified_users": "Ежемесячно идентифицированные пользователи",
"plan_upgraded_successfully": "Тариф успешно обновлён",
"premium_support_with_slas": "Премиум-поддержка с SLA",
"plan_hobby": "Хобби",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_unknown": "Неизвестно",
"remove_branding": "Удалить брендинг",
"startup": "Startup",
"startup_description": "Всё из тарифа Free плюс дополнительные функции.",
"switch_plan": "Сменить тариф",
"team_access_roles": "Роли доступа команды",
"unable_to_upgrade_plan": "Не удалось обновить тариф",
"unlimited_miu": "Безлимитный MIU",
"retry_setup": "Повторить настройку",
"scale_banner_description": "Откройте новые лимиты, командную работу и расширенные функции безопасности с тарифом Scale.",
"scale_banner_title": "Готовы развиваться?",
"scale_feature_api": "Полный доступ к API",
"scale_feature_quota": "Управление квотами",
"scale_feature_spam": "Защита от спама",
"scale_feature_teams": "Команды и роли доступа",
"status_trialing": "Пробный",
"stripe_setup_incomplete": "Настройка оплаты не завершена",
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
"subscription": "Подписка",
"subscription_description": "Управляйте своим тарифом и следите за использованием",
"unlimited_responses": "Неограниченное количество ответов",
"unlimited_surveys": "Неограниченное количество опросов",
"unlimited_team_members": "Неограниченное количество участников команды",
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить",
"uptime_sla_99": "SLA по времени безотказной работы (99%)",
"website_surveys": "Опросы на сайте"
"usage_cycle": "Usage cycle",
"used": "использовано",
"your_plan": "Ваш тариф"
},
"domain": {
"customize_favicon_description": "Загрузите свой favicon, чтобы персонализировать опросы по ссылке и усилить узнаваемость бренда.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
"everyone": "Все",
"expand_preview": "Развернуть предпросмотр",
"external_urls_paywall_tooltip": "Пожалуйста, обновите тариф до Startup, чтобы настраивать внешние URL. Это помогает нам предотвращать фишинг.",
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
"fallback_missing": "Запасное значение отсутствует",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Скрытое поле \"{fieldId}\" используется в квоте \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Респондент завершает опрос",
"follow_ups_modal_updated_successfull_toast": "Фоллоу-ап обновлён и будет сохранён после сохранения опроса.",
"follow_ups_new": "Новый фоллоу-ап",
"follow_ups_upgrade_button_text": "Обновите тариф для активации фоллоу-апов",
"formbricks_sdk_is_not_connected": "Formbricks SDK не подключён",
"four_points": "4 балла",
"heading": "Заголовок",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Нет, спасибо!",
"preview_survey_question_2_headline": "Хотите быть в курсе событий?",
"preview_survey_question_2_subheader": "Это пример описания.",
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
"preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?",
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
"preview_survey_welcome_card_headline": "Добро пожаловать!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хочешь что-то ещё добавить?",
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
"generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?",
+23 -53
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",
"api_keys": "API-nycklar",
"app": "App",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
"replace": "Ersätt",
"report_survey": "Rapportera enkät",
"request_pricing": "Begär prissättning",
"request_trial_license": "Begär provlicens",
"reset_to_default": "Återställ till standard",
"response": "Svar",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
"you_have_reached_your_monthly_miu_limit_of": "Du har nått din månatliga MIU-gräns på",
"you_have_reached_your_monthly_response_limit_of": "Du har nått din månatliga svarsgräns på",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
},
"billing": {
"1000_monthly_responses": "1 000 svar per månad",
"1_workspace": "1 arbetsyta",
"2000_contacts": "2 000 kontakter",
"3_workspaces": "3 arbetsytor",
"5000_monthly_responses": "5 000 svar per månad",
"7500_contacts": "7 500 kontakter",
"all_integrations": "Alla integrationer",
"annually": "Årligen",
"api_webhooks": "API och webhooks",
"app_surveys": "Appenkäter",
"attribute_based_targeting": "Attributbaserad inriktning",
"current": "Nuvarande",
"current_plan": "Nuvarande plan",
"current_tier_limit": "Nuvarande nivågräns",
"custom": "Anpassad och skalbar",
"custom_contacts_limit": "Anpassad kontaktgräns",
"custom_response_limit": "Anpassad svarsgräns",
"custom_workspace_limit": "Anpassad gräns för arbetsytor",
"email_embedded_surveys": "E-postinbäddade enkäter",
"email_follow_ups": "E-postuppföljningar",
"enterprise_description": "Premiumsupport och anpassade gränser.",
"everybody_has_the_free_plan_by_default": "Alla har gratisplanen som standard!",
"everything_in_free": "Allt i Gratis",
"everything_in_startup": "Allt i Startup",
"free": "Gratis",
"free_description": "Obegränsade enkäter, teammedlemmar och mer.",
"get_2_months_free": "Få 2 månader gratis",
"hosted_in_frankfurt": "Hostat i Frankfurt",
"ios_android_sdks": "iOS och Android SDK för mobilenkäter",
"link_surveys": "Länkenkäter (delbara)",
"logic_jumps_hidden_fields_recurring_surveys": "Logikhopp, dolda fält, återkommande enkäter, etc.",
"manage_card_details": "Hantera kortuppgifter",
"cancelling": "Avbryter",
"manage_subscription": "Hantera prenumeration",
"monthly": "Månadsvis",
"monthly_identified_users": "Månadsvis identifierade användare",
"plan_upgraded_successfully": "Plan uppgraderad",
"premium_support_with_slas": "Premiumsupport med SLA",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Skala",
"plan_unknown": "Okänd",
"remove_branding": "Ta bort varumärke",
"startup": "Startup",
"startup_description": "Allt i Gratis med ytterligare funktioner.",
"switch_plan": "Byt plan",
"team_access_roles": "Teamåtkomstroller",
"unable_to_upgrade_plan": "Kunde inte uppgradera plan",
"unlimited_miu": "Obegränsad MIU",
"retry_setup": "Försök igen med inställningen",
"scale_banner_description": "Lås upp högre gränser, samarbete i team och avancerade säkerhetsfunktioner med Scale-planen.",
"scale_banner_title": "Redo att växla upp?",
"scale_feature_api": "Full API-åtkomst",
"scale_feature_quota": "Kvot­hantering",
"scale_feature_spam": "Spamskydd",
"scale_feature_teams": "Team & åtkomstroller",
"status_trialing": "Testperiod",
"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",
"unlimited_responses": "Obegränsade svar",
"unlimited_surveys": "Obegränsade enkäter",
"unlimited_team_members": "Obegränsade teammedlemmar",
"unlimited_workspaces": "Obegränsat antal arbetsytor",
"upgrade": "Uppgradera",
"uptime_sla_99": "Drifttids-SLA (99%)",
"website_surveys": "Webbplatsenkäter"
"usage_cycle": "Usage cycle",
"used": "använt",
"your_plan": "Din plan"
},
"domain": {
"customize_favicon_description": "Ladda upp en egen favicon för att anpassa din länkenkät och stärka ditt varumärke.",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
"everyone": "Alla",
"expand_preview": "Expandera förhandsgranskning",
"external_urls_paywall_tooltip": "Vänligen uppgradera till Startup-planen för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
"fallback_missing": "Reservvärde saknas",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Dolt fält \"{fieldId}\" används i kvoten \"{quotaName}\"",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "Respondenten slutför enkäten",
"follow_ups_modal_updated_successfull_toast": "Uppföljning uppdaterad och sparas när du sparar enkäten.",
"follow_ups_new": "Ny uppföljning",
"follow_ups_upgrade_button_text": "Uppgradera för att aktivera uppföljningar",
"formbricks_sdk_is_not_connected": "Formbricks SDK är inte anslutet",
"four_points": "4 poäng",
"heading": "Rubrik",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "Nej, tack!",
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
"preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?",
"preview_survey_question_open_text_headline": "Finns det något annat du vill dela med dig av?",
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
"preview_survey_welcome_card_headline": "Välkommen!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Är det något mer du vill lägga till?",
"follow_up_label": "Finns det något annat du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?",
+29 -59
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",
"api_keys": "API 密钥",
"app": "应用",
@@ -175,12 +174,12 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"count_attributes": "{count, plural, other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人}}",
"count_members": "{count, plural, other {{count} 位成员}}",
"count_questions": "{count, plural, other {{count} 个问题} }",
"count_responses": "{count, plural, other {{count} 回复}}",
"count_selections": "{count, plural, other {{count} 个选择}}",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
"count_questions": "{count}个问题",
"count_responses": "{count, plural, other {{count} 回复} }",
"count_selections": "{count, plural, other {已选择{count}}}",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "重新排序和隐藏列",
"replace": "替换",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
"request_trial_license": "申请试用许可证",
"reset_to_default": "重置为 默认",
"response": "响应",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
"you_have_reached_your_monthly_miu_limit_of": "您 已经 达到 每月 的 MIU 限制",
"you_have_reached_your_monthly_response_limit_of": "您 已经 达到 每月 的 响应 限制",
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
},
"billing": {
"1000_monthly_responses": "每月 1,000 回复",
"1_workspace": "1 个工作区",
"2000_contacts": "2,000 联系人",
"3_workspaces": "3 个工作区",
"5000_monthly_responses": "5,000 每月 回复",
"7500_contacts": "7,500 联系人",
"all_integrations": "所有 集成",
"annually": "每年",
"api_webhooks": "API & Webhooks",
"app_surveys": "应用 程序 调查",
"attribute_based_targeting": "基于属性的目标定位",
"current": "当前",
"current_plan": "当前 计划",
"current_tier_limit": "当前 层 级 限制",
"custom": "自定义 & Scale",
"custom_contacts_limit": "自定义联系人上限",
"custom_response_limit": "自定义 响应限制",
"custom_workspace_limit": "自定义工作区数量限制",
"email_embedded_surveys": "邮件 嵌入 调查",
"email_follow_ups": "邮件 跟进",
"enterprise_description": "高级支持和自定义限制。",
"everybody_has_the_free_plan_by_default": "默认情况下,所有人都有免费计划!",
"everything_in_free": "所有 在 Free",
"everything_in_startup": "所有 在 Startup",
"free": "免费",
"free_description": "无限调查、团队成员和更多。",
"get_2_months_free": "获取 2 个月 免费",
"hosted_in_frankfurt": "托管在 法兰克福",
"ios_android_sdks": "iOS & Android 的 移动 调查 SDK",
"link_surveys": "链接 调查 (可共享)",
"logic_jumps_hidden_fields_recurring_surveys": "逻辑 跳转 , 隐藏 字段 , 定期 调查 , 等",
"manage_card_details": "管理卡片详情",
"cancelling": "正在取消",
"manage_subscription": "管理 订阅",
"monthly": "每月",
"monthly_identified_users": "每月 已识别的 用户",
"plan_upgraded_successfully": "计划 升级 成功",
"premium_support_with_slas": "优质支持与 SLAs",
"plan_hobby": "兴趣版",
"plan_pro": "专业版",
"plan_scale": "规模版",
"plan_unknown": "未知",
"remove_branding": "移除 品牌",
"startup": "初创企业",
"startup_description": "包含免费版的所有功能以及附加功能.",
"switch_plan": "切换 计划",
"team_access_roles": "团队访问角色",
"unable_to_upgrade_plan": "无法升级计划",
"unlimited_miu": "无限 MIU",
"retry_setup": "重试设置",
"scale_banner_description": "升级到 Scale 套餐,解锁更高额度、团队协作和高级安全功能",
"scale_banner_title": "准备好扩容了吗?",
"scale_feature_api": "完整 API 访问权限",
"scale_feature_quota": "额度管理",
"scale_feature_spam": "垃圾防护",
"scale_feature_teams": "团队与访问角色",
"status_trialing": "试用版",
"stripe_setup_incomplete": "账单设置未完成",
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
"subscription": "订阅",
"subscription_description": "管理你的订阅套餐并监控用量",
"unlimited_responses": "无限反馈",
"unlimited_surveys": "无限 调查",
"unlimited_team_members": "无限团队成员",
"unlimited_workspaces": "无限工作区",
"upgrade": "升级",
"uptime_sla_99": "正常运行时间 SLA (99%)",
"website_surveys": "网站 调查"
"usage_cycle": "Usage cycle",
"used": "已用",
"your_plan": "你的套餐"
},
"domain": {
"customize_favicon_description": "上传自定义 Favicon,个性化您的链接问卷体验,提升品牌形象。",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
"everyone": "所有 人",
"expand_preview": "展开预览",
"external_urls_paywall_tooltip": "请升级到 Startup 计划以自定义外部 URL。这有助于我们防网络钓鱼。",
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这有助于我们防网络钓鱼。",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "受访者 完成 调查",
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未连接",
"four_points": "4 分",
"heading": "标题",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_question_2_subheader": "这是一个示例描述。",
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
"preview_survey_question_open_text_headline": "还有其他想分享的内容吗?",
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
"preview_survey_welcome_card_headline": "欢迎!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!",
"follow_up_label": "还有其他想补充的吗?",
"follow_up_label": "还有其他想补充的内容吗?",
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
"generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?",
+28 -58
View File
@@ -134,7 +134,6 @@
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
"api_keys": "API 金鑰",
"app": "應用程式",
@@ -175,12 +174,12 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"count_attributes": "{count, plural, one {{count} 個屬性} other {{count} 個屬性}}",
"count_contacts": "{count, plural, one {{count} 位聯絡人} other {{count} 位聯絡人}}",
"count_members": "{count, plural, one {{count} 位成員} other {{count} 位成員}}",
"count_attributes": "{count, plural, other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 位聯絡人}}",
"count_members": "{count, plural, other {{count} 位成員}}",
"count_questions": "{count, plural, other {{count} 個問題}}",
"count_responses": "{count, plural, one {{count} 答覆} other {{count} 答覆}}",
"count_selections": "{count, plural, one {{count} 個選項} other {{count} 個選}}",
"count_responses": "{count, plural, other {{count} 答覆}}",
"count_selections": "{count, plural, other {{count} 個選}}",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -363,7 +362,6 @@
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"replace": "取代",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
@@ -481,7 +479,6 @@
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
"you_have_reached_your_monthly_miu_limit_of": "您已達到每月 MIU 上限:",
"you_have_reached_your_monthly_response_limit_of": "您已達到每月回應上限:",
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
@@ -968,57 +965,31 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"1000_monthly_responses": "1000 個每月回應",
"1_workspace": "1 個工作區",
"2000_contacts": "2000 個聯絡人",
"3_workspaces": "3 個工作區",
"5000_monthly_responses": "5000 個每月回應",
"7500_contacts": "7500 個聯絡人",
"all_integrations": "所有整合",
"annually": "每年",
"api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷",
"attribute_based_targeting": "基於屬性的定位",
"current": "目前",
"current_plan": "目前方案",
"current_tier_limit": "目前層級限制",
"custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_response_limit": "自訂回應上限",
"custom_workspace_limit": "自訂工作區上限",
"email_embedded_surveys": "電子郵件嵌入式問卷",
"email_follow_ups": "電子郵件後續追蹤",
"enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能",
"free": "免費",
"free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)",
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料",
"cancelling": "正在取消",
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"plan_hobby": "興趣版",
"plan_pro": "專業版",
"plan_scale": "規模版",
"plan_unknown": "未知",
"remove_branding": "移除品牌",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"team_access_roles": "團隊存取角色",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_miu": "無限 MIU",
"retry_setup": "重新設定",
"scale_banner_description": "加入 Scale 方案,解鎖更高限制、團隊協作和進階安全功能。",
"scale_banner_title": "準備好升級規模了嗎?",
"scale_feature_api": "完整 API 存取",
"scale_feature_quota": "額度管理",
"scale_feature_spam": "垃圾訊息防護",
"scale_feature_teams": "團隊與存取權限",
"status_trialing": "試用版",
"stripe_setup_incomplete": "帳單設定尚未完成",
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
"subscription": "訂閱",
"subscription_description": "管理您的訂閱方案並監控用量",
"unlimited_responses": "無限回應",
"unlimited_surveys": "無限問卷",
"unlimited_team_members": "無限團隊成員",
"unlimited_workspaces": "無限工作區",
"upgrade": "升級",
"uptime_sla_99": "正常運作時間 SLA (99%)",
"website_surveys": "網站問卷"
"usage_cycle": "Usage cycle",
"used": "已使用",
"your_plan": "您的方案"
},
"domain": {
"customize_favicon_description": "上傳自訂 Favicon,讓您的連結問卷體驗更具個人化,並強化品牌形象。",
@@ -1400,7 +1371,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
"everyone": "所有人",
"expand_preview": "展開預覽",
"external_urls_paywall_tooltip": "請升級至 Startup 計劃以自訂外部 URL。這有助防止網路釣魚攻擊。",
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
@@ -1453,7 +1424,6 @@
"follow_ups_modal_trigger_type_response": "回應者完成問卷",
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未連線",
"four_points": "4 分",
"heading": "標題",
@@ -3025,7 +2995,7 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_question_2_subheader": "這是一個範例說明。",
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎",
"preview_survey_question_open_text_headline": "還有其他想分享的嗎?",
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
"preview_survey_welcome_card_headline": "歡迎!",
@@ -3280,7 +3250,7 @@
"workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!",
"follow_up_label": "還有什麼想補充的嗎",
"follow_up_label": "還有其他想補充的嗎?",
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
"generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?",
@@ -23,14 +23,18 @@ vi.mock("@sentry/nextjs", () => ({
}),
}));
// Mock SENTRY_DSN constant
vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mocked-sentry-dsn",
IS_PRODUCTION: true,
AUDIT_LOG_ENABLED: true,
ENCRYPTION_KEY: "mocked-encryption-key",
REDIS_URL: undefined,
}));
// Mock SENTRY_DSN constant while preserving untouched exports.
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
SENTRY_DSN: "mocked-sentry-dsn",
IS_PRODUCTION: true,
AUDIT_LOG_ENABLED: true,
ENCRYPTION_KEY: "mocked-encryption-key",
REDIS_URL: undefined,
};
});
describe("utils", () => {
describe("handleApiError", () => {
@@ -1,7 +1,8 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentId: string) => {
try {
@@ -42,15 +43,27 @@ export const getOrganizationBilling = reactCache(async (organizationId: string)
id: organizationId,
},
select: {
billing: true,
billing: {
select: {
stripeCustomerId: true,
limits: true,
usageCycleAnchor: true,
stripe: true,
},
},
},
});
if (!organization) {
if (!organization?.billing) {
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
}
return ok(organization.billing);
return ok({
stripeCustomerId: organization.billing.stripeCustomerId,
limits: organization.billing.limits as TOrganizationBilling["limits"],
usageCycleAnchor: organization.billing.usageCycleAnchor,
...(organization.billing.stripe === null ? {} : { stripe: organization.billing.stripe }),
});
} catch (error) {
return err({
type: "internal_server_error",
@@ -103,8 +116,7 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
return err(billing.error);
}
// Determine the start date based on the plan type
const startDate = getBillingPeriodStartDate(billing.data);
const usageCycleWindow = getBillingUsageCycleWindow(billing.data);
// Get all environment IDs for the organization
const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId);
@@ -120,7 +132,7 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
where: {
AND: [
{ survey: { environmentId: { in: environmentIdsResult.data } } },
{ createdAt: { gte: startDate } },
{ createdAt: { gte: usageCycleWindow.start, lt: usageCycleWindow.end } },
],
},
});
@@ -1,17 +1,15 @@
import { Organization } from "@prisma/client";
import { TOrganizationBilling } from "@formbricks/types/organizations";
export const organizationId = "zo6u7apbattt8dquvzbgjjwb";
export const environmentId = "oh5cq6yu418itha55vsuj47e";
export const organizationBilling: Organization["billing"] = {
export const organizationBilling: TOrganizationBilling = {
stripeCustomerId: "cus_P78901234567890123456789",
plan: "scale",
period: "monthly",
limits: {
monthly: { responses: 100, miu: 1000 },
monthly: { responses: 100 },
projects: 1,
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
};
export const organizationEnvironments = {
@@ -1,4 +1,5 @@
import { Organization, Response } from "@prisma/client";
import { Response } from "@prisma/client";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
export const responseInput: Omit<Response, "id"> = {
@@ -77,15 +78,13 @@ export const response: Response = {
export const environmentId = "ou9sjm7a7qnilxhhhfszct95";
export const organizationId = "qybv4vk77pw71vnq9rmfrsvi";
export const organizationBilling: Organization["billing"] = {
export const organizationBilling: TOrganizationBilling = {
stripeCustomerId: "cus_P78901234567890123456789",
plan: "free",
period: "monthly",
limits: {
monthly: { responses: 100, miu: 1000 },
monthly: { responses: 100 },
projects: 1,
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
};
export const responseFilter: TGetResponsesFilter = {
@@ -81,7 +81,16 @@ describe("Organization Lib", () => {
const result = await getOrganizationBilling(organizationId);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: { id: organizationId },
select: { billing: true },
select: {
billing: {
select: {
stripeCustomerId: true,
limits: true,
usageCycleAnchor: true,
stripe: true,
},
},
},
});
expect(result.ok).toBe(true);
if (result.ok) {
@@ -175,18 +184,18 @@ describe("Organization Lib", () => {
}
});
test("return error if billing plan is not free and periodStart is not set", async () => {
test("return response count when usageCycleAnchor is not set", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
billing: { ...organizationBilling, periodStart: null },
billing: { ...organizationBilling, usageCycleAnchor: null },
});
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
vi.mocked(prisma.response.aggregate).mockResolvedValue({ _count: { id: 5 } });
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
expect(result.ok).toBe(true);
expect(prisma.response.aggregate).toHaveBeenCalledTimes(1);
if (result.ok) {
expect(result.data).toBe(5);
}
});
@@ -203,20 +212,6 @@ describe("Organization Lib", () => {
}
});
test("return for a free plan", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
billing: { ...organizationBilling, plan: "free" },
});
vi.mocked(prisma.response.aggregate).mockResolvedValue({ _count: { id: 5 } });
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(5);
}
});
test("handle internal_server_error in aggregation", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
const error = new Error("Aggregate error");
@@ -35,11 +35,15 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
IS_PRODUCTION: false,
ENCRYPTION_KEY: "test",
}));
vi.mock("@/lib/constants", async () => {
const actual = await vi.importActual<typeof import("@/lib/constants")>("@/lib/constants");
return {
...actual,
IS_FORMBRICKS_CLOUD: true,
IS_PRODUCTION: false,
ENCRYPTION_KEY: "test",
};
});
describe("Response Lib", () => {
beforeEach(() => {
@@ -1,4 +1,5 @@
import { logger } from "@formbricks/logger";
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";
@@ -35,7 +36,8 @@ export const GET = async (
});
}
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromSurveyId(params.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
+20 -16
View File
@@ -33,22 +33,26 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
},
}));
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
}));
// Mock constants that this test needs while preserving untouched exports.
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
};
});
// Mock next/headers
vi.mock("next/headers", () => ({
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { hasCloudEntitlement, hasCloudEntitlementWithLicenseGuard } from "./feature-access";
const mocks = vi.hoisted(() => ({
isCloud: true,
hasOrganizationEntitlement: vi.fn(),
hasOrganizationEntitlementWithLicenseGuard: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
get IS_FORMBRICKS_CLOUD() {
return mocks.isCloud;
},
};
});
vi.mock("@/modules/entitlements/lib/checks", () => ({
hasOrganizationEntitlement: mocks.hasOrganizationEntitlement,
hasOrganizationEntitlementWithLicenseGuard: mocks.hasOrganizationEntitlementWithLicenseGuard,
}));
describe("feature-access", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isCloud = true;
mocks.hasOrganizationEntitlement.mockResolvedValue(false);
mocks.hasOrganizationEntitlementWithLicenseGuard.mockResolvedValue(false);
});
test("hasCloudEntitlement returns false outside cloud mode", async () => {
mocks.isCloud = false;
const result = await hasCloudEntitlement("org_1", "custom-links-in-surveys");
expect(result).toBe(false);
expect(mocks.hasOrganizationEntitlement).not.toHaveBeenCalled();
});
test("hasCloudEntitlement returns delegated value in cloud mode", async () => {
mocks.hasOrganizationEntitlement.mockResolvedValueOnce(true);
const result = await hasCloudEntitlement("org_1", "custom-links-in-surveys");
expect(result).toBe(true);
expect(mocks.hasOrganizationEntitlement).toHaveBeenCalledWith("org_1", "custom-links-in-surveys");
});
test("hasCloudEntitlementWithLicenseGuard returns false outside cloud mode", async () => {
mocks.isCloud = false;
const result = await hasCloudEntitlementWithLicenseGuard("org_1", "rbac");
expect(result).toBe(false);
expect(mocks.hasOrganizationEntitlementWithLicenseGuard).not.toHaveBeenCalled();
});
test("hasCloudEntitlementWithLicenseGuard returns delegated value in cloud mode", async () => {
mocks.hasOrganizationEntitlementWithLicenseGuard.mockResolvedValueOnce(true);
const result = await hasCloudEntitlementWithLicenseGuard("org_1", "rbac");
expect(result).toBe(true);
expect(mocks.hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith("org_1", "rbac");
});
test("hasCloudEntitlementWithLicenseGuard propagates errors from entitlement checks", async () => {
mocks.hasOrganizationEntitlementWithLicenseGuard.mockRejectedValueOnce(
new Error("entitlement check failed")
);
await expect(hasCloudEntitlementWithLicenseGuard("org_1", "rbac")).rejects.toThrow(
"entitlement check failed"
);
});
});
@@ -0,0 +1,22 @@
import "server-only";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import {
hasOrganizationEntitlement,
hasOrganizationEntitlementWithLicenseGuard,
} from "@/modules/entitlements/lib/checks";
export const hasCloudEntitlement = async (
organizationId: string,
featureLookupKey: string
): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
return hasOrganizationEntitlement(organizationId, featureLookupKey);
};
export const hasCloudEntitlementWithLicenseGuard = async (
organizationId: string,
featureLookupKey: string
): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
return hasOrganizationEntitlementWithLicenseGuard(organizationId, featureLookupKey);
};
@@ -0,0 +1,12 @@
import "server-only";
export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
CUSTOM_REDIRECT_URL: "custom-redirect-url",
CUSTOM_LINKS_IN_SURVEYS: "custom-links-in-surveys",
FOLLOW_UPS: "follow-ups",
HIDE_BRANDING: "hide-branding",
QUOTA_MANAGEMENT: "quota-management",
RBAC: "rbac",
SPAM_PROTECTION: "spam-protection",
CONTACTS: "contacts",
} as const;
@@ -14,14 +14,18 @@ var mutableConstants: { AUDIT_LOG_ENABLED: boolean }; // NOSONAR / test code
// For safety with hoisted mocks, initialize immediately.
mutableConstants = { AUDIT_LOG_ENABLED: true };
vi.mock("@/lib/constants", () => ({
// AUDIT_LOG_ENABLED will be controlled by mutableConstants
get AUDIT_LOG_ENABLED() {
// Guard against mutableConstants being undefined during early hoisting phases if not initialized above
return mutableConstants ? mutableConstants.AUDIT_LOG_ENABLED : true; // Default to true if somehow undefined
},
AUDIT_LOG_GET_USER_IP: true,
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
// AUDIT_LOG_ENABLED will be controlled by mutableConstants
get AUDIT_LOG_ENABLED() {
// Guard against mutableConstants being undefined during early hoisting phases if not initialized above
return mutableConstants ? mutableConstants.AUDIT_LOG_ENABLED : true; // Default to true if somehow undefined
},
AUDIT_LOG_GET_USER_IP: true,
};
});
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: vi.fn().mockResolvedValue("127.0.0.1"),
}));
+68 -43
View File
@@ -3,7 +3,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants";
import { WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -11,49 +11,9 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
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 { createSubscription } from "@/modules/ee/billing/api/lib/create-subscription";
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
const ZUpgradePlanAction = z.object({
environmentId: ZId,
priceLookupKey: z.enum(STRIPE_PRICE_LOOKUP_KEYS),
});
export const upgradePlanAction = authenticatedActionClient.inputSchema(ZUpgradePlanAction).action(
withAuditLogging(
"subscriptionUpdated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpgradePlanAction>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createSubscription(
organizationId,
parsedInput.environmentId,
parsedInput.priceLookupKey
);
ctx.auditLoggingCtx.newObject = { priceLookupKey: parsedInput.priceLookupKey };
return result;
}
)
);
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
const ZManageSubscriptionAction = z.object({
environmentId: ZId,
@@ -124,3 +84,68 @@ export const isSubscriptionCancelledAction = authenticatedActionClient
return await isSubscriptionCancelled(parsedInput.organizationId);
});
const ZCreatePricingTableCustomerSessionAction = z.object({
environmentId: ZId,
});
export const createPricingTableCustomerSessionAction = authenticatedActionClient
.inputSchema(ZCreatePricingTableCustomerSessionAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
if (!stripeClient) {
return { clientSecret: null };
}
const customerSession = await stripeClient.customerSessions.create({
customer: organization.billing.stripeCustomerId,
components: {
pricing_table: {
enabled: true,
},
},
});
return { clientSecret: customerSession.client_secret ?? null };
});
const ZRetryStripeSetupAction = z.object({
organizationId: ZId,
});
export const retryStripeSetupAction = authenticatedActionClient
.inputSchema(ZRetryStripeSetupAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
await ensureCloudStripeSetupForOrganization(parsedInput.organizationId);
});
@@ -1,64 +0,0 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
import { getStripeClient } from "./stripe-client";
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
const stripe = getStripeClient();
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata?.organizationId)
throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id);
const organization = await getOrganization(checkoutSession.metadata.organizationId);
if (!organization)
throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId);
const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string, {
expand: ["items.data.price"],
});
let period: "monthly" | "yearly" = "monthly";
if (subscription.items?.data && subscription.items.data.length > 0) {
const firstItem = subscription.items.data[0];
const interval = firstItem.price?.recurring?.interval;
period = interval === "year" ? "yearly" : "monthly";
}
await updateOrganization(checkoutSession.metadata.organizationId, {
billing: {
...organization.billing,
stripeCustomerId: checkoutSession.customer as string,
plan: PROJECT_FEATURE_KEYS.STARTUP,
period,
limits: {
projects: BILLING_LIMITS.STARTUP.PROJECTS,
monthly: {
responses: BILLING_LIMITS.STARTUP.RESPONSES,
miu: BILLING_LIMITS.STARTUP.MIU,
},
},
periodStart: new Date(),
},
});
logger.info(
{
organizationId: checkoutSession.metadata.organizationId,
plan: PROJECT_FEATURE_KEYS.STARTUP,
period,
checkoutSessionId: checkoutSession.id,
},
"Subscription activated"
);
const stripeCustomer = await stripe.customers.retrieve(checkoutSession.customer as string);
if (stripeCustomer && !stripeCustomer.deleted) {
await stripe.customers.update(stripeCustomer.id, {
name: organization.name,
metadata: { organizationId: organization.id },
});
}
};
@@ -1,86 +0,0 @@
import { TFunction } from "i18next";
export type TPricingPlan = {
id: string;
name: string;
featured: boolean;
CTA?: string;
description: string;
price: {
monthly: string;
yearly: string;
};
mainFeatures: string[];
href?: string;
};
export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } => {
const freePlan: TPricingPlan = {
id: "free",
name: t("environments.settings.billing.free"),
featured: false,
description: t("environments.settings.billing.free_description"),
price: { monthly: "$0", yearly: "$0" },
mainFeatures: [
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.1000_monthly_responses"),
t("environments.settings.billing.2000_contacts"),
t("environments.settings.billing.1_workspace"),
t("environments.settings.billing.unlimited_team_members"),
t("environments.settings.billing.link_surveys"),
t("environments.settings.billing.website_surveys"),
t("environments.settings.billing.app_surveys"),
t("environments.settings.billing.ios_android_sdks"),
t("environments.settings.billing.email_embedded_surveys"),
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.all_integrations"),
t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
],
};
const startupPlan: TPricingPlan = {
id: "startup",
name: t("environments.settings.billing.startup"),
featured: true,
CTA: t("common.start_free_trial"),
description: t("environments.settings.billing.startup_description"),
price: { monthly: "$49", yearly: "$490" },
mainFeatures: [
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_contacts"),
t("environments.settings.billing.3_workspaces"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.attribute_based_targeting"),
],
};
const customPlan: TPricingPlan = {
id: "custom",
name: t("environments.settings.billing.custom"),
featured: false,
CTA: t("common.request_pricing"),
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.custom"),
yearly: t("environments.settings.billing.custom"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.custom_response_limit"),
t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_workspace_limit"),
t("environments.settings.billing.team_access_roles"),
t("environments.workspace.languages.multi_language_surveys"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.premium_support_with_slas"),
],
href: "https://formbricks.com/custom-plan?source=billingView",
};
return {
plans: [freePlan, startupPlan, customPlan],
};
};
@@ -6,7 +6,7 @@ export const createCustomerPortalSession = async (stripeCustomerId: string, retu
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,
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
});
const session = await stripe.billingPortal.sessions.create({
@@ -1,57 +0,0 @@
import { logger } from "@formbricks/logger";
import { STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
import { getStripeClient } from "./stripe-client";
export const createSubscription = async (
organizationId: string,
environmentId: string,
priceLookupKey: STRIPE_PRICE_LOOKUP_KEYS
) => {
try {
const stripe = getStripeClient();
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Organization not found.");
const priceObject = (
await stripe.prices.list({
lookup_keys: [priceLookupKey],
})
).data[0];
if (!priceObject) throw new Error("Price not found");
// Always create a checkout session - let Stripe handle existing customers
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: priceObject.id,
quantity: 1,
},
],
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
customer: organization.billing.stripeCustomerId ?? undefined,
allow_promotion_codes: true,
subscription_data: {
metadata: { organizationId },
trial_period_days: 15,
},
metadata: { organizationId },
billing_address_collection: "required",
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
payment_method_data: { allow_redisplay: "always" },
});
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
} catch (err) {
logger.error(err, "Error creating subscription");
return {
status: 500,
newPlan: true,
url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
};
}
};
@@ -1,69 +0,0 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
const invoice = event.data.object as Stripe.Invoice;
const subscription = invoice.parent?.subscription_details?.subscription;
const subscriptionId = typeof subscription === "string" ? subscription : subscription?.id;
if (!subscriptionId) {
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
return { status: 400, message: "No subscription ID found in invoice" };
}
try {
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const organizationId = subscription.metadata?.organizationId;
if (!organizationId) {
logger.warn(
{
subscriptionId,
invoiceId: invoice.id,
},
"No organizationId found in subscription metadata"
);
return { status: 400, message: "No organizationId found in subscription" };
}
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const periodStartTimestamp = invoice.lines.data[0]?.period?.start;
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
await updateOrganization(organizationId, {
billing: {
...organization.billing,
periodStart,
},
});
logger.info(
{
organizationId,
periodStart,
invoiceId: invoice.id,
},
"Billing period updated successfully"
);
return { status: 200, message: "Billing period updated successfully" };
} catch (error) {
logger.error(error, "Error updating billing period", {
invoiceId: invoice.id,
subscriptionId,
});
return { status: 500, message: "Error updating billing period" };
}
};
@@ -1,10 +1,72 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
import {
findOrganizationIdByStripeCustomerId,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "@/modules/ee/billing/lib/organization-billing";
import { getStripeClient, getStripeWebhookSecret } from "./stripe-client";
const relevantEvents = new Set([
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.finalized",
"entitlements.active_entitlement_summary.updated",
]);
const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
if (!("metadata" in eventObject) || !eventObject.metadata) {
return null;
}
const { organizationId } = eventObject.metadata as Record<string, unknown>;
return typeof organizationId === "string" ? organizationId : null;
};
const getCustomerId = (eventObject: Stripe.Event.Data.Object): string | null => {
if (!("customer" in eventObject) || typeof eventObject.customer !== "string") {
return null;
}
return eventObject.customer;
};
const getClientReferenceId = (eventObject: Stripe.Event.Data.Object): string | null => {
if (!("client_reference_id" in eventObject) || typeof eventObject.client_reference_id !== "string") {
return null;
}
return eventObject.client_reference_id;
};
const resolveOrganizationId = async (eventObject: Stripe.Event.Data.Object): Promise<string | null> => {
const metadataOrgId = getMetadataOrganizationId(eventObject);
if (metadataOrgId) return metadataOrgId;
const clientReferenceId = getClientReferenceId(eventObject);
if (clientReferenceId) return clientReferenceId;
const customerId = getCustomerId(eventObject);
if (!customerId) return null;
return await findOrganizationIdByStripeCustomerId(customerId);
};
const getUnresolvedOrganizationResponse = (event: Stripe.Event) => {
logger.warn(
{ eventType: event.type, eventId: event.id },
"Skipping Stripe webhook: organization not resolved"
);
if (event.type === "checkout.session.completed") {
return { status: 500, message: "Checkout completed but organization could not be resolved." };
}
return { status: 200, message: { received: true } };
};
export const webhookHandler = async (requestBody: string, stripeSignature: string) => {
let stripe: Stripe;
let webhookSecret: string;
@@ -27,12 +89,30 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
return { status: 400, message: `Webhook Error: ${errorMessage}` };
}
if (event.type === "checkout.session.completed") {
await handleCheckoutSessionCompleted(event);
} else if (event.type === "invoice.finalized") {
await handleInvoiceFinalized(event);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event);
if (!relevantEvents.has(event.type)) {
return { status: 200, message: { received: true } };
}
const eventObject = event.data.object as Stripe.Event.Data.Object;
const organizationId = await resolveOrganizationId(eventObject);
if (!organizationId) {
return getUnresolvedOrganizationResponse(event);
}
try {
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,
});
} catch (error) {
logger.error(
{ error, eventId: event.id, organizationId, eventType: event.type },
"Failed to sync billing snapshot from Stripe webhook"
);
return { status: 500, message: "Stripe webhook processing failed; please retry." };
}
return { status: 200, message: { received: true } };
};
@@ -1,41 +0,0 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
const organizationId = stripeSubscriptionObject.metadata.organizationId;
if (!organizationId) {
logger.error({ event, organizationId }, "No organizationId found in subscription");
return { status: 400, message: "skipping, no organizationId found" };
}
const organization = await getOrganization(organizationId);
if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId);
await updateOrganization(organizationId, {
billing: {
...organization.billing,
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
periodStart: new Date(),
period: "monthly",
},
});
logger.info(
{
organizationId,
subscriptionId: stripeSubscriptionObject.id,
},
"Subscription cancelled - downgraded to FREE plan"
);
};
@@ -1,66 +1,22 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
interface SliderProps {
interface BillingSliderProps {
className?: string;
value: number;
max: number;
freeTierLimit: number;
metric: string;
}
export const BillingSlider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, value, max, freeTierLimit, metric, ...props }, ref) => {
const { t } = useTranslation();
return (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300">
<div
style={{ width: `calc(${Math.min(value / max, 0.93) * 100}%)` }}
className="absolute h-full bg-slate-800"></div>
<div
style={{
width: `${((freeTierLimit - value) / max) * 100}%`,
left: `${(value / max) * 100}%`,
}}
className="absolute h-full bg-slate-400"></div>
</SliderPrimitive.Track>
export const BillingSlider = ({ className, value, max }: BillingSliderProps) => {
const percentage = Math.min((value / max) * 100, 100);
<div
style={{ left: `calc(${Math.min(value / max, 0.93) * 100}%)` }}
className="absolute mt-4 h-6 w-px bg-slate-400"></div>
<div
style={{ left: `calc(${Math.min(value / max, 0.93) * 100}% + 0.5rem)` }}
className="absolute mt-16 text-sm text-slate-700 dark:text-slate-200">
<p className="text-xs">
{t("environments.settings.billing.current")}:
<br />
{value} {metric}
</p>
</div>
<div
style={{ left: `${(freeTierLimit / max) * 100}%` }}
className="absolute mt-4 h-6 w-px bg-slate-300"></div>
<div
style={{ left: `calc(${(freeTierLimit / max) * 100}% + 0.5rem)` }}
className="absolute mt-16 text-sm text-slate-700">
<p className="text-xs">
{t("environments.settings.billing.current_tier_limit")}:
<br />
{freeTierLimit} {metric}
</p>
</div>
</SliderPrimitive.Root>
);
}
);
BillingSlider.displayName = SliderPrimitive.Root.displayName;
return (
<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")}
/>
</div>
);
};
@@ -1,216 +0,0 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { TPricingPlan } from "../api/lib/constants";
interface PricingCardProps {
plan: TPricingPlan;
planPeriod: TOrganizationBillingPeriod;
organization: TOrganization;
onUpgrade: () => Promise<void>;
onManageSubscription: () => Promise<void>;
projectFeatureKeys: {
FREE: string;
STARTUP: string;
CUSTOM: string;
};
}
export const PricingCard = ({
planPeriod,
plan,
onUpgrade,
onManageSubscription,
organization,
projectFeatureKeys,
}: PricingCardProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const displayPrice = (() => {
if (plan.id === projectFeatureKeys.CUSTOM) {
return plan.price.monthly;
}
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
})();
const isCurrentPlan = useMemo(() => {
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
return true;
}
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
return true;
}
return organization.billing.plan === plan.id && organization.billing.period === planPeriod;
}, [
organization.billing.period,
organization.billing.plan,
plan.id,
planPeriod,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
]);
const CTAButton = useMemo(() => {
if (isCurrentPlan) {
return null;
}
if (plan.id === projectFeatureKeys.CUSTOM) {
return (
<Button
variant="outline"
loading={loading}
onClick={() => {
window.open(plan.href, "_blank", "noopener,noreferrer");
}}
className="flex justify-center bg-white">
{plan.CTA ?? t("common.request_pricing")}
</Button>
);
}
if (plan.id === projectFeatureKeys.STARTUP) {
if (organization.billing.plan === projectFeatureKeys.FREE) {
return (
<Button
loading={loading}
variant="default"
onClick={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
}}
className="flex justify-center">
{plan.CTA ?? t("common.start_free_trial")}
</Button>
);
}
return (
<Button
loading={loading}
onClick={() => {
setContactModalOpen(true);
}}
className="flex justify-center">
{t("environments.settings.billing.switch_plan")}
</Button>
);
}
return null;
}, [
isCurrentPlan,
loading,
onUpgrade,
organization.billing.plan,
plan.CTA,
plan.featured,
plan.href,
plan.id,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
]);
return (
<div
key={plan.id}
className={cn(
plan.featured
? "z-10 bg-white shadow-lg ring-1 ring-slate-900/10"
: "bg-slate-100 ring-1 ring-white/10 lg:bg-transparent lg:pb-8 lg:ring-0",
"relative rounded-xl"
)}>
<div className="p-8 lg:pt-12 xl:p-10 xl:pt-14">
<div className="flex gap-x-2">
<h2
id={plan.id}
className={cn(
plan.featured ? "text-slate-900" : "text-slate-800",
"text-sm font-semibold leading-6"
)}>
{plan.name}
</h2>
{isCurrentPlan && (
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
)}
</div>
<div className="flex flex-col items-end gap-6 sm:flex-row sm:justify-between lg:flex-col lg:items-stretch">
<div className="mt-2 flex items-end gap-x-1">
<p
className={cn(
plan.featured ? "text-slate-900" : "text-slate-800",
"text-4xl font-bold tracking-tight"
)}>
{displayPrice}
</p>
{plan.id !== projectFeatureKeys.CUSTOM && (
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"}
</p>
</div>
)}
</div>
{CTAButton}
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
<Button
loading={loading}
onClick={async () => {
setLoading(true);
await onManageSubscription();
setLoading(false);
}}
className="flex justify-center bg-[#635bff]">
{t("environments.settings.billing.manage_subscription")}
</Button>
)}
</div>
<div className="mt-8 flow-root sm:mt-10">
<ul
className={cn(
plan.featured
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
: "divide-white/5 border-white/5 text-slate-800",
"-my-2 divide-y border-t text-sm leading-6 lg:border-t-0"
)}>
{plan.mainFeatures.map((mainFeature) => (
<li key={mainFeature} className="flex gap-x-3 py-2">
<CheckIcon
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
aria-hidden="true"
/>
{mainFeature}
</li>
))}
</ul>
</div>
</div>
<ConfirmationModal
title="Please reach out to us"
open={contactModalOpen}
setOpen={setContactModalOpen}
onConfirm={() => setContactModalOpen(false)}
buttonText="Close"
buttonVariant="default"
body="To switch your billing rhythm, please reach out to hola@formbricks.com"
/>
</div>
);
};
@@ -1,69 +1,207 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import Script from "next/script";
import { createElement, useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { TOrganization, TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
import { getCloudPricingData } from "../api/lib/constants";
import { BillingSlider } from "./billing-slider";
import { PricingCard } from "./pricing-card";
import {
createPricingTableCustomerSessionAction,
isSubscriptionCancelledAction,
manageSubscriptionAction,
retryStripeSetupAction,
} from "../actions";
import { UsageCard } from "./usage-card";
const STRIPE_SUPPORTED_LOCALES = new Set([
"bg",
"cs",
"da",
"de",
"el",
"en",
"en-GB",
"es",
"es-419",
"et",
"fi",
"fil",
"fr",
"fr-CA",
"hr",
"hu",
"id",
"it",
"ja",
"ko",
"lt",
"lv",
"ms",
"mt",
"nb",
"nl",
"pl",
"pt",
"pt-BR",
"ro",
"ru",
"sk",
"sl",
"sv",
"th",
"tr",
"vi",
"zh",
"zh-HK",
"zh-TW",
]);
const getStripeLocaleOverride = (locale?: string): string | undefined => {
if (!locale) return undefined;
const normalizedLocale = locale.trim();
if (STRIPE_SUPPORTED_LOCALES.has(normalizedLocale)) {
return normalizedLocale;
}
const baseLocale = normalizedLocale.split("-")[0];
if (STRIPE_SUPPORTED_LOCALES.has(baseLocale)) {
return baseLocale;
}
return undefined;
};
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
interface PricingTableProps {
organization: TOrganization;
environmentId: string;
peopleCount: number;
responseCount: number;
projectCount: number;
stripePriceLookupKeys: {
STARTUP_MAY25_MONTHLY: string;
STARTUP_MAY25_YEARLY: string;
};
projectFeatureKeys: {
FREE: string;
STARTUP: string;
CUSTOM: string;
};
usageCycleStart: Date;
usageCycleEnd: Date;
hasBillingRights: boolean;
currentCloudPlan: "hobby" | "pro" | "scale" | "unknown";
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
stripePublishableKey: string | null;
stripePricingTableId: string | null;
isStripeSetupIncomplete: boolean;
}
const getCurrentCloudPlanLabel = (
plan: "hobby" | "pro" | "scale" | "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");
return t("environments.settings.billing.plan_unknown");
};
export const PricingTable = ({
environmentId,
organization,
peopleCount,
projectFeatureKeys,
responseCount,
projectCount,
stripePriceLookupKeys,
usageCycleStart,
usageCycleEnd,
hasBillingRights,
currentCloudPlan,
currentSubscriptionStatus,
stripePublishableKey,
stripePricingTableId,
isStripeSetupIncomplete,
}: PricingTableProps) => {
const { t } = useTranslation();
const [planPeriod, setPlanPeriod] = useState<TOrganizationBillingPeriod>(
organization.billing.period ?? "monthly"
);
const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => {
setPlanPeriod(period);
};
const { t, i18n } = useTranslation();
const router = useRouter();
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
string | null
>(null);
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
const showPricingTable =
hasBillingRights && isUpgradeablePlan && !!stripePublishableKey && !!stripePricingTableId;
const canManageSubscription =
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
const stripeLocaleOverride = useMemo(
() => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
[i18n.language, i18n.resolvedLanguage]
);
const stripePricingTableProps = useMemo(() => {
const props: Record<string, string> = {
"pricing-table-id": stripePricingTableId ?? "",
"publishable-key": stripePublishableKey ?? "",
};
if (stripeLocaleOverride) {
props["__locale-override"] = stripeLocaleOverride;
}
if (pricingTableCustomerSessionClientSecret) {
props["customer-session-client-secret"] = pricingTableCustomerSessionClientSecret;
} else {
props["client-reference-id"] = organization.id;
}
return props;
}, [
organization.id,
pricingTableCustomerSessionClientSecret,
stripeLocaleOverride,
stripePricingTableId,
stripePublishableKey,
]);
useEffect(() => {
const checkSubscriptionStatus = async () => {
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
organizationId: organization.id,
});
if (isSubscriptionCancelledResponse?.data) {
setCancellingOn(isSubscriptionCancelledResponse.data.date);
if (!hasBillingRights || !canManageSubscription) {
setCancellingOn(null);
return;
}
try {
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
organizationId: organization.id,
});
if (isSubscriptionCancelledResponse?.data) {
setCancellingOn(isSubscriptionCancelledResponse.data.date);
}
} catch {
// Ignore permission/network failures here and keep rendering billing UI.
}
};
checkSubscriptionStatus();
}, [organization.id]);
}, [canManageSubscription, hasBillingRights, organization.id]);
useEffect(() => {
if (!showPricingTable) {
setPricingTableCustomerSessionClientSecret(null);
return;
}
if (globalThis.window !== undefined) {
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
}
const loadPricingTableCustomerSession = async () => {
try {
const response = await createPricingTableCustomerSessionAction({ environmentId });
setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
} catch {
setPricingTableCustomerSessionClientSecret(null);
}
};
void loadPricingTableCustomerSession();
}, [environmentId, showPricingTable]);
const openCustomerPortal = async () => {
const manageSubscriptionResponse = await manageSubscriptionAction({
@@ -74,226 +212,145 @@ export const PricingTable = ({
}
};
const upgradePlan = async (priceLookupKey) => {
const retryStripeSetup = async () => {
setIsRetryingStripeSetup(true);
try {
const upgradePlanResponse = await upgradePlanAction({
environmentId,
priceLookupKey,
});
if (!upgradePlanResponse?.data) {
throw new Error(t("common.something_went_wrong_please_try_again"));
}
const { status, newPlan, url } = upgradePlanResponse.data;
if (status != 200) {
throw new Error(t("common.something_went_wrong_please_try_again"));
}
if (!newPlan) {
toast.success(t("environments.settings.billing.plan_upgraded_successfully"));
} else if (newPlan && url) {
router.push(url);
const response = await retryStripeSetupAction({ organizationId: organization.id });
if (response?.data) {
router.refresh();
} else {
throw new Error(t("common.something_went_wrong_please_try_again"));
toast.error(t("common.something_went_wrong_please_try_again"));
}
} catch (err) {
if (err instanceof Error) {
toast.error(err.message);
} else {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
}
}
};
const onUpgrade = async (planId: string) => {
if (planId === "startup") {
await upgradePlan(
planPeriod === "monthly"
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
: stripePriceLookupKeys.STARTUP_MAY25_YEARLY
);
return;
}
if (planId === "custom") {
window.location.href = "https://formbricks.com/custom-plan?source=billingView";
return;
}
if (planId === "free") {
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
} catch {
setIsRetryingStripeSetup(false);
}
};
const responsesUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
currentCloudPlan === "scale" && organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
currentCloudPlan === "scale" && organization.billing.limits.projects === null;
const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})} - ${usageCycleEnd.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})}`;
return (
<main>
<div className="flex flex-col gap-8">
<div className="flex flex-col">
<div className="flex w-full">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
<span className="capitalize">{organization.billing.plan}</span>
{cancellingOn && (
<Badge
className="mx-2"
size="normal"
type="warning"
text={`Cancelling: ${
cancellingOn
? cancellingOn.toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
: ""
}`}
/>
)}
</h2>
{organization.billing.stripeCustomerId && organization.billing.plan === "free" && (
<div className="flex w-full justify-end">
<Button
size="sm"
variant="secondary"
className="justify-center py-2 shadow-sm"
onClick={openCustomerPortal}>
{t("environments.settings.billing.manage_card_details")}
</Button>
</div>
)}
</div>
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 shadow-sm dark:bg-slate-800">
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",
responsesUnlimitedCheck && "mb-0 flex-row"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.responses")}</p>
{organization.billing.limits.monthly.responses && (
<BillingSlider
className="slider-class mb-8"
value={responseCount}
max={organization.billing.limits.monthly.responses * 1.5}
freeTierLimit={organization.billing.limits.monthly.responses}
metric={t("common.responses")}
/>
)}
{responsesUnlimitedCheck && (
<Badge
type="success"
size="normal"
text={t("environments.settings.billing.unlimited_responses")}
/>
)}
</div>
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",
peopleUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">
{t("environments.settings.billing.monthly_identified_users")}
<div className="flex flex-col gap-4">
{isStripeSetupIncomplete && hasBillingRights && (
<Alert variant="warning">
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
<AlertDescription>
{t("environments.settings.billing.stripe_setup_incomplete_description")}
</AlertDescription>
<AlertButton onClick={() => void retryStripeSetup()} loading={isRetryingStripeSetup}>
{t("environments.settings.billing.retry_setup")}
</AlertButton>
</Alert>
)}
<SettingsCard
title={t("environments.settings.billing.subscription")}
description={t("environments.settings.billing.subscription_description")}
buttonInfo={
canManageSubscription
? {
text: t("environments.settings.billing.manage_subscription"),
onClick: () => void openCustomerPortal(),
variant: "default",
}
: undefined
}>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-slate-700">
{t("environments.settings.billing.your_plan")}
</p>
{organization.billing.limits.monthly.miu && (
<BillingSlider
className="slider-class mb-8"
value={peopleCount}
max={organization.billing.limits.monthly.miu * 1.5}
freeTierLimit={organization.billing.limits.monthly.miu}
metric={"MIU"}
/>
)}
{peopleUnlimitedCheck && (
<Badge type="success" size="normal" text={t("environments.settings.billing.unlimited_miu")} />
)}
<div className="flex items-center gap-2">
<Badge type="success" size="normal" text={getCurrentCloudPlanLabel(currentCloudPlan, t)} />
{currentSubscriptionStatus === "trialing" && (
<Badge
type="warning"
size="normal"
text={t("environments.settings.billing.status_trialing")}
/>
)}
{cancellingOn && (
<Badge
type="warning"
size="normal"
text={`${t("environments.settings.billing.cancelling")}: ${cancellingOn.toLocaleDateString(
"en-US",
{
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}
)}`}
/>
)}
</div>
</div>
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-6",
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.workspaces")}</p>
{organization.billing.limits.projects && (
<BillingSlider
className="slider-class mb-8"
value={projectCount}
max={organization.billing.limits.projects * 1.5}
freeTierLimit={organization.billing.limits.projects}
metric={t("common.workspaces")}
/>
)}
<UsageCard
metric={t("common.responses")}
currentCount={responseCount}
limit={organization.billing.limits.monthly.responses}
isUnlimited={responsesUnlimitedCheck}
unlimitedLabel={t("environments.settings.billing.unlimited_responses")}
/>
{projectsUnlimitedCheck && (
<Badge
type="success"
size="normal"
text={t("environments.settings.billing.unlimited_workspaces")}
/>
)}
<p className="text-sm text-slate-500">
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
</p>
<UsageCard
metric={t("common.workspaces")}
currentCount={projectCount}
limit={organization.billing.limits.projects}
isUnlimited={projectsUnlimitedCheck}
unlimitedLabel={t("environments.settings.billing.unlimited_workspaces")}
/>
</div>
</SettingsCard>
{currentCloudPlan === "pro" && (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1.5">
<h3 className="text-lg font-semibold text-white">
{t("environments.settings.billing.scale_banner_title")}
</h3>
<p className="text-sm text-slate-300">
{t("environments.settings.billing.scale_banner_description")}
</p>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-400">
<span>&#10003; {t("environments.settings.billing.scale_feature_teams")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_api")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_quota")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_spam")}</span>
</div>
</div>
<Button variant="secondary" size="sm" onClick={openCustomerPortal} className="shrink-0">
{t("environments.settings.billing.upgrade")}
</Button>
</div>
</div>
</div>
)}
{hasBillingRights && (
<div className="mx-auto mb-12">
<div className="gap-x-2">
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
<button
aria-pressed={planPeriod === "monthly"}
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
}`}
onClick={() => handleMonthlyToggle("monthly")}>
{t("environments.settings.billing.monthly")}
</button>
<button
aria-pressed={planPeriod === "yearly"}
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
}`}
onClick={() => handleMonthlyToggle("yearly")}>
{t("environments.settings.billing.annually")}
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
{t("environments.settings.billing.get_2_months_free")} 🔥
</span>
</button>
</div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
<div
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true"
/>
{getCloudPricingData(t).plans.map((plan) => (
<PricingCard
planPeriod={planPeriod}
key={plan.id}
plan={plan}
onUpgrade={async () => {
await onUpgrade(plan.id);
}}
organization={organization}
projectFeatureKeys={projectFeatureKeys}
onManageSubscription={openCustomerPortal}
/>
))}
</div>
</div>
{showPricingTable && (
<div className="mb-12 w-full max-w-4xl">
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
{createElement("stripe-pricing-table", stripePricingTableProps)}
</div>
)}
</div>
@@ -0,0 +1,41 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@/modules/ui/components/badge";
import { BillingSlider } from "./billing-slider";
interface UsageCardProps {
metric: string;
currentCount: number;
limit: number | null;
isUnlimited: boolean;
unlimitedLabel: string;
}
export const UsageCard = ({ metric, currentCount, limit, isUnlimited, unlimitedLabel }: UsageCardProps) => {
const { t } = useTranslation();
if (isUnlimited) {
return (
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-700">{metric}</p>
<Badge type="success" size="normal" text={unlimitedLabel} />
</div>
);
}
if (!limit) return null;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-700">{metric}</p>
<p className="text-sm text-slate-600">
{currentCount.toLocaleString()} / {limit.toLocaleString()}{" "}
<span className="text-slate-400">{t("environments.settings.billing.used")}</span>
</p>
</div>
<BillingSlider value={currentCount} max={limit} />
</div>
);
};
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getOrganizationBillingWithReadThroughSync: vi.fn(),
}));
vi.mock("./organization-billing", () => ({
getOrganizationBillingWithReadThroughSync: mocks.getOrganizationBillingWithReadThroughSync,
}));
describe("cloud-billing-display", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-10T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
test("returns billing context with plan from stripe", async () => {
const billing = { stripe: { plan: "pro" }, usageCycleAnchor: new Date("2026-01-15T00:00:00.000Z") };
mocks.getOrganizationBillingWithReadThroughSync.mockResolvedValue(billing);
const { getCloudBillingDisplayContext } = await import("./cloud-billing-display");
const result = await getCloudBillingDisplayContext("org_1");
expect(result).toEqual({
organizationId: "org_1",
currentCloudPlan: "pro",
currentSubscriptionStatus: null,
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
billing,
});
});
test("returns unknown when stripe is null", async () => {
const billing = { stripe: null, usageCycleAnchor: null };
mocks.getOrganizationBillingWithReadThroughSync.mockResolvedValue(billing);
const { getCloudBillingDisplayContext } = await import("./cloud-billing-display");
const result = await getCloudBillingDisplayContext("org_1");
expect(result.currentCloudPlan).toBe("unknown");
});
test("throws ResourceNotFoundError when billing is null", async () => {
mocks.getOrganizationBillingWithReadThroughSync.mockResolvedValue(null);
const { getCloudBillingDisplayContext } = await import("./cloud-billing-display");
await expect(getCloudBillingDisplayContext("org_missing")).rejects.toThrow(
"OrganizationBilling with ID org_missing not found"
);
});
});
@@ -0,0 +1,49 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "unknown";
export type TCloudBillingDisplayContext = {
organizationId: string;
currentCloudPlan: TCloudBillingDisplayPlan;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
usageCycleStart: Date;
usageCycleEnd: Date;
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
};
const resolveCurrentCloudPlan = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): TCloudBillingDisplayPlan => {
return billing.stripe?.plan ?? "unknown";
};
const resolveCurrentSubscriptionStatus = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): TOrganizationStripeSubscriptionStatus | null => {
return billing.stripe?.subscriptionStatus ?? null;
};
export const getCloudBillingDisplayContext = async (
organizationId: string
): Promise<TCloudBillingDisplayContext> => {
const billing = await getOrganizationBillingWithReadThroughSync(organizationId);
if (!billing) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const usageCycleWindow = getBillingUsageCycleWindow(billing);
return {
organizationId,
currentCloudPlan: resolveCurrentCloudPlan(billing),
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
usageCycleStart: usageCycleWindow.start,
usageCycleEnd: usageCycleWindow.end,
billing,
};
};
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
isCloud: true,
meterEventsCreate: vi.fn(),
loggerWarn: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
get IS_FORMBRICKS_CLOUD() {
return mocks.isCloud;
},
};
});
vi.mock("./stripe-client", () => ({
stripeClient: {
billing: {
meterEvents: { create: mocks.meterEventsCreate },
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
describe("metering", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isCloud = true;
});
test("records meter event with Date createdAt", async () => {
const { recordResponseCreatedMeterEvent } = await import("./metering");
await recordResponseCreatedMeterEvent({
stripeCustomerId: "cus_1",
responseId: "resp_1",
createdAt: new Date("2026-01-01T00:00:00Z"),
});
expect(mocks.meterEventsCreate).toHaveBeenCalledWith({
event_name: "response_created",
identifier: "response_created:resp_1",
timestamp: Math.floor(new Date("2026-01-01T00:00:00Z").getTime() / 1000),
payload: { stripe_customer_id: "cus_1", value: "1" },
});
});
test("records meter event with string createdAt", async () => {
const { recordResponseCreatedMeterEvent } = await import("./metering");
await recordResponseCreatedMeterEvent({
stripeCustomerId: "cus_1",
responseId: "resp_2",
createdAt: "2026-01-01T00:00:00Z",
});
expect(mocks.meterEventsCreate).toHaveBeenCalledWith(
expect.objectContaining({ timestamp: expect.any(Number) })
);
});
test("records meter event without timestamp when createdAt is null", async () => {
const { recordResponseCreatedMeterEvent } = await import("./metering");
await recordResponseCreatedMeterEvent({
stripeCustomerId: "cus_1",
responseId: "resp_3",
createdAt: null,
});
expect(mocks.meterEventsCreate).toHaveBeenCalledWith({
event_name: "response_created",
identifier: "response_created:resp_3",
payload: { stripe_customer_id: "cus_1", value: "1" },
});
});
test("skips when stripeCustomerId is null", async () => {
const { recordResponseCreatedMeterEvent } = await import("./metering");
await recordResponseCreatedMeterEvent({
stripeCustomerId: null,
responseId: "resp_4",
});
expect(mocks.meterEventsCreate).not.toHaveBeenCalled();
});
test("skips when not cloud", async () => {
mocks.isCloud = false;
const { recordResponseCreatedMeterEvent } = await import("./metering");
await recordResponseCreatedMeterEvent({
stripeCustomerId: "cus_1",
responseId: "resp_5",
});
expect(mocks.meterEventsCreate).not.toHaveBeenCalled();
});
test("logs warning on error", async () => {
mocks.meterEventsCreate.mockRejectedValue(new Error("stripe error"));
const { recordResponseCreatedMeterEvent } = await import("./metering");
await recordResponseCreatedMeterEvent({
stripeCustomerId: "cus_1",
responseId: "resp_6",
});
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), stripeCustomerId: "cus_1", responseId: "resp_6" },
"Failed to record Stripe meter event for response_created"
);
});
});
@@ -0,0 +1,37 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { stripeClient } from "./stripe-client";
export const recordResponseCreatedMeterEvent = async (input: {
stripeCustomerId: string | null | undefined;
responseId: string;
createdAt?: Date | string | null;
}): Promise<void> => {
if (IS_FORMBRICKS_CLOUD && stripeClient && input.stripeCustomerId) {
try {
let createdAtSeconds: number | undefined;
if (input.createdAt instanceof Date || typeof input.createdAt === "string") {
createdAtSeconds = Math.floor(new Date(input.createdAt).getTime() / 1000);
}
await stripeClient.billing.meterEvents.create({
event_name: "response_created",
identifier: `response_created:${input.responseId}`,
...(typeof createdAtSeconds === "number" && Number.isFinite(createdAtSeconds)
? { timestamp: createdAtSeconds }
: {}),
payload: {
stripe_customer_id: input.stripeCustomerId,
value: "1",
},
});
} catch (error) {
logger.warn(
{ error, stripeCustomerId: input.stripeCustomerId, responseId: input.responseId },
"Failed to record Stripe meter event for response_created"
);
}
}
};
@@ -0,0 +1,704 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
findOrganizationIdByStripeCustomerId,
getOrganizationBillingWithReadThroughSync,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "./organization-billing";
const mocks = vi.hoisted(() => ({
isCloud: true,
getBillingCacheKey: vi.fn(),
prismaOrganizationFindUnique: vi.fn(),
prismaOrganizationBillingFindUnique: vi.fn(),
prismaOrganizationBillingCreate: vi.fn(),
prismaOrganizationBillingUpsert: vi.fn(),
prismaOrganizationBillingUpdate: vi.fn(),
cacheWithCache: vi.fn(),
cacheDel: vi.fn(),
loggerWarn: vi.fn(),
getCloudPlanFromProduct: vi.fn(),
customersCreate: vi.fn(),
productsList: vi.fn(),
productsRetrieve: vi.fn(),
subscriptionsList: vi.fn(),
subscriptionsCreate: vi.fn(),
subscriptionsCancel: vi.fn(),
pricesList: vi.fn(),
entitlementsList: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
get IS_FORMBRICKS_CLOUD() {
return mocks.isCloud;
},
};
});
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
organization: {
billing: mocks.getBillingCacheKey,
},
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findUnique: mocks.prismaOrganizationFindUnique,
},
organizationBilling: {
findUnique: mocks.prismaOrganizationBillingFindUnique,
create: mocks.prismaOrganizationBillingCreate,
upsert: mocks.prismaOrganizationBillingUpsert,
update: mocks.prismaOrganizationBillingUpdate,
},
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
withCache: mocks.cacheWithCache,
del: mocks.cacheDel,
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
warn: mocks.loggerWarn,
},
}));
vi.mock("./stripe-plan", async (importOriginal) => {
const actual = await importOriginal<typeof import("./stripe-plan")>();
return {
...actual,
getCloudPlanFromProduct: mocks.getCloudPlanFromProduct,
};
});
vi.mock("./stripe-client", () => ({
stripeClient: {
customers: { create: mocks.customersCreate },
products: {
list: mocks.productsList,
retrieve: mocks.productsRetrieve,
},
subscriptions: {
list: mocks.subscriptionsList,
create: mocks.subscriptionsCreate,
cancel: mocks.subscriptionsCancel,
},
prices: { list: mocks.pricesList },
entitlements: {
activeEntitlements: {
list: mocks.entitlementsList,
},
},
},
}));
describe("organization-billing", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isCloud = true;
mocks.getBillingCacheKey.mockReturnValue("billing-cache-key");
mocks.getCloudPlanFromProduct.mockReturnValue("pro");
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.productsList.mockResolvedValue({
data: [
{
id: "prod_hobby",
metadata: { formbricks_plan: "hobby" },
default_price: null,
},
],
});
mocks.productsRetrieve.mockImplementation(async (productId: string) => ({
id: productId,
metadata:
productId === "prod_hobby"
? { formbricks_plan: "hobby" }
: productId === "prod_pro"
? { formbricks_plan: "pro" }
: productId === "prod_scale"
? { formbricks_plan: "scale" }
: {},
}));
mocks.pricesList.mockResolvedValue({
data: [{ id: "price_hobby_1" }],
});
mocks.entitlementsList.mockResolvedValue({ data: [], has_more: false });
mocks.prismaOrganizationBillingCreate.mockResolvedValue({
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: null,
});
});
test("ensureStripeCustomerForOrganization returns null when org does not exist", async () => {
mocks.prismaOrganizationFindUnique.mockResolvedValue(null);
const result = await ensureStripeCustomerForOrganization("org_missing");
expect(result).toEqual({ customerId: null });
expect(mocks.customersCreate).not.toHaveBeenCalled();
});
test("ensureStripeCustomerForOrganization returns existing customer id", 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,
});
const result = await ensureStripeCustomerForOrganization("org_1");
expect(result).toEqual({ customerId: "cus_existing" });
expect(mocks.customersCreate).not.toHaveBeenCalled();
expect(mocks.prismaOrganizationBillingUpsert).not.toHaveBeenCalled();
});
test("ensureStripeCustomerForOrganization creates and stores a Stripe customer", async () => {
mocks.prismaOrganizationFindUnique.mockResolvedValue({
id: "org_1",
name: "Org 1",
});
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: null,
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
const result = await ensureStripeCustomerForOrganization("org_1");
expect(result).toEqual({ customerId: "cus_new" });
expect(mocks.customersCreate).toHaveBeenCalledWith(
{
name: "Org 1",
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-customer-org_1" }
);
expect(mocks.prismaOrganizationBillingUpsert).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
create: expect.objectContaining({
organizationId: "org_1",
stripeCustomerId: "cus_new",
}),
update: expect.objectContaining({
stripeCustomerId: "cus_new",
stripe: expect.objectContaining({
lastSyncedAt: expect.any(String),
}),
}),
});
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe returns billing unchanged when customer is missing", async () => {
const billing = {
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
};
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
...billing,
usageCycleAnchor: new Date(),
stripe: null,
});
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result).toEqual(billing);
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe ignores duplicate webhook events", async () => {
const billing = {
stripeCustomerId: "cus_1",
stripe: {
lastSyncedEventId: "evt_1",
lastStripeEventCreatedAt: new Date("2026-02-19T00:00:00.000Z").toISOString(),
},
};
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
...billing,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
});
const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_1", created: 1739923200 });
expect(result).toEqual(
expect.objectContaining({
stripeCustomerId: billing.stripeCustomerId,
stripe: billing.stripe,
})
);
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe ignores older webhook events", async () => {
const billing = {
stripeCustomerId: "cus_1",
stripe: {
lastStripeEventCreatedAt: "2026-02-20T00:00:00.000Z",
},
};
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
...billing,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: billing.stripe,
});
const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_old", created: 1739923200 });
expect(result).toEqual(
expect.objectContaining({
stripeCustomerId: billing.stripeCustomerId,
stripe: billing.stripe,
})
);
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe stores normalized stripe snapshot", 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_pro" },
recurring: { usage_type: "licensed", interval: "year" },
},
},
],
},
},
],
});
mocks.entitlementsList.mockResolvedValue({
data: [
{ id: "ent_0", lookup_key: "workspace-limit-5" },
{ id: "ent_00", lookup_key: "responses-included-2000" },
{ id: "ent_1", lookup_key: "custom-links-in-surveys" },
{ id: "ent_2", lookup_key: "custom-links-in-surveys" },
{ id: "ent_3", lookup_key: null },
],
has_more: false,
});
const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_new", created: 1739923300 });
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
data: {
stripeCustomerId: "cus_1",
limits: {
projects: 5,
monthly: {
responses: 2000,
},
},
stripe: expect.objectContaining({
plan: "pro",
subscriptionId: "sub_1",
features: ["workspace-limit-5", "responses-included-2000", "custom-links-in-surveys"],
lastSyncedEventId: "evt_new",
lastStripeEventCreatedAt: expect.any(String),
lastSyncedAt: expect.any(String),
}),
usageCycleAnchor: expect.any(Date),
},
});
expect(result?.stripe?.plan).toBe("pro");
expect(result?.stripe?.features).toEqual([
"workspace-limit-5",
"responses-included-2000",
"custom-links-in-surveys",
]);
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe prefers higher-tier active subscription over hobby", async () => {
mocks.getCloudPlanFromProduct.mockImplementation(
(product: { metadata?: { formbricks_plan?: string } }) => {
if (product.metadata?.formbricks_plan === "hobby") return "hobby";
if (product.metadata?.formbricks_plan === "pro") return "pro";
return "unknown";
}
);
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_hobby",
created: 1739923100,
status: "active",
billing_cycle_anchor: 1739923100,
items: {
data: [
{
price: {
product: { id: "prod_hobby", metadata: { formbricks_plan: "hobby" } },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
{
id: "sub_pro",
created: 1739923200,
status: "active",
billing_cycle_anchor: 1739923200,
items: {
data: [
{
price: {
product: { id: "prod_pro", metadata: { formbricks_plan: "pro" } },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result?.stripe?.subscriptionId).toBe("sub_pro");
expect(result?.stripe?.plan).toBe("pro");
});
test("getOrganizationBillingWithReadThroughSync returns cached billing when no stripe customer exists", async () => {
const cachedBilling = {
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date().toISOString(),
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(result).toEqual(cachedBilling);
expect(mocks.prismaOrganizationBillingFindUnique).not.toHaveBeenCalled();
});
test("getOrganizationBillingWithReadThroughSync returns fresh cached billing without sync", async () => {
const cachedBilling = {
stripeCustomerId: "cus_1",
stripe: { lastSyncedAt: new Date().toISOString() },
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(result).toEqual(cachedBilling);
expect(mocks.prismaOrganizationBillingFindUnique).not.toHaveBeenCalled();
});
test("getOrganizationBillingWithReadThroughSync falls back to cached billing when sync fails", async () => {
const cachedBilling = {
stripeCustomerId: "cus_1",
stripe: { lastSyncedAt: new Date(Date.now() - 6 * 60 * 1000).toISOString() },
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: { lastSyncedAt: new Date(Date.now() - 6 * 60 * 1000).toISOString() },
});
mocks.subscriptionsList.mockRejectedValue(new Error("stripe down"));
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(result).toEqual(cachedBilling);
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), organizationId: "org_1" },
"Failed to refresh billing snapshot from Stripe"
);
});
test("getOrganizationBillingWithReadThroughSync bypasses Redis cache in self-hosted mode", async () => {
mocks.isCloud = false;
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: null,
});
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(mocks.cacheWithCache).not.toHaveBeenCalled();
expect(result).toEqual({
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: expect.any(Date),
});
});
test("getOrganizationBillingWithReadThroughSync returns null when organization billing is missing", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue(null);
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>) => await fn());
await expect(getOrganizationBillingWithReadThroughSync("org_1")).resolves.toBeNull();
});
test("findOrganizationIdByStripeCustomerId returns matching organization id", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({ organizationId: "org_1" });
const result = await findOrganizationIdByStripeCustomerId("cus_1");
expect(result).toBe("org_1");
expect(mocks.prismaOrganizationBillingFindUnique).toHaveBeenCalledWith({
where: {
stripeCustomerId: "cus_1",
},
select: { organizationId: true },
});
});
test("ensureCloudStripeSetupForOrganization does nothing when cloud mode is disabled", async () => {
mocks.isCloud = false;
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.prismaOrganizationFindUnique).not.toHaveBeenCalled();
});
test("ensureCloudStripeSetupForOrganization provisions hobby subscription when org has no active subscription", async () => {
mocks.prismaOrganizationFindUnique.mockResolvedValueOnce({
id: "org_1",
name: "Org 1",
});
mocks.prismaOrganizationBillingFindUnique
.mockResolvedValueOnce({
stripeCustomerId: null,
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
})
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
})
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
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" },
},
},
],
},
},
],
});
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.productsList).toHaveBeenCalledWith({
active: true,
limit: 100,
});
expect(mocks.pricesList).toHaveBeenCalledWith({
product: "prod_hobby",
active: true,
limit: 100,
});
expect(mocks.subscriptionsCreate).toHaveBeenCalledWith(
{
customer: "cus_new",
items: [{ price: "price_hobby_1", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
});
test("reconcileCloudStripeSubscriptionsForOrganization cancels hobby when paid subscription is active", async () => {
mocks.getCloudPlanFromProduct.mockImplementation(
(product: { metadata?: { formbricks_plan?: string } }) => {
if (product.metadata?.formbricks_plan === "hobby") return "hobby";
if (product.metadata?.formbricks_plan === "pro") return "pro";
return "unknown";
}
);
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_hobby",
created: 1739923100,
status: "active",
items: {
data: [
{
price: {
product: { id: "prod_hobby", metadata: { formbricks_plan: "hobby" } },
},
},
],
},
},
{
id: "sub_pro",
created: 1739923200,
status: "active",
items: {
data: [
{
price: {
product: { id: "prod_pro", metadata: { formbricks_plan: "pro" } },
},
},
],
},
},
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,618 @@
import "server-only";
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import {
type TOrganizationBilling,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { stripeClient } from "./stripe-client";
import { CLOUD_PLAN_LEVEL, type TCloudStripePlan, getCloudPlanFromProduct } from "./stripe-plan";
const BILLING_SYNC_STALE_MS = 5 * 60 * 1000;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<string>(["trialing", "active", "past_due", "unpaid", "paused"]);
const ORGANIZATION_BILLING_SELECT = {
stripeCustomerId: true,
limits: true,
usageCycleAnchor: true,
stripe: true,
} satisfies Prisma.OrganizationBillingSelect;
type TOrganizationBillingRecord = Prisma.OrganizationBillingGetPayload<{
select: typeof ORGANIZATION_BILLING_SELECT;
}>;
const getBillingCacheKey = (organizationId: string) => createCacheKey.organization.billing(organizationId);
export const invalidateOrganizationBillingCache = async (organizationId: string): Promise<void> => {
await cache.del([getBillingCacheKey(organizationId)]);
};
const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
limits: {
projects: IS_FORMBRICKS_CLOUD ? 1 : 3,
monthly: {
responses: IS_FORMBRICKS_CLOUD ? 250 : 1500,
},
},
stripeCustomerId: null,
usageCycleAnchor: null,
});
const mapBillingRecord = (billing: TOrganizationBillingRecord | null): TOrganizationBilling | null => {
if (!billing) {
return null;
}
return {
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
};
};
const toIsoStringOrNull = (date: Date | null | undefined): string | null =>
date ? date.toISOString() : null;
const getDateFromBilling = (value: string | null | undefined): Date | null => {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const listAllActiveEntitlements = async (customerId: string): Promise<string[]> => {
if (!stripeClient) return [];
const featureLookupKeys: string[] = [];
let startingAfter: string | undefined;
do {
const result = await stripeClient.entitlements.activeEntitlements.list({
customer: customerId,
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
for (const entitlement of result.data) {
if (entitlement.lookup_key) {
featureLookupKeys.push(entitlement.lookup_key);
}
}
const lastItem = result.data.at(-1);
startingAfter = result.has_more && lastItem ? lastItem.id : undefined;
} while (startingAfter);
return [...new Set(featureLookupKeys)];
};
const parseMaxNumericEntitlementLimit = (features: string[], prefix: string): number | null => {
let maxValue: number | null = null;
for (const feature of features) {
if (!feature.startsWith(prefix)) continue;
const rawValue = feature.slice(prefix.length);
if (!/^\d+$/.test(rawValue)) continue;
const parsed = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsed)) continue;
maxValue = maxValue === null ? parsed : Math.max(maxValue, parsed);
}
return maxValue;
};
const hydrateSubscriptionProducts = async <
TSubscription extends {
items: {
data: Array<{
price: {
product: string | Stripe.Product | Stripe.DeletedProduct;
};
}>;
};
},
>(
subscriptions: TSubscription[]
): Promise<TSubscription[]> => {
if (!stripeClient || subscriptions.length === 0) {
return subscriptions;
}
const client = stripeClient;
const productIds = [
...new Set(
subscriptions.flatMap((subscription) =>
subscription.items.data.flatMap((item) =>
typeof item.price.product === "string" ? [item.price.product] : []
)
)
),
];
if (productIds.length === 0) {
return subscriptions;
}
const products = await Promise.all(
productIds.map(async (productId) => [productId, await client.products.retrieve(productId)] as const)
);
const productsById = new Map(products);
return subscriptions.map((subscription) => ({
...subscription,
items: {
...subscription.items,
data: subscription.items.data.map((item) => ({
...item,
price: {
...item.price,
product:
typeof item.price.product === "string"
? (productsById.get(item.price.product) ?? item.price.product)
: item.price.product,
},
})),
},
}));
};
const getSubscriptionTopPlanLevel = (
subscription: {
items: {
data: Array<{
price: {
product: string | Stripe.Product | Stripe.DeletedProduct;
};
}>;
};
} | null
): number => {
if (!subscription) return CLOUD_PLAN_LEVEL.unknown;
let topLevel: number = CLOUD_PLAN_LEVEL.unknown;
for (const item of subscription.items.data) {
const plan = getCloudPlanFromProduct(item.price.product);
topLevel = Math.max(topLevel, CLOUD_PLAN_LEVEL[plan]);
}
return topLevel;
};
const resolveCurrentSubscription = async (customerId: string) => {
if (!stripeClient) return null;
const subscriptions = await stripeClient.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
});
const subscriptionsWithProducts = await hydrateSubscriptionProducts(subscriptions.data);
const preferred = [...subscriptionsWithProducts]
.filter((subscription) => ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status))
.sort((left, right) => {
const leftLevel = getSubscriptionTopPlanLevel(left);
const rightLevel = getSubscriptionTopPlanLevel(right);
if (leftLevel !== rightLevel) {
return rightLevel - leftLevel;
}
return right.created - left.created;
})[0];
return preferred ?? null;
};
const resolveCloudPlanFromSubscription = (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
) => {
if (!subscription) return "hobby" as TCloudStripePlan;
let resolvedPlan: TCloudStripePlan = "unknown";
for (const item of subscription.items.data) {
const plan = getCloudPlanFromProduct(item.price.product);
if (CLOUD_PLAN_LEVEL[plan] > CLOUD_PLAN_LEVEL[resolvedPlan]) {
resolvedPlan = plan;
}
}
return resolvedPlan;
};
const resolveSubscriptionStatus = (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
): TOrganizationStripeSubscriptionStatus | null => {
return subscription?.status ?? null;
};
const resolveUsageCycleAnchor = (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
): Date | null => {
if (!subscription?.billing_cycle_anchor) return null;
return new Date(subscription.billing_cycle_anchor * 1000);
};
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const products = await stripeClient.products.list({
active: true,
limit: 100,
});
const hobbyProduct = products.data.find((product) => product.metadata.formbricks_plan === "hobby");
if (!hobbyProduct) {
throw new Error("Stripe product metadata formbricks_plan=hobby not found");
}
const defaultPrice =
typeof hobbyProduct.default_price === "string" ? null : (hobbyProduct.default_price ?? null);
const fallbackPrices = await stripeClient.prices.list({
product: hobbyProduct.id,
active: true,
limit: 100,
});
const hobbyPrice =
defaultPrice ??
fallbackPrices.data.find(
(price) => price.recurring?.interval === "month" && price.recurring.usage_type === "licensed"
) ??
fallbackPrices.data[0] ??
null;
if (!hobbyPrice) {
throw new Error(`No active price found for Stripe hobby product ${hobbyProduct.id}`);
}
await stripeClient.subscriptions.create(
{
customer: customerId,
items: [{ price: hobbyPrice.id, quantity: 1 }],
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
);
};
const ensureOrganizationBillingRecord = async (
organizationId: string
): Promise<TOrganizationBilling | null> => {
const existingBilling = await prisma.organizationBilling.findUnique({
where: { organizationId },
select: ORGANIZATION_BILLING_SELECT,
});
if (existingBilling) {
return mapBillingRecord(existingBilling);
}
const organizationExists = await prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true },
});
if (!organizationExists) {
return null;
}
const defaultBilling = getDefaultOrganizationBilling();
const billing = await prisma.organizationBilling.upsert({
where: { organizationId },
update: {},
create: {
organizationId,
stripeCustomerId: defaultBilling.stripeCustomerId,
limits: defaultBilling.limits,
usageCycleAnchor: defaultBilling.usageCycleAnchor,
},
select: ORGANIZATION_BILLING_SELECT,
});
return mapBillingRecord(billing);
};
export const ensureStripeCustomerForOrganization = async (
organizationId: string
): Promise<{ customerId: string | null }> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
return { customerId: null };
}
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true, name: true },
});
if (!organization) {
return { customerId: null };
}
const billing = await ensureOrganizationBillingRecord(organization.id);
if (!billing) {
return { customerId: null };
}
if (billing.stripeCustomerId) {
return { customerId: billing.stripeCustomerId };
}
const customer = await stripeClient.customers.create(
{
name: organization.name,
metadata: { organizationId: organization.id },
},
{ idempotencyKey: `ensure-customer-${organization.id}` }
);
const updatedStripeSnapshot = {
...billing.stripe,
lastSyncedAt: new Date().toISOString(),
};
await prisma.organizationBilling.upsert({
where: { organizationId: organization.id },
create: {
organizationId: organization.id,
stripeCustomerId: customer.id,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
stripe: updatedStripeSnapshot,
},
update: {
stripeCustomerId: customer.id,
stripe: updatedStripeSnapshot,
},
});
await invalidateOrganizationBillingCache(organization.id);
return { customerId: customer.id };
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
event?: { id: string; created: number }
): Promise<TOrganizationBilling | null> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
return null;
}
const billing = await ensureOrganizationBillingRecord(organizationId);
if (!billing) {
return null;
}
const customerId = billing.stripeCustomerId;
if (!customerId) return billing;
const existingStripeSnapshot = billing.stripe;
const previousEventDate = getDateFromBilling(existingStripeSnapshot?.lastStripeEventCreatedAt ?? null);
const incomingEventDate = event ? new Date(event.created * 1000) : null;
if (event?.id && existingStripeSnapshot?.lastSyncedEventId === event.id) {
return billing;
}
if (incomingEventDate && previousEventDate && incomingEventDate < previousEventDate) {
return billing;
}
const [subscription, featureLookupKeys] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
]);
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
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 projectsLimit = workspaceLimitFromEntitlements ?? previousLimits?.projects ?? null;
if (workspaceLimitFromEntitlements === null && previousLimits?.projects == null) {
logger.warn(
{ organizationId, customerId, cloudPlan, featureLookupKeys },
"No workspace limit entitlement found in Stripe entitlements; preserving previous projects limit"
);
}
const responsesIncludedLimit =
responsesIncludedFromEntitlements ?? previousLimits?.monthly?.responses ?? null;
if (responsesIncludedFromEntitlements === null && previousLimits?.monthly?.responses == null) {
logger.warn(
{ organizationId, customerId, cloudPlan, featureLookupKeys },
"No responses included entitlement found in Stripe entitlements; preserving previous responses limit"
);
}
const updatedBilling: TOrganizationBilling = {
stripeCustomerId: customerId,
limits: {
projects: projectsLimit,
monthly: {
responses: responsesIncludedLimit,
},
},
usageCycleAnchor,
stripe: {
...billing.stripe,
plan: cloudPlan,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
features: featureLookupKeys,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),
lastSyncedAt: new Date().toISOString(),
lastSyncedEventId: event?.id ?? existingStripeSnapshot?.lastSyncedEventId ?? null,
},
};
await prisma.organizationBilling.update({
where: { organizationId },
data: {
stripeCustomerId: updatedBilling.stripeCustomerId,
limits: updatedBilling.limits,
usageCycleAnchor: updatedBilling.usageCycleAnchor,
stripe: updatedBilling.stripe,
},
});
await invalidateOrganizationBillingCache(organizationId);
return updatedBilling;
};
const isSnapshotStale = (billing: TOrganizationBilling | null): boolean => {
const lastSyncedAt = getDateFromBilling(billing?.stripe?.lastSyncedAt ?? null);
if (!lastSyncedAt) return true;
return Date.now() - lastSyncedAt.getTime() > BILLING_SYNC_STALE_MS;
};
const getOrganizationBillingFromDatabase = async (
organizationId: string
): Promise<TOrganizationBilling | null> => {
return await ensureOrganizationBillingRecord(organizationId);
};
export const getOrganizationBillingWithReadThroughSync = async (
organizationId: string
): Promise<TOrganizationBilling | null> => {
if (!IS_FORMBRICKS_CLOUD) {
// Self-hosted does not need Stripe read-through sync or Redis-backed billing cache.
return await getOrganizationBillingFromDatabase(organizationId);
}
const cachedBilling = await cache.withCache(
async () => await getOrganizationBillingFromDatabase(organizationId),
getBillingCacheKey(organizationId),
BILLING_SYNC_STALE_MS
);
if (!cachedBilling?.stripeCustomerId) {
return cachedBilling;
}
if (!isSnapshotStale(cachedBilling)) {
return cachedBilling;
}
try {
const syncedBilling = await syncOrganizationBillingFromStripe(organizationId);
return syncedBilling ?? cachedBilling;
} catch (error) {
logger.warn({ error, organizationId }, "Failed to refresh billing snapshot from Stripe");
return cachedBilling;
}
};
export const deleteStripeCustomer = async (stripeCustomerId: string): Promise<void> => {
if (!stripeClient) return;
await stripeClient.customers.del(stripeCustomerId);
};
export const findOrganizationIdByStripeCustomerId = async (customerId: string): Promise<string | null> => {
const billing = await prisma.organizationBilling.findUnique({
where: {
stripeCustomerId: customerId,
},
select: {
organizationId: true,
},
});
return billing?.organizationId ?? null;
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string,
idempotencySuffix = "reconcile"
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
const billing = await getOrganizationBillingFromDatabase(organizationId);
const customerId = billing?.stripeCustomerId;
if (!customerId) return;
const subscriptions = await client.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
});
const subscriptionsWithProducts = await hydrateSubscriptionProducts(subscriptions.data);
const activeSubscriptions = subscriptionsWithProducts.filter((subscription) =>
ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)
);
const subscriptionsWithPlanLevel = activeSubscriptions.map((subscription) => ({
subscription,
planLevel: getSubscriptionTopPlanLevel(subscription),
}));
const unknownPlanSubscriptions = subscriptionsWithPlanLevel.filter(
({ planLevel }) => planLevel === CLOUD_PLAN_LEVEL.unknown
);
if (unknownPlanSubscriptions.length > 0) {
logger.warn(
{
organizationId,
subscriptionIds: unknownPlanSubscriptions.map(({ subscription }) => subscription.id),
},
"Found subscriptions with unknown plan level during reconciliation"
);
}
const hasPaidOrTrialSubscription = subscriptionsWithPlanLevel.some(
({ planLevel }) => planLevel > CLOUD_PLAN_LEVEL.hobby || planLevel === CLOUD_PLAN_LEVEL.unknown
);
if (hasPaidOrTrialSubscription) {
const hobbySubscriptions = subscriptionsWithPlanLevel.filter(
({ planLevel }) => planLevel === CLOUD_PLAN_LEVEL.hobby
);
await Promise.all(
hobbySubscriptions.map(({ subscription }) =>
client.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
);
return;
}
if (subscriptionsWithPlanLevel.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
}
};
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};
@@ -0,0 +1,41 @@
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
describe("stripe-client", () => {
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
test("returns null when no Stripe secret key is configured", async () => {
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return actual;
});
vi.doMock("@/lib/env", () => ({
env: {
STRIPE_SECRET_KEY: "",
},
}));
const { stripeClient } = await import("./stripe-client");
expect(stripeClient).toBeNull();
});
test("creates a Stripe client when secret key exists", async () => {
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return actual;
});
vi.doMock("@/lib/env", () => ({
env: {
STRIPE_SECRET_KEY: "sk_test_123",
},
}));
const { stripeClient } = await import("./stripe-client");
expect(stripeClient).toBeInstanceOf(Stripe);
});
});
@@ -0,0 +1,10 @@
import "server-only";
import Stripe from "stripe";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
export const stripeClient = env.STRIPE_SECRET_KEY
? new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
})
: null;
@@ -0,0 +1,30 @@
import Stripe from "stripe";
import { describe, expect, test } from "vitest";
import { getCloudPlanFromProduct } from "./stripe-plan";
const product = (input: Partial<Stripe.Product> & Pick<Stripe.Product, "id">): Stripe.Product =>
input as Stripe.Product;
describe("stripe-plan", () => {
test("maps known product metadata values to cloud plans", () => {
expect(
getCloudPlanFromProduct(product({ id: "prod_hobby", metadata: { formbricks_plan: "hobby" } }))
).toBe("hobby");
expect(getCloudPlanFromProduct(product({ id: "prod_pro", metadata: { formbricks_plan: "pro" } }))).toBe(
"pro"
);
expect(
getCloudPlanFromProduct(product({ id: "prod_scale", metadata: { formbricks_plan: "scale" } }))
).toBe("scale");
});
test("falls back to unknown for missing or unknown products", () => {
expect(getCloudPlanFromProduct(null)).toBe("unknown");
expect(getCloudPlanFromProduct(undefined)).toBe("unknown");
expect(getCloudPlanFromProduct("prod_unknown")).toBe("unknown");
expect(getCloudPlanFromProduct(product({ id: "prod_unknown", metadata: {} }))).toBe("unknown");
expect(
getCloudPlanFromProduct(product({ id: "prod_unknown", metadata: { formbricks_plan: "enterprise" } }))
).toBe("unknown");
});
});
@@ -0,0 +1,37 @@
import "server-only";
import Stripe from "stripe";
export const CLOUD_PLAN_LEVEL = {
hobby: 0,
pro: 1,
scale: 2,
unknown: -1,
} as const;
export type TCloudStripePlan = keyof typeof CLOUD_PLAN_LEVEL;
const CLOUD_PRODUCT_METADATA_TO_PLAN = {
hobby: "hobby",
pro: "pro",
scale: "scale",
} as const satisfies Record<string, Exclude<TCloudStripePlan, "unknown">>;
const getProductPlanMetadata = (
product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined
): string | null => {
if (!product || typeof product === "string" || product.deleted) {
return null;
}
return product.metadata.formbricks_plan ?? null;
};
export const getCloudPlanFromProduct = (
product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined
): TCloudStripePlan => {
const metadataPlan = getProductPlanMetadata(product);
if (!metadataPlan) return "unknown";
return (
CLOUD_PRODUCT_METADATA_TO_PLAN[metadataPlan as keyof typeof CLOUD_PRODUCT_METADATA_TO_PLAN] ?? "unknown"
);
};
+19 -11
View File
@@ -1,13 +1,11 @@
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { PROJECT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@/lib/constants";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
} from "@/lib/organization/service";
import { env } from "@/lib/env";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -23,8 +21,14 @@ export const PricingPage = async (props) => {
notFound();
}
const [peopleCount, responseCount, projectCount] = await Promise.all([
getMonthlyActiveOrganizationPeopleCount(organization.id),
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
const organizationWithSyncedBilling = {
...organization,
billing: cloudBillingDisplayContext.billing,
};
const [responseCount, projectCount] = await Promise.all([
getMonthlyOrganizationResponseCount(organization.id),
getOrganizationProjectsCount(organization.id),
]);
@@ -43,14 +47,18 @@ export const PricingPage = async (props) => {
</PageHeader>
<PricingTable
organization={organization}
organization={organizationWithSyncedBilling}
environmentId={params.environmentId}
peopleCount={peopleCount}
responseCount={responseCount}
projectCount={projectCount}
stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS}
projectFeatureKeys={PROJECT_FEATURE_KEYS}
hasBillingRights={hasBillingRights}
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
/>
</PageContentWrapper>
);
@@ -34,7 +34,7 @@ export const SingleContactPage = async (props: {
throw new Error(t("environments.contacts.contact_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
// Derive contact identifier from metadata array
const getAttributeValue = (key: string): string | undefined => {
@@ -6,6 +6,7 @@ import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors
import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
@@ -99,7 +100,8 @@ export const POST = withV1ApiWrapper({
const { userId, attributes } = jsonInput;
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(

Some files were not shown because too many files have changed in this diff Show More