From 021e4b4f0f48e92ff498ef712a8d7fa2aa1fd295 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 23 Jan 2026 14:20:47 -0600 Subject: [PATCH] feat(billing): add new premium org card --- .../premium-org-upgrade.component.html | 71 ++++++ .../premium-org-upgrade.component.spec.ts | 221 ++++++++++++++++++ .../premium-org-upgrade.component.ts | 156 +++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.html create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.ts diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.html b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.html new file mode 100644 index 00000000000..69d238cb521 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.html @@ -0,0 +1,71 @@ +@if (!loading()) { +
+
+
+

+ {{ "upgradeYourPlan" | i18n }} +

+

+ {{ "upgradeShareEvenMore" | i18n }} +

+
+ +
+ @if (familiesCardDetails) { + +

+ {{ familiesCardDetails.title }} +

+
+ } + + @if (teamsCardDetails) { + +

+ {{ teamsCardDetails.title }} +

+
+ } + + @if (enterpriseCardDetails) { + +

+ {{ enterpriseCardDetails.title }} +

+
+ } +
+ +
+

+ {{ "organizationUpgradeTaxInformationMessage" | i18n }} +

+
+
+
+} diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.spec.ts new file mode 100644 index 00000000000..22c2638e81d --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.spec.ts @@ -0,0 +1,221 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { of, throwError } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierIds, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { BillingServicesModule } from "../../../services"; + +import { PremiumOrgUpgradeComponent } from "./premium-org-upgrade.component"; + +describe("PremiumOrgUpgradeComponent", () => { + let sut: PremiumOrgUpgradeComponent; + let fixture: ComponentFixture; + const mockI18nService = mock(); + const mockSubscriptionPricingService = mock(); + const mockToastService = mock(); + + // Mock pricing tiers data + const mockPersonalPricingTiers: PersonalSubscriptionPricingTier[] = [ + { + id: PersonalSubscriptionPricingTierIds.Families, + name: "planNameFamilies", + description: "Family plan for up to 6 users", + passwordManager: { + type: "packaged", + annualPrice: 40, + features: [ + { key: "feature1", value: "Feature A" }, + { key: "feature2", value: "Feature B" }, + { key: "feature3", value: "Feature C" }, + ], + users: 6, + }, + } as PersonalSubscriptionPricingTier, + ]; + + const mockBusinessPricingTiers: BusinessSubscriptionPricingTier[] = [ + { + id: BusinessSubscriptionPricingTierIds.Teams, + name: "planNameTeams", + description: "Teams plan for growing businesses", + passwordManager: { + type: "scalable", + annualPricePerUser: 48, + features: [ + { key: "teamFeature1", value: "Teams Feature 1" }, + { key: "teamFeature2", value: "Teams Feature 2" }, + { key: "teamFeature3", value: "Teams Feature 3" }, + ], + }, + } as BusinessSubscriptionPricingTier, + { + id: BusinessSubscriptionPricingTierIds.Enterprise, + name: "planNameEnterprise", + description: "Enterprise plan for large organizations", + passwordManager: { + type: "scalable", + annualPricePerUser: 72, + features: [ + { key: "entFeature1", value: "Enterprise Feature 1" }, + { key: "entFeature2", value: "Enterprise Feature 2" }, + { key: "entFeature3", value: "Enterprise Feature 3" }, + ], + }, + } as BusinessSubscriptionPricingTier, + ]; + + beforeEach(async () => { + jest.resetAllMocks(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPersonalPricingTiers), + ); + mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue( + of(mockBusinessPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [PremiumOrgUpgradeComponent, PricingCardComponent, CdkTrapFocus], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: ToastService, useValue: mockToastService }, + ], + }) + .overrideComponent(PremiumOrgUpgradeComponent, { + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PremiumOrgUpgradeComponent); + sut = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(sut).toBeTruthy(); + }); + + it("should set loading to false after pricing tiers are loaded", () => { + expect(sut["loading"]()).toBe(false); + }); + + it("should set up pricing tier details for all three plans", () => { + expect(sut["familiesCardDetails"]).toBeDefined(); + expect(sut["teamsCardDetails"]).toBeDefined(); + expect(sut["enterpriseCardDetails"]).toBeDefined(); + }); + + describe("card details creation", () => { + it("should create families card details correctly", () => { + expect(sut["familiesCardDetails"].title).toBe("planNameFamilies"); + expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users"); + expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12); + expect(sut["familiesCardDetails"].price.cadence).toBe("monthly"); + expect(sut["familiesCardDetails"].button.type).toBe("primary"); + expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies"); + expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]); + }); + + it("should create teams card details correctly", () => { + expect(sut["teamsCardDetails"].title).toBe("planNameTeams"); + expect(sut["teamsCardDetails"].tagline).toBe("Teams plan for growing businesses"); + expect(sut["teamsCardDetails"].price.amount).toBe(48 / 12); + expect(sut["teamsCardDetails"].price.cadence).toBe("monthly"); + expect(sut["teamsCardDetails"].button.type).toBe("secondary"); + expect(sut["teamsCardDetails"].button.text).toBe("upgradeToTeams"); + expect(sut["teamsCardDetails"].features).toEqual([ + "Teams Feature 1", + "Teams Feature 2", + "Teams Feature 3", + ]); + }); + + it("should create enterprise card details correctly", () => { + expect(sut["enterpriseCardDetails"].title).toBe("planNameEnterprise"); + expect(sut["enterpriseCardDetails"].tagline).toBe("Enterprise plan for large organizations"); + expect(sut["enterpriseCardDetails"].price.amount).toBe(72 / 12); + expect(sut["enterpriseCardDetails"].price.cadence).toBe("monthly"); + expect(sut["enterpriseCardDetails"].button.type).toBe("secondary"); + expect(sut["enterpriseCardDetails"].button.text).toBe("upgradeToEnterprise"); + expect(sut["enterpriseCardDetails"].features).toEqual([ + "Enterprise Feature 1", + "Enterprise Feature 2", + "Enterprise Feature 3", + ]); + }); + }); + + describe("plan selection", () => { + it("should emit planSelected with families pricing tier when families plan is selected", () => { + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + // The first PricingCardComponent corresponds to the families plan + const familiesCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[0]; + familiesCard.triggerEventHandler("buttonClick", {}); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families); + }); + + it("should emit planSelected with teams pricing tier when teams plan is selected", () => { + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + // The second PricingCardComponent corresponds to the teams plan + const teamsCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[1]; + teamsCard.triggerEventHandler("buttonClick", {}); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Teams); + }); + + it("should emit planSelected with enterprise pricing tier when enterprise plan is selected", () => { + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + // The third PricingCardComponent corresponds to the enterprise plan + const enterpriseCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[2]; + enterpriseCard.triggerEventHandler("buttonClick", {}); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Enterprise); + }); + }); + + describe("error handling", () => { + it("should show toast and set loading to false on error", () => { + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + throwError(() => new Error("API Error")), + ); + mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue( + of(mockBusinessPricingTiers), + ); + + fixture = TestBed.createComponent(PremiumOrgUpgradeComponent); + sut = fixture.componentInstance; + fixture.detectChanges(); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "unexpectedError", + }); + expect(sut["loading"]()).toBe(false); + expect(sut["familiesCardDetails"]).toBeUndefined(); + expect(sut["teamsCardDetails"]).toBeUndefined(); + expect(sut["enterpriseCardDetails"]).toBeUndefined(); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.ts new file mode 100644 index 00000000000..5a5d21d363e --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade/premium-org-upgrade.component.ts @@ -0,0 +1,156 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit, output, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { combineLatest, catchError, of } from "rxjs"; + +import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + BusinessSubscriptionPricingTierIds, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonType, DialogModule, ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-premium-org-upgrade", + imports: [ + CommonModule, + DialogModule, + SharedModule, + BillingServicesModule, + PricingCardComponent, + CdkTrapFocus, + ], + templateUrl: "./premium-org-upgrade.component.html", +}) +export class PremiumOrgUpgradeComponent implements OnInit { + planSelected = output(); + protected readonly loading = signal(true); + protected familiesCardDetails!: SubscriptionPricingCardDetails; + protected teamsCardDetails!: SubscriptionPricingCardDetails; + protected enterpriseCardDetails!: SubscriptionPricingCardDetails; + + protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; + protected teamsPlanType = BusinessSubscriptionPricingTierIds.Teams; + protected enterprisePlanType = BusinessSubscriptionPricingTierIds.Enterprise; + + constructor( + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + combineLatest([ + this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(), + this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(), + ]) + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([[], []]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe( + ([personalPlans, businessPlans]: [ + PersonalSubscriptionPricingTier[], + BusinessSubscriptionPricingTier[], + ]) => { + this.setupCardDetails(personalPlans, businessPlans); + this.loading.set(false); + }, + ); + } + + private setupCardDetails( + personalPlans: PersonalSubscriptionPricingTier[], + businessPlans: BusinessSubscriptionPricingTier[], + ): void { + const familiesTier = personalPlans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Families, + ); + const teamsTier = businessPlans.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams, + ); + const enterpriseTier = businessPlans.find( + (tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise, + ); + + if (familiesTier) { + this.familiesCardDetails = this.createCardDetails(familiesTier, "primary"); + } + + if (teamsTier) { + this.teamsCardDetails = this.createCardDetails(teamsTier, "secondary"); + } + + if (enterpriseTier) { + this.enterpriseCardDetails = this.createCardDetails(enterpriseTier, "secondary"); + } + } + + private createCardDetails( + tier: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier, + buttonType: ButtonType, + ): SubscriptionPricingCardDetails { + let buttonText: string; + switch (tier.id) { + case PersonalSubscriptionPricingTierIds.Families: + buttonText = "upgradeToFamilies"; + break; + case BusinessSubscriptionPricingTierIds.Teams: + buttonText = "upgradeToTeams"; + break; + case BusinessSubscriptionPricingTierIds.Enterprise: + buttonText = "upgradeToEnterprise"; + break; + default: + buttonText = ""; + } + + let priceAmount: number | undefined; + + if ("annualPrice" in tier.passwordManager) { + priceAmount = tier.passwordManager.annualPrice; + } else if ("annualPricePerUser" in tier.passwordManager) { + priceAmount = tier.passwordManager.annualPricePerUser; + } + + return { + title: tier.name, + tagline: tier.description, + price: + priceAmount && priceAmount > 0 + ? { + amount: priceAmount / 12, + cadence: SubscriptionCadenceIds.Monthly, + } + : undefined, + button: { + text: this.i18nService.t(buttonText), + type: buttonType, + }, + features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value), + }; + } +}