From 37eeffd03aaef08b85c11ab1d14d4bdc5dbd337e Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 9 Feb 2026 15:09:37 -0500 Subject: [PATCH] [PM-29602] Build Upgrade Dialogs (#18539) * BREAKING CHANGE: rename tax-client and add proration endpoint update * fix(billing)!: rename tax-client in components * feat(billing): Add upgrade endpoint * fix(billing): update preview client error * fix(billing): add billing address to clients * feat(billing): Update messages for changes * feat(biilling): Update unified upgrade dialog logic * feat(billing): add new premium org card * feat(billing): add premium org component * fix(billing): Update account billing client and remove redundant status * fix(billing): unified upgrade dialog add feature flag and tests * fix(billing): update unified upgrade logic * fix(billing): update tests and logic update update fix * fix(billing): add required messages message * fix(billing): update unified dialog logic and re-add comments * feat(billing): improves premium org upgrade dialog Adds a close button to the premium organization upgrade dialog. Updates the success toast message after upgrading to teams. Hides the formatted amount for credit discounts. Sets the change detection strategy to OnPush for improved performance. * fix(billing): prevents multiple upgrade dialogs from opening Adds a check to prevent multiple upgrade dialogs from opening simultaneously. Ensures correct redirection to the organization vault after upgrading to Teams or Enterprise. * Feat(tooltip): Add `showTooltipOnFocus` input to TooltipDirective * Fix(billing): Disable tooltip on focus for various billing buttons * Refactor(billing): Standardize subscription cadence display * Refactor(billing): Update InvoicePreview with prorated amount details * Refactor(billing): Enhance Premium Org Upgrade Payment logic * Feat(billing): Add SubscriptionCadence import to account billing client * refactor(i18n): Rename 'premiumMembershipDiscount' to 'premiumSubscriptionCredit' * fix(billing): Ensure encrypted org key is present during upgrade * refactor(billing): revert PremiumUpgradeDialog focus management * refactor(billing): Clean up subscription details and type definitions * feat(billing): Add dedicated Premium to Organization upgrade dialog * refactor(billing): Return organization ID from PremiumOrgUpgradeService * refactor(billing): Remove premium to org upgrade logic from UnifiedUpgradeDialog * feat(billing): Integrate PremiumOrgUpgradeDialog into account subscription * Refactor: Make `openUpgradeDialog` return `void` * Remove obsolete `planSelectionStepTitleOverride` tests * Feature: Add 'Back' status to UpgradePaymentStatus * Test: Mock `OrganizationService` in `PremiumOrgUpgradePaymentComponent` tests * Chore: Remove redundant comment in unified upgrade dialog HTML * refactor(billing): Remove obsolete unified upgrade change * refactor(billing): remove unused ApiService and DestroyRef * feat(billing): add pre-condition checks for premium org upgrade dialog * refactor(billing): clean up unused dialog data and HTML comment * refactor(billing): rename premium org upgrade dialog flag * feat(billing): close premium org upgrade dialog if feature is disabled * feat(payment): add hideHeader input to DisplayPaymentMethodComponent * refactor(billing): update premium org upgrade payment to display existing payment method * test(billing): update premium org upgrade payment component tests * docs(billing): refine JSDoc for PremiumOrgUpgradeDialogParams * Revert "Feat(tooltip): Add `showTooltipOnFocus` input to TooltipDirective" This reverts commit 02f62bc0fdafa00753b03691adeb25b9fb674d25. * Revert "Fix(billing): Disable tooltip on focus for various billing buttons" This reverts commit 91f7747df76272028963f9b387ccc3239655a020. * fix(billing): Ensure early exit for closed premium org upgrade payment * refactor: rename PremiumOrgUpgradeComponent to PremiumOrgUpgradePlanSelectionComponent * feat(i18n): add payment method update error translation key * feat(billing): introduce DisplayPaymentMethodInlineComponent * feat(billing): integrate inline payment method in PremiumOrgUpgradePayment * feat(pricing): allow hiding pricing term in cart summary * refactor(billing): optimize invoice preview and update cart configuration * refactor(billing): migrate AccountSubscriptionComponent state to signals * chore(html): improve form field layout and accessibility * feat(pricing): add `hidePricingTerm` input and basic header logic * feat(pricing): apply `hidePricingTerm` to cart item breakdowns * docs(pricing): update cart summary documentation for `hideBreakdown` and `hidePricingTerm` * test(pricing): add tests for `hidePricingTerm` and refine term display selector * refactor(pricing): update cart summary test selectors for robustness * docs: reformat `hideBreakdown` description in `CartSummaryComponent` MDX * refactor: remoe additonal DisplayPaymentMethodInlineComponent in imports * Revert "feat(i18n): add payment method update error translation key" This reverts commit b4aeb74e1aebd91f2cfebcd88ef2ddb1f68f06a8. * feat(i18n): Add payment method update error message * refactor(pricing): move CartSummaryComponent hidePricingTerm to input * docs(pricing): update CartSummaryComponent `hidePricingTerm` usage in MDX * test(pricing): update CartSummaryComponent `hidePricingTerm` tests and stories * chore(pricing): add spacing in CartSummaryComponent spec assertion * refactor(billing): Use ngOnInit for dialog initialization logic * refactor(billing): Migrate hidePricingTerm from Cart type to direct input * Refactor: Update payment method action buttons to use `bitLink` * feat(billing): add hidePricingTerm input to MockCartSummaryComponent --- .../account-subscription.component.html | 2 +- .../account-subscription.component.ts | 55 +- .../premium-org-upgrade-dialog.component.html | 13 + ...emium-org-upgrade-dialog.component.spec.ts | 464 ++++++++++++++ .../premium-org-upgrade-dialog.component.ts | 213 +++++++ ...premium-org-upgrade-payment.component.html | 65 ++ ...mium-org-upgrade-payment.component.spec.ts | 576 ++++++++++++++++++ .../premium-org-upgrade-payment.component.ts | 469 ++++++++++++++ .../premium-org-upgrade.service.spec.ts | 266 ++++++++ .../services/premium-org-upgrade.service.ts | 129 ++++ ...-org-upgrade-plan-selection.component.html | 82 +++ ...g-upgrade-plan-selection.component.spec.ts | 221 +++++++ ...um-org-upgrade-plan-selection.component.ts | 169 +++++ .../unified-upgrade-dialog.component.spec.ts | 56 +- .../upgrade-account.component.html | 1 + .../upgrade-payment.component.html | 6 +- ...display-payment-method-inline.component.ts | 230 +++++++ .../display-payment-method.component.ts | 7 +- .../app/billing/payment/components/index.ts | 1 + apps/web/src/locales/en/messages.json | 49 ++ .../subscription-pricing-card-details.ts | 7 +- .../cart-summary/cart-summary.component.html | 35 +- .../cart-summary/cart-summary.component.mdx | 92 ++- .../cart-summary.component.spec.ts | 51 +- .../cart-summary.component.stories.ts | 18 + .../cart-summary/cart-summary.component.ts | 3 + 26 files changed, 3240 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.html create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.html create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component.ts create mode 100644 apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts 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);