mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 02:03:39 +00:00
[PM-29138] fix defect with pricing service on self host (#17819)
* [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
This commit is contained in:
@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
const mockFamiliesPlan = {
|
||||
type: PlanType.FamiliesAnnually2025,
|
||||
@@ -328,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setupEnvironmentService = (
|
||||
envService: MockProxy<EnvironmentService>,
|
||||
region: Region = Region.US,
|
||||
) => {
|
||||
envService.environment$ = of({
|
||||
getRegion: () => region,
|
||||
isCloud: () => region !== Region.SelfHosted,
|
||||
} as any);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||
setupEnvironmentService(environmentService);
|
||||
|
||||
service = new DefaultSubscriptionPricingService(
|
||||
billingApiService,
|
||||
configService,
|
||||
i18nService,
|
||||
logService,
|
||||
environmentService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
|
||||
@@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
|
||||
@@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("Premium plan API error");
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -914,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan API response", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response missing the Seat property
|
||||
const malformedResponse = {
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan with invalid price types", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response with price as string instead of number
|
||||
const malformedResponse = {
|
||||
Seat: {
|
||||
StripePriceId: "price_seat",
|
||||
Price: "10", // Should be a number
|
||||
},
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Observable behavior and caching", () => {
|
||||
@@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to avoid conflicts with beforeEach setup
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
|
||||
|
||||
@@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe to the premium pricing tier multiple times
|
||||
@@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to test from scratch
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue({
|
||||
@@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
storage: { price: 999 },
|
||||
} as PremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
// Create a new service instance with the feature flag disabled
|
||||
const newService = new DefaultSubscriptionPricingService(
|
||||
@@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe with feature flag disabled
|
||||
@@ -1071,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Self-hosted environment behavior", () => {
|
||||
it("should not call API for self-hosted environment", () => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
|
||||
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
// Trigger subscriptions by calling the methods
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
|
||||
|
||||
// API should not be called for self-hosted environments
|
||||
expect(getPlansSpy).not.toHaveBeenCalled();
|
||||
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
|
||||
expect(tiers).toHaveLength(2); // Premium and Families
|
||||
|
||||
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
|
||||
expect(premiumTier).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.features).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
|
||||
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";
|
||||
|
||||
@@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
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.
|
||||
*/
|
||||
@@ -66,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -80,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
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> = 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 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)
|
||||
@@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
provided: premiumPlan.storage.provided,
|
||||
seat: premiumPlan.seat?.price,
|
||||
storage: premiumPlan.storage?.price,
|
||||
provided: premiumPlan.storage?.provided,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
@@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.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),
|
||||
)!;
|
||||
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(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
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.plansResponse$.pipe(
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
@@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(
|
||||
freePlan?.PasswordManager?.maxCollections,
|
||||
),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
@@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
|
||||
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)!;
|
||||
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.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,
|
||||
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.plansResponse$.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(),
|
||||
],
|
||||
},
|
||||
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: () => ({
|
||||
@@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
limitedUsersV2: (users?: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
limitedCollectionsV2: (collections?: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
@@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
projectsIncludedV2: (projects?: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
@@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
includedMachineAccountsV2: (included?: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
|
||||
@@ -27,26 +27,26 @@ type HasFeatures = {
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
annualPricePerAdditionalStorageGB?: number;
|
||||
};
|
||||
|
||||
type HasProvidedStorage = {
|
||||
providedStorageGB: number;
|
||||
providedStorageGB?: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage &
|
||||
HasProvidedStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
users?: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
@@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerUser?: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
@@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
annualPricePerUser?: number;
|
||||
annualPricePerAdditionalServiceAccount?: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
|
||||
Reference in New Issue
Block a user