From c0add3146090a8e2fc954bb6cfccdfe3478f5c51 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 1 Oct 2025 18:31:52 -0400 Subject: [PATCH] fix(billing): remove upgrade-flow service --- .../individual/upgrade/services/index.ts | 1 - .../services/upgrade-flow.service.spec.ts | 388 ------------------ .../upgrade/services/upgrade-flow.service.ts | 131 ------ 3 files changed, 520 deletions(-) delete mode 100644 apps/web/src/app/billing/individual/upgrade/services/index.ts delete mode 100644 apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts delete mode 100644 apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts diff --git a/apps/web/src/app/billing/individual/upgrade/services/index.ts b/apps/web/src/app/billing/individual/upgrade/services/index.ts deleted file mode 100644 index 85d1290935f..00000000000 --- a/apps/web/src/app/billing/individual/upgrade/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./upgrade-flow.service"; diff --git a/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts deleted file mode 100644 index 08de227c871..00000000000 --- a/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { Router } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; - -import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { DialogRef, DialogService } from "@bitwarden/components"; - -import { BitwardenSubscriber } from "../../../types"; -import { PersonalSubscriptionPricingTierIds } from "../../../types/subscription-pricing-tier"; -import { - UpgradeAccountDialogComponent, - UpgradeAccountDialogResult, - UpgradeAccountDialogStatus, -} from "../upgrade-account-dialog/upgrade-account-dialog.component"; -import { - UpgradePaymentDialogComponent, - UpgradePaymentDialogResult, - UpgradePaymentDialogStatus, -} from "../upgrade-payment-dialog/upgrade-payment-dialog.component"; - -import { UpgradeFlowResult, UpgradeFlowService } from "./upgrade-flow.service"; - -/** - * Creates a mock DialogRef that implements the required properties for testing - * @param result The result that will be emitted by the closed observable - * @returns A mock DialogRef object - */ -function createMockDialogRef(result: T): DialogRef { - // Create a mock that implements the DialogRef interface - return { - // The closed property is readonly in the actual DialogRef - closed: of(result), - } as DialogRef; -} - -// Mock the open method of a dialog component to return the provided DialogRefs -// Supports multiple calls by returning different refs in sequence -function mockDialogOpenMethod(component: any, ...refs: DialogRef[]) { - const spy = jest.spyOn(component, "open"); - refs.forEach((ref) => spy.mockReturnValueOnce(ref)); - return spy; -} - -describe("UpgradeFlowService", () => { - let sut: UpgradeFlowService; - let dialogService: MockProxy; - let accountService: MockProxy; - let router: MockProxy; - - // Mock account - const mockAccount: Account = { - id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, - }; - - // Mock subscriber - const mockSubscriber: BitwardenSubscriber = { - type: "account", - data: mockAccount, - }; - - beforeEach(() => { - dialogService = mock(); - accountService = mock(); - router = mock(); - - // Setup account service to return mock account - accountService.activeAccount$ = of(mockAccount); - - // Setup router to return resolved promises for navigation - router.navigate.mockResolvedValue(true); - - TestBed.configureTestingModule({ - providers: [ - UpgradeFlowService, - { provide: DialogService, useValue: dialogService }, - { provide: AccountService, useValue: accountService }, - { provide: Router, useValue: router }, - ], - }); - - sut = TestBed.inject(UpgradeFlowService); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("startUpgradeFlow", () => { - it("should return cancelled when upgrade account dialog is closed", async () => { - // Setup mock dialog references - const upgradeAccountDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.Closed, - plan: null, - }); - - // Added to verify no payment dialog is opened - jest.spyOn(UpgradePaymentDialogComponent, "open"); - mockDialogOpenMethod(UpgradeAccountDialogComponent, upgradeAccountDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Cancelled); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); - expect(UpgradePaymentDialogComponent.open).not.toHaveBeenCalled(); - }); - - it("should return upgraded result when premium payment is successful and navigate to premium subscription settings", async () => { - // Arrange - Setup mock dialog references - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Premium, - }); - - const mockPaymentDialogRef = createMockDialogRef({ - status: UpgradePaymentDialogStatus.UpgradedToPremium, - organizationId: null, - }); - - mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); - mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Upgraded); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); - expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledWith( - dialogService, - expect.objectContaining({ - data: expect.objectContaining({ - plan: PersonalSubscriptionPricingTierIds.Premium, - subscriber: mockSubscriber, - }), - }), - ); - expect(router.navigate).toHaveBeenCalledWith(["/settings/subscription"]); - }); - - it("should return upgraded result when families payment is successful", async () => { - // Arrange - Setup mock dialog references - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Families, - }); - - const organizationId = "mock-org-id"; - const mockPaymentDialogRef = createMockDialogRef({ - status: UpgradePaymentDialogStatus.UpgradedToFamilies, - organizationId: organizationId, - }); - - mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); - mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Upgraded); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); - expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledWith( - dialogService, - expect.objectContaining({ - data: expect.objectContaining({ - plan: PersonalSubscriptionPricingTierIds.Families, - subscriber: mockSubscriber, - }), - }), - ); - // Verify navigation occurs by default - expect(router.navigate).toHaveBeenCalledWith([ - `/organizations/${organizationId}/billing/subscription`, - ]); - }); - - it("should return to upgrade dialog when user clicks back in payment dialog", async () => { - // Arrange - Setup mock dialog references for first cycle - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Premium, - }); - - const mockPaymentDialogRef = createMockDialogRef({ - status: UpgradePaymentDialogStatus.Back, - organizationId: null, - }); - - // Setup mock dialog for second cycle (when user cancels) - const mockSecondUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.Closed, - plan: null, - }); - - mockDialogOpenMethod( - UpgradeAccountDialogComponent, - mockUpgradeDialogRef, - mockSecondUpgradeDialogRef, - ); - - mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Cancelled); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledTimes(2); - expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledTimes(1); - expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService); - expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(2, dialogService); - expect(UpgradePaymentDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService, { - data: { - plan: PersonalSubscriptionPricingTierIds.Premium, - subscriber: mockSubscriber, - }, - }); - }); - - it("should handle a successful upgrade flow with going back and forth", async () => { - // Arrange - Setup mock dialog references for first cycle - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Premium, - }); - - const mockPaymentDialogRef = createMockDialogRef({ - status: UpgradePaymentDialogStatus.Back, - organizationId: null, - }); - - // Setup mock dialog for second cycle (when user selects families plan) - const mockSecondUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Families, - }); - - const mockSecondPaymentDialogRef = createMockDialogRef({ - status: UpgradePaymentDialogStatus.UpgradedToFamilies, - organizationId: "mock-org-id", - }); - - mockDialogOpenMethod( - UpgradeAccountDialogComponent, - mockUpgradeDialogRef, - mockSecondUpgradeDialogRef, - ); - - mockDialogOpenMethod( - UpgradePaymentDialogComponent, - mockPaymentDialogRef, - mockSecondPaymentDialogRef, - ); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Upgraded); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledTimes(2); - expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledTimes(2); - expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService); - expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(2, dialogService); - expect(UpgradePaymentDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService, { - data: { - plan: PersonalSubscriptionPricingTierIds.Premium, - subscriber: mockSubscriber, - }, - }); - expect(UpgradePaymentDialogComponent.open).toHaveBeenNthCalledWith(2, dialogService, { - data: { - plan: PersonalSubscriptionPricingTierIds.Families, - subscriber: mockSubscriber, - }, - }); - expect(router.navigate).toHaveBeenCalledWith([ - `/organizations/mock-org-id/billing/subscription`, - ]); - }); - - it("should return cancelled result if payment dialog is closed without a successful payment", async () => { - // Setup mock dialog references - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Premium, - }); - - const mockPaymentDialogRef = createMockDialogRef( - "cancelled" as any, - ); - - mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); - mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Cancelled); - }); - - it("should throw error for missing account information", async () => { - // Setup account service to return null - accountService.activeAccount$ = of(null as any); - - // Expect error - await expect(sut.startUpgradeFlow()).rejects.toThrow(); - }); - it("should return cancelled if upgrade dialog returns null result", async () => { - // Setup mock dialog references - const upgradeAccountDialogRef = createMockDialogRef(null); - - // Added to verify no payment dialog is opened - jest.spyOn(UpgradePaymentDialogComponent, "open"); - mockDialogOpenMethod(UpgradeAccountDialogComponent, upgradeAccountDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Cancelled); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); - expect(UpgradePaymentDialogComponent.open).not.toHaveBeenCalled(); - }); - - it("should return cancelled if payment dialog returns null result", async () => { - // Setup mock dialog references - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Premium, - }); - - const mockPaymentDialogRef = createMockDialogRef(null); - - mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); - mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); - - // Act - const result = await sut.startUpgradeFlow(); - - // Assert - expect(result).toBe(UpgradeFlowResult.Cancelled); - expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); - expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledWith( - dialogService, - expect.objectContaining({ - data: expect.objectContaining({ - plan: PersonalSubscriptionPricingTierIds.Premium, - subscriber: mockSubscriber, - }), - }), - ); - }); - - it("should not navigate when autoNavigate is false", async () => { - // Arrange - Setup mock dialog references - const mockUpgradeDialogRef = createMockDialogRef({ - status: UpgradeAccountDialogStatus.ProceededToPayment, - plan: PersonalSubscriptionPricingTierIds.Premium, - }); - - const mockPaymentDialogRef = createMockDialogRef({ - status: UpgradePaymentDialogStatus.UpgradedToPremium, - organizationId: null, - }); - - mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); - mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); - - // Reset router mock to ensure clean state - router.navigate.mockReset(); - - // Act - const result = await sut.startUpgradeFlow(false); - - // Assert - expect(result).toBe(UpgradeFlowResult.Upgraded); - expect(router.navigate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts deleted file mode 100644 index 4d5f3cff795..00000000000 --- a/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Router } from "@angular/router"; -import { lastValueFrom } from "rxjs"; - -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { DialogRef, DialogService } from "@bitwarden/components"; - -import { BitwardenSubscriber, mapAccountToSubscriber } from "../../../types"; -import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier"; -import { - UpgradeAccountDialogComponent, - UpgradeAccountDialogResult, - UpgradeAccountDialogStatus, -} from "../upgrade-account-dialog/upgrade-account-dialog.component"; -import { - UpgradePaymentDialogComponent, - UpgradePaymentDialogParams, - UpgradePaymentDialogResult, - UpgradePaymentDialogStatus, -} from "../upgrade-payment-dialog/upgrade-payment-dialog.component"; -export const UpgradeFlowResult = { - Upgraded: "upgraded", - Cancelled: "cancelled", -} as const; - -export type UpgradeFlowResult = UnionOfValues; - -/** - * Service to manage the account upgrade flow through multiple dialogs - */ -@Injectable({ providedIn: "root" }) -export class UpgradeFlowService { - // References to open dialogs - private upgradeToPremiumDialogRef?: DialogRef; - private upgradePaymentDialogRef?: DialogRef; - private subscriber: BitwardenSubscriber | null = null; - - constructor( - private dialogService: DialogService, - private accountService: AccountService, - private router: Router, - ) { - this.accountService.activeAccount$.pipe(mapAccountToSubscriber).subscribe((subscriber) => { - this.subscriber = subscriber; - }); - } - - /** - * Start the account upgrade flow - * - * This method will open the upgrade account dialog and handle the flow - * between it and the payment dialog if needed. On successful upgrade, - * it will navigate to the appropriate subscription page. - * - * @param autoNavigate Whether to automatically navigate on success (default: true) - * @returns A promise resolving to the upgrade flow result - */ - async startUpgradeFlow(autoNavigate = true): Promise { - if (!this.subscriber) { - throw new Error("No active subscriber found for upgrade flow"); - } - - while (true) { - const accountResult = await this.openUpgradeAccountDialog(); - if ( - !accountResult || - accountResult.status !== UpgradeAccountDialogStatus.ProceededToPayment - ) { - return UpgradeFlowResult.Cancelled; - } - - const paymentResult = await this.openUpgradePaymentDialog(accountResult.plan); - if (!paymentResult) { - return UpgradeFlowResult.Cancelled; - } - - if (paymentResult.status === UpgradePaymentDialogStatus.Back) { - continue; // Go back to account selection dialog - } - - return await this.handleUpgradeSuccess(paymentResult, autoNavigate); - } - } - - private async openUpgradeAccountDialog(): Promise { - this.upgradeToPremiumDialogRef = UpgradeAccountDialogComponent.open(this.dialogService); - const result = await lastValueFrom(this.upgradeToPremiumDialogRef.closed); - this.upgradeToPremiumDialogRef = undefined; - return result; - } - - private async openUpgradePaymentDialog( - plan: PersonalSubscriptionPricingTierId | null, - ): Promise { - this.upgradePaymentDialogRef = UpgradePaymentDialogComponent.open(this.dialogService, { - data: { - plan, - subscriber: this.subscriber, - } as UpgradePaymentDialogParams, - }); - const result = await lastValueFrom(this.upgradePaymentDialogRef.closed); - this.upgradePaymentDialogRef = undefined; - return result; - } - - private async handleUpgradeSuccess( - paymentResult: UpgradePaymentDialogResult, - autoNavigate: boolean, - ): Promise { - const { status } = paymentResult; - - if (status === UpgradePaymentDialogStatus.UpgradedToPremium) { - if (autoNavigate) { - await this.router.navigate(["/settings/subscription"]); - } - return UpgradeFlowResult.Upgraded; - } - - if (status === UpgradePaymentDialogStatus.UpgradedToFamilies && paymentResult.organizationId) { - if (autoNavigate) { - await this.router.navigate([ - `/organizations/${paymentResult.organizationId}/billing/subscription`, - ]); - } - return UpgradeFlowResult.Upgraded; - } - - return UpgradeFlowResult.Cancelled; - } -}