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 32c67df1434..7f698ae50d1 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 @@ -1,8 +1,10 @@ import { Component, input, output } from "@angular/core"; 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 { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { PersonalSubscriptionPricingTierId, @@ -58,6 +60,8 @@ describe("UnifiedUpgradeDialogComponent", () => { let component: UnifiedUpgradeDialogComponent; let fixture: ComponentFixture; const mockDialogRef = mock(); + const mockRouter = mock(); + const mockPremiumInterestStateService = mock(); const mockAccount: Account = { id: "user-id" as UserId, @@ -74,11 +78,16 @@ describe("UnifiedUpgradeDialogComponent", () => { }; beforeEach(async () => { + // Reset mocks + jest.clearAllMocks(); + await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: defaultDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -121,6 +130,8 @@ describe("UnifiedUpgradeDialogComponent", () => { providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -161,6 +172,8 @@ describe("UnifiedUpgradeDialogComponent", () => { providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -191,11 +204,11 @@ describe("UnifiedUpgradeDialogComponent", () => { }); describe("previousStep", () => { - it("should go back to plan selection and clear selected plan", () => { + it("should go back to plan selection and clear selected plan", async () => { component["step"].set(UnifiedUpgradeDialogStep.Payment); component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); - component["previousStep"](); + await component["previousStep"](); expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); expect(component["selectedPlan"]()).toBeNull(); @@ -222,6 +235,8 @@ describe("UnifiedUpgradeDialogComponent", () => { providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -241,4 +256,169 @@ describe("UnifiedUpgradeDialogComponent", () => { expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true); }); }); + + describe("onComplete with premium interest", () => { + it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockRouter.navigate.mockResolvedValue(true); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await component["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + + it("should not clear premium interest when upgrading to families", async () => { + const result: UpgradePaymentResult = { + status: "upgradedToFamilies", + organizationId: "org-123", + }; + + await component["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled(); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-123", + }); + }); + + it("should use standard redirect when no premium interest exists", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + mockRouter.navigate.mockResolvedValue(true); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await customComponent["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith([ + "/settings/subscription/user-subscription", + ]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + }); + + describe("onCloseClicked with premium interest", () => { + it("should clear premium interest when modal is closed", async () => { + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + + await component["onCloseClicked"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); + + describe("previousStep with premium interest", () => { + it("should NOT clear premium interest when navigating between steps", async () => { + component["step"].set(UnifiedUpgradeDialogStep.Payment); + component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); + + await component["previousStep"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + + it("should clear premium interest when backing out of dialog completely", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: PersonalSubscriptionPricingTierIds.Premium, + }; + + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + await customComponent["previousStep"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); }); 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 07b21a9fb4b..02d48e8d8f4 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 @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, Inject, OnInit, signal } from "@angular/core"; 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 { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; @@ -94,6 +95,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { private dialogRef: DialogRef, @Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams, private router: Router, + private premiumInterestStateService: PremiumInterestStateService, ) {} ngOnInit(): void { @@ -110,7 +112,9 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.selectedPlan.set(planId); this.nextStep(); } - protected onCloseClicked(): void { + protected async onCloseClicked(): Promise { + // Clear premium interest when user closes/abandons modal + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } @@ -124,18 +128,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit { } } - protected previousStep(): void { + protected async previousStep(): Promise { // If we are on the payment step and there was no initial step, go back to plan selection this is to prevent // going back to payment step if the dialog was opened directly to payment step if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) { this.step.set(UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(null); } else { + // Clear premium interest when backing out of dialog completely + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } } - protected onComplete(result: UpgradePaymentResult): void { + protected async onComplete(result: UpgradePaymentResult): Promise { let status: UnifiedUpgradeDialogStatus; switch (result.status) { case "upgradedToPremium": @@ -153,6 +159,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.close({ status, organizationId: result.organizationId }); + // Check premium interest and route to vault for marketing-initiated premium upgrades + if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { + const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( + this.params.account.id, + ); + if (hasPremiumInterest) { + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + await this.router.navigate(["/vault"]); + return; // Exit early, don't use redirectOnCompletion + } + } + + // Use redirectOnCompletion for standard upgrade flows if ( this.params.redirectOnCompletion && (status === UnifiedUpgradeDialogStatus.UpgradedToPremium || @@ -162,7 +181,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { status === UnifiedUpgradeDialogStatus.UpgradedToFamilies ? `/organizations/${result.organizationId}/vault` : "/settings/subscription/user-subscription"; - void this.router.navigate([redirectUrl]); + await this.router.navigate([redirectUrl]); } }