fix: restrict selected entitlements during trial

This commit is contained in:
Matti Nannt
2026-03-11 15:57:36 +01:00
parent 99bd2ba256
commit fa63729165
7 changed files with 74 additions and 0 deletions
@@ -31,6 +31,7 @@ const baseContext: TOrganizationEntitlementsContext = {
licenseStatus: "no-license",
licenseFeatures: null,
stripeCustomerId: "cus_1",
subscriptionStatus: null,
usageCycleAnchor: null,
};
@@ -61,6 +62,33 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(true);
});
test("returns false for trial-restricted follow-ups while trialing", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["follow-ups"],
subscriptionStatus: "trialing",
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "follow-ups")).toBe(false);
});
test("returns false for trial-restricted custom links while trialing", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["custom-links-in-surveys"],
subscriptionStatus: "trialing",
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "custom-links-in-surveys")).toBe(false);
});
test("returns false for trial-restricted custom redirect while trialing", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["custom-redirect-url"],
subscriptionStatus: "trialing",
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "custom-redirect-url")).toBe(false);
});
test("returns false when feature not present", async () => {
mockGetContext.mockResolvedValue(baseContext);
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "hide-branding")).toBe(false);
@@ -102,10 +130,20 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
...baseContext,
features: ["custom-redirect-url"],
licenseStatus: "active",
subscriptionStatus: "active",
licenseFeatures: {} as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "custom-redirect-url")).toBe(true);
});
test("does not affect unrelated features while trialing", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["rbac"],
subscriptionStatus: "trialing",
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(true);
});
});
describe("getOrganizationEntitlementLimits", () => {
@@ -1,4 +1,5 @@
import "server-only";
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { getOrganizationEntitlementsContext } from "./provider";
import { isEntitlementFeature } from "./types";
@@ -11,6 +12,12 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLice
contacts: "contacts",
};
const TRIAL_RESTRICTED_ENTITLEMENTS = new Set<string>([
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.FOLLOW_UPS,
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CUSTOM_LINKS_IN_SURVEYS,
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CUSTOM_REDIRECT_URL,
]);
export const hasOrganizationEntitlement = async (
organizationId: string,
featureLookupKey: string
@@ -37,6 +44,14 @@ export const hasOrganizationEntitlementWithLicenseGuard = async (
return false;
}
if (
context.source === "cloud_stripe" &&
context.subscriptionStatus === "trialing" &&
TRIAL_RESTRICTED_ENTITLEMENTS.has(featureLookupKey)
) {
return false;
}
if (context.licenseStatus === "no-license") {
return true;
}
@@ -49,6 +49,7 @@ describe("getCloudOrganizationEntitlementsContext", () => {
licenseStatus: "no-license",
licenseFeatures: null,
stripeCustomerId: "cus_1",
subscriptionStatus: null,
usageCycleAnchor,
});
});
@@ -67,6 +68,7 @@ describe("getCloudOrganizationEntitlementsContext", () => {
expect(result.features).toEqual([]);
expect(result.limits).toEqual({ projects: null, monthlyResponses: null });
expect(result.stripeCustomerId).toBeNull();
expect(result.subscriptionStatus).toBeNull();
expect(result.usageCycleAnchor).toBeNull();
});
@@ -97,4 +99,18 @@ describe("getCloudOrganizationEntitlementsContext", () => {
expect(result.features).toEqual(["rbac"]);
});
test("exposes subscription status from billing stripe snapshot", async () => {
mockGetBilling.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: { projects: 5, monthly: { responses: 1000 } },
usageCycleAnchor: null,
stripe: { features: ["follow-ups"], subscriptionStatus: "trialing" },
} as any);
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
const result = await getCloudOrganizationEntitlementsContext("org1");
expect(result.subscriptionStatus).toBe("trialing");
});
});
@@ -33,6 +33,7 @@ export const getCloudOrganizationEntitlementsContext = async (
licenseStatus: license.status,
licenseFeatures: license.features,
stripeCustomerId: billing.stripeCustomerId ?? null,
subscriptionStatus: billing.stripe?.subscriptionStatus ?? null,
usageCycleAnchor: toDateOrNull(billing.usageCycleAnchor),
};
};
@@ -43,6 +43,7 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
licenseStatus: "no-license",
licenseFeatures: null,
stripeCustomerId: null,
subscriptionStatus: null,
usageCycleAnchor: null,
});
});
@@ -55,6 +55,7 @@ export const getSelfHostedOrganizationEntitlementsContext = async (
licenseStatus: license.status,
licenseFeatures: license.features,
stripeCustomerId: null,
subscriptionStatus: null,
usageCycleAnchor: null,
};
};
@@ -1,3 +1,4 @@
import type { TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
import type {
TEnterpriseLicenseFeatures,
@@ -35,5 +36,6 @@ export type TOrganizationEntitlementsContext = {
licenseStatus: TEnterpriseLicenseStatusReturn;
licenseFeatures: TEnterpriseLicenseFeatures | null;
stripeCustomerId: string | null;
subscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
usageCycleAnchor: Date | null;
};