mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +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:
@@ -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<CipherView | null>(null);
|
||||
protected readonly launchingCipher = signal<CipherView | null>(null);
|
||||
|
||||
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
@if (isLoadingPrices$ | async) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<bit-container>
|
||||
<bit-section>
|
||||
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
|
||||
@@ -44,7 +54,7 @@
|
||||
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
|
||||
{{
|
||||
"premiumPriceWithFamilyPlan"
|
||||
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
|
||||
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
|
||||
}}
|
||||
<a
|
||||
bitLink
|
||||
@@ -87,17 +97,17 @@
|
||||
/>
|
||||
<bit-hint>{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
|
||||
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
|
||||
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
|
||||
{{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
|
||||
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB ×
|
||||
{{ storageGBPrice | currency: "$" }} =
|
||||
{{ additionalStorageCost | currency: "$" }}
|
||||
{{ storagePrice$ | async | currency: "$" }} =
|
||||
{{ storageCost$ | async | currency: "$" }}
|
||||
<hr class="tw-my-3" />
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
@@ -106,6 +116,8 @@
|
||||
<app-enter-payment-method
|
||||
[group]="formGroup.controls.paymentMethod"
|
||||
[showBankAccount]="false"
|
||||
[showAccountCredit]="true"
|
||||
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
|
||||
>
|
||||
</app-enter-payment-method>
|
||||
<app-enter-billing-address
|
||||
@@ -116,17 +128,26 @@
|
||||
</div>
|
||||
<div class="tw-mb-4">
|
||||
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
|
||||
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
|
||||
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
|
||||
<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 | currency: "USD $" }}/{{ "year" | i18n }}
|
||||
<strong>{{ "total" | i18n }}:</strong> {{ total$ | async | currency: "USD $" }}/{{
|
||||
"year" | i18n
|
||||
}}
|
||||
</p>
|
||||
<button type="submit" buttonType="primary" bitButton bitFormButton>
|
||||
<button
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
bitButton
|
||||
bitFormButton
|
||||
[disabled]="!(hasEnoughAccountCredit$ | async)"
|
||||
>
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
</form>
|
||||
</bit-container>
|
||||
}
|
||||
|
||||
@@ -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<boolean>;
|
||||
protected accountCredit$: Observable<number>;
|
||||
protected hasEnoughAccountCredit$: Observable<boolean>;
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApiService>;
|
||||
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
@@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => {
|
||||
continuationToken: null,
|
||||
};
|
||||
|
||||
const mockPremiumPlanResponse: PremiumPlanResponse = {
|
||||
seat: {
|
||||
price: 10,
|
||||
},
|
||||
storage: {
|
||||
price: 4,
|
||||
},
|
||||
} as PremiumPlanResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
i18nService = mock<I18nService>();
|
||||
logService = mock<LogService>();
|
||||
@@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
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<ApiService>();
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorToastService = mock<ToastService>();
|
||||
|
||||
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<ApiService>();
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorToastService = mock<ToastService>();
|
||||
|
||||
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<ApiService>();
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorToastService = mock<ToastService>();
|
||||
|
||||
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<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", () => {
|
||||
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<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,23 +68,45 @@ export class SubscriptionPricingService {
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.apiService.getPlans(),
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = of({
|
||||
// premium plan is not configured server-side so for now, hardcode it
|
||||
basePrice: 10,
|
||||
additionalStoragePricePerGb: 4,
|
||||
}).pipe(
|
||||
map((details) => ({
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> = 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<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: details.basePrice,
|
||||
annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb,
|
||||
annualPrice: premiumPrices.seat,
|
||||
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
|
||||
@@ -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<ListResponse<PlanResponse>>;
|
||||
|
||||
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;
|
||||
|
||||
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
|
||||
|
||||
abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
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> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user