mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
resolve conflict
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
+4
-2
@@ -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(`/`);
|
||||
|
||||
+1
-1
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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");
|
||||
}
|
||||
|
||||
+4
-3
@@ -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");
|
||||
}
|
||||
|
||||
+15
-11
@@ -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[],
|
||||
};
|
||||
|
||||
+1
-52
@@ -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
@@ -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
|
||||
|
||||
Vendored
+2
-1
@@ -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 = {
|
||||
|
||||
Vendored
+26
-7
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 l’offre Scale.",
|
||||
"scale_banner_title": "Prêt à passer à la vitesse supérieure ?",
|
||||
"scale_feature_api": "Accès API complet",
|
||||
"scale_feature_quota": "Gestion des quotas",
|
||||
"scale_feature_spam": "Protection contre le spam",
|
||||
"scale_feature_teams": "Équipes & rôles d’accès",
|
||||
"status_trialing": "Essai",
|
||||
"stripe_setup_incomplete": "Configuration de la facturation incomplète",
|
||||
"stripe_setup_incomplete_description": "La configuration de la facturation n’a pas abouti. Merci de réessayer pour activer ton abonnement.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Gère ton abonnement et surveille ta consommation",
|
||||
"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 l’hameç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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Vă rugăm să treceți 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ăugați?",
|
||||
"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
@@ -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
@@ -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": "Kvothantering",
|
||||
"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?",
|
||||
|
||||
@@ -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": "你想创建什么样的工作流?",
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+4
-6
@@ -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(() => {
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
@@ -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>✓ {t("environments.settings.billing.scale_feature_teams")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_api")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_quota")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_spam")}</span>
|
||||
</div>
|
||||
</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"
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user