From 0ec170c7f3204d45131baa3ec668d9f669060d9b Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 24 Sep 2025 15:53:24 -0400 Subject: [PATCH] feat(billing): Add 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 insertions(+) create mode 100644 apps/web/src/app/billing/individual/upgrade/services/index.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts create 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 new file mode 100644 index 00000000000..85d1290935f --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000..08de227c871 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts @@ -0,0 +1,388 @@ +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 new file mode 100644 index 00000000000..ce40c67ab77 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts @@ -0,0 +1,131 @@ +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 { + 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: any, + ): 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; + } +}