1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-26793] Fetch premium plan from pricing service (#16858)

* Fetch premium plan from pricing service

* Resolve Claude feedback
This commit is contained in:
Alex Morask
2025-10-23 09:13:26 -05:00
committed by GitHub
parent 7f86f2d0ac
commit 7321e3132b
9 changed files with 591 additions and 224 deletions

View File

@@ -104,7 +104,7 @@ export class AtRiskPasswordsComponent implements OnInit {
* The UI utilize a bitBadge which does not support async actions (like bitButton does). * The UI utilize a bitBadge which does not support async actions (like bitButton does).
* @protected * @protected
*/ */
protected launchingCipher = signal<CipherView | null>(null); protected readonly launchingCipher = signal<CipherView | null>(null);
private activeUserData$ = this.accountService.activeAccount$.pipe( private activeUserData$ = this.accountService.activeAccount$.pipe(
filterOutNullish(), filterOutNullish(),

View File

@@ -1,132 +1,153 @@
<bit-container> @if (isLoadingPrices$ | async) {
<bit-section> <ng-container>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2> <i
<bit-callout class="bwi bwi-spinner bwi-spin tw-text-muted"
type="info" title="{{ 'loading' | i18n }}"
*ngIf="hasPremiumFromAnyOrganization$ | async" aria-hidden="true"
title="{{ 'youHavePremiumAccess' | i18n }}" ></i>
icon="bwi bwi-star-f" <span class="tw-sr-only">{{ "loading" | i18n }}</span>
> </ng-container>
{{ "alreadyPremiumFromOrg" | i18n }} } @else {
</bit-callout> <bit-container>
<bit-callout type="success"> <bit-section>
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p> <h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<ul class="bwi-ul"> <bit-callout
<li> type="info"
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i> *ngIf="hasPremiumFromAnyOrganization$ | async"
{{ "premiumSignUpStorage" | i18n }} title="{{ 'youHavePremiumAccess' | i18n }}"
</li> icon="bwi bwi-star-f"
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
> >
{{ "purchasePremium" | i18n }} {{ "alreadyPremiumFromOrg" | i18n }}
</a> </bit-callout>
</bit-callout> <bit-callout type="success">
</bit-section> <p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<bit-section *ngIf="isSelfHost"> <ul class="bwi-ul">
<individual-self-hosting-license-uploader <li>
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()" <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
/> {{ "premiumSignUpStorage" | i18n }}
</bit-section> </li>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment"> <li>
<bit-section> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2> {{ "premiumSignUpTwoStepOptions" | i18n }}
<div class="tw-grid tw-grid-cols-12 tw-gap-4"> </li>
<bit-form-field class="tw-col-span-6"> <li>
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
<input {{ "premiumSignUpEmergency" | i18n }}
bitInput </li>
formControlName="additionalStorage" <li>
type="number" <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
step="1" {{ "premiumSignUpReports" | i18n }}
placeholder="{{ 'additionalStorageGbDesc' | i18n }}" </li>
/> <li>
<bit-hint>{{ <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
"additionalStorageIntervalDesc" {{ "premiumSignUpTotp" | i18n }}
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) </li>
}}</bit-hint> <li>
</bit-form-field> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
</div> {{ "premiumSignUpSupport" | i18n }}
</bit-section> </li>
<bit-section> <li>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br /> {{ "premiumSignUpFuture" | i18n }}
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times; </li>
{{ storageGBPrice | currency: "$" }} = </ul>
{{ additionalStorageCost | currency: "$" }} <p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
<hr class="tw-my-3" /> {{
</bit-section> "premiumPriceWithFamilyPlan"
<bit-section> | i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3> }}
<div class="tw-mb-4"> <a
<app-enter-payment-method bitLink
[group]="formGroup.controls.paymentMethod" linkType="primary"
[showBankAccount]="false" routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
> >
</app-enter-payment-method> {{ "purchasePremium" | i18n }}
<app-enter-billing-address </a>
[group]="formGroup.controls.billingAddress" </bit-callout>
[scenario]="{ type: 'checkout', supportsTaxId: false }" </bit-section>
> <bit-section *ngIf="isSelfHost">
</app-enter-billing-address> <individual-self-hosting-license-uploader
</div> (onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
<div class="tw-mb-4"> />
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col"> </bit-section>
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span> <form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span> <bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div> </div>
</div> </bit-section>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" /> <bit-section>
<p bitTypography="body1"> <h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }} {{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
</p> {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
<button type="submit" buttonType="primary" bitButton bitFormButton> {{ storagePrice$ | async | currency: "$" }} =
{{ "submit" | i18n }} {{ storageCost$ | async | currency: "$" }}
</button> <hr class="tw-my-3" />
</bit-section> </bit-section>
</form> <bit-section>
</bit-container> <h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
[showAccountCredit]="true"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total$ | async | currency: "USD $" }}/{{
"year" | i18n
}}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
}

View File

@@ -4,7 +4,19 @@ import { Component, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms"; import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs"; import {
combineLatest,
concatMap,
filter,
from,
map,
Observable,
of,
startWith,
switchMap,
catchError,
shareReplay,
} from "rxjs";
import { debounceTime } from "rxjs/operators"; import { debounceTime } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -26,7 +38,9 @@ import {
tokenizablePaymentMethodToLegacyEnum, tokenizablePaymentMethodToLegacyEnum,
NonTokenizablePaymentMethods, NonTokenizablePaymentMethods,
} from "@bitwarden/web-vault/app/billing/payment/types"; } from "@bitwarden/web-vault/app/billing/payment/types";
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
@Component({ @Component({
templateUrl: "./premium.component.html", templateUrl: "./premium.component.html",
@@ -37,7 +51,6 @@ export class PremiumComponent {
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected hasPremiumFromAnyOrganization$: Observable<boolean>; protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected accountCredit$: Observable<number>;
protected hasEnoughAccountCredit$: Observable<boolean>; protected hasEnoughAccountCredit$: Observable<boolean>;
protected formGroup = new FormGroup({ protected formGroup = new FormGroup({
@@ -46,13 +59,66 @@ export class PremiumComponent {
billingAddress: EnterBillingAddressComponent.getFormGroup(), billingAddress: EnterBillingAddressComponent.getFormGroup(),
}); });
premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
map((tiers) => {
const premiumPlan = tiers.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
if (!premiumPlan) {
throw new Error("Could not find Premium plan");
}
return {
seat: premiumPlan.passwordManager.annualPrice,
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat));
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
protected isLoadingPrices$ = this.premiumPrices$.pipe(
map(() => false),
startWith(true),
catchError(() => of(false)),
);
storageCost$ = combineLatest([
this.storagePrice$,
this.formGroup.controls.additionalStorage.valueChanges.pipe(
startWith(this.formGroup.value.additionalStorage),
),
]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage));
subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe(
map(([premiumPrice, storageCost]) => premiumPrice + storageCost),
);
tax$ = this.formGroup.valueChanges.pipe(
filter(() => this.formGroup.valid),
debounceTime(1000),
switchMap(async () => {
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
this.formGroup.value.additionalStorage,
billingAddress,
);
return taxAmounts.tax;
}),
startWith(0),
);
total$ = combineLatest([this.subtotal$, this.tax$]).pipe(
map(([subtotal, tax]) => subtotal + tax),
);
protected cloudWebVaultURL: string; protected cloudWebVaultURL: string;
protected isSelfHost = false; protected isSelfHost = false;
protected estimatedTax: number = 0;
protected readonly familyPlanMaxUserCount = 6; protected readonly familyPlanMaxUserCount = 6;
protected readonly premiumPrice = 10;
protected readonly storageGBPrice = 4;
constructor( constructor(
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
@@ -67,6 +133,7 @@ export class PremiumComponent {
private accountService: AccountService, private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient, private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient, private taxClient: TaxClient,
private subscriptionPricingService: SubscriptionPricingService,
) { ) {
this.isSelfHost = this.platformUtilsService.isSelfHost(); this.isSelfHost = this.platformUtilsService.isSelfHost();
@@ -76,23 +143,23 @@ export class PremiumComponent {
), ),
); );
// Fetch account credit const accountCredit$ = this.accountService.activeAccount$.pipe(
this.accountCredit$ = this.accountService.activeAccount$.pipe(
mapAccountToSubscriber, mapAccountToSubscriber,
switchMap((account) => this.subscriberBillingClient.getCredit(account)), switchMap((account) => this.subscriberBillingClient.getCredit(account)),
); );
// Check if user has enough account credit for the purchase
this.hasEnoughAccountCredit$ = combineLatest([ this.hasEnoughAccountCredit$ = combineLatest([
this.accountCredit$, accountCredit$,
this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), this.total$,
this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe(
startWith(this.formGroup.value.paymentMethod.type),
),
]).pipe( ]).pipe(
map(([credit, formValue]) => { map(([credit, total, paymentMethod]) => {
const selectedPaymentType = formValue.paymentMethod?.type; if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) {
if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { return true;
return true; // Not using account credit, so this check doesn't apply
} }
return credit >= this.total; return credit >= total;
}), }),
); );
@@ -116,14 +183,6 @@ export class PremiumComponent {
}), }),
) )
.subscribe(); .subscribe();
this.formGroup.valueChanges
.pipe(
debounceTime(1000),
switchMap(async () => await this.refreshSalesTax()),
takeUntilDestroyed(),
)
.subscribe();
} }
finalizeUpgrade = async () => { finalizeUpgrade = async () => {
@@ -177,38 +236,11 @@ export class PremiumComponent {
await this.postFinalizeUpgrade(); await this.postFinalizeUpgrade();
}; };
protected get additionalStorageCost(): number {
return this.storageGBPrice * this.formGroup.value.additionalStorage;
}
protected get premiumURL(): string { protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
} }
protected get subtotal(): number {
return this.premiumPrice + this.additionalStorageCost;
}
protected get total(): number {
return this.subtotal + this.estimatedTax;
}
protected async onLicenseFileSelectedChanged(): Promise<void> { protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade(); await this.postFinalizeUpgrade();
} }
private async refreshSalesTax(): Promise<void> {
if (this.formGroup.invalid) {
return;
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
this.formGroup.value.additionalStorage,
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}
} }

View File

@@ -1,9 +1,12 @@
import { TestBed } from "@angular/core/testing"; import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@@ -18,7 +21,8 @@ import { SubscriptionPricingService } from "./subscription-pricing.service";
describe("SubscriptionPricingService", () => { describe("SubscriptionPricingService", () => {
let service: SubscriptionPricingService; let service: SubscriptionPricingService;
let apiService: MockProxy<ApiService>; let billingApiService: MockProxy<BillingApiServiceAbstraction>;
let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>; let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let toastService: MockProxy<ToastService>; let toastService: MockProxy<ToastService>;
@@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => {
continuationToken: null, continuationToken: null,
}; };
const mockPremiumPlanResponse: PremiumPlanResponse = {
seat: {
price: 10,
},
storage: {
price: 4,
},
} as PremiumPlanResponse;
beforeAll(() => { beforeAll(() => {
i18nService = mock<I18nService>(); i18nService = mock<I18nService>();
logService = mock<LogService>(); logService = mock<LogService>();
@@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => {
}); });
beforeEach(() => { beforeEach(() => {
apiService = mock<ApiService>(); billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
apiService.getPlans.mockResolvedValue(mockPlansResponse); billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
SubscriptionPricingService, SubscriptionPricingService,
{ provide: ApiService, useValue: apiService }, { provide: BillingApiServiceAbstraction, useValue: billingApiService },
{ provide: ConfigService, useValue: configService },
{ provide: I18nService, useValue: i18nService }, { provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: logService }, { provide: LogService, useValue: logService },
{ provide: ToastService, useValue: toastService }, { provide: ToastService, useValue: toastService },
@@ -406,13 +423,16 @@ describe("SubscriptionPricingService", () => {
}); });
it("should handle API errors by logging and showing toast", (done) => { it("should handle API errors by logging and showing toast", (done) => {
const errorApiService = mock<ApiService>(); const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>(); const errorToastService = mock<ToastService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => { errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") { if (key === "unexpectedError") {
@@ -422,7 +442,8 @@ describe("SubscriptionPricingService", () => {
}); });
const errorService = new SubscriptionPricingService( const errorService = new SubscriptionPricingService(
errorApiService, errorBillingApiService,
errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorToastService, errorToastService,
@@ -591,13 +612,16 @@ describe("SubscriptionPricingService", () => {
}); });
it("should handle API errors by logging and showing toast", (done) => { it("should handle API errors by logging and showing toast", (done) => {
const errorApiService = mock<ApiService>(); const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>(); const errorToastService = mock<ToastService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => { errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") { if (key === "unexpectedError") {
@@ -607,7 +631,8 @@ describe("SubscriptionPricingService", () => {
}); });
const errorService = new SubscriptionPricingService( const errorService = new SubscriptionPricingService(
errorApiService, errorBillingApiService,
errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorToastService, errorToastService,
@@ -831,13 +856,16 @@ describe("SubscriptionPricingService", () => {
}); });
it("should handle API errors by logging and showing toast", (done) => { it("should handle API errors by logging and showing toast", (done) => {
const errorApiService = mock<ApiService>(); const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>(); const errorToastService = mock<ToastService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => { errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") { if (key === "unexpectedError") {
@@ -847,7 +875,8 @@ describe("SubscriptionPricingService", () => {
}); });
const errorService = new SubscriptionPricingService( const errorService = new SubscriptionPricingService(
errorApiService, errorBillingApiService,
errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorToastService, errorToastService,
@@ -871,9 +900,137 @@ describe("SubscriptionPricingService", () => {
}); });
}); });
describe("Edge case handling", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
const errorService = new SubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to error in premium plan fetch
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalledWith(
"Failed to fetch premium plan from API",
testError,
);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
// 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 SubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to validation error
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
// 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 SubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to validation error
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
});
describe("Observable behavior and caching", () => { describe("Observable behavior and caching", () => {
it("should share API response between multiple subscriptions", () => { it("should share API response between multiple subscriptions", () => {
const getPlansResponse = jest.spyOn(apiService, "getPlans"); const getPlansResponse = jest.spyOn(billingApiService, "getPlans");
// Subscribe to multiple observables // Subscribe to multiple observables
service.getPersonalSubscriptionPricingTiers$().subscribe(); service.getPersonalSubscriptionPricingTiers$().subscribe();
@@ -883,5 +1040,67 @@ describe("SubscriptionPricingService", () => {
// API should only be called once due to shareReplay // API should only be called once due to shareReplay
expect(getPlansResponse).toHaveBeenCalledTimes(1); expect(getPlansResponse).toHaveBeenCalledTimes(1);
}); });
it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => {
// Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
// Create a new service instance with the feature flag enabled
const newService = new SubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
toastService,
);
// Subscribe to the premium pricing tier multiple times
newService.getPersonalSubscriptionPricingTiers$().subscribe();
newService.getPersonalSubscriptionPricingTiers$().subscribe();
// API should only be called once due to shareReplay on premiumPlanResponse$
expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1);
});
it("should use hardcoded premium price when feature flag is disabled", (done) => {
// Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({
seat: { price: 999 }, // Different price to verify hardcoded value is used
storage: { price: 999 },
} as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
// Create a new service instance with the feature flag disabled
const newService = new SubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
toastService,
);
// Subscribe with feature flag disabled
newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
const premiumTier = tiers.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
// Should use hardcoded value of 10, not the API response value of 999
expect(premiumTier!.passwordManager.annualPrice).toBe(10);
expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4);
done();
});
});
}); });
}); });

View File

@@ -1,11 +1,14 @@
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs"; import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs";
import { catchError } from "rxjs/operators"; import { catchError } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanType } 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@@ -20,8 +23,18 @@ import {
@Injectable({ providedIn: BillingServicesModule }) @Injectable({ providedIn: BillingServicesModule })
export class SubscriptionPricingService { export class SubscriptionPricingService {
/**
* Fallback premium pricing used when the feature flag is disabled.
* These values represent the legacy pricing model and will not reflect
* server-side price changes. They are retained for backward compatibility
* during the feature flag rollout period.
*/
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
constructor( constructor(
private apiService: ApiService, private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private i18nService: I18nService, private i18nService: I18nService,
private logService: LogService, private logService: LogService,
private toastService: ToastService, private toastService: ToastService,
@@ -55,34 +68,56 @@ export class SubscriptionPricingService {
); );
private plansResponse$: Observable<ListResponse<PlanResponse>> = from( private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.apiService.getPlans(), this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false })); ).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private premium$: Observable<PersonalSubscriptionPricingTier> = of({ private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
// premium plan is not configured server-side so for now, hardcode it this.billingApiService.getPremiumPlan(),
basePrice: 10, ).pipe(
additionalStoragePricePerGb: 4, catchError((error: unknown) => {
}).pipe( this.logService.error("Failed to fetch premium plan from API", error);
map((details) => ({ throw error; // Re-throw to propagate to higher-level error handler
id: PersonalSubscriptionPricingTierIds.Premium, }),
name: this.i18nService.t("premium"), shareReplay({ bufferSize: 1, refCount: false }),
description: this.i18nService.t("planDescPremium"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: details.basePrice,
annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
); );
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
.pipe(
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
switchMap((fetchPremiumFromPricingService) =>
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
})),
)
: of({
seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
}),
),
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPrices.seat,
annualPricePerAdditionalStorageGB: premiumPrices.storage,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe( private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => { map((plans) => {
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!; const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;

View File

@@ -1,3 +1,5 @@
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
@@ -25,6 +27,8 @@ export abstract class BillingApiServiceAbstraction {
abstract getPlans(): Promise<ListResponse<PlanResponse>>; abstract getPlans(): Promise<ListResponse<PlanResponse>>;
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>; abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>; abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>;

View File

@@ -0,0 +1,47 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class PremiumPlanResponse extends BaseResponse {
seat: {
stripePriceId: string;
price: number;
};
storage: {
stripePriceId: string;
price: number;
};
constructor(response: any) {
super(response);
const seat = this.getResponseProperty("Seat");
if (!seat || typeof seat !== "object") {
throw new Error("PremiumPlanResponse: Missing or invalid 'Seat' property");
}
this.seat = new PurchasableResponse(seat);
const storage = this.getResponseProperty("Storage");
if (!storage || typeof storage !== "object") {
throw new Error("PremiumPlanResponse: Missing or invalid 'Storage' property");
}
this.storage = new PurchasableResponse(storage);
}
}
class PurchasableResponse extends BaseResponse {
stripePriceId: string;
price: number;
constructor(response: any) {
super(response);
this.stripePriceId = this.getResponseProperty("StripePriceId");
if (!this.stripePriceId || typeof this.stripePriceId !== "string") {
throw new Error("PurchasableResponse: Missing or invalid 'StripePriceId' property");
}
this.price = this.getResponseProperty("Price");
if (typeof this.price !== "number" || isNaN(this.price)) {
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
}
}
}

View File

@@ -1,6 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ApiService } from "../../abstractions/api.service"; import { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ListResponse } from "../../models/response/list.response"; import { ListResponse } from "../../models/response/list.response";
@@ -61,10 +63,15 @@ export class BillingApiService implements BillingApiServiceAbstraction {
} }
async getPlans(): Promise<ListResponse<PlanResponse>> { async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, false, true); const r = await this.apiService.send("GET", "/plans", null, true, true);
return new ListResponse(r, PlanResponse); return new ListResponse(r, PlanResponse);
} }
async getPremiumPlan(): Promise<PremiumPlanResponse> {
const response = await this.apiService.send("GET", "/plans/premium", null, true, true);
return new PremiumPlanResponse(response);
}
async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string> { async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string> {
const response = await this.apiService.send( const response = await this.apiService.send(
"GET", "GET",

View File

@@ -30,6 +30,7 @@ export enum FeatureFlag {
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
/* Key Management */ /* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration", PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
/* Key Management */ /* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.PrivateKeyRegeneration]: FALSE,