1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +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:
Kyle Denney
2025-12-08 19:24:37 -06:00
committed by GitHub
parent 4c56a9693c
commit dfe2e283a0
12 changed files with 406 additions and 292 deletions

View File

@@ -157,7 +157,7 @@ export class CloudHostedPremiumVNextComponent {
return { return {
tier, tier,
price: price:
tier?.passwordManager.type === "standalone" tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) ? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0, : 0,
features: tier?.passwordManager.features.map((f) => f.value) || [], features: tier?.passwordManager.features.map((f) => f.value) || [],
@@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent {
return { return {
tier, tier,
price: price:
tier?.passwordManager.type === "packaged" tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) ? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0, : 0,
features: tier?.passwordManager.features.map((f) => f.value) || [], features: tier?.passwordManager.features.map((f) => f.value) || [],

View File

@@ -1,15 +1,15 @@
import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { Component, computed, DestroyRef, input, OnInit, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { catchError, of } from "rxjs"; import { catchError, of } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import { import {
PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds, PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds, SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier"; } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -32,14 +32,6 @@ export type UpgradeAccountResult = {
plan: PersonalSubscriptionPricingTierId | null; plan: PersonalSubscriptionPricingTierId | null;
}; };
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType };
features: string[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
@@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId>(); planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>(); closeClicked = output<UpgradeAccountStatus>();
protected readonly loading = signal(true); protected readonly loading = signal(true);
protected premiumCardDetails!: CardDetails; protected premiumCardDetails!: SubscriptionPricingCardDetails;
protected familiesCardDetails!: CardDetails; protected familiesCardDetails!: SubscriptionPricingCardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
@@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit {
private createCardDetails( private createCardDetails(
tier: PersonalSubscriptionPricingTier, tier: PersonalSubscriptionPricingTier,
buttonType: ButtonType, buttonType: ButtonType,
): CardDetails { ): SubscriptionPricingCardDetails {
return { return {
title: tier.name, title: tier.name,
tagline: tier.description, tagline: tier.description,
price: { price: tier.passwordManager.annualPrice
amount: tier.passwordManager.annualPrice / 12, ? {
cadence: SubscriptionCadenceIds.Monthly, amount: tier.passwordManager.annualPrice / 12,
}, cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: { button: {
text: this.i18nService.t( text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium", this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",

View File

@@ -200,7 +200,8 @@ export class UpgradePaymentService {
} }
private getPasswordManagerSeats(planDetails: PlanDetails): number { private getPasswordManagerSeats(planDetails: PlanDetails): number {
return "users" in planDetails.details.passwordManager return "users" in planDetails.details.passwordManager &&
planDetails.details.passwordManager.users
? planDetails.details.passwordManager.users ? planDetails.details.passwordManager.users
: 0; : 0;
} }

View File

@@ -20,33 +20,35 @@
<div <div
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md" class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
> >
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2"> <div class="tw-flex tw-items-center tw-justify-between">
<h3 slot="title" class="tw-m-0" bitTypography="h3"> <h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ "upgradeToPremium" | i18n }} {{ "upgradeToPremium" | i18n }}
</h3> </h3>
</div> </div>
<!-- Tagline with consistent height (exactly 2 lines) --> <!-- Tagline with consistent height (exactly 2 lines) -->
<div class="tw-mb-6 tw-h-6"> <div class="tw-h-6">
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2"> <p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
{{ cardDetails.tagline }} {{ cardDetails.tagline }}
</p> </p>
</div> </div>
<!-- Price Section --> <!-- Price Section -->
<div class="tw-mb-6"> @if (cardDetails.price) {
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap"> <div class="tw-mt-5">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{ <div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
cardDetails.price.amount | currency: "$" <span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
}}</span> cardDetails.price.amount | currency: "$"
<span bitTypography="helper" class="tw-text-muted"> }}</span>
/ {{ cardDetails.price.cadence | i18n }} <span bitTypography="helper" class="tw-text-muted">
</span> / {{ cardDetails.price.cadence | i18n }}
</span>
</div>
</div> </div>
</div> }
<!-- Button space (always reserved) --> <!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12"> <div class="tw-my-5 tw-h-12">
<button <button
bitButton bitButton
[buttonType]="cardDetails.button.type" [buttonType]="cardDetails.button.type"

View File

@@ -206,4 +206,39 @@ describe("PremiumUpgradeDialogComponent", () => {
}); });
}); });
}); });
describe("self-hosted environment", () => {
it("should handle null price data for self-hosted environment", async () => {
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: undefined as any, // self-host will have these prices empty
annualPricePerAdditionalStorageGB: undefined as any,
providedStorageGB: undefined as any,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
};
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([selfHostedPremiumTier]),
);
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const selfHostedComponent = selfHostedFixture.componentInstance;
selfHostedFixture.detectChanges();
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.price).toBeUndefined();
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
});
});
}); });

View File

@@ -42,6 +42,23 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
}, },
}; };
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Complete online security",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
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!" },
],
},
};
export default { export default {
title: "Billing/Premium Upgrade Dialog", title: "Billing/Premium Upgrade Dialog",
component: PremiumUpgradeDialogComponent, component: PremiumUpgradeDialogComponent,
@@ -86,11 +103,11 @@ export default {
t: (key: string) => { t: (key: string) => {
switch (key) { switch (key) {
case "upgradeNow": case "upgradeNow":
return "Upgrade Now"; return "Upgrade now";
case "month": case "month":
return "month"; return "month";
case "upgradeToPremium": case "upgradeToPremium":
return "Upgrade To Premium"; return "Upgrade to Premium";
default: default:
return key; return key;
} }
@@ -116,3 +133,18 @@ export default {
type Story = StoryObj<PremiumUpgradeDialogComponent>; type Story = StoryObj<PremiumUpgradeDialogComponent>;
export const Default: Story = {}; export const Default: Story = {};
export const NoPricingData: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: SubscriptionPricingServiceAbstraction,
useValue: {
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
},
},
],
}),
],
};

View File

@@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ChangeDetectionStrategy, Component } from "@angular/core";
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs"; import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import { import {
PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds, PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds, SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier"; } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { import {
ButtonModule, ButtonModule,
ButtonType,
CenterPositionStrategy, CenterPositionStrategy,
DialogModule, DialogModule,
DialogRef, DialogRef,
@@ -27,14 +26,6 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};
@Component({ @Component({
selector: "billing-premium-upgrade-dialog", selector: "billing-premium-upgrade-dialog",
standalone: true, standalone: true,
@@ -51,9 +42,8 @@ type CardDetails = {
templateUrl: "./premium-upgrade-dialog.component.html", templateUrl: "./premium-upgrade-dialog.component.html",
}) })
export class PremiumUpgradeDialogComponent { export class PremiumUpgradeDialogComponent {
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
.getPersonalSubscriptionPricingTiers$() this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
.pipe(
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)), map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => { catchError((error: unknown) => {
@@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
this.dialogRef.close(); this.dialogRef.close();
} }
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails { private mapPremiumTierToCardDetails(
tier: PersonalSubscriptionPricingTier,
): SubscriptionPricingCardDetails {
return { return {
title: tier.name, title: tier.name,
tagline: tier.description, tagline: tier.description,
price: { price: tier.passwordManager.annualPrice
amount: tier.passwordManager.annualPrice / 12, ? {
cadence: SubscriptionCadenceIds.Monthly, amount: tier.passwordManager.annualPrice / 12,
}, cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: { button: {
text: this.i18nService.t("upgradeNow"), text: this.i18nService.t("upgradeNow"),
type: "primary", type: "primary",

View File

@@ -0,0 +1,10 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};

View File

@@ -1498,7 +1498,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: SubscriptionPricingServiceAbstraction, provide: SubscriptionPricingServiceAbstraction,
useClass: DefaultSubscriptionPricingService, useClass: DefaultSubscriptionPricingService,
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService], deps: [
BillingApiServiceAbstraction,
ConfigService,
I18nServiceAbstraction,
LogService,
EnvironmentService,
],
}), }),
safeProvider({ safeProvider({
provide: OrganizationManagementPreferencesService, provide: OrganizationManagementPreferencesService,

View File

@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
let configService: MockProxy<ConfigService>; let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>; let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let environmentService: MockProxy<EnvironmentService>;
const mockFamiliesPlan = { const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually2025, 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(() => { beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>(); billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>(); configService = mock<ConfigService>();
environmentService = mock<EnvironmentService>();
billingApiService.getPlans.mockResolvedValue(mockPlansResponse); billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value) configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
setupEnvironmentService(environmentService);
service = new DefaultSubscriptionPricingService( service = new DefaultSubscriptionPricingService(
billingApiService, billingApiService,
configService, configService,
i18nService, i18nService,
logService, logService,
environmentService,
); );
}); });
@@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key); errorI18nService.t.mockImplementation((key: string) => key);
@@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService, errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorEnvironmentService,
); );
errorService.getPersonalSubscriptionPricingTiers$().subscribe({ errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key); errorI18nService.t.mockImplementation((key: string) => key);
@@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService, errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorEnvironmentService,
); );
errorService.getBusinessSubscriptionPricingTiers$().subscribe({ errorService.getBusinessSubscriptionPricingTiers$().subscribe({
@@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key); errorI18nService.t.mockImplementation((key: string) => key);
@@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService, errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorEnvironmentService,
); );
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({ errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
@@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => { it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>(); const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("Premium plan API error"); const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
setupEnvironmentService(errorEnvironmentService);
const errorService = new DefaultSubscriptionPricingService( const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService, errorBillingApiService,
errorConfigService, errorConfigService,
i18nService, i18nService,
logService, logService,
errorEnvironmentService,
); );
errorService.getPersonalSubscriptionPricingTiers$().subscribe({ 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", () => { describe("Observable behavior and caching", () => {
@@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to avoid conflicts with beforeEach setup // Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>(); const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>(); const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true)); newConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(newEnvironmentService);
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan"); const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
@@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService, newConfigService,
i18nService, i18nService,
logService, logService,
newEnvironmentService,
); );
// Subscribe to the premium pricing tier multiple times // Subscribe to the premium pricing tier multiple times
@@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to test from scratch // Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>(); const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>(); const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({ newBillingApiService.getPremiumPlan.mockResolvedValue({
@@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
storage: { price: 999 }, storage: { price: 999 },
} as PremiumPlanResponse); } as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false)); newConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(newEnvironmentService);
// Create a new service instance with the feature flag disabled // Create a new service instance with the feature flag disabled
const newService = new DefaultSubscriptionPricingService( const newService = new DefaultSubscriptionPricingService(
@@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService, newConfigService,
i18nService, i18nService,
logService, logService,
newEnvironmentService,
); );
// Subscribe with feature flag disabled // 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();
});
});
});
}); });

View File

@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
private configService: ConfigService, private configService: ConfigService,
private i18nService: I18nService, private i18nService: I18nService,
private logService: LogService, private logService: LogService,
private environmentService: EnvironmentService,
) {} ) {}
/** /**
* Gets personal subscription pricing tiers (Premium and Families). * Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors. * 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. * @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request. * @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). * Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors. * 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. * @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request. * @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). * Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors. * 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. * @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request. * @throws Error if any errors occur during api request.
*/ */
@@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
}), }),
); );
private plansResponse$: Observable<ListResponse<PlanResponse>> = from( private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
this.billingApiService.getPlans(), this.environmentService.environment$.pipe(
).pipe(shareReplay({ bufferSize: 1, refCount: false })); 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( private premiumPlanResponse$: Observable<PremiumPlanResponse> =
this.billingApiService.getPremiumPlan(), this.environmentService.environment$.pipe(
).pipe( take(1),
catchError((error: unknown) => { switchMap((environment) =>
this.logService.error("Failed to fetch premium plan from API", error); !environment.isCloud()
return throwError(() => error); // Re-throw to propagate to higher-level error handler ? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
}), : from(this.billingApiService.getPremiumPlan()).pipe(
shareReplay({ bufferSize: 1, refCount: false }), 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 private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService) .getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
@@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
fetchPremiumFromPricingService fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe( ? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({ map((premiumPlan) => ({
seat: premiumPlan.seat.price, seat: premiumPlan.seat?.price,
storage: premiumPlan.storage.price, storage: premiumPlan.storage?.price,
provided: premiumPlan.storage.provided, provided: premiumPlan.storage?.provided,
})), })),
) )
: of({ : of({
@@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
})), })),
); );
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe( private families$: Observable<PersonalSubscriptionPricingTier> =
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)), this.organizationPlansResponse$.pipe(
map(([plans, milestone3FeatureEnabled]) => { combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
const familiesPlan = plans.data.find( map(([plans, milestone3FeatureEnabled]) => {
(plan) => const familiesPlan = plans.data.find(
plan.type === (plan) =>
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025), plan.type ===
)!; (milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
);
return { return {
id: PersonalSubscriptionPricingTierIds.Families, id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"), name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"), description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually], availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: { passwordManager: {
type: "packaged", type: "packaged",
users: familiesPlan.PasswordManager.baseSeats, users: familiesPlan?.PasswordManager?.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice, annualPrice: familiesPlan?.PasswordManager?.basePrice,
annualPricePerAdditionalStorageGB: annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb, familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb, providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
features: [ features: [
this.featureTranslations.premiumAccounts(), this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(), this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(), this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(), this.featureTranslations.familiesSharedStorage(),
], ],
}, },
}; };
}), }),
); );
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe( private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => { map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!; const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
return { return {
id: BusinessSubscriptionPricingTierIds.Free, id: BusinessSubscriptionPricingTierIds.Free,
@@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
passwordManager: { passwordManager: {
type: "free", type: "free",
features: [ features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats), this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections), this.featureTranslations.limitedCollectionsV2(
freePlan?.PasswordManager?.maxCollections,
),
this.featureTranslations.alwaysFree(), this.featureTranslations.alwaysFree(),
], ],
}, },
@@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
type: "free", type: "free",
features: [ features: [
this.featureTranslations.twoSecretsIncluded(), this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects), this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
], ],
}, },
}; };
}), }),
); );
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe( private teams$: Observable<BusinessSubscriptionPricingTier> =
map((plans) => { this.organizationPlansResponse$.pipe(
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!; map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
return { return {
id: BusinessSubscriptionPricingTierIds.Teams, id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"), name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"), description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: { passwordManager: {
type: "scalable", type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice, annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB: annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb, annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb, providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
features: [ features: [
this.featureTranslations.secureItemSharing(), this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(), this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(), this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(), this.featureTranslations.scimSupport(),
], ],
}, },
secretsManager: { secretsManager: {
type: "scalable", type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice, annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount: annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount, annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [ features: [
this.featureTranslations.unlimitedSecretsAndProjects(), this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2( this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount, 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(),
],
},
}), }),
), );
);
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 = { private featureTranslations = {
builtInAuthenticator: () => ({ builtInAuthenticator: () => ({
@@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "familiesSharedStorage", key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"), value: this.i18nService.t("familiesSharedStorage"),
}), }),
limitedUsersV2: (users: number) => ({ limitedUsersV2: (users?: number) => ({
key: "limitedUsersV2", key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users), value: this.i18nService.t("limitedUsersV2", users),
}), }),
limitedCollectionsV2: (collections: number) => ({ limitedCollectionsV2: (collections?: number) => ({
key: "limitedCollectionsV2", key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections), value: this.i18nService.t("limitedCollectionsV2", collections),
}), }),
@@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "twoSecretsIncluded", key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"), value: this.i18nService.t("twoSecretsIncluded"),
}), }),
projectsIncludedV2: (projects: number) => ({ projectsIncludedV2: (projects?: number) => ({
key: "projectsIncludedV2", key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects), value: this.i18nService.t("projectsIncludedV2", projects),
}), }),
@@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "unlimitedSecretsAndProjects", key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"), value: this.i18nService.t("unlimitedSecretsAndProjects"),
}), }),
includedMachineAccountsV2: (included: number) => ({ includedMachineAccountsV2: (included?: number) => ({
key: "includedMachineAccountsV2", key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included), value: this.i18nService.t("includedMachineAccountsV2", included),
}), }),

View File

@@ -27,26 +27,26 @@ type HasFeatures = {
}; };
type HasAdditionalStorage = { type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number; annualPricePerAdditionalStorageGB?: number;
}; };
type HasProvidedStorage = { type HasProvidedStorage = {
providedStorageGB: number; providedStorageGB?: number;
}; };
type StandalonePasswordManager = HasFeatures & type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage & HasAdditionalStorage &
HasProvidedStorage & { HasProvidedStorage & {
type: "standalone"; type: "standalone";
annualPrice: number; annualPrice?: number;
}; };
type PackagedPasswordManager = HasFeatures & type PackagedPasswordManager = HasFeatures &
HasProvidedStorage & HasProvidedStorage &
HasAdditionalStorage & { HasAdditionalStorage & {
type: "packaged"; type: "packaged";
users: number; users?: number;
annualPrice: number; annualPrice?: number;
}; };
type FreePasswordManager = HasFeatures & { type FreePasswordManager = HasFeatures & {
@@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
HasProvidedStorage & HasProvidedStorage &
HasAdditionalStorage & { HasAdditionalStorage & {
type: "scalable"; type: "scalable";
annualPricePerUser: number; annualPricePerUser?: number;
}; };
type FreeSecretsManager = HasFeatures & { type FreeSecretsManager = HasFeatures & {
@@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
type ScalableSecretsManager = HasFeatures & { type ScalableSecretsManager = HasFeatures & {
type: "scalable"; type: "scalable";
annualPricePerUser: number; annualPricePerUser?: number;
annualPricePerAdditionalServiceAccount: number; annualPricePerAdditionalServiceAccount?: number;
}; };
export type PersonalSubscriptionPricingTier = { export type PersonalSubscriptionPricingTier = {