From 486eff31923a2f680f0fc16bd503bd8f7d81d460 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 23 Jan 2026 14:19:44 -0600 Subject: [PATCH] feat(biilling): Update unified upgrade dialog logic --- .../unified-upgrade-dialog.component.html | 40 ++-- .../unified-upgrade-dialog.component.spec.ts | 197 +++++++++++++++++- .../unified-upgrade-dialog.component.ts | 56 ++++- 3 files changed, 268 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html index 83c940da97f..1b4c21759d1 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html @@ -1,15 +1,31 @@ @if (step() == PlanSelectionStep) { - + @if (hasPremiumPersonally()) { + + } @else { + + } } @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) { - + @if (hasPremiumPersonally()) { + + } @else { + + } } diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index 6bc0efb9e96..3f97baf888e 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -3,17 +3,26 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Router } from "@angular/router"; import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, + BusinessSubscriptionPricingTierId, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; +import { PremiumOrgUpgradeComponent } from "../premium-org-upgrade/premium-org-upgrade.component"; +import { + PremiumOrgUpgradePaymentComponent, + PremiumOrgUpgradePaymentResult, + PremiumOrgUpgradePaymentStatus, +} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component"; import { UpgradeAccountComponent, UpgradeAccountStatus, @@ -33,6 +42,7 @@ import { selector: "app-upgrade-account", template: "", standalone: true, + providers: [UpgradeAccountComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradeAccountComponent { @@ -46,6 +56,7 @@ class MockUpgradeAccountComponent { selector: "app-upgrade-payment", template: "", standalone: true, + providers: [UpgradePaymentComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradePaymentComponent { @@ -55,13 +66,43 @@ class MockUpgradePaymentComponent { complete = output(); } +@Component({ + selector: "app-premium-org-upgrade", + template: "", + standalone: true, + providers: [PremiumOrgUpgradeComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPremiumOrgUpgradeComponent { + readonly dialogTitleMessageOverride = input(null); + readonly hideContinueWithoutUpgradingButton = input(false); + planSelected = output(); + closeClicked = output(); +} + +@Component({ + selector: "app-premium-org-upgrade-payment", + template: "", + standalone: true, + providers: [PremiumOrgUpgradePaymentComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPremiumOrgUpgradePaymentComponent { + readonly selectedPlanId = input< + PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId | null + >(null); + readonly account = input(null); + goBack = output(); + complete = output<{ status: PremiumOrgUpgradePaymentStatus; organizationId: string | null }>(); +} + describe("UnifiedUpgradeDialogComponent", () => { let component: UnifiedUpgradeDialogComponent; let fixture: ComponentFixture; const mockDialogRef = mock(); const mockRouter = mock(); const mockPremiumInterestStateService = mock(); - + const mockBillingAccountProfileStateService = mock(); const mockAccount: Account = { id: "user-id" as UserId, ...mockAccountInfoWith({ @@ -97,14 +138,28 @@ describe("UnifiedUpgradeDialogComponent", () => { { provide: DIALOG_DATA, useValue: dialogData }, { provide: Router, useValue: mockRouter }, { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + { + provide: BillingAccountProfileStateService, + useValue: mockBillingAccountProfileStateService, + }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], + imports: [ + UpgradeAccountComponent, + UpgradePaymentComponent, + PremiumOrgUpgradeComponent, + PremiumOrgUpgradePaymentComponent, + ], }, add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + imports: [ + MockUpgradeAccountComponent, + MockUpgradePaymentComponent, + MockPremiumOrgUpgradeComponent, + MockPremiumOrgUpgradePaymentComponent, + ], }, }) .compileComponents(); @@ -126,22 +181,36 @@ describe("UnifiedUpgradeDialogComponent", () => { // Default mock: no premium interest mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); - + mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true)); await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + imports: [UnifiedUpgradeDialogComponent], providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: defaultDialogData }, { provide: Router, useValue: mockRouter }, { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + { + provide: BillingAccountProfileStateService, + useValue: mockBillingAccountProfileStateService, + }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], + imports: [ + UpgradeAccountComponent, + UpgradePaymentComponent, + PremiumOrgUpgradeComponent, + PremiumOrgUpgradePaymentComponent, + ], }, add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + imports: [ + MockUpgradeAccountComponent, + MockUpgradePaymentComponent, + MockPremiumOrgUpgradeComponent, + MockPremiumOrgUpgradePaymentComponent, + ], }, }) .compileComponents(); @@ -401,4 +470,118 @@ describe("UnifiedUpgradeDialogComponent", () => { expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); }); }); + + describe("Premium Org Upgrade edge cases", () => { + it("should handle selecting a business plan (Teams) and move to payment step", async () => { + const { component } = await createComponentWithDialogData(defaultDialogData); + + component["onPlanSelected"]("teams" as BusinessSubscriptionPricingTierId); + + expect(component["selectedPlan"]()).toBe("teams"); + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); + }); + + it("should handle completing premium org upgrade to Teams successfully", async () => { + const { component } = await createComponentWithDialogData(defaultDialogData); + mockRouter.navigate.mockResolvedValue(true); + + const result = { + status: "upgradedToTeams" as const, + organizationId: "org-123", + }; + + await component["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToTeams", + organizationId: "org-123", + }); + }); + + it("should handle completing premium org upgrade to Enterprise successfully", async () => { + const { component } = await createComponentWithDialogData(defaultDialogData); + mockRouter.navigate.mockResolvedValue(true); + + const result = { + status: "upgradedToEnterprise" as const, + organizationId: "org-456", + }; + + await component["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToEnterprise", + organizationId: "org-456", + }); + }); + + it("should handle user closing during premium org plan selection", async () => { + const { component } = await createComponentWithDialogData(defaultDialogData); + + await component["onCloseClicked"](); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + + it("should go back from premium org payment step to plan selection", async () => { + const { component } = await createComponentWithDialogData(defaultDialogData); + component["step"].set(UnifiedUpgradeDialogStep.Payment); + component["selectedPlan"].set("teams" as BusinessSubscriptionPricingTierId); + + await component["previousStep"](); + + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + + it("should initialize with business plan when specified", async () => { + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: "teams" as BusinessSubscriptionPricingTierId, + }; + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); + expect(customComponent["selectedPlan"]()).toBe("teams"); + }); + + it("should handle closed status during premium org upgrade", async () => { + const { component } = await createComponentWithDialogData(defaultDialogData); + + const result: PremiumOrgUpgradePaymentResult = { status: "closed", organizationId: null }; + + await component["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "closed", + organizationId: null, + }); + }); + + it("should handle redirectOnCompletion for families upgrade with organization", async () => { + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockRouter.navigate.mockResolvedValue(true); + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + const result = { + status: "upgradedToFamilies" as const, + organizationId: "org-789", + }; + + await customComponent["onComplete"](result); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-789/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-789", + }); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 63017760195..1d3a984b188 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -1,11 +1,23 @@ import { DIALOG_DATA } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, Inject, OnInit, signal } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + Inject, + OnInit, + signal, +} from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTierId, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, @@ -17,6 +29,11 @@ import { import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients"; import { BillingServicesModule } from "../../../services"; +import { PremiumOrgUpgradeComponent } from "../premium-org-upgrade/premium-org-upgrade.component"; +import { + PremiumOrgUpgradePaymentComponent, + PremiumOrgUpgradePaymentResult, +} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component"; import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; import { @@ -28,6 +45,8 @@ export const UnifiedUpgradeDialogStatus = { Closed: "closed", UpgradedToPremium: "upgradedToPremium", UpgradedToFamilies: "upgradedToFamilies", + UpgradedToTeams: "upgradedToTeams", + UpgradedToEnterprise: "upgradedToEnterprise", } as const; export const UnifiedUpgradeDialogStep = { @@ -57,7 +76,7 @@ export type UnifiedUpgradeDialogResult = { export type UnifiedUpgradeDialogParams = { account: Account; initialStep?: UnifiedUpgradeDialogStep | null; - selectedPlan?: PersonalSubscriptionPricingTierId | null; + selectedPlan?: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId | null; planSelectionStepTitleOverride?: string | null; hideContinueWithoutUpgradingButton?: boolean; redirectOnCompletion?: boolean; @@ -73,6 +92,8 @@ export type UnifiedUpgradeDialogParams = { UpgradeAccountComponent, UpgradePaymentComponent, BillingServicesModule, + PremiumOrgUpgradeComponent, + PremiumOrgUpgradePaymentComponent, ], providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient], templateUrl: "./unified-upgrade-dialog.component.html", @@ -82,11 +103,23 @@ export class UnifiedUpgradeDialogComponent implements OnInit { protected readonly step = signal( UnifiedUpgradeDialogStep.PlanSelection, ); - protected readonly selectedPlan = signal(null); + protected readonly selectedPlan = signal< + PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId | null + >(null); protected readonly account = signal(null); protected readonly planSelectionStepTitleOverride = signal(null); protected readonly hideContinueWithoutUpgradingButton = signal(false); protected readonly hasPremiumInterest = signal(false); + protected readonly hasPremiumPersonally = toSignal( + this.billingAccountProfileStateService.hasPremiumPersonally$(this.params.account.id), + { initialValue: false }, + ); + + // Type-narrowed computed signal for app-upgrade-payment + // When hasPremiumPersonally is false, selectedPlan will only contain PersonalSubscriptionPricingTierId + protected readonly selectedPersonalPlanId = computed( + () => this.selectedPlan() as PersonalSubscriptionPricingTierId | null, + ); protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment; protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection; @@ -96,6 +129,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { @Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams, private router: Router, private premiumInterestStateService: PremiumInterestStateService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit(): Promise { @@ -121,7 +155,9 @@ export class UnifiedUpgradeDialogComponent implements OnInit { } } - protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void { + protected onPlanSelected( + planId: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId, + ): void { this.selectedPlan.set(planId); this.nextStep(); } @@ -150,9 +186,17 @@ export class UnifiedUpgradeDialogComponent implements OnInit { } } - protected async onComplete(result: UpgradePaymentResult): Promise { + protected async onComplete( + result: UpgradePaymentResult | PremiumOrgUpgradePaymentResult, + ): Promise { let status: UnifiedUpgradeDialogStatus; switch (result.status) { + case "upgradedToTeams": + status = UnifiedUpgradeDialogStatus.UpgradedToTeams; + break; + case "upgradedToEnterprise": + status = UnifiedUpgradeDialogStatus.UpgradedToEnterprise; + break; case "upgradedToPremium": status = UnifiedUpgradeDialogStatus.UpgradedToPremium; break;