1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

feat(billing): Add dedicated Premium to Organization upgrade dialog

This commit is contained in:
Stephon Brown
2026-02-04 14:53:55 -05:00
parent 127b6a29f9
commit cfe7108a31
3 changed files with 673 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<!-- Premium-to-Organization upgrade flow: User already has premium personally and is upgrading to Teams/Enterprise -->
@if (step() == PlanSelectionStep) {
<app-premium-org-upgrade
(planSelected)="onPlanSelected($event)"
(closeClicked)="onCloseClicked()"
/>
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
<app-premium-org-upgrade-payment
[selectedPlanId]="selectedPlan()"
[account]="account()"
(goBack)="previousStep()"
(complete)="onComplete($event)"
/>
}

View File

@@ -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<string | null>(null);
readonly hideContinueWithoutUpgradingButton = input<boolean>(false);
planSelected = output<BusinessSubscriptionPricingTierId>();
closeClicked = output<PremiumOrgUpgradePaymentStatus>();
}
@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<Account | null>(null);
goBack = output<void>();
complete = output<{ status: PremiumOrgUpgradePaymentStatus; organizationId: string | null }>();
}
describe("PremiumOrgUpgradeDialogComponent", () => {
let component: PremiumOrgUpgradeDialogComponent;
let fixture: ComponentFixture<PremiumOrgUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockRouter = mock<Router>();
const mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const mockConfigService = mock<ConfigService>();
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<PremiumOrgUpgradeDialogComponent>;
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();
});
});
});
});

View File

@@ -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<typeof PremiumOrgUpgradeDialogStatus>;
export type PremiumOrgUpgradeDialogStep = UnionOfValues<typeof PremiumOrgUpgradeDialogStep>;
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>(
PremiumOrgUpgradeDialogStep.PlanSelection,
);
protected readonly selectedPlan = signal<
BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null
>(null);
protected readonly account = signal<Account | null>(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<PremiumOrgUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: PremiumOrgUpgradeDialogParams,
private router: Router,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
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<void> {
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<void> {
// 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<void> {
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<PremiumOrgUpgradeDialogResult>
*/
static open(
dialogService: DialogService,
dialogConfig: DialogConfig<PremiumOrgUpgradeDialogParams>,
): DialogRef<PremiumOrgUpgradeDialogResult> {
return dialogService.open<PremiumOrgUpgradeDialogResult>(PremiumOrgUpgradeDialogComponent, {
data: dialogConfig.data,
});
}
}