From 7321e3132bb19611d6453b0744165f09d7d62fdc Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:13:26 -0500 Subject: [PATCH] [PM-26793] Fetch premium plan from pricing service (#16858) * Fetch premium plan from pricing service * Resolve Claude feedback --- .../at-risk-passwords.component.ts | 2 +- .../individual/premium/premium.component.html | 279 ++++++++++-------- .../individual/premium/premium.component.ts | 134 +++++---- .../subscription-pricing.service.spec.ts | 249 +++++++++++++++- .../services/subscription-pricing.service.ts | 89 ++++-- .../billing-api.service.abstraction.ts | 4 + .../models/response/premium-plan.response.ts | 47 +++ .../billing/services/billing-api.service.ts | 9 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 9 files changed, 591 insertions(+), 224 deletions(-) create mode 100644 libs/common/src/billing/models/response/premium-plan.response.ts diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 6551c84a4e2..6918bedb9bf 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -104,7 +104,7 @@ export class AtRiskPasswordsComponent implements OnInit { * The UI utilize a bitBadge which does not support async actions (like bitButton does). * @protected */ - protected launchingCipher = signal(null); + protected readonly launchingCipher = signal(null); private activeUserData$ = this.accountService.activeAccount$.pipe( filterOutNullish(), diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index d08b942ff8b..39b32be0853 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -1,132 +1,153 @@ - - -

{{ "goPremium" | i18n }}

- - {{ "alreadyPremiumFromOrg" | i18n }} - - -

{{ "premiumUpgradeUnlockFeatures" | i18n }}

-
    -
  • - - {{ "premiumSignUpStorage" | i18n }} -
  • -
  • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
  • -
  • - - {{ "premiumSignUpEmergency" | i18n }} -
  • -
  • - - {{ "premiumSignUpReports" | i18n }} -
  • -
  • - - {{ "premiumSignUpTotp" | i18n }} -
  • -
  • - - {{ "premiumSignUpSupport" | i18n }} -
  • -
  • - - {{ "premiumSignUpFuture" | i18n }} -
  • -
-

- {{ - "premiumPriceWithFamilyPlan" - | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount - }} - - {{ "bitwardenFamiliesPlan" | i18n }} - -

- + + {{ "loading" | i18n }} + +} @else { + + +

{{ "goPremium" | i18n }}

+ - {{ "purchasePremium" | i18n }} -
-
-
- - - -
- -

{{ "addons" | i18n }}

-
- - {{ "additionalStorageGb" | i18n }} - - {{ - "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) - }} - -
-
- -

{{ "summary" | i18n }}

- {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × - {{ storageGBPrice | currency: "$" }} = - {{ additionalStorageCost | currency: "$" }} -
-
- -

{{ "paymentInformation" | i18n }}

-
- + +

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+
    +
  • + + {{ "premiumSignUpStorage" | i18n }} +
  • +
  • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
  • +
  • + + {{ "premiumSignUpEmergency" | i18n }} +
  • +
  • + + {{ "premiumSignUpReports" | i18n }} +
  • +
  • + + {{ "premiumSignUpTotp" | i18n }} +
  • +
  • + + {{ "premiumSignUpSupport" | i18n }} +
  • +
  • + + {{ "premiumSignUpFuture" | i18n }} +
  • +
+

+ {{ + "premiumPriceWithFamilyPlan" + | i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount + }} + + {{ "bitwardenFamiliesPlan" | i18n }} + +

+ -
- - -
-
-
- {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} - {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} + {{ "purchasePremium" | i18n }} + + + + + + + + +

{{ "addons" | i18n }}

+
+ + {{ "additionalStorageGb" | i18n }} + + {{ + "additionalStorageIntervalDesc" + | i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n) + }} +
-
-
-

- {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} -

- - - - + + +

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × + {{ storagePrice$ | async | currency: "$" }} = + {{ storageCost$ | async | currency: "$" }} +
+
+ +

{{ "paymentInformation" | i18n }}

+
+ + + + +
+
+
+ {{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }} + {{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: {{ total$ | async | currency: "USD $" }}/{{ + "year" | i18n + }} +

+ +
+ + +} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index d541ab95b95..526b020a9e3 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -4,7 +4,19 @@ import { Component, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; 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 { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -26,7 +38,9 @@ import { tokenizablePaymentMethodToLegacyEnum, NonTokenizablePaymentMethods, } 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 { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; @Component({ templateUrl: "./premium.component.html", @@ -37,7 +51,6 @@ export class PremiumComponent { @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; - protected accountCredit$: Observable; protected hasEnoughAccountCredit$: Observable; protected formGroup = new FormGroup({ @@ -46,13 +59,66 @@ export class PremiumComponent { 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 isSelfHost = false; - - protected estimatedTax: number = 0; protected readonly familyPlanMaxUserCount = 6; - protected readonly premiumPrice = 10; - protected readonly storageGBPrice = 4; constructor( private activatedRoute: ActivatedRoute, @@ -67,6 +133,7 @@ export class PremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, + private subscriptionPricingService: SubscriptionPricingService, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -76,23 +143,23 @@ export class PremiumComponent { ), ); - // Fetch account credit - this.accountCredit$ = this.accountService.activeAccount$.pipe( + const accountCredit$ = this.accountService.activeAccount$.pipe( mapAccountToSubscriber, switchMap((account) => this.subscriberBillingClient.getCredit(account)), ); - // Check if user has enough account credit for the purchase this.hasEnoughAccountCredit$ = combineLatest([ - this.accountCredit$, - this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), + accountCredit$, + this.total$, + this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe( + startWith(this.formGroup.value.paymentMethod.type), + ), ]).pipe( - map(([credit, formValue]) => { - const selectedPaymentType = formValue.paymentMethod?.type; - if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { - return true; // Not using account credit, so this check doesn't apply + map(([credit, total, paymentMethod]) => { + if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) { + return true; } - return credit >= this.total; + return credit >= total; }), ); @@ -116,14 +183,6 @@ export class PremiumComponent { }), ) .subscribe(); - - this.formGroup.valueChanges - .pipe( - debounceTime(1000), - switchMap(async () => await this.refreshSalesTax()), - takeUntilDestroyed(), - ) - .subscribe(); } finalizeUpgrade = async () => { @@ -177,38 +236,11 @@ export class PremiumComponent { await this.postFinalizeUpgrade(); }; - protected get additionalStorageCost(): number { - return this.storageGBPrice * this.formGroup.value.additionalStorage; - } - protected get premiumURL(): string { 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 { await this.postFinalizeUpgrade(); } - - private async refreshSalesTax(): Promise { - 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; - } } diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts index 0fb33020bc3..de80cdcbdbf 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts @@ -1,9 +1,12 @@ import { TestBed } from "@angular/core/testing"; 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 { 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 { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -18,7 +21,8 @@ import { SubscriptionPricingService } from "./subscription-pricing.service"; describe("SubscriptionPricingService", () => { let service: SubscriptionPricingService; - let apiService: MockProxy; + let billingApiService: MockProxy; + let configService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; let toastService: MockProxy; @@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => { continuationToken: null, }; + const mockPremiumPlanResponse: PremiumPlanResponse = { + seat: { + price: 10, + }, + storage: { + price: 4, + }, + } as PremiumPlanResponse; + beforeAll(() => { i18nService = mock(); logService = mock(); @@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => { }); beforeEach(() => { - apiService = mock(); + billingApiService = mock(); + configService = mock(); - 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({ providers: [ SubscriptionPricingService, - { provide: ApiService, useValue: apiService }, + { provide: BillingApiServiceAbstraction, useValue: billingApiService }, + { provide: ConfigService, useValue: configService }, { provide: I18nService, useValue: i18nService }, { provide: LogService, useValue: logService }, { provide: ToastService, useValue: toastService }, @@ -406,13 +423,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); 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) => { if (key === "unexpectedError") { @@ -422,7 +442,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -591,13 +612,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); 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) => { if (key === "unexpectedError") { @@ -607,7 +631,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -831,13 +856,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); 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) => { if (key === "unexpectedError") { @@ -847,7 +875,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -871,9 +900,137 @@ describe("SubscriptionPricingService", () => { }); }); + describe("Edge case handling", () => { + it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + 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(); + const errorConfigService = mock(); + + // 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(); + const errorConfigService = mock(); + + // 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", () => { it("should share API response between multiple subscriptions", () => { - const getPlansResponse = jest.spyOn(apiService, "getPlans"); + const getPlansResponse = jest.spyOn(billingApiService, "getPlans"); // Subscribe to multiple observables service.getPersonalSubscriptionPricingTiers$().subscribe(); @@ -883,5 +1040,67 @@ describe("SubscriptionPricingService", () => { // API should only be called once due to shareReplay 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(); + const newConfigService = mock(); + + 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(); + const newConfigService = mock(); + + 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(); + }); + }); }); }); diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/apps/web/src/app/billing/services/subscription-pricing.service.ts index 82ec9f180b9..71729a42d23 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.ts @@ -1,11 +1,14 @@ 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 { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -20,8 +23,18 @@ import { @Injectable({ providedIn: BillingServicesModule }) 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( - private apiService: ApiService, + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, private i18nService: I18nService, private logService: LogService, private toastService: ToastService, @@ -55,34 +68,56 @@ export class SubscriptionPricingService { ); private plansResponse$: Observable> = from( - this.apiService.getPlans(), + this.billingApiService.getPlans(), ).pipe(shareReplay({ bufferSize: 1, refCount: false })); - private premium$: Observable = of({ - // premium plan is not configured server-side so for now, hardcode it - basePrice: 10, - additionalStoragePricePerGb: 4, - }).pipe( - map((details) => ({ - id: PersonalSubscriptionPricingTierIds.Premium, - name: this.i18nService.t("premium"), - 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 premiumPlanResponse$: Observable = from( + this.billingApiService.getPremiumPlan(), + ).pipe( + catchError((error: unknown) => { + this.logService.error("Failed to fetch premium plan from API", error); + throw error; // Re-throw to propagate to higher-level error handler + }), + shareReplay({ bufferSize: 1, refCount: false }), ); + private premium$: Observable = 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 = this.plansResponse$.pipe( map((plans) => { const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!; diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index d581fdaa95c..ef01c98ecb5 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -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 { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; @@ -25,6 +27,8 @@ export abstract class BillingApiServiceAbstraction { abstract getPlans(): Promise>; + abstract getPremiumPlan(): Promise; + abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise; abstract getProviderInvoices(providerId: string): Promise; diff --git a/libs/common/src/billing/models/response/premium-plan.response.ts b/libs/common/src/billing/models/response/premium-plan.response.ts new file mode 100644 index 00000000000..f5df560a601 --- /dev/null +++ b/libs/common/src/billing/models/response/premium-plan.response.ts @@ -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"); + } + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 165ebf5c3b4..673d4a9784e 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -1,6 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ListResponse } from "../../models/response/list.response"; @@ -61,10 +63,15 @@ export class BillingApiService implements BillingApiServiceAbstraction { } async getPlans(): Promise> { - 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); } + async getPremiumPlan(): Promise { + const response = await this.apiService.send("GET", "/plans/premium", null, true, true); + return new PremiumPlanResponse(response); + } + async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise { const response = await this.apiService.send( "GET", diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e26fa69fa91..d9cd1dbfab3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -30,6 +30,7 @@ export enum FeatureFlag { PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", + PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, + [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE,