1
0
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:
Kyle Denney
2025-09-22 15:01:46 -05:00
committed by GitHub
parent f3c50e159f
commit c796e9514e
4 changed files with 1443 additions and 0 deletions

View File

@@ -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);
});
});
});

View File

@@ -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"),
}),
};
}

View 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;
};

View File

@@ -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"
}
}