mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 18:33:50 +00:00
* [PM-29138] fix defect with pricing service on self host * use iscloud instead of manually checking region * fixing strict compile issues * spacing updates from design review * final spacing edits * pr feedback * typechecking
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
import {
|
|
combineLatest,
|
|
combineLatestWith,
|
|
from,
|
|
map,
|
|
Observable,
|
|
of,
|
|
shareReplay,
|
|
switchMap,
|
|
take,
|
|
throwError,
|
|
} from "rxjs";
|
|
import { catchError } from "rxjs/operators";
|
|
|
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
|
import { PlanType } from "@bitwarden/common/billing/enums";
|
|
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
|
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
|
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
import { LogService } from "@bitwarden/logging";
|
|
|
|
import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction";
|
|
import {
|
|
BusinessSubscriptionPricingTier,
|
|
BusinessSubscriptionPricingTierIds,
|
|
PersonalSubscriptionPricingTier,
|
|
PersonalSubscriptionPricingTierIds,
|
|
SubscriptionCadenceIds,
|
|
} from "../types/subscription-pricing-tier";
|
|
|
|
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
|
|
/**
|
|
* Fallback premium pricing used when the feature flag is disabled.
|
|
* These values represent the legacy pricing model and will not reflect
|
|
* server-side price changes. They are retained for backward compatibility
|
|
* during the feature flag rollout period.
|
|
*/
|
|
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
|
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
|
private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1;
|
|
|
|
constructor(
|
|
private billingApiService: BillingApiServiceAbstraction,
|
|
private configService: ConfigService,
|
|
private i18nService: I18nService,
|
|
private logService: LogService,
|
|
private environmentService: EnvironmentService,
|
|
) {}
|
|
|
|
/**
|
|
* Gets personal subscription pricing tiers (Premium and Families).
|
|
* Throws any errors that occur during api request so callers must handle errors.
|
|
* Pricing information will be undefined if current environment is self-hosted.
|
|
* @returns An observable of an array of personal subscription pricing tiers.
|
|
* @throws Error if any errors occur during api request.
|
|
*/
|
|
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
|
|
combineLatest([this.premium$, this.families$]).pipe(
|
|
catchError((error: unknown) => {
|
|
this.logService.error("Failed to load personal subscription pricing tiers", error);
|
|
return throwError(() => error);
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
|
* Throws any errors that occur during api request so callers must handle errors.
|
|
* Pricing information will be undefined if current environment is self-hosted.
|
|
* @returns An observable of an array of business subscription pricing tiers.
|
|
* @throws Error if any errors occur during api request.
|
|
*/
|
|
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
|
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
|
|
catchError((error: unknown) => {
|
|
this.logService.error("Failed to load business subscription pricing tiers", error);
|
|
return throwError(() => error);
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
|
* Throws any errors that occur during api request so callers must handle errors.
|
|
* Pricing information will be undefined if current environment is self-hosted.
|
|
* @returns An observable of an array of business subscription pricing tiers for developers.
|
|
* @throws Error if any errors occur during api request.
|
|
*/
|
|
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
|
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
|
|
catchError((error: unknown) => {
|
|
this.logService.error("Failed to load developer subscription pricing tiers", error);
|
|
return throwError(() => error);
|
|
}),
|
|
);
|
|
|
|
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
|
|
this.environmentService.environment$.pipe(
|
|
take(1),
|
|
switchMap((environment) =>
|
|
!environment.isCloud()
|
|
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
|
|
: from(this.billingApiService.getPlans()),
|
|
),
|
|
shareReplay({ bufferSize: 1, refCount: false }),
|
|
);
|
|
|
|
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
|
|
this.environmentService.environment$.pipe(
|
|
take(1),
|
|
switchMap((environment) =>
|
|
!environment.isCloud()
|
|
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
|
|
: from(this.billingApiService.getPremiumPlan()).pipe(
|
|
catchError((error: unknown) => {
|
|
this.logService.error("Failed to fetch premium plan from API", error);
|
|
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
|
}),
|
|
),
|
|
),
|
|
shareReplay({ bufferSize: 1, refCount: false }),
|
|
);
|
|
|
|
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
|
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
|
.pipe(
|
|
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
|
|
switchMap((fetchPremiumFromPricingService) =>
|
|
fetchPremiumFromPricingService
|
|
? this.premiumPlanResponse$.pipe(
|
|
map((premiumPlan) => ({
|
|
seat: premiumPlan.seat?.price,
|
|
storage: premiumPlan.storage?.price,
|
|
provided: premiumPlan.storage?.provided,
|
|
})),
|
|
)
|
|
: of({
|
|
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
|
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
|
provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB,
|
|
}),
|
|
),
|
|
map((premiumPrices) => ({
|
|
id: PersonalSubscriptionPricingTierIds.Premium,
|
|
name: this.i18nService.t("premium"),
|
|
description: this.i18nService.t("advancedOnlineSecurity"),
|
|
availableCadences: [SubscriptionCadenceIds.Annually],
|
|
passwordManager: {
|
|
type: "standalone",
|
|
annualPrice: premiumPrices.seat,
|
|
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
|
providedStorageGB: premiumPrices.provided,
|
|
features: [
|
|
this.featureTranslations.builtInAuthenticator(),
|
|
this.featureTranslations.secureFileStorage(),
|
|
this.featureTranslations.emergencyAccess(),
|
|
this.featureTranslations.breachMonitoring(),
|
|
this.featureTranslations.andMoreFeatures(),
|
|
],
|
|
},
|
|
})),
|
|
);
|
|
|
|
private families$: Observable<PersonalSubscriptionPricingTier> =
|
|
this.organizationPlansResponse$.pipe(
|
|
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
|
map(([plans, milestone3FeatureEnabled]) => {
|
|
const familiesPlan = plans.data.find(
|
|
(plan) =>
|
|
plan.type ===
|
|
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
|
);
|
|
|
|
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,
|
|
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
|
|
features: [
|
|
this.featureTranslations.premiumAccounts(),
|
|
this.featureTranslations.familiesUnlimitedSharing(),
|
|
this.featureTranslations.familiesUnlimitedCollections(),
|
|
this.featureTranslations.familiesSharedStorage(),
|
|
],
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
|
|
map((plans): BusinessSubscriptionPricingTier => {
|
|
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.organizationPlansResponse$.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,
|
|
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
|
|
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.organizationPlansResponse$.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,
|
|
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
|
|
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.organizationPlansResponse$.pipe(
|
|
map(
|
|
(): BusinessSubscriptionPricingTier => ({
|
|
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 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"),
|
|
}),
|
|
};
|
|
}
|