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:
@@ -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) || [],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user