diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.html new file mode 100644 index 00000000000..69ff7a6e5d4 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.html @@ -0,0 +1,14 @@ + +@if (step() == PlanSelectionStep) { + +} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) { + +} diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.spec.ts new file mode 100644 index 00000000000..a44f2eece7a --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.spec.ts @@ -0,0 +1,449 @@ +import { ChangeDetectionStrategy, 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 { of } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTierId, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +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 { + PremiumOrgUpgradeDialogComponent, + PremiumOrgUpgradeDialogParams, + PremiumOrgUpgradeDialogStep, +} from "./premium-org-upgrade-dialog.component"; + +@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< + BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null + >(null); + readonly account = input(null); + goBack = output(); + complete = output<{ status: PremiumOrgUpgradePaymentStatus; organizationId: string | null }>(); +} + +describe("PremiumOrgUpgradeDialogComponent", () => { + let component: PremiumOrgUpgradeDialogComponent; + let fixture: ComponentFixture; + const mockDialogRef = mock(); + const mockRouter = mock(); + const mockBillingAccountProfileStateService = mock(); + const mockConfigService = mock(); + const mockAccount: Account = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), + }; + + const defaultDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + initialStep: null, + selectedPlan: null, + planSelectionStepTitleOverride: null, + }; + + /** + * Helper function to create and configure a fresh component instance with custom dialog data + */ + async function createComponentWithDialogData( + dialogData: PremiumOrgUpgradeDialogParams, + waitForStable = false, + ): Promise<{ + fixture: ComponentFixture; + component: PremiumOrgUpgradeDialogComponent; + }> { + TestBed.resetTestingModule(); + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, PremiumOrgUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: dialogData }, + { provide: Router, useValue: mockRouter }, + { + provide: BillingAccountProfileStateService, + useValue: mockBillingAccountProfileStateService, + }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }) + .overrideComponent(PremiumOrgUpgradeDialogComponent, { + remove: { + imports: [PremiumOrgUpgradeComponent, PremiumOrgUpgradePaymentComponent], + }, + add: { + imports: [MockPremiumOrgUpgradeComponent, MockPremiumOrgUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const newFixture = TestBed.createComponent(PremiumOrgUpgradeDialogComponent); + const newComponent = newFixture.componentInstance; + newFixture.detectChanges(); + + if (waitForStable) { + await newFixture.whenStable(); + } + + return { fixture: newFixture, component: newComponent }; + } + + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks(); + + mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true)); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + + await TestBed.configureTestingModule({ + imports: [PremiumOrgUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: defaultDialogData }, + { provide: Router, useValue: mockRouter }, + { + provide: BillingAccountProfileStateService, + useValue: mockBillingAccountProfileStateService, + }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }) + .overrideComponent(PremiumOrgUpgradeDialogComponent, { + remove: { + imports: [PremiumOrgUpgradeComponent, PremiumOrgUpgradePaymentComponent], + }, + add: { + imports: [MockPremiumOrgUpgradeComponent, MockPremiumOrgUpgradePaymentComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PremiumOrgUpgradeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default values", () => { + expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + expect(component["account"]()).toEqual(mockAccount); + expect(component["planSelectionStepTitleOverride"]()).toBeNull(); + }); + + it("should initialize with custom initial step", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + initialStep: PremiumOrgUpgradeDialogStep.Payment, + selectedPlan: "teams" as BusinessSubscriptionPricingTierId, + }; + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + expect(customComponent["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment); + expect(customComponent["selectedPlan"]()).toBe("teams"); + }); + + describe("custom dialog title", () => { + it("should use null as default when no override is provided", () => { + expect(component["planSelectionStepTitleOverride"]()).toBeNull(); + }); + + it("should use custom title when provided in dialog config", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + initialStep: PremiumOrgUpgradeDialogStep.PlanSelection, + selectedPlan: null, + planSelectionStepTitleOverride: "upgradeYourPlan", + }; + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan"); + }); + }); + + describe("onPlanSelected", () => { + it("should set selected plan and move to payment step", () => { + component["onPlanSelected"]("teams" as BusinessSubscriptionPricingTierId); + + expect(component["selectedPlan"]()).toBe("teams"); + expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment); + }); + + it("should handle selecting Enterprise plan", () => { + component["onPlanSelected"]("enterprise" as BusinessSubscriptionPricingTierId); + + expect(component["selectedPlan"]()).toBe("enterprise"); + expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment); + }); + }); + + describe("previousStep", () => { + it("should go back to plan selection and clear selected plan", async () => { + component["step"].set(PremiumOrgUpgradeDialogStep.Payment); + component["selectedPlan"].set("teams" as BusinessSubscriptionPricingTierId); + + await component["previousStep"](); + + expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + + it("should close dialog when backing out from initial step", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + initialStep: PremiumOrgUpgradeDialogStep.Payment, + selectedPlan: "teams" as BusinessSubscriptionPricingTierId, + }; + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + await customComponent["previousStep"](); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); + + describe("onComplete", () => { + it("should handle completing upgrade to Families successfully", async () => { + const { component: testComponent } = await createComponentWithDialogData(defaultDialogData); + mockRouter.navigate.mockResolvedValue(true); + + const result = { + status: "upgradedToFamilies" as const, + organizationId: "org-111", + }; + + await testComponent["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-111", + }); + }); + + it("should handle completing upgrade to Teams successfully", async () => { + const { component: testComponent } = await createComponentWithDialogData(defaultDialogData); + mockRouter.navigate.mockResolvedValue(true); + + const result = { + status: "upgradedToTeams" as const, + organizationId: "org-123", + }; + + await testComponent["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToTeams", + organizationId: "org-123", + }); + }); + + it("should handle completing upgrade to Enterprise successfully", async () => { + const { component: testComponent } = await createComponentWithDialogData(defaultDialogData); + mockRouter.navigate.mockResolvedValue(true); + + const result = { + status: "upgradedToEnterprise" as const, + organizationId: "org-456", + }; + + await testComponent["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToEnterprise", + organizationId: "org-456", + }); + }); + + it("should redirect to organization vault after Teams upgrade when redirectOnCompletion is true", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockRouter.navigate.mockResolvedValue(true); + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + const result = { + status: "upgradedToTeams" as const, + organizationId: "org-123", + }; + + await customComponent["onComplete"](result); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-123/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToTeams", + organizationId: "org-123", + }); + }); + + it("should redirect to organization vault after Enterprise upgrade when redirectOnCompletion is true", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockRouter.navigate.mockResolvedValue(true); + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + const result = { + status: "upgradedToEnterprise" as const, + organizationId: "org-789", + }; + + await customComponent["onComplete"](result); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-789/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToEnterprise", + organizationId: "org-789", + }); + }); + + it("should redirect to organization vault after Families upgrade when redirectOnCompletion is true", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockRouter.navigate.mockResolvedValue(true); + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + const result = { + status: "upgradedToFamilies" as const, + organizationId: "org-999", + }; + + await customComponent["onComplete"](result); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-999/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-999", + }); + }); + + it("should not redirect when redirectOnCompletion is false", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: false, + }; + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + const result = { + status: "upgradedToTeams" as const, + organizationId: "org-123", + }; + + await customComponent["onComplete"](result); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToTeams", + organizationId: "org-123", + }); + }); + + it("should handle closed status", async () => { + const { component: testComponent } = await createComponentWithDialogData(defaultDialogData); + + const result: PremiumOrgUpgradePaymentResult = { status: "closed", organizationId: null }; + + await testComponent["onComplete"](result); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "closed", + organizationId: null, + }); + }); + }); + + describe("onCloseClicked", () => { + it("should close dialog", async () => { + await component["onCloseClicked"](); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); + + describe("Child Component Display Logic", () => { + describe("Plan Selection Step", () => { + it("should display app-premium-org-upgrade on plan selection step", async () => { + const { fixture } = await createComponentWithDialogData(defaultDialogData); + + const premiumOrgUpgradeElement = + fixture.nativeElement.querySelector("app-premium-org-upgrade"); + + expect(premiumOrgUpgradeElement).toBeTruthy(); + }); + }); + + describe("Payment Step", () => { + it("should display app-premium-org-upgrade-payment on payment step", async () => { + const customDialogData: PremiumOrgUpgradeDialogParams = { + account: mockAccount, + initialStep: PremiumOrgUpgradeDialogStep.Payment, + selectedPlan: "teams" as BusinessSubscriptionPricingTierId, + }; + + const { fixture } = await createComponentWithDialogData(customDialogData); + + const premiumOrgUpgradePaymentElement = fixture.nativeElement.querySelector( + "app-premium-org-upgrade-payment", + ); + + expect(premiumOrgUpgradePaymentElement).toBeTruthy(); + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.ts new file mode 100644 index 00000000000..0026700b167 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.ts @@ -0,0 +1,210 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + computed, + Inject, + OnInit, + signal, +} from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { Router } from "@angular/router"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTierId, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + ButtonModule, + DialogConfig, + DialogModule, + DialogRef, + DialogService, +} from "@bitwarden/components"; + +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 { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; + +export const PremiumOrgUpgradeDialogStatus = { + Closed: "closed", + UpgradedToFamilies: "upgradedToFamilies", + UpgradedToTeams: "upgradedToTeams", + UpgradedToEnterprise: "upgradedToEnterprise", +} as const; + +export const PremiumOrgUpgradeDialogStep = { + PlanSelection: "planSelection", + Payment: "payment", +} as const; + +export type PremiumOrgUpgradeDialogStatus = UnionOfValues; +export type PremiumOrgUpgradeDialogStep = UnionOfValues; + +export type PremiumOrgUpgradeDialogResult = { + status: PremiumOrgUpgradeDialogStatus; + organizationId?: string | null; +}; + +/** + * Parameters for the PremiumOrgUpgradeDialog component. + * In order to open the dialog to a specific step, you must provide the `initialStep` parameter and a `selectedPlan` if the step is `Payment`. + * + * @property {Account} account - The user account information. + * @property {PremiumOrgUpgradeDialogStep | null} PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan (Families, Teams, or Enterprise)y. + * @property {BusinessSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any. + * @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title. + * @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button. + * @property {boolean} [redirectOnCompletion] - Whether to redirect after successful upgrade to organization vault. + */ +export type PremiumOrgUpgradeDialogParams = { + account: Account; + initialStep?: PremiumOrgUpgradeDialogStep | null; + selectedPlan?: BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null; + redirectOnCompletion?: boolean; +}; + +@Component({ + selector: "app-premium-org-upgrade-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + DialogModule, + ButtonModule, + BillingServicesModule, + PremiumOrgUpgradeComponent, + PremiumOrgUpgradePaymentComponent, + ], + providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient], + templateUrl: "./premium-org-upgrade-dialog.component.html", +}) +export class PremiumOrgUpgradeDialogComponent implements OnInit { + // Use signals for dialog state because inputs depend on parent component + protected readonly step = signal( + PremiumOrgUpgradeDialogStep.PlanSelection, + ); + protected readonly selectedPlan = signal< + BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null + >(null); + protected readonly account = signal(null); + protected readonly hasPremiumPersonally = toSignal( + this.billingAccountProfileStateService.hasPremiumPersonally$(this.params.account.id), + { initialValue: false }, + ); + protected readonly premiumToOrganizationUpgradeEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade), + { initialValue: false }, + ); + protected readonly isPremiumOrgUpgradeEnabled = computed( + () => this.hasPremiumPersonally() && this.premiumToOrganizationUpgradeEnabled(), + ); + + protected readonly PaymentStep = PremiumOrgUpgradeDialogStep.Payment; + protected readonly PlanSelectionStep = PremiumOrgUpgradeDialogStep.PlanSelection; + + constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) private params: PremiumOrgUpgradeDialogParams, + private router: Router, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private configService: ConfigService, + ) {} + + async ngOnInit(): Promise { + this.account.set(this.params.account); + this.step.set(this.params.initialStep ?? PremiumOrgUpgradeDialogStep.PlanSelection); + this.selectedPlan.set(this.params.selectedPlan ?? null); + } + + protected onPlanSelected( + planId: BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId, + ): void { + this.selectedPlan.set(planId); + this.nextStep(); + } + + protected async onCloseClicked(): Promise { + this.close({ status: PremiumOrgUpgradeDialogStatus.Closed }); + } + + private close(result: PremiumOrgUpgradeDialogResult): void { + this.dialogRef.close(result); + } + + protected nextStep() { + if (this.step() === PremiumOrgUpgradeDialogStep.PlanSelection) { + this.step.set(PremiumOrgUpgradeDialogStep.Payment); + } + } + + 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() === PremiumOrgUpgradeDialogStep.Payment && this.params?.initialStep == null) { + this.step.set(PremiumOrgUpgradeDialogStep.PlanSelection); + this.selectedPlan.set(null); + } else { + this.close({ status: PremiumOrgUpgradeDialogStatus.Closed }); + } + } + + protected async onComplete(result: PremiumOrgUpgradePaymentResult): Promise { + let status: PremiumOrgUpgradeDialogStatus; + switch (result.status) { + case "upgradedToFamilies": + status = PremiumOrgUpgradeDialogStatus.UpgradedToFamilies; + break; + case "upgradedToTeams": + status = PremiumOrgUpgradeDialogStatus.UpgradedToTeams; + break; + case "upgradedToEnterprise": + status = PremiumOrgUpgradeDialogStatus.UpgradedToEnterprise; + break; + case "closed": + status = PremiumOrgUpgradeDialogStatus.Closed; + break; + default: + status = PremiumOrgUpgradeDialogStatus.Closed; + } + + this.close({ status, organizationId: result.organizationId }); + + // Redirect to organization vault after successful upgrade + if ( + this.params.redirectOnCompletion && + (status === PremiumOrgUpgradeDialogStatus.UpgradedToFamilies || + status === PremiumOrgUpgradeDialogStatus.UpgradedToEnterprise || + status === PremiumOrgUpgradeDialogStatus.UpgradedToTeams) + ) { + const redirectUrl = `/organizations/${result.organizationId}/vault`; + await this.router.navigate([redirectUrl]); + } + } + + /** + * Opens the premium org upgrade dialog. + * + * @param dialogService - The dialog service used to open the component + * @param dialogConfig - The configuration for the dialog including PremiumOrgUpgradeDialogParams data + * @returns A dialog reference object of type DialogRef + */ + static open( + dialogService: DialogService, + dialogConfig: DialogConfig, + ): DialogRef { + return dialogService.open(PremiumOrgUpgradeDialogComponent, { + data: dialogConfig.data, + }); + } +}