From c796e9514e5da0e476f8f513bd48075d75c1528c Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:01:46 -0500 Subject: [PATCH] [PM-25029] new pricing service (#16473) * [PM-25029] first draft of pricing service * [PM-25029] pricing service, getting closer * [PM-25029] pricing service and tests finished * removing unused translation * pr feedback * new test names to reflect change away from monthly calculation --- .../subscription-pricing.service.spec.ts | 887 ++++++++++++++++++ .../services/subscription-pricing.service.ts | 360 +++++++ .../types/subscription-pricing-tier.ts | 85 ++ apps/web/src/locales/en/messages.json | 111 +++ 4 files changed, 1443 insertions(+) create mode 100644 apps/web/src/app/billing/services/subscription-pricing.service.spec.ts create mode 100644 apps/web/src/app/billing/services/subscription-pricing.service.ts create mode 100644 apps/web/src/app/billing/types/subscription-pricing-tier.ts diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts new file mode 100644 index 00000000000..0fb33020bc3 --- /dev/null +++ b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts @@ -0,0 +1,887 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { + BusinessSubscriptionPricingTierIds, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "../types/subscription-pricing-tier"; + +import { SubscriptionPricingService } from "./subscription-pricing.service"; + +describe("SubscriptionPricingService", () => { + let service: SubscriptionPricingService; + let apiService: MockProxy; + let i18nService: MockProxy; + let logService: MockProxy; + let toastService: MockProxy; + + const mockFamiliesPlan = { + type: PlanType.FamiliesAnnually, + productTier: ProductTierType.Families, + name: "Families (Annually)", + isAnnual: true, + nameLocalizationKey: "planNameFamilies", + descriptionLocalizationKey: "planDescFamiliesV2", + canBeUsedByBusiness: false, + trialPeriodDays: 7, + hasSelfHost: false, + hasPolicies: false, + hasGroups: false, + hasDirectory: false, + hasEvents: false, + hasTotp: true, + has2fa: true, + hasApi: true, + hasSso: false, + hasResetPassword: false, + hasSend: true, + usersGetPremium: true, + upgradeSortOrder: 1, + displaySortOrder: 1, + legacyYear: 2024, + disabled: false, + PasswordManager: { + baseSeats: 6, + baseStorageGb: 1, + basePrice: 36, + seatPrice: 0, + additionalStoragePricePerGb: 4, + allowSeatAutoscale: false, + maxSeats: 6, + maxCollections: null, + maxProjects: null, + }, + SecretsManager: null, + } as any as PlanResponse; + + const mockTeamsPlan = { + type: PlanType.TeamsAnnually, + productTier: ProductTierType.Teams, + name: "Teams (Annually)", + isAnnual: true, + nameLocalizationKey: "planNameTeams", + descriptionLocalizationKey: "planDescTeams", + canBeUsedByBusiness: true, + trialPeriodDays: 7, + hasSelfHost: true, + hasPolicies: true, + hasGroups: true, + hasDirectory: true, + hasEvents: true, + hasTotp: true, + has2fa: true, + hasApi: true, + hasSso: true, + hasResetPassword: true, + hasSend: true, + usersGetPremium: false, + upgradeSortOrder: 2, + displaySortOrder: 2, + legacyYear: 2024, + disabled: false, + PasswordManager: { + baseSeats: 0, + baseStorageGb: 1, + basePrice: 0, + seatPrice: 36, + additionalStoragePricePerGb: 4, + allowSeatAutoscale: true, + maxSeats: null, + maxCollections: null, + maxProjects: null, + }, + SecretsManager: { + baseSeats: 0, + baseStorageGb: 0, + basePrice: 0, + seatPrice: 72, + additionalPricePerServiceAccount: 6, + baseServiceAccount: 20, + allowSeatAutoscale: true, + maxSeats: null, + maxCollections: null, + maxProjects: null, + }, + } as any as PlanResponse; + + const mockEnterprisePlan = { + type: PlanType.EnterpriseAnnually, + productTier: ProductTierType.Enterprise, + name: "Enterprise (Annually)", + isAnnual: true, + nameLocalizationKey: "planNameEnterprise", + descriptionLocalizationKey: "planDescEnterpriseV2", + canBeUsedByBusiness: true, + trialPeriodDays: 7, + hasSelfHost: true, + hasPolicies: true, + hasGroups: true, + hasDirectory: true, + hasEvents: true, + hasTotp: true, + has2fa: true, + hasApi: true, + hasSso: true, + hasResetPassword: true, + hasSend: true, + usersGetPremium: false, + upgradeSortOrder: 3, + displaySortOrder: 3, + legacyYear: 2024, + disabled: false, + PasswordManager: { + baseSeats: 0, + baseStorageGb: 1, + basePrice: 0, + seatPrice: 48, + additionalStoragePricePerGb: 4, + allowSeatAutoscale: true, + maxSeats: null, + maxCollections: null, + maxProjects: null, + }, + SecretsManager: { + baseSeats: 0, + baseStorageGb: 0, + basePrice: 0, + seatPrice: 84, + additionalPricePerServiceAccount: 6, + baseServiceAccount: 50, + allowSeatAutoscale: true, + maxSeats: null, + maxCollections: null, + maxProjects: null, + }, + } as any as PlanResponse; + + const mockFreePlan = { + type: PlanType.Free, + productTier: ProductTierType.Free, + name: "Free", + isAnnual: false, + nameLocalizationKey: "planNameFree", + descriptionLocalizationKey: "planDescFreeV2", + canBeUsedByBusiness: true, + trialPeriodDays: null, + hasSelfHost: false, + hasPolicies: false, + hasGroups: false, + hasDirectory: false, + hasEvents: false, + hasTotp: false, + has2fa: true, + hasApi: false, + hasSso: false, + hasResetPassword: false, + hasSend: true, + usersGetPremium: false, + upgradeSortOrder: 0, + displaySortOrder: 0, + legacyYear: 2024, + disabled: false, + PasswordManager: { + baseSeats: 2, + baseStorageGb: null, + basePrice: 0, + seatPrice: 0, + additionalStoragePricePerGb: null, + allowSeatAutoscale: false, + maxSeats: 2, + maxCollections: 2, + maxProjects: null, + }, + SecretsManager: { + baseSeats: 2, + baseStorageGb: null, + basePrice: 0, + seatPrice: 0, + additionalPricePerServiceAccount: null, + baseServiceAccount: 0, + allowSeatAutoscale: false, + maxSeats: 2, + maxCollections: null, + maxProjects: 3, + }, + } as any as PlanResponse; + + const mockPlansResponse: any = { + data: [mockFamiliesPlan, mockTeamsPlan, mockEnterprisePlan, mockFreePlan], + continuationToken: null, + }; + + beforeAll(() => { + i18nService = mock(); + logService = mock(); + toastService = mock(); + + i18nService.t.mockImplementation((key: string, ...args: any[]) => { + switch (key) { + // Plan names + case "premium": + return "Premium"; + case "planNameFamilies": + return "Families"; + case "planNameTeams": + return "Teams"; + case "planNameEnterprise": + return "Enterprise"; + case "planNameFree": + return "Free"; + case "planNameCustom": + return "Custom"; + + // Plan descriptions + case "planDescPremium": + return "Premium plan description"; + case "planDescFamiliesV2": + return "Families plan description"; + case "planDescFreeV2": + return `Free plan for ${args[0]} user`; + case "planDescEnterpriseV2": + return "Enterprise plan description"; + case "planDescCustom": + return "Custom plan description"; + case "teamsPlanUpgradeMessage": + return "Resilient protection for growing teams"; + + // Feature translations + case "builtInAuthenticator": + return "Built-in authenticator"; + case "secureFileStorage": + return "Secure file storage"; + case "emergencyAccess": + return "Emergency access"; + case "breachMonitoring": + return "Breach monitoring"; + case "andMoreFeatures": + return "And more features"; + case "premiumAccounts": + return "6 premium accounts"; + case "familiesUnlimitedSharing": + return "Unlimited sharing for families"; + case "familiesUnlimitedCollections": + return "Unlimited collections for families"; + case "familiesSharedStorage": + return "Shared storage for families"; + case "limitedUsersV2": + return `Limited to ${args[0]} users`; + case "limitedCollectionsV2": + return `Limited to ${args[0]} collections`; + case "alwaysFree": + return "Always free"; + case "twoSecretsIncluded": + return "Two secrets included"; + case "projectsIncludedV2": + return `${args[0]} projects included`; + case "secureItemSharing": + return "Secure item sharing"; + case "eventLogMonitoring": + return "Event log monitoring"; + case "directoryIntegration": + return "Directory integration"; + case "scimSupport": + return "SCIM support"; + case "unlimitedSecretsAndProjects": + return "Unlimited secrets and projects"; + case "includedMachineAccountsV2": + return `${args[0]} machine accounts included`; + case "enterpriseSecurityPolicies": + return "Enterprise security policies"; + case "passwordLessSso": + return "Passwordless SSO"; + case "accountRecovery": + return "Account recovery"; + case "selfHostOption": + return "Self-host option"; + case "complimentaryFamiliesPlan": + return "Complimentary families plan"; + case "unlimitedUsers": + return "Unlimited users"; + case "strengthenCybersecurity": + return "Strengthen cybersecurity"; + case "boostProductivity": + return "Boost productivity"; + case "seamlessIntegration": + return "Seamless integration"; + case "unexpectedError": + return "An unexpected error has occurred."; + default: + return key; + } + }); + }); + + beforeEach(() => { + apiService = mock(); + + apiService.getPlans.mockResolvedValue(mockPlansResponse); + + TestBed.configureTestingModule({ + providers: [ + SubscriptionPricingService, + { provide: ApiService, useValue: apiService }, + { provide: I18nService, useValue: i18nService }, + { provide: LogService, useValue: logService }, + { provide: ToastService, useValue: toastService }, + ], + }); + + service = TestBed.inject(SubscriptionPricingService); + }); + + describe("getPersonalSubscriptionPricingTiers$", () => { + it("should return Premium and Families pricing tiers with correct structure", (done) => { + service.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => { + expect(tiers).toHaveLength(2); + + const premiumTier = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + expect(premiumTier).toEqual({ + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Premium plan description", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "builtInAuthenticator", value: "Built-in authenticator" }, + { key: "secureFileStorage", value: "Secure file storage" }, + { key: "emergencyAccess", value: "Emergency access" }, + { key: "breachMonitoring", value: "Breach monitoring" }, + { key: "andMoreFeatures", value: "And more features" }, + ], + }, + }); + + const familiesTier = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Families, + ); + expect(familiesTier).toEqual({ + id: PersonalSubscriptionPricingTierIds.Families, + name: "Families", + description: "Families plan description", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "packaged", + users: mockFamiliesPlan.PasswordManager.baseSeats, + annualPrice: mockFamiliesPlan.PasswordManager.basePrice, + annualPricePerAdditionalStorageGB: + mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb, + features: [ + { key: "premiumAccounts", value: "6 premium accounts" }, + { key: "familiesUnlimitedSharing", value: "Unlimited sharing for families" }, + { key: "familiesUnlimitedCollections", value: "Unlimited collections for families" }, + { key: "familiesSharedStorage", value: "Shared storage for families" }, + ], + }, + }); + + expect(i18nService.t).toHaveBeenCalledWith("premium"); + expect(i18nService.t).toHaveBeenCalledWith("planDescPremium"); + expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies"); + expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2"); + expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator"); + expect(i18nService.t).toHaveBeenCalledWith("secureFileStorage"); + expect(i18nService.t).toHaveBeenCalledWith("emergencyAccess"); + expect(i18nService.t).toHaveBeenCalledWith("breachMonitoring"); + expect(i18nService.t).toHaveBeenCalledWith("andMoreFeatures"); + expect(i18nService.t).toHaveBeenCalledWith("premiumAccounts"); + expect(i18nService.t).toHaveBeenCalledWith("familiesUnlimitedSharing"); + expect(i18nService.t).toHaveBeenCalledWith("familiesUnlimitedCollections"); + expect(i18nService.t).toHaveBeenCalledWith("familiesSharedStorage"); + + done(); + }); + }); + + it("should handle API errors by logging and showing toast", (done) => { + const errorApiService = mock(); + const errorI18nService = mock(); + const errorLogService = mock(); + const errorToastService = mock(); + + const testError = new Error("API error"); + errorApiService.getPlans.mockRejectedValue(testError); + + errorI18nService.t.mockImplementation((key: string) => { + if (key === "unexpectedError") { + return "An unexpected error has occurred."; + } + return key; + }); + + const errorService = new SubscriptionPricingService( + errorApiService, + errorI18nService, + errorLogService, + errorToastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + expect(tiers).toEqual([]); + expect(errorLogService.error).toHaveBeenCalledWith(testError); + expect(errorToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + + it("should contain correct pricing", (done) => { + service.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => { + const premiumTier = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + )!; + const familiesTier = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Families, + )!; + + expect(premiumTier.passwordManager.annualPrice).toEqual(10); + expect(premiumTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(4); + + expect(familiesTier.passwordManager.annualPrice).toEqual( + mockFamiliesPlan.PasswordManager.basePrice, + ); + expect(familiesTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual( + mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb, + ); + + done(); + }); + }); + }); + + describe("getBusinessSubscriptionPricingTiers$", () => { + it("should return Teams, Enterprise, and Custom pricing tiers with correct structure", (done) => { + service.getBusinessSubscriptionPricingTiers$().subscribe((tiers) => { + expect(tiers).toHaveLength(3); + + const teamsTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams, + ); + expect(teamsTier).toEqual({ + id: BusinessSubscriptionPricingTierIds.Teams, + name: "Teams", + description: "Resilient protection for growing teams", + availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], + passwordManager: { + type: "scalable", + annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice, + annualPricePerAdditionalStorageGB: + mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + features: [ + { key: "secureItemSharing", value: "Secure item sharing" }, + { key: "eventLogMonitoring", value: "Event log monitoring" }, + { key: "directoryIntegration", value: "Directory integration" }, + { key: "scimSupport", value: "SCIM support" }, + ], + }, + secretsManager: { + type: "scalable", + annualPricePerUser: mockTeamsPlan.SecretsManager!.seatPrice, + annualPricePerAdditionalServiceAccount: + mockTeamsPlan.SecretsManager!.additionalPricePerServiceAccount, + features: [ + { key: "unlimitedSecretsAndProjects", value: "Unlimited secrets and projects" }, + { + key: "includedMachineAccountsV2", + value: `${mockTeamsPlan.SecretsManager!.baseServiceAccount} machine accounts included`, + }, + ], + }, + }); + + const enterpriseTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise, + ); + expect(enterpriseTier).toEqual({ + id: BusinessSubscriptionPricingTierIds.Enterprise, + name: "Enterprise", + description: "Enterprise plan description", + availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], + passwordManager: { + type: "scalable", + annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice, + annualPricePerAdditionalStorageGB: + mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + features: [ + { key: "enterpriseSecurityPolicies", value: "Enterprise security policies" }, + { key: "passwordLessSso", value: "Passwordless SSO" }, + { key: "accountRecovery", value: "Account recovery" }, + { key: "selfHostOption", value: "Self-host option" }, + { key: "complimentaryFamiliesPlan", value: "Complimentary families plan" }, + ], + }, + secretsManager: { + type: "scalable", + annualPricePerUser: mockEnterprisePlan.SecretsManager!.seatPrice, + annualPricePerAdditionalServiceAccount: + mockEnterprisePlan.SecretsManager!.additionalPricePerServiceAccount, + features: [ + { key: "unlimitedUsers", value: "Unlimited users" }, + { + key: "includedMachineAccountsV2", + value: `${mockEnterprisePlan.SecretsManager!.baseServiceAccount} machine accounts included`, + }, + ], + }, + }); + + const customTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Custom, + ); + expect(customTier).toEqual({ + id: BusinessSubscriptionPricingTierIds.Custom, + name: "Custom", + description: "Custom plan description", + availableCadences: [], + passwordManager: { + type: "custom", + features: [ + { key: "strengthenCybersecurity", value: "Strengthen cybersecurity" }, + { key: "boostProductivity", value: "Boost productivity" }, + { key: "seamlessIntegration", value: "Seamless integration" }, + ], + }, + }); + + expect(i18nService.t).toHaveBeenCalledWith("planNameTeams"); + expect(i18nService.t).toHaveBeenCalledWith("teamsPlanUpgradeMessage"); + expect(i18nService.t).toHaveBeenCalledWith("planNameEnterprise"); + expect(i18nService.t).toHaveBeenCalledWith("planDescEnterpriseV2"); + expect(i18nService.t).toHaveBeenCalledWith("planNameCustom"); + expect(i18nService.t).toHaveBeenCalledWith("planDescCustom"); + expect(i18nService.t).toHaveBeenCalledWith("secureItemSharing"); + expect(i18nService.t).toHaveBeenCalledWith("eventLogMonitoring"); + expect(i18nService.t).toHaveBeenCalledWith("directoryIntegration"); + expect(i18nService.t).toHaveBeenCalledWith("scimSupport"); + expect(i18nService.t).toHaveBeenCalledWith("unlimitedSecretsAndProjects"); + expect(i18nService.t).toHaveBeenCalledWith("includedMachineAccountsV2", 20); + expect(i18nService.t).toHaveBeenCalledWith("enterpriseSecurityPolicies"); + expect(i18nService.t).toHaveBeenCalledWith("passwordLessSso"); + expect(i18nService.t).toHaveBeenCalledWith("accountRecovery"); + expect(i18nService.t).toHaveBeenCalledWith("selfHostOption"); + expect(i18nService.t).toHaveBeenCalledWith("complimentaryFamiliesPlan"); + expect(i18nService.t).toHaveBeenCalledWith("unlimitedUsers"); + expect(i18nService.t).toHaveBeenCalledWith("includedMachineAccountsV2", 50); + expect(i18nService.t).toHaveBeenCalledWith("strengthenCybersecurity"); + expect(i18nService.t).toHaveBeenCalledWith("boostProductivity"); + expect(i18nService.t).toHaveBeenCalledWith("seamlessIntegration"); + + done(); + }); + }); + + it("should handle API errors by logging and showing toast", (done) => { + const errorApiService = mock(); + const errorI18nService = mock(); + const errorLogService = mock(); + const errorToastService = mock(); + + const testError = new Error("API error"); + errorApiService.getPlans.mockRejectedValue(testError); + + errorI18nService.t.mockImplementation((key: string) => { + if (key === "unexpectedError") { + return "An unexpected error has occurred."; + } + return key; + }); + + const errorService = new SubscriptionPricingService( + errorApiService, + errorI18nService, + errorLogService, + errorToastService, + ); + + errorService.getBusinessSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + expect(tiers).toEqual([]); + expect(errorLogService.error).toHaveBeenCalledWith(testError); + expect(errorToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + + it("should contain correct pricing", (done) => { + service.getBusinessSubscriptionPricingTiers$().subscribe((tiers) => { + const teamsTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams, + )!; + const enterpriseTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise, + )!; + + const teamsPasswordManager = teamsTier.passwordManager as any; + const teamsSecretsManager = teamsTier.secretsManager as any; + expect(teamsPasswordManager.annualPricePerUser).toEqual( + mockTeamsPlan.PasswordManager.seatPrice, + ); + expect(teamsPasswordManager.annualPricePerAdditionalStorageGB).toEqual( + mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + ); + expect(teamsSecretsManager.annualPricePerUser).toEqual( + mockTeamsPlan.SecretsManager.seatPrice, + ); + expect(teamsSecretsManager.annualPricePerAdditionalServiceAccount).toEqual( + mockTeamsPlan.SecretsManager.additionalPricePerServiceAccount, + ); + + const enterprisePasswordManager = enterpriseTier.passwordManager as any; + const enterpriseSecretsManager = enterpriseTier.secretsManager as any; + expect(enterprisePasswordManager.annualPricePerUser).toEqual( + mockEnterprisePlan.PasswordManager.seatPrice, + ); + expect(enterprisePasswordManager.annualPricePerAdditionalStorageGB).toEqual( + mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + ); + expect(enterpriseSecretsManager.annualPricePerUser).toEqual( + mockEnterprisePlan.SecretsManager.seatPrice, + ); + expect(enterpriseSecretsManager.annualPricePerAdditionalServiceAccount).toEqual( + mockEnterprisePlan.SecretsManager.additionalPricePerServiceAccount, + ); + + done(); + }); + }); + + it("should not include secretsManager for Custom tier", (done) => { + service.getBusinessSubscriptionPricingTiers$().subscribe((tiers) => { + const customTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Custom, + )!; + expect(customTier.secretsManager).toBeUndefined(); + done(); + }); + }); + }); + + describe("getDeveloperSubscriptionPricingTiers$", () => { + it("should return Free, Teams, and Enterprise pricing tiers with correct structure", (done) => { + service.getDeveloperSubscriptionPricingTiers$().subscribe((tiers) => { + expect(tiers).toHaveLength(3); + + const freeTier = tiers.find((tier) => tier.id === BusinessSubscriptionPricingTierIds.Free); + expect(freeTier).toEqual({ + id: BusinessSubscriptionPricingTierIds.Free, + name: "Free", + description: "Free plan for 1 user", + availableCadences: [], + passwordManager: { + type: "free", + features: [ + { + key: "limitedUsersV2", + value: `Limited to ${mockFreePlan.PasswordManager.maxSeats} users`, + }, + { + key: "limitedCollectionsV2", + value: `Limited to ${mockFreePlan.PasswordManager.maxCollections} collections`, + }, + { key: "alwaysFree", value: "Always free" }, + ], + }, + secretsManager: { + type: "free", + features: [ + { key: "twoSecretsIncluded", value: "Two secrets included" }, + { + key: "projectsIncludedV2", + value: `${mockFreePlan.SecretsManager!.maxProjects} projects included`, + }, + ], + }, + }); + + const teamsTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams, + ); + expect(teamsTier).toEqual({ + id: BusinessSubscriptionPricingTierIds.Teams, + name: "Teams", + description: "Resilient protection for growing teams", + availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], + passwordManager: { + type: "scalable", + annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice, + annualPricePerAdditionalStorageGB: + mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + features: [ + { key: "secureItemSharing", value: "Secure item sharing" }, + { key: "eventLogMonitoring", value: "Event log monitoring" }, + { key: "directoryIntegration", value: "Directory integration" }, + { key: "scimSupport", value: "SCIM support" }, + ], + }, + secretsManager: { + type: "scalable", + annualPricePerUser: mockTeamsPlan.SecretsManager!.seatPrice, + annualPricePerAdditionalServiceAccount: + mockTeamsPlan.SecretsManager!.additionalPricePerServiceAccount, + features: [ + { key: "unlimitedSecretsAndProjects", value: "Unlimited secrets and projects" }, + { + key: "includedMachineAccountsV2", + value: `${mockTeamsPlan.SecretsManager!.baseServiceAccount} machine accounts included`, + }, + ], + }, + }); + + const enterpriseTier = tiers.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise, + ); + expect(enterpriseTier).toEqual({ + id: BusinessSubscriptionPricingTierIds.Enterprise, + name: "Enterprise", + description: "Enterprise plan description", + availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], + passwordManager: { + type: "scalable", + annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice, + annualPricePerAdditionalStorageGB: + mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + features: [ + { key: "enterpriseSecurityPolicies", value: "Enterprise security policies" }, + { key: "passwordLessSso", value: "Passwordless SSO" }, + { key: "accountRecovery", value: "Account recovery" }, + { key: "selfHostOption", value: "Self-host option" }, + { key: "complimentaryFamiliesPlan", value: "Complimentary families plan" }, + ], + }, + secretsManager: { + type: "scalable", + annualPricePerUser: mockEnterprisePlan.SecretsManager!.seatPrice, + annualPricePerAdditionalServiceAccount: + mockEnterprisePlan.SecretsManager!.additionalPricePerServiceAccount, + features: [ + { key: "unlimitedUsers", value: "Unlimited users" }, + { + key: "includedMachineAccountsV2", + value: `${mockEnterprisePlan.SecretsManager!.baseServiceAccount} machine accounts included`, + }, + ], + }, + }); + + expect(i18nService.t).toHaveBeenCalledWith("planNameFree"); + expect(i18nService.t).toHaveBeenCalledWith("planDescFreeV2", "1"); + expect(i18nService.t).toHaveBeenCalledWith( + "limitedUsersV2", + mockFreePlan.PasswordManager.maxSeats, + ); + expect(i18nService.t).toHaveBeenCalledWith( + "limitedCollectionsV2", + mockFreePlan.PasswordManager.maxCollections, + ); + expect(i18nService.t).toHaveBeenCalledWith("alwaysFree"); + expect(i18nService.t).toHaveBeenCalledWith("twoSecretsIncluded"); + expect(i18nService.t).toHaveBeenCalledWith( + "projectsIncludedV2", + mockFreePlan.SecretsManager!.maxProjects, + ); + expect(i18nService.t).toHaveBeenCalledWith("planNameTeams"); + expect(i18nService.t).toHaveBeenCalledWith("teamsPlanUpgradeMessage"); + expect(i18nService.t).toHaveBeenCalledWith("planNameEnterprise"); + expect(i18nService.t).toHaveBeenCalledWith("planDescEnterpriseV2"); + expect(i18nService.t).toHaveBeenCalledWith("secureItemSharing"); + expect(i18nService.t).toHaveBeenCalledWith("eventLogMonitoring"); + expect(i18nService.t).toHaveBeenCalledWith("directoryIntegration"); + expect(i18nService.t).toHaveBeenCalledWith("scimSupport"); + expect(i18nService.t).toHaveBeenCalledWith("unlimitedSecretsAndProjects"); + expect(i18nService.t).toHaveBeenCalledWith("includedMachineAccountsV2", 20); + expect(i18nService.t).toHaveBeenCalledWith("enterpriseSecurityPolicies"); + expect(i18nService.t).toHaveBeenCalledWith("passwordLessSso"); + expect(i18nService.t).toHaveBeenCalledWith("accountRecovery"); + expect(i18nService.t).toHaveBeenCalledWith("selfHostOption"); + expect(i18nService.t).toHaveBeenCalledWith("complimentaryFamiliesPlan"); + expect(i18nService.t).toHaveBeenCalledWith("unlimitedUsers"); + expect(i18nService.t).toHaveBeenCalledWith("includedMachineAccountsV2", 50); + + done(); + }); + }); + + it("should handle API errors by logging and showing toast", (done) => { + const errorApiService = mock(); + const errorI18nService = mock(); + const errorLogService = mock(); + const errorToastService = mock(); + + const testError = new Error("API error"); + errorApiService.getPlans.mockRejectedValue(testError); + + errorI18nService.t.mockImplementation((key: string) => { + if (key === "unexpectedError") { + return "An unexpected error has occurred."; + } + return key; + }); + + const errorService = new SubscriptionPricingService( + errorApiService, + errorI18nService, + errorLogService, + errorToastService, + ); + + errorService.getDeveloperSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + expect(tiers).toEqual([]); + expect(errorLogService.error).toHaveBeenCalledWith(testError); + expect(errorToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + }); + + describe("Observable behavior and caching", () => { + it("should share API response between multiple subscriptions", () => { + const getPlansResponse = jest.spyOn(apiService, "getPlans"); + + // Subscribe to multiple observables + service.getPersonalSubscriptionPricingTiers$().subscribe(); + service.getBusinessSubscriptionPricingTiers$().subscribe(); + service.getDeveloperSubscriptionPricingTiers$().subscribe(); + + // API should only be called once due to shareReplay + expect(getPlansResponse).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/apps/web/src/app/billing/services/subscription-pricing.service.ts new file mode 100644 index 00000000000..fad797bed51 --- /dev/null +++ b/apps/web/src/app/billing/services/subscription-pricing.service.ts @@ -0,0 +1,360 @@ +import { Injectable } from "@angular/core"; +import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs"; +import { catchError } from "rxjs/operators"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlanType } from "@bitwarden/common/billing/enums"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { BillingServicesModule } from "@bitwarden/web-vault/app/billing/services/billing-services.module"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierIds, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; + +@Injectable({ providedIn: BillingServicesModule }) +export class SubscriptionPricingService { + constructor( + private apiService: ApiService, + private i18nService: I18nService, + private logService: LogService, + private toastService: ToastService, + ) {} + + getPersonalSubscriptionPricingTiers$ = (): Observable => + combineLatest([this.premium$, this.families$]).pipe( + catchError((error: unknown) => { + this.logService.error(error); + this.showUnexpectedErrorToast(); + return of([]); + }), + ); + + getBusinessSubscriptionPricingTiers$ = (): Observable => + combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe( + catchError((error: unknown) => { + this.logService.error(error); + this.showUnexpectedErrorToast(); + return of([]); + }), + ); + + getDeveloperSubscriptionPricingTiers$ = (): Observable => + combineLatest([this.free$, this.teams$, this.enterprise$]).pipe( + catchError((error: unknown) => { + this.logService.error(error); + this.showUnexpectedErrorToast(); + return of([]); + }), + ); + + private plansResponse$: Observable> = from( + this.apiService.getPlans(), + ).pipe(shareReplay({ bufferSize: 1, refCount: false })); + + private premium$: Observable = of({ + // premium plan is not configured server-side so for now, hardcode it + basePrice: 10, + additionalStoragePricePerGb: 4, + }).pipe( + map((details) => ({ + id: PersonalSubscriptionPricingTierIds.Premium, + name: this.i18nService.t("premium"), + description: this.i18nService.t("planDescPremium"), + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: details.basePrice, + annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb, + features: [ + this.featureTranslations.builtInAuthenticator(), + this.featureTranslations.secureFileStorage(), + this.featureTranslations.emergencyAccess(), + this.featureTranslations.breachMonitoring(), + this.featureTranslations.andMoreFeatures(), + ], + }, + })), + ); + + private families$: Observable = this.plansResponse$.pipe( + map((plans) => { + const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!; + + return { + id: PersonalSubscriptionPricingTierIds.Families, + name: this.i18nService.t("planNameFamilies"), + description: this.i18nService.t("planDescFamiliesV2"), + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "packaged", + users: familiesPlan.PasswordManager.baseSeats, + annualPrice: familiesPlan.PasswordManager.basePrice, + annualPricePerAdditionalStorageGB: + familiesPlan.PasswordManager.additionalStoragePricePerGb, + features: [ + this.featureTranslations.premiumAccounts(), + this.featureTranslations.familiesUnlimitedSharing(), + this.featureTranslations.familiesUnlimitedCollections(), + this.featureTranslations.familiesSharedStorage(), + ], + }, + }; + }), + ); + + private free$: Observable = this.plansResponse$.pipe( + map((plans) => { + const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!; + + return { + id: BusinessSubscriptionPricingTierIds.Free, + name: this.i18nService.t("planNameFree"), + description: this.i18nService.t("planDescFreeV2", "1"), + availableCadences: [], + passwordManager: { + type: "free", + features: [ + this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats), + this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections), + this.featureTranslations.alwaysFree(), + ], + }, + secretsManager: { + type: "free", + features: [ + this.featureTranslations.twoSecretsIncluded(), + this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects), + ], + }, + }; + }), + ); + + private teams$: Observable = this.plansResponse$.pipe( + map((plans) => { + const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!; + + return { + id: BusinessSubscriptionPricingTierIds.Teams, + name: this.i18nService.t("planNameTeams"), + description: this.i18nService.t("teamsPlanUpgradeMessage"), + availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], + passwordManager: { + type: "scalable", + annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice, + annualPricePerAdditionalStorageGB: + annualTeamsPlan.PasswordManager.additionalStoragePricePerGb, + features: [ + this.featureTranslations.secureItemSharing(), + this.featureTranslations.eventLogMonitoring(), + this.featureTranslations.directoryIntegration(), + this.featureTranslations.scimSupport(), + ], + }, + secretsManager: { + type: "scalable", + annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice, + annualPricePerAdditionalServiceAccount: + annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount, + features: [ + this.featureTranslations.unlimitedSecretsAndProjects(), + this.featureTranslations.includedMachineAccountsV2( + annualTeamsPlan.SecretsManager.baseServiceAccount, + ), + ], + }, + }; + }), + ); + + private enterprise$: Observable = this.plansResponse$.pipe( + map((plans) => { + const annualEnterprisePlan = plans.data.find( + (plan) => plan.type === PlanType.EnterpriseAnnually, + )!; + + return { + id: BusinessSubscriptionPricingTierIds.Enterprise, + name: this.i18nService.t("planNameEnterprise"), + description: this.i18nService.t("planDescEnterpriseV2"), + availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], + passwordManager: { + type: "scalable", + annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice, + annualPricePerAdditionalStorageGB: + annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + features: [ + this.featureTranslations.enterpriseSecurityPolicies(), + this.featureTranslations.passwordLessSso(), + this.featureTranslations.accountRecovery(), + this.featureTranslations.selfHostOption(), + this.featureTranslations.complimentaryFamiliesPlan(), + ], + }, + secretsManager: { + type: "scalable", + annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice, + annualPricePerAdditionalServiceAccount: + annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount, + features: [ + this.featureTranslations.unlimitedUsers(), + this.featureTranslations.includedMachineAccountsV2( + annualEnterprisePlan.SecretsManager.baseServiceAccount, + ), + ], + }, + }; + }), + ); + + private custom$: Observable = this.plansResponse$.pipe( + map(() => ({ + id: BusinessSubscriptionPricingTierIds.Custom, + name: this.i18nService.t("planNameCustom"), + description: this.i18nService.t("planDescCustom"), + availableCadences: [], + passwordManager: { + type: "custom", + features: [ + this.featureTranslations.strengthenCybersecurity(), + this.featureTranslations.boostProductivity(), + this.featureTranslations.seamlessIntegration(), + ], + }, + })), + ); + + private showUnexpectedErrorToast() { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + } + + private featureTranslations = { + builtInAuthenticator: () => ({ + key: "builtInAuthenticator", + value: this.i18nService.t("builtInAuthenticator"), + }), + emergencyAccess: () => ({ + key: "emergencyAccess", + value: this.i18nService.t("emergencyAccess"), + }), + breachMonitoring: () => ({ + key: "breachMonitoring", + value: this.i18nService.t("breachMonitoring"), + }), + andMoreFeatures: () => ({ + key: "andMoreFeatures", + value: this.i18nService.t("andMoreFeatures"), + }), + premiumAccounts: () => ({ + key: "premiumAccounts", + value: this.i18nService.t("premiumAccounts"), + }), + secureFileStorage: () => ({ + key: "secureFileStorage", + value: this.i18nService.t("secureFileStorage"), + }), + familiesUnlimitedSharing: () => ({ + key: "familiesUnlimitedSharing", + value: this.i18nService.t("familiesUnlimitedSharing"), + }), + familiesUnlimitedCollections: () => ({ + key: "familiesUnlimitedCollections", + value: this.i18nService.t("familiesUnlimitedCollections"), + }), + familiesSharedStorage: () => ({ + key: "familiesSharedStorage", + value: this.i18nService.t("familiesSharedStorage"), + }), + limitedUsersV2: (users: number) => ({ + key: "limitedUsersV2", + value: this.i18nService.t("limitedUsersV2", users), + }), + limitedCollectionsV2: (collections: number) => ({ + key: "limitedCollectionsV2", + value: this.i18nService.t("limitedCollectionsV2", collections), + }), + alwaysFree: () => ({ + key: "alwaysFree", + value: this.i18nService.t("alwaysFree"), + }), + twoSecretsIncluded: () => ({ + key: "twoSecretsIncluded", + value: this.i18nService.t("twoSecretsIncluded"), + }), + projectsIncludedV2: (projects: number) => ({ + key: "projectsIncludedV2", + value: this.i18nService.t("projectsIncludedV2", projects), + }), + secureItemSharing: () => ({ + key: "secureItemSharing", + value: this.i18nService.t("secureItemSharing"), + }), + eventLogMonitoring: () => ({ + key: "eventLogMonitoring", + value: this.i18nService.t("eventLogMonitoring"), + }), + directoryIntegration: () => ({ + key: "directoryIntegration", + value: this.i18nService.t("directoryIntegration"), + }), + scimSupport: () => ({ + key: "scimSupport", + value: this.i18nService.t("scimSupport"), + }), + unlimitedSecretsAndProjects: () => ({ + key: "unlimitedSecretsAndProjects", + value: this.i18nService.t("unlimitedSecretsAndProjects"), + }), + includedMachineAccountsV2: (included: number) => ({ + key: "includedMachineAccountsV2", + value: this.i18nService.t("includedMachineAccountsV2", included), + }), + enterpriseSecurityPolicies: () => ({ + key: "enterpriseSecurityPolicies", + value: this.i18nService.t("enterpriseSecurityPolicies"), + }), + passwordLessSso: () => ({ + key: "passwordLessSso", + value: this.i18nService.t("passwordLessSso"), + }), + accountRecovery: () => ({ + key: "accountRecovery", + value: this.i18nService.t("accountRecovery"), + }), + selfHostOption: () => ({ + key: "selfHostOption", + value: this.i18nService.t("selfHostOption"), + }), + complimentaryFamiliesPlan: () => ({ + key: "complimentaryFamiliesPlan", + value: this.i18nService.t("complimentaryFamiliesPlan"), + }), + unlimitedUsers: () => ({ + key: "unlimitedUsers", + value: this.i18nService.t("unlimitedUsers"), + }), + strengthenCybersecurity: () => ({ + key: "strengthenCybersecurity", + value: this.i18nService.t("strengthenCybersecurity"), + }), + boostProductivity: () => ({ + key: "boostProductivity", + value: this.i18nService.t("boostProductivity"), + }), + seamlessIntegration: () => ({ + key: "seamlessIntegration", + value: this.i18nService.t("seamlessIntegration"), + }), + }; +} diff --git a/apps/web/src/app/billing/types/subscription-pricing-tier.ts b/apps/web/src/app/billing/types/subscription-pricing-tier.ts new file mode 100644 index 00000000000..8febc4b86db --- /dev/null +++ b/apps/web/src/app/billing/types/subscription-pricing-tier.ts @@ -0,0 +1,85 @@ +export const PersonalSubscriptionPricingTierIds = { + Premium: "premium", + Families: "families", +} as const; + +export const BusinessSubscriptionPricingTierIds = { + Free: "free", + Teams: "teams", + Enterprise: "enterprise", + Custom: "custom", +} as const; + +export const SubscriptionCadenceIds = { + Annually: "annually", + Monthly: "monthly", +} as const; + +export type PersonalSubscriptionPricingTierId = + (typeof PersonalSubscriptionPricingTierIds)[keyof typeof PersonalSubscriptionPricingTierIds]; +export type BusinessSubscriptionPricingTierId = + (typeof BusinessSubscriptionPricingTierIds)[keyof typeof BusinessSubscriptionPricingTierIds]; +export type SubscriptionCadence = + (typeof SubscriptionCadenceIds)[keyof typeof SubscriptionCadenceIds]; + +type HasFeatures = { + features: { key: string; value: string }[]; +}; + +type HasAdditionalStorage = { + annualPricePerAdditionalStorageGB: number; +}; + +type StandalonePasswordManager = HasFeatures & + HasAdditionalStorage & { + type: "standalone"; + annualPrice: number; + }; + +type PackagedPasswordManager = HasFeatures & + HasAdditionalStorage & { + type: "packaged"; + users: number; + annualPrice: number; + }; + +type FreePasswordManager = HasFeatures & { + type: "free"; +}; + +type CustomPasswordManager = HasFeatures & { + type: "custom"; +}; + +type ScalablePasswordManager = HasFeatures & + HasAdditionalStorage & { + type: "scalable"; + annualPricePerUser: number; + }; + +type FreeSecretsManager = HasFeatures & { + type: "free"; +}; + +type ScalableSecretsManager = HasFeatures & { + type: "scalable"; + annualPricePerUser: number; + annualPricePerAdditionalServiceAccount: number; +}; + +export type PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierId; + name: string; + description: string; + availableCadences: Omit[]; // personal plans are only ever annual + passwordManager: StandalonePasswordManager | PackagedPasswordManager; +}; + +export type BusinessSubscriptionPricingTier = { + id: BusinessSubscriptionPricingTierId; + name: string; + description: string; + availableCadences: SubscriptionCadence[]; + passwordManager: FreePasswordManager | ScalablePasswordManager | CustomPasswordManager; + secretsManager?: FreeSecretsManager | ScalableSecretsManager; +}; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1d41534447a..11d2d8e3dd8 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11383,5 +11383,116 @@ }, "familiesMembership": { "message": "Families membership" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "planDescFamiliesV2": { + "message": "Premium security for your family" + }, + "planDescFreeV2": { + "message": "Share with $COUNT$ other user", + "placeholders": { + "count": { + "content": "$1", + "example": "1" + } + } + }, + "planDescEnterpriseV2": { + "message": "Advanced capabilities for any organization" + }, + "planNameCustom": { + "message": "Custom plan" + }, + "planDescCustom": { + "message": "Bitwarden scales with businesses of all sizes to secure passwords and sensitive information. If you're part of a large enterprise, contact sales to request a quote." + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "familiesUnlimitedSharing": { + "message": "Unlimited sharing - choose who sees what" + }, + "familiesUnlimitedCollections": { + "message": "Unlimited family collections" + }, + "familiesSharedStorage": { + "message": "Shared storage for important family info" + }, + "limitedUsersV2": { + "message": "Up to $COUNT$ members", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, + "limitedCollectionsV2": { + "message": "Up to $COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, + "alwaysFree": { + "message": "Always free" + }, + "twoSecretsIncluded": { + "message": "2 secrets" + }, + "projectsIncludedV2": { + "message": "$COUNT$ project(s)", + "placeholders": { + "count": { + "content": "$1", + "example": "1" + } + } + }, + "secureItemSharing": { + "message": "Secure item sharing" + }, + "scimSupport": { + "message": "SCIM support" + }, + "includedMachineAccountsV2": { + "message": "$COUNT$ machine accounts", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "enterpriseSecurityPolicies": { + "message": "Enterprise security policies" + }, + "selfHostOption": { + "message": "Self-host option" + }, + "complimentaryFamiliesPlan": { + "message": "Complimentary families plan for all users" + }, + "strengthenCybersecurity": { + "message": "Strengthen cybersecurity" + }, + "boostProductivity": { + "message": "Boost productivity" + }, + "seamlessIntegration": { + "message": "Seamless integration" } }