diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.html b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html index 9bb788c1f36..a9b9a69f90e 100644 --- a/apps/web/src/app/billing/individual/subscription/account-subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html @@ -25,7 +25,7 @@ diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts index 183f4f82666..d8e25de7965 100644 --- a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, lastValueFrom, map } from "rxjs"; +import { firstValueFrom, lastValueFrom, map, switchMap, of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -39,6 +39,11 @@ import { openOffboardingSurvey, } from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component"; +import { + PremiumOrgUpgradeDialogComponent, + PremiumOrgUpgradeDialogParams, +} from "../upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component"; + @Component({ templateUrl: "./account-subscription.component.html", changeDetection: ChangeDetectionStrategy.OnPush, @@ -65,20 +70,30 @@ export class AccountSubscriptionComponent { private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction); private toastService = inject(ToastService); + readonly account = toSignal(this.accountService.activeAccount$); + + readonly hasPremiumPersonally = toSignal( + this.accountService.activeAccount$.pipe( + switchMap((account) => { + if (!account) { + return of(false); + } + return this.billingAccountProfileStateService.hasPremiumPersonally$(account.id); + }), + ), + { initialValue: false }, + ); + readonly subscription = resource({ loader: async () => { const redirectToPremiumPage = async (): Promise => { await this.router.navigate(["/settings/subscription/premium"]); return null; }; - const account = await firstValueFrom(this.accountService.activeAccount$); - if (!account) { + if (!this.account()) { return await redirectToPremiumPage(); } - const hasPremiumPersonally = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), - ); - if (!hasPremiumPersonally) { + if (!this.hasPremiumPersonally()) { return await redirectToPremiumPage(); } return await this.accountBillingClient.getSubscription(); @@ -177,6 +192,13 @@ export class AccountSubscriptionComponent { { initialValue: false }, ); + readonly canUpgradeFromPremium = computed(() => { + // Since account is checked in hasPremiumPersonally, no need to check again here + const hasPremiumPersonally = this.hasPremiumPersonally(); + const upgradeEnabled = this.premiumToOrganizationUpgradeEnabled(); + return hasPremiumPersonally && upgradeEnabled; + }); + onSubscriptionCardAction = async (action: SubscriptionCardAction) => { switch (action) { case SubscriptionCardActions.ContactSupport: @@ -209,7 +231,7 @@ export class AccountSubscriptionComponent { await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }); break; case SubscriptionCardActions.UpgradePlan: - // TODO: Implement upgrade plan navigation + await this.openUpgradeDialog(); break; } }; @@ -288,4 +310,21 @@ export class AccountSubscriptionComponent { } } }; + + openUpgradeDialog = async (): Promise => { + const account = this.account(); + if (!account) { + return; + } + + const dialogParams: PremiumOrgUpgradeDialogParams = { + account, + redirectOnCompletion: true, + }; + + const dialogRef = PremiumOrgUpgradeDialogComponent.open(this.dialogService, { + data: dialogParams, + }); + await firstValueFrom(dialogRef.closed); + }; } 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..7392e83f7ca --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.html @@ -0,0 +1,13 @@ +@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..8c425966948 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.spec.ts @@ -0,0 +1,464 @@ +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 { + PremiumOrgUpgradePaymentComponent, + PremiumOrgUpgradePaymentResult, + PremiumOrgUpgradePaymentStatus, +} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component"; +import { PremiumOrgUpgradePlanSelectionComponent } from "../premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component"; + +import { + PremiumOrgUpgradeDialogComponent, + PremiumOrgUpgradeDialogParams, + PremiumOrgUpgradeDialogStep, +} from "./premium-org-upgrade-dialog.component"; + +@Component({ + selector: "app-premium-org-upgrade-plan-selection", + template: "", + standalone: true, + providers: [PremiumOrgUpgradePlanSelectionComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPremiumOrgUpgradePlanSelectionComponent { + 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, + }; + + /** + * 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: [PremiumOrgUpgradePlanSelectionComponent, PremiumOrgUpgradePaymentComponent], + }, + add: { + imports: [ + MockPremiumOrgUpgradePlanSelectionComponent, + 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: [PremiumOrgUpgradePlanSelectionComponent, PremiumOrgUpgradePaymentComponent], + }, + add: { + imports: [ + MockPremiumOrgUpgradePlanSelectionComponent, + 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); + }); + + 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("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("Premium and Feature Flag Requirements", () => { + it("should close dialog immediately if user does not have premium", async () => { + mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(false)); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + + await createComponentWithDialogData(defaultDialogData, true); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + + it("should close dialog immediately if feature flag is not enabled", async () => { + mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true)); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + await createComponentWithDialogData(defaultDialogData, true); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + + it("should close dialog immediately if user does not have premium and feature flag is not enabled", async () => { + mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(false)); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + await createComponentWithDialogData(defaultDialogData, true); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); + + describe("Child Component Display Logic", () => { + describe("Plan Selection Step", () => { + it("should display app-premium-org-upgrade-plan-selection on plan selection step", async () => { + const { fixture } = await createComponentWithDialogData(defaultDialogData); + + const premiumOrgUpgradeElement = fixture.nativeElement.querySelector( + "app-premium-org-upgrade-plan-selection", + ); + + 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..6e99587e3b5 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.ts @@ -0,0 +1,213 @@ +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 { + PremiumOrgUpgradePaymentComponent, + PremiumOrgUpgradePaymentResult, +} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component"; +import { PremiumOrgUpgradePlanSelectionComponent } from "../premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.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} [initialStep] - The initial step to open the dialog to, if any. + * @property {BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any. + * @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, + PremiumOrgUpgradePlanSelectionComponent, + 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 showPremiumToOrganizationUpgrade = 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 { + if (!this.showPremiumToOrganizationUpgrade()) { + // If the premium to organization upgrade feature is not enabled or user does not have premium personally, close the dialog + this.close({ status: PremiumOrgUpgradeDialogStatus.Closed }); + return; + } + 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, + }); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html new file mode 100644 index 00000000000..610f30f8465 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html @@ -0,0 +1,65 @@ +
+ @let changingPayment = isChangingPaymentMethod(); + + {{ upgradeToMessage() }} + + +
+
+ + {{ "organizationName" | i18n }} + + + {{ "organizationNameDescription" | i18n }} + + +
+ +
+ + + @if (!changingPayment) { +
{{ "billingAddress" | i18n }}
+ + + } +
+
+
+ +
+
+ + + + +
+
diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts new file mode 100644 index 00000000000..a6b8354f858 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts @@ -0,0 +1,576 @@ +import { + Component, + input, + ChangeDetectionStrategy, + CUSTOM_ELEMENTS_SCHEMA, + signal, + output, +} from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { CartSummaryComponent } from "@bitwarden/pricing"; + +import { AccountBillingClient } from "../../../clients/account-billing.client"; +import { PreviewInvoiceClient } from "../../../clients/preview-invoice.client"; +import { SubscriberBillingClient } from "../../../clients/subscriber-billing.client"; +import { + EnterBillingAddressComponent, + DisplayPaymentMethodInlineComponent, +} from "../../../payment/components"; + +import { + PremiumOrgUpgradePaymentComponent, + PremiumOrgUpgradePaymentStatus, +} from "./premium-org-upgrade-payment.component"; +import { PremiumOrgUpgradeService } from "./services/premium-org-upgrade.service"; + +// Mock Components +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "billing-cart-summary", + template: `

Mock Cart Summary

`, + providers: [{ provide: CartSummaryComponent, useClass: MockCartSummaryComponent }], +}) +class MockCartSummaryComponent { + readonly cart = input.required(); + readonly header = input(); + readonly isExpanded = signal(false); + readonly hidePricingTerm = input(false); +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-display-payment-method-inline", + template: `

Mock Display Payment Method

`, + providers: [ + { + provide: DisplayPaymentMethodInlineComponent, + useClass: MockDisplayPaymentMethodInlineComponent, + }, + ], +}) +class MockDisplayPaymentMethodInlineComponent { + readonly subscriber = input.required(); + readonly paymentMethod = input(); + readonly updated = output(); + readonly changePaymentMethodClicked = output(); +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-enter-billing-address", + template: `

Mock Enter Billing Address

`, + providers: [ + { + provide: EnterBillingAddressComponent, + useClass: MockEnterBillingAddressComponent, + }, + ], +}) +class MockEnterBillingAddressComponent { + readonly scenario = input.required(); + readonly group = input.required(); + + static getFormGroup = () => + new FormGroup({ + country: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + postalCode: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + line1: new FormControl(null), + line2: new FormControl(null), + city: new FormControl(null), + state: new FormControl(null), + taxId: new FormControl(null), + }); +} + +describe("PremiumOrgUpgradePaymentComponent", () => { + beforeAll(() => { + // Mock IntersectionObserver - required because DialogComponent uses it to detect scrollable content. + // This browser API doesn't exist in the Jest/Node.js test environment. + // This is necessary because we are unable to mock DialogComponent which is not directly importable + global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve() {} + } as any; + }); + + let component: PremiumOrgUpgradePaymentComponent; + let fixture: ComponentFixture; + const mockPremiumOrgUpgradeService = mock(); + const mockSubscriptionPricingService = mock(); + const mockToastService = mock(); + const mockAccountBillingClient = mock(); + const mockPreviewInvoiceClient = mock(); + const mockLogService = mock(); + const mockOrganizationService = mock(); + const mockSubscriberBillingClient = mock(); + const mockApiService = mock(); + const mockAccountService = mock(); + const mockI18nService = { t: jest.fn((key: string, ...params: any[]) => key) }; + + const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account; + const mockTeamsPlan: BusinessSubscriptionPricingTier = { + id: "teams", + name: "Teams", + description: "Teams plan", + availableCadences: ["annually"], + passwordManager: { + annualPricePerUser: 48, + type: "scalable", + features: [], + }, + secretsManager: { + annualPricePerUser: 24, + type: "scalable", + features: [], + }, + }; + const mockFamiliesPlan: PersonalSubscriptionPricingTier = { + id: "families", + name: "Families", + description: "Families plan", + availableCadences: ["annually"], + passwordManager: { + annualPrice: 40, + users: 6, + type: "packaged", + features: [], + }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined); + mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined); + mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({ + tax: 5.0, + total: 53.0, + credit: 10.0, + newPlanProratedMonths: 1, + }); + mockOrganizationService.organizations$.mockReturnValue(of([])); + mockAccountService.activeAccount$ = of(mockAccount); + mockSubscriberBillingClient.getPaymentMethod.mockResolvedValue({ + type: "card", + brand: "visa", + last4: "4242", + expiration: "12/2025", + }); + + mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue( + of([mockTeamsPlan]), + ); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of([mockFamiliesPlan]), + ); + + await TestBed.configureTestingModule({ + imports: [PremiumOrgUpgradePaymentComponent], + providers: [ + { provide: PremiumOrgUpgradeService, useValue: mockPremiumOrgUpgradeService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: ToastService, useValue: mockToastService }, + { provide: LogService, useValue: mockLogService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: AccountBillingClient, useValue: mockAccountBillingClient }, + { provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient }, + { provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient }, + { provide: AccountService, useValue: mockAccountService }, + { provide: ApiService, useValue: mockApiService }, + { + provide: KeyService, + useValue: { + makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]), + }, + }, + { + provide: SyncService, + useValue: { fullSync: jest.fn().mockResolvedValue(undefined) }, + }, + { provide: OrganizationService, useValue: mockOrganizationService }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(PremiumOrgUpgradePaymentComponent, { + add: { + imports: [ + MockEnterBillingAddressComponent, + MockDisplayPaymentMethodInlineComponent, + MockCartSummaryComponent, + ], + }, + remove: { + imports: [ + EnterBillingAddressComponent, + DisplayPaymentMethodInlineComponent, + CartSummaryComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId); + fixture.componentRef.setInput("account", mockAccount); + fixture.detectChanges(); + + // Wait for ngOnInit to complete + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with the correct plan details", () => { + expect(component["selectedPlan"]()).not.toBeNull(); + expect(component["selectedPlan"]()?.details.id).toBe("teams"); + expect(component["upgradeToMessage"]()).toContain("upgradeToTeams"); + }); + + it("should handle invalid plan id that doesn't exist in pricing tiers", async () => { + // Create a fresh component with an invalid plan ID from the start + const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + const newComponent = newFixture.componentInstance; + + newFixture.componentRef.setInput( + "selectedPlanId", + "non-existent-plan" as BusinessSubscriptionPricingTierId, + ); + newFixture.componentRef.setInput("account", mockAccount); + newFixture.detectChanges(); + + await newFixture.whenStable(); + + expect(newComponent["selectedPlan"]()).toBeNull(); + }); + + it("should handle invoice preview errors gracefully", fakeAsync(() => { + mockPremiumOrgUpgradeService.previewProratedInvoice.mockRejectedValue( + new Error("Network error"), + ); + + // Component should still render and be usable even when invoice preview fails + fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId); + fixture.componentRef.setInput("account", mockAccount); + fixture.detectChanges(); + tick(); + + expect(component).toBeTruthy(); + expect(component["selectedPlan"]()).not.toBeNull(); + expect(mockToastService.showToast).not.toHaveBeenCalled(); + })); + + describe("submit", () => { + it("should successfully upgrade to organization", async () => { + const completeSpy = jest.spyOn(component["complete"], "emit"); + + // Mock processUpgrade to bypass form validation + jest.spyOn(component as any, "processUpgrade").mockResolvedValue({ + status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + organizationId: null, + }); + + component["formGroup"].setValue({ + organizationName: "My New Org", + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "plansUpdated", + }); + expect(completeSpy).toHaveBeenCalledWith({ + status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + organizationId: null, + }); + }); + + it("should show an error toast if upgrade fails", async () => { + // Mock processUpgrade to throw an error + jest + .spyOn(component as any, "processUpgrade") + .mockRejectedValue(new Error("Submission Error")); + + component["formGroup"].setValue({ + organizationName: "My New Org", + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "upgradeErrorMessage", + }); + }); + + it("should not submit if the form is invalid", async () => { + const markAllAsTouchedSpy = jest.spyOn(component["formGroup"], "markAllAsTouched"); + component["formGroup"].get("organizationName")?.setValue(""); + fixture.detectChanges(); + + await component["submit"](); + + expect(markAllAsTouchedSpy).toHaveBeenCalled(); + expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled(); + }); + }); + + it("should map plan id to correct upgrade status", () => { + expect(component["getUpgradeStatus"]("families" as PersonalSubscriptionPricingTierId)).toBe( + PremiumOrgUpgradePaymentStatus.UpgradedToFamilies, + ); + expect(component["getUpgradeStatus"]("teams" as BusinessSubscriptionPricingTierId)).toBe( + PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + ); + expect(component["getUpgradeStatus"]("enterprise" as BusinessSubscriptionPricingTierId)).toBe( + PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise, + ); + expect(component["getUpgradeStatus"]("some-other-plan" as any)).toBe( + PremiumOrgUpgradePaymentStatus.Closed, + ); + }); + + describe("Invoice Preview", () => { + it("should return zero values when billing address is incomplete", fakeAsync(() => { + component["formGroup"].patchValue({ + organizationName: "Test Org", + billingAddress: { + country: "US", + postalCode: "", // Missing postal code + }, + }); + + // Advance time to allow any async operations to complete + tick(1500); + fixture.detectChanges(); + + const estimatedInvoice = component["estimatedInvoice"](); + expect(estimatedInvoice.tax).toBe(0); + expect(estimatedInvoice.total).toBe(0); + })); + }); + + describe("Form Validation", () => { + it("should validate organization name is required", () => { + component["formGroup"].patchValue({ organizationName: "" }); + expect(component["formGroup"].get("organizationName")?.invalid).toBe(true); + }); + + it("should validate organization name when provided", () => { + component["formGroup"].patchValue({ organizationName: "My Organization" }); + expect(component["formGroup"].get("organizationName")?.valid).toBe(true); + }); + }); + + describe("Cart Calculation", () => { + it("should calculate cart with correct values for selected plan", () => { + const cart = component["cart"](); + expect(cart.passwordManager.seats.cost).toBe(48); // Teams annual price per user + expect(cart.passwordManager.seats.quantity).toBe(1); + expect(cart.cadence).toBe("annually"); + }); + + it("should return default cart when no plan is selected", () => { + component["selectedPlan"].set(null); + const cart = component["cart"](); + + expect(cart.passwordManager.seats.cost).toBe(0); + expect(cart.passwordManager.seats.quantity).toBe(0); + expect(cart.estimatedTax).toBe(0); + }); + }); + + describe("ngAfterViewInit", () => { + it("should collapse cart summary after view init", () => { + const mockCartSummary = { + isExpanded: signal(true), + } as any; + jest.spyOn(component, "cartSummaryComponent").mockReturnValue(mockCartSummary); + + component.ngAfterViewInit(); + + expect(mockCartSummary.isExpanded()).toBe(false); + }); + }); + + describe("Plan Price Calculation", () => { + it("should calculate price for personal plan with annualPrice", () => { + const price = component["getPlanPrice"](mockFamiliesPlan); + expect(price).toBe(40); + }); + + it("should calculate price for business plan with annualPricePerUser", () => { + const price = component["getPlanPrice"](mockTeamsPlan); + expect(price).toBe(48); + }); + + it("should return 0 when passwordManager is missing", () => { + const invalidPlan = { ...mockTeamsPlan, passwordManager: undefined } as any; + const price = component["getPlanPrice"](invalidPlan); + expect(price).toBe(0); + }); + }); + + describe("processUpgrade", () => { + beforeEach(() => { + // Set paymentMethod signal for these tests + component["paymentMethod"].set({ + type: "card", + brand: "visa", + last4: "4242", + expiration: "12/2025", + }); + }); + + it("should throw error when billing address is incomplete", async () => { + component["formGroup"].patchValue({ + organizationName: "Test Org", + billingAddress: { + country: "", + postalCode: "", + }, + }); + + await expect(component["processUpgrade"]()).rejects.toThrow("Billing address is incomplete"); + }); + + it("should throw error when organization name is missing", async () => { + component["formGroup"].patchValue({ + organizationName: "", + billingAddress: { + country: "US", + postalCode: "12345", + }, + }); + + await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required"); + }); + }); + + describe("Plan Membership Messages", () => { + it("should return correct membership message for families plan", async () => { + const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + const newComponent = newFixture.componentInstance; + + newFixture.componentRef.setInput( + "selectedPlanId", + "families" as PersonalSubscriptionPricingTierId, + ); + newFixture.componentRef.setInput("account", mockAccount); + newFixture.detectChanges(); + await newFixture.whenStable(); + + expect(newComponent["planMembershipMessage"]()).toBe("familiesMembership"); + }); + + it("should return correct membership message for teams plan", () => { + expect(component["planMembershipMessage"]()).toBe("teamsMembership"); + }); + + it("should return correct membership message for enterprise plan", async () => { + const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + const newComponent = newFixture.componentInstance; + + newFixture.componentRef.setInput( + "selectedPlanId", + "enterprise" as BusinessSubscriptionPricingTierId, + ); + newFixture.componentRef.setInput("account", mockAccount); + newFixture.detectChanges(); + await newFixture.whenStable(); + + expect(newComponent["planMembershipMessage"]()).toBe("enterpriseMembership"); + }); + }); + + describe("Error Handling", () => { + it("should log error and continue when submit fails", async () => { + jest.spyOn(component as any, "processUpgrade").mockRejectedValue(new Error("Network error")); + + component["formGroup"].setValue({ + organizationName: "My New Org", + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", expect.any(Error)); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "upgradeErrorMessage", + }); + }); + }); + + describe("goBack Output", () => { + it("should emit goBack event when back action is triggered", () => { + const goBackSpy = jest.spyOn(component["goBack"], "emit"); + component["goBack"].emit(); + expect(goBackSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts new file mode 100644 index 00000000000..4a7207686b8 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts @@ -0,0 +1,469 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + input, + OnInit, + output, + signal, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { + catchError, + of, + combineLatest, + startWith, + debounceTime, + switchMap, + Observable, + from, + defer, + map, + tap, +} from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { Cart, CartSummaryComponent } from "@bitwarden/pricing"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { SubscriberBillingClient } from "../../../clients/subscriber-billing.client"; +import { + EnterBillingAddressComponent, + getBillingAddressFromForm, + DisplayPaymentMethodInlineComponent, +} from "../../../payment/components"; +import { MaskedPaymentMethod } from "../../../payment/types"; +import { BitwardenSubscriber, mapAccountToSubscriber } from "../../../types"; + +import { + PremiumOrgUpgradeService, + PremiumOrgUpgradePlanDetails, + InvoicePreview, +} from "./services/premium-org-upgrade.service"; + +export const PremiumOrgUpgradePaymentStatus = { + Closed: "closed", + UpgradedToTeams: "upgradedToTeams", + UpgradedToEnterprise: "upgradedToEnterprise", + UpgradedToFamilies: "upgradedToFamilies", +} as const; + +export type PremiumOrgUpgradePaymentStatus = UnionOfValues; + +export type PremiumOrgUpgradePaymentResult = { + status: PremiumOrgUpgradePaymentStatus; + organizationId?: string | null; +}; + +@Component({ + selector: "app-premium-org-upgrade-payment", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DialogModule, + SharedModule, + CartSummaryComponent, + ButtonModule, + EnterBillingAddressComponent, + DisplayPaymentMethodInlineComponent, + ], + providers: [PremiumOrgUpgradeService], + templateUrl: "./premium-org-upgrade-payment.component.html", +}) +export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit { + private readonly INITIAL_TAX_VALUE = 0; + private readonly DEFAULT_SEAT_COUNT = 1; + private readonly DEFAULT_CADENCE = "annually"; + private readonly PLAN_MEMBERSHIP_MESSAGES: Record = { + families: "familiesMembership", + teams: "teamsMembership", + enterprise: "enterpriseMembership", + }; + private readonly UPGRADE_STATUS_MAP: Record = { + families: PremiumOrgUpgradePaymentStatus.UpgradedToFamilies, + teams: PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + enterprise: PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise, + }; + private readonly UPGRADE_MESSAGE_KEYS: Record = { + families: "upgradeToFamilies", + teams: "upgradeToTeams", + enterprise: "upgradeToEnterprise", + }; + + protected readonly selectedPlanId = input.required< + PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId + >(); + protected readonly account = input.required(); + protected goBack = output(); + protected complete = output(); + + readonly cartSummaryComponent = viewChild.required(CartSummaryComponent); + + protected formGroup = new FormGroup({ + organizationName: new FormControl("", [Validators.required]), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + protected readonly selectedPlan = signal(null); + protected readonly loading = signal(true); + protected readonly upgradeToMessage = signal(""); + + // Signals for payment method + protected readonly paymentMethod = signal(null); + protected readonly subscriber = signal(null); + /** + * Indicates whether the payment method is currently being changed. + * This is used to disable the submit button while a payment method change is in progress. + * or to hide other UI elements as needed. + */ + protected readonly isChangingPaymentMethod = signal(false); + + protected readonly planMembershipMessage = computed( + () => this.PLAN_MEMBERSHIP_MESSAGES[this.selectedPlanId()] ?? "", + ); + + // Use defer to lazily create the observable when subscribed to + protected estimatedInvoice$ = defer(() => + combineLatest([this.formGroup.controls.billingAddress.valueChanges]).pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + switchMap(() => this.refreshInvoicePreview$()), + ), + ); + + protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, { + initialValue: this.getEmptyInvoicePreview(), + }); + + private readonly i18nService = inject(I18nService); + private readonly subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction); + private readonly toastService = inject(ToastService); + private readonly logService = inject(LogService); + private readonly destroyRef = inject(DestroyRef); + private readonly premiumOrgUpgradeService = inject(PremiumOrgUpgradeService); + private readonly subscriberBillingClient = inject(SubscriberBillingClient); + private readonly accountService = inject(AccountService); + + constructor() {} + // Cart Summary data + protected readonly cart = computed(() => { + if (!this.selectedPlan()) { + return { + hidePricingTerm: true, + passwordManager: { + seats: { + translationKey: this.planMembershipMessage(), + cost: 0, + quantity: 0, + hideBreakdown: true, + }, + }, + cadence: this.DEFAULT_CADENCE, + estimatedTax: this.INITIAL_TAX_VALUE, + }; + } + + return { + hidePricingTerm: true, + passwordManager: { + seats: { + translationKey: this.getMembershipTranslationKey(), + translationParams: this.getMembershipTranslationParams(), + cost: this.getCartCost(), + quantity: this.DEFAULT_SEAT_COUNT, + hideBreakdown: true, + }, + }, + cadence: this.DEFAULT_CADENCE, + estimatedTax: this.estimatedInvoice().tax, + credit: { + value: this.estimatedInvoice().credit, + translationKey: "premiumSubscriptionCredit", + }, + }; + }); + + async ngOnInit(): Promise { + // If the selected plan is Personal Premium, no upgrade is needed + if (this.selectedPlanId() == PersonalSubscriptionPricingTierIds.Premium) { + this.complete.emit({ + status: PremiumOrgUpgradePaymentStatus.Closed, + organizationId: null, + }); + return; + } + + combineLatest([ + this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(), + this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(), + ]) + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([personalPlans, businessPlans]) => { + const plans: (PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier)[] = [ + ...personalPlans, + ...businessPlans, + ]; + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + + if (planDetails) { + this.setSelectedPlan(planDetails); + this.setUpgradeMessage(planDetails); + } else { + this.complete.emit({ + status: PremiumOrgUpgradePaymentStatus.Closed, + organizationId: null, + }); + return; + } + }); + + this.accountService.activeAccount$ + .pipe( + mapAccountToSubscriber, + switchMap((subscriber) => + from(this.subscriberBillingClient.getPaymentMethod(subscriber)).pipe( + map((paymentMethod) => ({ subscriber, paymentMethod })), + ), + ), + tap(({ subscriber, paymentMethod }) => { + this.subscriber.set(subscriber); + this.paymentMethod.set(paymentMethod); + this.loading.set(false); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + ngAfterViewInit(): void { + const cartSummaryComponent = this.cartSummaryComponent(); + cartSummaryComponent.isExpanded.set(false); + } + + /** + * Updates the payment method when changed through the DisplayPaymentMethodComponent. + * @param newPaymentMethod The updated payment method details + */ + handlePaymentMethodUpdate(newPaymentMethod: MaskedPaymentMethod) { + this.paymentMethod.set(newPaymentMethod); + } + + /** + * Handles changes to the payment method changing state. + * @param isChanging Whether the payment method is currently being changed + */ + handlePaymentMethodChangingStateChange(isChanging: boolean) { + this.isChangingPaymentMethod.set(isChanging); + } + + protected submit = async (): Promise => { + if (!this.formGroup.valid) { + this.formGroup.markAllAsTouched(); + return; + } + + if (!this.selectedPlan()) { + throw new Error("No plan selected"); + } + + try { + const result = await this.processUpgrade(); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("plansUpdated", this.selectedPlan()?.details.name), + }); + this.complete.emit(result); + } catch (error: unknown) { + this.logService.error("Upgrade failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("upgradeErrorMessage"), + }); + } + }; + + private async processUpgrade(): Promise { + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const organizationName = this.formGroup.value?.organizationName; + + if (!billingAddress.country || !billingAddress.postalCode) { + throw new Error("Billing address is incomplete"); + } + + if (!organizationName) { + throw new Error("Organization name is required"); + } + + const organizationId = await this.premiumOrgUpgradeService.upgradeToOrganization( + this.account(), + organizationName, + this.selectedPlan()!, + billingAddress, + ); + + return { + status: this.getUpgradeStatus(this.selectedPlanId()), + organizationId, + }; + } + + private getUpgradeStatus(planId: string): PremiumOrgUpgradePaymentStatus { + return this.UPGRADE_STATUS_MAP[planId] ?? PremiumOrgUpgradePaymentStatus.Closed; + } + + /** + * Gets the appropriate translation key for the membership display. + * Returns a prorated message if the plan has prorated months, otherwise returns the standard plan message. + */ + private getMembershipTranslationKey(): string { + return this.estimatedInvoice()?.newPlanProratedMonths > 0 + ? "planProratedMembershipInMonths" + : this.planMembershipMessage(); + } + + /** + * Gets the translation parameters for the membership display. + * For prorated plans, returns an array with the plan name and formatted month duration. + * For non-prorated plans, returns an empty array. + */ + private getMembershipTranslationParams(): string[] { + if (this.estimatedInvoice()?.newPlanProratedMonths > 0) { + const months = this.estimatedInvoice()!.newPlanProratedMonths; + const monthLabel = this.formatMonthLabel(months); + return [this.selectedPlan()!.details.name, monthLabel]; + } + return []; + } + + /** + * Formats month count into a readable string (e.g., "1 month", "3 months"). + */ + private formatMonthLabel(months: number): string { + return `${months} month${months > 1 ? "s" : ""}`; + } + + /** + * Calculates the cart cost, using prorated amount if available, otherwise the plan cost. + */ + private getCartCost(): number { + const proratedAmount = this.estimatedInvoice().newPlanProratedAmount; + return proratedAmount && proratedAmount > 0 ? proratedAmount : this.selectedPlan()!.cost; + } + + /** + * Sets the selected plan with tier, details, and cost. + */ + private setSelectedPlan( + planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier, + ): void { + this.selectedPlan.set({ + tier: this.selectedPlanId(), + details: planDetails, + cost: this.getPlanPrice(planDetails), + }); + } + + /** + * Sets the upgrade message based on the selected plan. + */ + private setUpgradeMessage( + planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier, + ): void { + const messageKey = this.UPGRADE_MESSAGE_KEYS[this.selectedPlanId()]; + const message = messageKey ? this.i18nService.t(messageKey, planDetails.name) : ""; + this.upgradeToMessage.set(message); + } + + /** + * Calculates the price for the currently selected plan. + * + * This method retrieves the `passwordManager` details from the selected plan. It then determines + * the appropriate price based on the properties available on the `passwordManager` object. + * It prioritizes `annualPrice` for individual-style plans and falls back to `annualPricePerUser` + * for user-based plans. + * + * @returns The annual price of the plan as a number. Returns `0` if the plan or its price cannot be determined. + */ + private getPlanPrice( + plan: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier, + ): number { + const passwordManager = plan.passwordManager; + if (!passwordManager) { + return 0; + } + + if ("annualPrice" in passwordManager) { + return passwordManager.annualPrice ?? 0; + } else if ("annualPricePerUser" in passwordManager) { + return passwordManager.annualPricePerUser ?? 0; + } + return 0; + } + + /** + * Returns an empty invoice preview with default values. + */ + private getEmptyInvoicePreview(): InvoicePreview { + return { + tax: this.INITIAL_TAX_VALUE, + total: 0, + credit: 0, + newPlanProratedMonths: 0, + newPlanProratedAmount: 0, + }; + } + + /** + * Refreshes the invoice preview based on the current form state. + */ + private refreshInvoicePreview$(): Observable { + if (this.formGroup.invalid || !this.selectedPlan()) { + return of(this.getEmptyInvoicePreview()); + } + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + if (!billingAddress.country || !billingAddress.postalCode) { + return of(this.getEmptyInvoicePreview()); + } + + return from( + this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress), + ).pipe( + catchError((error: unknown) => { + this.logService.error("Invoice preview failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("invoicePreviewErrorMessage"), + }); + return of(this.getEmptyInvoicePreview()); + }), + ); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts new file mode 100644 index 00000000000..6719943cf72 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts @@ -0,0 +1,266 @@ +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BusinessSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { KeyService } from "@bitwarden/key-management"; + +import { AccountBillingClient } from "../../../../clients/account-billing.client"; +import { PreviewInvoiceClient } from "../../../../clients/preview-invoice.client"; +import { BillingAddress } from "../../../../payment/types"; + +import { + PremiumOrgUpgradePlanDetails, + PremiumOrgUpgradeService, +} from "./premium-org-upgrade.service"; + +describe("PremiumOrgUpgradeService", () => { + let service: PremiumOrgUpgradeService; + let accountBillingClient: jest.Mocked; + let previewInvoiceClient: jest.Mocked; + let syncService: jest.Mocked; + let keyService: jest.Mocked; + let organizationService: jest.Mocked; + + const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account; + const mockPlanDetails: PremiumOrgUpgradePlanDetails = { + tier: BusinessSubscriptionPricingTierIds.Teams, + details: { + id: BusinessSubscriptionPricingTierIds.Teams, + name: "Teams", + passwordManager: { + annualPrice: 48, + users: 1, + }, + }, + } as any; + const mockBillingAddress: BillingAddress = { + country: "US", + postalCode: "12345", + line1: null, + line2: null, + city: null, + state: null, + taxId: null, + }; + + beforeEach(() => { + accountBillingClient = { + upgradePremiumToOrganization: jest.fn().mockResolvedValue(undefined), + } as any; + previewInvoiceClient = { + previewProrationForPremiumUpgrade: jest + .fn() + .mockResolvedValue({ tax: 5, total: 55, credit: 0 }), + } as any; + syncService = { + fullSync: jest.fn().mockResolvedValue(undefined), + } as any; + keyService = { + makeOrgKey: jest + .fn() + .mockResolvedValue([{ encryptedString: "encrypted-string" }, "decrypted-key"]), + } as any; + organizationService = { + organizations$: jest.fn().mockReturnValue( + of([ + { + id: "new-org-id", + name: "Test Organization", + isOwner: true, + } as Organization, + ]), + ), + } as any; + + TestBed.configureTestingModule({ + providers: [ + PremiumOrgUpgradeService, + { provide: AccountBillingClient, useValue: accountBillingClient }, + { provide: PreviewInvoiceClient, useValue: previewInvoiceClient }, + { provide: SyncService, useValue: syncService }, + { provide: AccountService, useValue: { activeAccount$: of(mockAccount) } }, + { provide: KeyService, useValue: keyService }, + { provide: OrganizationService, useValue: organizationService }, + ], + }); + + service = TestBed.inject(PremiumOrgUpgradeService); + }); + + describe("upgradeToOrganization", () => { + it("should successfully upgrade premium account to organization and return organization ID", async () => { + const result = await service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ); + + expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith( + "Test Organization", + "encrypted-string", + 2, // ProductTierType.Teams + "annually", + mockBillingAddress, + ); + expect(keyService.makeOrgKey).toHaveBeenCalledWith("user-id"); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + expect(organizationService.organizations$).toHaveBeenCalledWith("user-id"); + expect(result).toBe("new-org-id"); + }); + + it("should throw an error if organization name is missing", async () => { + await expect( + service.upgradeToOrganization(mockAccount, "", mockPlanDetails, mockBillingAddress), + ).rejects.toThrow("Organization name is required for organization upgrade"); + }); + + it("should throw an error if billing address is incomplete", async () => { + const incompleteBillingAddress: BillingAddress = { + country: "", + postalCode: "", + line1: null, + line2: null, + city: null, + state: null, + taxId: null, + }; + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + incompleteBillingAddress, + ), + ).rejects.toThrow("Billing address information is incomplete"); + }); + + it("should throw an error for invalid plan tier", async () => { + const invalidPlanDetails = { + tier: "invalid-tier" as any, + details: mockPlanDetails.details, + cost: 0, + }; + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + invalidPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Invalid plan tier for organization upgrade"); + }); + + it("should propagate error if key generation fails", async () => { + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Key generation failed"); + }); + + it("should throw an error if encrypted string is undefined", async () => { + keyService.makeOrgKey.mockResolvedValue([ + { encryptedString: null } as any, + "decrypted-key" as any, + ]); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Failed to generate encrypted organization key"); + }); + + it("should propagate error if upgrade API call fails", async () => { + accountBillingClient.upgradePremiumToOrganization.mockRejectedValue( + new Error("API call failed"), + ); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("API call failed"); + }); + + it("should propagate error if sync fails", async () => { + syncService.fullSync.mockRejectedValue(new Error("Sync failed")); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Sync failed"); + }); + + it("should throw an error if organization is not found after sync", async () => { + organizationService.organizations$.mockReturnValue( + of([ + { + id: "different-org-id", + name: "Different Organization", + isOwner: true, + } as Organization, + ]), + ); + + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Failed to find newly created organization"); + }); + + it("should throw an error if no organizations are returned", async () => { + organizationService.organizations$.mockReturnValue(of([])); + + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Failed to find newly created organization"); + }); + }); + + describe("previewProratedInvoice", () => { + it("should call previewProrationForPremiumUpgrade and return invoice preview", async () => { + const result = await service.previewProratedInvoice(mockPlanDetails, mockBillingAddress); + + expect(result).toEqual({ tax: 5, total: 55, credit: 0 }); + expect(previewInvoiceClient.previewProrationForPremiumUpgrade).toHaveBeenCalledWith( + 2, // ProductTierType.Teams + mockBillingAddress, + ); + }); + + it("should throw an error if invoice preview fails", async () => { + previewInvoiceClient.previewProrationForPremiumUpgrade.mockRejectedValue( + new Error("Invoice API error"), + ); + await expect( + service.previewProratedInvoice(mockPlanDetails, mockBillingAddress), + ).rejects.toThrow("Invoice API error"); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts new file mode 100644 index 00000000000..59c97e0373e --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { KeyService } from "@bitwarden/key-management"; + +import { AccountBillingClient, PreviewInvoiceClient } from "../../../../clients"; +import { BillingAddress } from "../../../../payment/types"; + +export type PremiumOrgUpgradePlanDetails = { + tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId; + details: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier; + cost: number; + proratedAmount?: number; +}; + +export type PaymentFormValues = { + organizationName?: string | null; + billingAddress: { + country: string; + postalCode: string; + }; +}; + +export interface InvoicePreview { + tax: number; + total: number; + credit: number; + newPlanProratedMonths: number; + newPlanProratedAmount?: number; +} + +@Injectable() +export class PremiumOrgUpgradeService { + constructor( + private accountBillingClient: AccountBillingClient, + private previewInvoiceClient: PreviewInvoiceClient, + private syncService: SyncService, + private keyService: KeyService, + private organizationService: OrganizationService, + ) {} + + async previewProratedInvoice( + planDetails: PremiumOrgUpgradePlanDetails, + billingAddress: BillingAddress, + ): Promise { + const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier); + + const invoicePreviewResponse = + await this.previewInvoiceClient.previewProrationForPremiumUpgrade(tier, billingAddress); + + return { + tax: invoicePreviewResponse.tax, + total: invoicePreviewResponse.total, + credit: invoicePreviewResponse.credit, + newPlanProratedMonths: invoicePreviewResponse.newPlanProratedMonths, + newPlanProratedAmount: invoicePreviewResponse.newPlanProratedAmount, + }; + } + + async upgradeToOrganization( + account: Account, + organizationName: string, + planDetails: PremiumOrgUpgradePlanDetails, + billingAddress: BillingAddress, + ): Promise { + if (!organizationName) { + throw new Error("Organization name is required for organization upgrade"); + } + + if (!billingAddress?.country || !billingAddress?.postalCode) { + throw new Error("Billing address information is incomplete"); + } + + const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier); + const [encryptedKey] = await this.keyService.makeOrgKey(account.id); + + if (!encryptedKey.encryptedString) { + throw new Error("Failed to generate encrypted organization key"); + } + + await this.accountBillingClient.upgradePremiumToOrganization( + organizationName, + encryptedKey.encryptedString, + tier, + SubscriptionCadenceIds.Annually, + billingAddress, + ); + + await this.syncService.fullSync(true); + + // Get the newly created organization + const organizations = await firstValueFrom(this.organizationService.organizations$(account.id)); + + const newOrg = organizations?.find((org) => org.name === organizationName && org.isOwner); + + if (!newOrg) { + throw new Error("Failed to find newly created organization"); + } + + return newOrg.id; + } + + private ProductTierTypeFromSubscriptionTierId( + tierId: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId, + ): ProductTierType { + switch (tierId) { + case "families": + return ProductTierType.Families; + case "teams": + return ProductTierType.Teams; + case "enterprise": + return ProductTierType.Enterprise; + default: + throw new Error("Invalid plan tier for organization upgrade"); + } + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.html b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.html new file mode 100644 index 00000000000..8f8bd11a02b --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.html @@ -0,0 +1,82 @@ +@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-plan-selection/premium-org-upgrade-plan-selection.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.spec.ts new file mode 100644 index 00000000000..2abeaa7d702 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.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 { PremiumOrgUpgradePlanSelectionComponent } from "./premium-org-upgrade-plan-selection.component"; + +describe("PremiumOrgUpgradePlanSelectionComponent", () => { + let sut: PremiumOrgUpgradePlanSelectionComponent; + 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: [PremiumOrgUpgradePlanSelectionComponent, PricingCardComponent, CdkTrapFocus], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: ToastService, useValue: mockToastService }, + ], + }) + .overrideComponent(PremiumOrgUpgradePlanSelectionComponent, { + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PremiumOrgUpgradePlanSelectionComponent); + 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("month"); + 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("month"); + 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("month"); + 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(PremiumOrgUpgradePlanSelectionComponent); + 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-plan-selection/premium-org-upgrade-plan-selection.component.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.ts new file mode 100644 index 00000000000..7d1ccd466ae --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.ts @@ -0,0 +1,169 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { + Component, + DestroyRef, + OnInit, + output, + signal, + ChangeDetectionStrategy, +} 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, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonType, DialogModule, ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; +export const PremiumOrgUpgradeStatus = { + Closed: "closed", + ProceededToPayment: "proceeded-to-payment", +} as const; + +export type PremiumOrgUpgradeStatus = UnionOfValues; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-premium-org-upgrade-plan-selection", + imports: [ + CommonModule, + DialogModule, + SharedModule, + BillingServicesModule, + PricingCardComponent, + CdkTrapFocus, + ], + templateUrl: "./premium-org-upgrade-plan-selection.component.html", +}) +export class PremiumOrgUpgradePlanSelectionComponent implements OnInit { + planSelected = output(); + closeClicked = output(); + protected closedStatus = PremiumOrgUpgradeStatus.Closed; + + 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, + ) {} + + async ngOnInit(): Promise { + 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]) => { + 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; + let shouldShowPerUser = false; + + if ("annualPrice" in tier.passwordManager) { + priceAmount = tier.passwordManager.annualPrice; + } else if ("annualPricePerUser" in tier.passwordManager) { + priceAmount = tier.passwordManager.annualPricePerUser; + shouldShowPerUser = true; + } + + return { + title: tier.name, + tagline: tier.description, + price: + priceAmount && priceAmount > 0 + ? { + amount: priceAmount / 12, + cadence: "month", + showPerUser: shouldShowPerUser, + } + : undefined, + button: { + text: this.i18nService.t(buttonText), + type: buttonType, + }, + features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value), + }; + } +} 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..b3b8dbb170d 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 @@ -33,6 +33,7 @@ import { selector: "app-upgrade-account", template: "", standalone: true, + providers: [UpgradeAccountComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradeAccountComponent { @@ -46,6 +47,7 @@ class MockUpgradeAccountComponent { selector: "app-upgrade-payment", template: "", standalone: true, + providers: [UpgradePaymentComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradePaymentComponent { @@ -61,7 +63,6 @@ describe("UnifiedUpgradeDialogComponent", () => { const mockDialogRef = mock(); const mockRouter = mock(); const mockPremiumInterestStateService = mock(); - const mockAccount: Account = { id: "user-id" as UserId, ...mockAccountInfoWith({ @@ -126,9 +127,8 @@ describe("UnifiedUpgradeDialogComponent", () => { // Default mock: no premium interest mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + imports: [UnifiedUpgradeDialogComponent], providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: defaultDialogData }, @@ -401,4 +401,54 @@ describe("UnifiedUpgradeDialogComponent", () => { expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); }); }); + + describe("Child Component Display Logic", () => { + it("should display app-upgrade-account on plan selection step", async () => { + const { fixture } = await createComponentWithDialogData(defaultDialogData); + + const upgradeAccountElement = fixture.nativeElement.querySelector("app-upgrade-account"); + + expect(upgradeAccountElement).toBeTruthy(); + }); + + it("should display app-upgrade-payment on payment step", async () => { + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: PersonalSubscriptionPricingTierIds.Premium, + }; + + const { fixture } = await createComponentWithDialogData(customDialogData); + + const upgradePaymentElement = fixture.nativeElement.querySelector("app-upgrade-payment"); + + expect(upgradePaymentElement).toBeTruthy(); + }); + }); + + describe("redirectOnCompletion", () => { + 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/upgrade-account/upgrade-account.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html index f1aebac7695..209629ce9ae 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html @@ -6,6 +6,7 @@ >
+ + + } + + `, + standalone: true, + imports: [SharedModule, EnterPaymentMethodComponent, IconComponent], + providers: [SubscriberBillingClient], +}) +export class DisplayPaymentMethodInlineComponent { + readonly subscriber = input.required(); + readonly paymentMethod = input.required(); + readonly updated = output(); + readonly changingStateChanged = output(); + + protected formGroup = EnterPaymentMethodComponent.getFormGroup(); + + private readonly enterPaymentMethodComponent = viewChild( + EnterPaymentMethodComponent, + ); + + protected readonly isChangingPayment = signal(false); + protected readonly cardBrandIcon = computed(() => getCardBrandIcon(this.paymentMethod())); + + private readonly billingClient = inject(SubscriberBillingClient); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly logService = inject(LogService); + + /** + * Initiates the payment method change process by displaying the inline form. + */ + protected changePaymentMethod = async (): Promise => { + this.isChangingPayment.set(true); + this.changingStateChanged.emit(true); + }; + + /** + * Submits the payment method update form. + * Validates the form, tokenizes the payment method, and sends the update request. + */ + protected submit = async (): Promise => { + try { + if (!this.formGroup.valid) { + this.formGroup.markAllAsTouched(); + throw new Error("Form is invalid"); + } + + const component = this.enterPaymentMethodComponent(); + if (!component) { + throw new Error("Payment method component not found"); + } + + const paymentMethod = await component.tokenize(); + if (!paymentMethod) { + throw new Error("Failed to tokenize payment method"); + } + + const billingAddress = + this.formGroup.value.type !== TokenizablePaymentMethods.payPal + ? this.formGroup.controls.billingAddress.getRawValue() + : null; + + await this.handlePaymentMethodUpdate(paymentMethod, billingAddress); + } catch (error) { + this.logService.error("Error submitting payment method update:", error); + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("paymentMethodUpdateError"), + }); + throw error; + } + }; + + /** + * Handles the payment method update API call and result processing. + */ + private async handlePaymentMethodUpdate(paymentMethod: any, billingAddress: any): Promise { + const result = await this.billingClient.updatePaymentMethod( + this.subscriber(), + paymentMethod, + billingAddress, + ); + + switch (result.type) { + case "success": { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("paymentMethodUpdated"), + }); + this.updated.emit(result.value); + this.isChangingPayment.set(false); + this.changingStateChanged.emit(false); + this.formGroup.reset(); + break; + } + case "error": { + this.logService.error("Error submitting payment method update:", result); + + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("paymentMethodUpdateError"), + }); + break; + } + } + } + + /** + * Cancels the inline editing and resets the form. + */ + protected cancel = (): void => { + this.formGroup.reset(); + this.changingStateChanged.emit(false); + this.isChangingPayment.set(false); + }; +} diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index c5ffa4268ed..f8e244b3b7a 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, input, Input, Output } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; @@ -15,7 +15,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial selector: "app-display-payment-method", template: ` -

{{ "paymentMethod" | i18n }}

+ @if (!hideHeader()) { +

{{ "paymentMethod" | i18n }}

+ } @if (paymentMethod) { @switch (paymentMethod.type) { @case ("bankAccount") { @@ -81,6 +83,7 @@ export class DisplayPaymentMethodComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() updated = new EventEmitter(); + protected readonly hideHeader = input(false); constructor(private dialogService: DialogService) {} diff --git a/apps/web/src/app/billing/payment/components/index.ts b/apps/web/src/app/billing/payment/components/index.ts index 5e10fa4763b..d570d3265e2 100644 --- a/apps/web/src/app/billing/payment/components/index.ts +++ b/apps/web/src/app/billing/payment/components/index.ts @@ -2,6 +2,7 @@ export * from "./add-account-credit-dialog.component"; export * from "./change-payment-method-dialog.component"; export * from "./display-account-credit.component"; export * from "./display-billing-address.component"; +export * from "./display-payment-method-inline.component"; export * from "./display-payment-method.component"; export * from "./edit-billing-address-dialog.component"; export * from "./enter-billing-address.component"; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 042eec107d4..fc2f463d9e6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12794,5 +12794,54 @@ }, "perUser": { "message": "per user" + }, + "upgradeToTeams": { + "message": "Upgrade to Teams" + }, + "upgradeToEnterprise": { + "message": "Upgrade to Enterprise" + }, + "upgradeShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise" + }, + "organizationUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "invoicePreviewErrorMessage": { + "message": "Encountered an error while generating the invoice preview." + }, + "planProratedMembershipInMonths": { + "message": "Prorated $PLAN$ membership ($NUMOFMONTHS$)", + "placeholders": { + "plan": { + "content": "$1", + "example": "Families" + }, + "numofmonths": { + "content": "$2", + "example": "6 Months" + } + } + }, + "premiumSubscriptionCredit": { + "message": "Premium subscription credit" + }, + "enterpriseMembership": { + "message": "Enterprise membership" + }, + "teamsMembership": { + "message": "Teams membership" + }, + "plansUpdated": { + "message": "You've upgraded to $PLAN$!", + "placeholders": { + "plan": { + "content": "$1", + "example": "Families" + } + } + }, + "paymentMethodUpdateError": { + "message": "There was an error updating your payment method." } } diff --git a/libs/angular/src/billing/types/subscription-pricing-card-details.ts b/libs/angular/src/billing/types/subscription-pricing-card-details.ts index 5f37f91c4f0..8430f6d35b5 100644 --- a/libs/angular/src/billing/types/subscription-pricing-card-details.ts +++ b/libs/angular/src/billing/types/subscription-pricing-card-details.ts @@ -1,10 +1,13 @@ -import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { BitwardenIcon, ButtonType } from "@bitwarden/components"; export type SubscriptionPricingCardDetails = { title: string; tagline: string; - price?: { amount: number; cadence: SubscriptionCadence }; + price?: { + amount: number; + cadence: "month" | "monthly" | "year" | "annually"; + showPerUser?: boolean; + }; button: { text: string; type: ButtonType; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index d3a0ad25e6c..4e3e75baed9 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -1,6 +1,6 @@ @let cart = this.cart(); @let term = this.term(); - +@let hideTerm = this.hidePricingTerm();
@@ -16,7 +16,9 @@ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD   - / {{ term }} + @if (!hideTerm) { + / {{ term | i18n }} + } }
@@ -86,8 +90,11 @@ ) }} @if (!additionalStorage.hideBreakdown) { - x {{ additionalStorage.cost | currency: "USD" : "symbol" }} / - {{ term }} + x {{ additionalStorage.cost | currency: "USD" : "symbol" }} + @if (!hideTerm) { + / + {{ term }} + } }
@@ -125,7 +132,10 @@ @if (!secretsManagerSeats.hideBreakdown) { x {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} - / {{ term }} + @if (!hideTerm) { + / + {{ term }} + } }

{{ "total" | i18n }}

- {{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }} + {{ total() | currency: "USD" : "symbol" }} + @if (!hidePricingTerm()) { + / {{ term | i18n }} + }
diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx index 542181f8264..65b5b7ea037 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -29,6 +29,8 @@ behavior across Bitwarden applications. - [With Percent Discount](#with-percent-discount) - [With Amount Discount](#with-amount-discount) - [With Discount and Credit](#with-discount-and-credit) + - [Hidden Cost Breakdown](#hidden-cost-breakdown) + - [Hidden Pricing Term](#hidden-pricing-term) - [Custom Header Template](#custom-header-template) - [Premium Plan](#premium-plan) - [Families Plan](#families-plan) @@ -53,10 +55,16 @@ import { CartSummaryComponent, Cart } from "@bitwarden/pricing"; ### Inputs -| Input | Type | Description | -| -------- | ------------------------ | ------------------------------------------------------------------------------- | -| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence | -| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header | +| Input | Type | Description | +| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------- | +| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence | +| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header | +| `hidePricingTerm` | `boolean` | **Optional.** When true, hides the billing term (e.g., "/ month", "/ year") from the header | + +**Note:** Individual `CartItem` objects in the cart can include: + +- `hideBreakdown` (boolean): Hides the cost breakdown (quantity × unit price) for that specific line + item ### Events @@ -73,6 +81,7 @@ export type CartItem = { quantity: number; // Number of items cost: number; // Cost per item discount?: Discount; // Optional item-level discount + hideBreakdown?: boolean; // Optional: hide cost breakdown (quantity × unit price) }; export type Cart = { @@ -468,6 +477,74 @@ Show cart with both discount and credit applied: ``` +### Hidden Cost Breakdown + +Show cart with hidden cost breakdowns (hides quantity × unit price for line items): + + + +```html + + +``` + +### Hidden Pricing Term + +Show cart with hidden pricing term (hides "/ month" or "/ year" from header): + + + +```html + + +``` + ### Custom Header Template Show cart with custom header template: @@ -546,6 +623,10 @@ Show cart with families plan: keys - **Custom Header Templates**: Optional header input allows for custom header designs while maintaining cart functionality +- **Hidden Cost Breakdown**: Individual cart items can hide their cost breakdown (quantity × unit + price) using the `hideBreakdown` property +- **Hidden Pricing Term**: Component can hide the billing term ("/ month" or "/ year") from the + header using the `hidePricingTerm` input - **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts - **Consistent Formatting**: Maintains uniform display of prices, quantities, and cadence - **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values, OnPush @@ -561,6 +642,9 @@ Show cart with families plan: - Use valid translation keys for CartItem translationKey (for i18n lookup) - Provide complete Cart object with all required fields - Use "annually" or "monthly" for cadence (not "year" or "month") +- Use `hideBreakdown` on individual cart items when you want to hide cost breakdowns +- Use the `hidePricingTerm` component input when the billing term shouldn't be displayed in the + header ### ❌ Don't diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index 9fabd3ecc59..ac5dfcc610a 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -192,7 +192,7 @@ describe("CartSummaryComponent", () => { it("should display correct secrets manager information", () => { // Arrange const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]')); - const smHeading = smSection.query(By.css("h3")); + const smHeading = smSection?.query(By.css('div[bitTypography="h5"]')); const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]')) .nativeElement.textContent; const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]')) @@ -200,7 +200,8 @@ describe("CartSummaryComponent", () => { // Act/ Assert expect(smSection).toBeTruthy(); - expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager"); + expect(smHeading).toBeTruthy(); + expect(smHeading!.nativeElement.textContent.trim()).toBe("Secrets Manager"); // Check seats line item expect(sectionText).toContain("3 Secrets Manager seats"); @@ -245,7 +246,7 @@ describe("CartSummaryComponent", () => { it("should display term (month/year) in default header", () => { // Arrange / Act - const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-main")); + const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted")); // Find the span that contains the term const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/")); @@ -253,6 +254,42 @@ describe("CartSummaryComponent", () => { expect(termElement).toBeTruthy(); expect(termElement!.nativeElement.textContent.trim()).toBe("/ month"); }); + + it("should hide term when hidePricingTerm is true", () => { + // Arrange + const cartWithHiddenTerm: Cart = { + ...mockCart, + }; + fixture.componentRef.setInput("cart", cartWithHiddenTerm); + fixture.componentRef.setInput("hidePricingTerm", true); + fixture.detectChanges(); + + // Act + const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted")); + const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/")); + + // Assert + expect(component.hidePricingTerm()).toBe(true); + expect(termElement).toBeFalsy(); + }); + + it("should show term when hidePricingTerm is false", () => { + // Arrange + const cartWithVisibleTerm: Cart = { + ...mockCart, + }; + fixture.componentRef.setInput("cart", cartWithVisibleTerm); + fixture.detectChanges(); + + // Act + const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted")); + const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/")); + + // Assert + expect(component.hidePricingTerm()).toBe(false); + expect(termElement).toBeTruthy(); + expect(termElement!.nativeElement.textContent).toContain("/ month"); + }); }); describe("hideBreakdown Property", () => { @@ -287,7 +324,7 @@ describe("CartSummaryComponent", () => { ); // Assert - expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month"); + expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month"); }); it("should hide cost breakdown for additional storage when hideBreakdown is true", () => { @@ -401,7 +438,7 @@ describe("CartSummaryComponent", () => { const discountSection = fixture.debugElement.query( By.css('[data-testid="discount-section"]'), ); - const discountLabel = discountSection.query(By.css("h3")); + const discountLabel = discountSection.query(By.css("div.tw-text-success-600")); const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]')); // Act / Assert @@ -426,7 +463,7 @@ describe("CartSummaryComponent", () => { const discountSection = fixture.debugElement.query( By.css('[data-testid="discount-section"]'), ); - const discountLabel = discountSection.query(By.css("h3")); + const discountLabel = discountSection.query(By.css("div.tw-text-success-600")); const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]')); // Act / Assert @@ -481,7 +518,7 @@ describe("CartSummaryComponent", () => { fixture.detectChanges(); const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]')); - const creditLabel = creditSection.query(By.css("h3")); + const creditLabel = creditSection.query(By.css('div[bitTypography="body1"]')); const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]')); // Act / Assert diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts index 3a2c2cbf5e0..f51919e45e7 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -432,3 +432,21 @@ export const WithDiscountAndCredit: Story = { } satisfies Cart, }, }; + +export const HiddenPricingTerm: Story = { + name: "Hidden Pricing Term", + args: { + cart: { + passwordManager: { + seats: { + quantity: 5, + translationKey: "members", + cost: 50.0, + }, + }, + cadence: "monthly", + estimatedTax: 9.6, + } satisfies Cart, + hidePricingTerm: true, + }, +}; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index cdf66ab0b4d..c98340defeb 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -37,6 +37,9 @@ export class CartSummaryComponent { // Optional inputs readonly header = input>(); + // Hide pricing term (e.g., "/ month" or "/ year") if true + readonly hidePricingTerm = input(false); + // UI state readonly isExpanded = signal(true);