mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23: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": {
|
||||
"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