mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[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
This commit is contained in:
@@ -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<ApiService>;
|
||||||
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
let logService: MockProxy<LogService>;
|
||||||
|
let toastService: MockProxy<ToastService>;
|
||||||
|
|
||||||
|
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<I18nService>();
|
||||||
|
logService = mock<LogService>();
|
||||||
|
toastService = mock<ToastService>();
|
||||||
|
|
||||||
|
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>();
|
||||||
|
|
||||||
|
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<ApiService>();
|
||||||
|
const errorI18nService = mock<I18nService>();
|
||||||
|
const errorLogService = mock<LogService>();
|
||||||
|
const errorToastService = mock<ToastService>();
|
||||||
|
|
||||||
|
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<ApiService>();
|
||||||
|
const errorI18nService = mock<I18nService>();
|
||||||
|
const errorLogService = mock<LogService>();
|
||||||
|
const errorToastService = mock<ToastService>();
|
||||||
|
|
||||||
|
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<ApiService>();
|
||||||
|
const errorI18nService = mock<I18nService>();
|
||||||
|
const errorLogService = mock<LogService>();
|
||||||
|
const errorToastService = mock<ToastService>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PersonalSubscriptionPricingTier[]> =>
|
||||||
|
combineLatest([this.premium$, this.families$]).pipe(
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
this.logService.error(error);
|
||||||
|
this.showUnexpectedErrorToast();
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||||
|
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
this.logService.error(error);
|
||||||
|
this.showUnexpectedErrorToast();
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||||
|
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
this.logService.error(error);
|
||||||
|
this.showUnexpectedErrorToast();
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||||
|
this.apiService.getPlans(),
|
||||||
|
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||||
|
|
||||||
|
private premium$: Observable<PersonalSubscriptionPricingTier> = 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<PersonalSubscriptionPricingTier> = 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<BusinessSubscriptionPricingTier> = 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<BusinessSubscriptionPricingTier> = 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<BusinessSubscriptionPricingTier> = 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<BusinessSubscriptionPricingTier> = 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"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
85
apps/web/src/app/billing/types/subscription-pricing-tier.ts
Normal file
85
apps/web/src/app/billing/types/subscription-pricing-tier.ts
Normal file
@@ -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<SubscriptionCadence, "monthly">[]; // 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;
|
||||||
|
};
|
||||||
@@ -11383,5 +11383,116 @@
|
|||||||
},
|
},
|
||||||
"familiesMembership": {
|
"familiesMembership": {
|
||||||
"message": "Families membership"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user