1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 10:43:47 +00:00

feat(billing): Add Upgrade flow service

This commit is contained in:
Stephon Brown
2025-09-24 15:53:24 -04:00
parent f127f2db5c
commit 0ec170c7f3
3 changed files with 520 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from "./upgrade-flow.service";

View File

@@ -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<T>(result: T): DialogRef<T> {
// Create a mock that implements the DialogRef interface
return {
// The closed property is readonly in the actual DialogRef
closed: of(result),
} as DialogRef<T>;
}
// 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<any>[]) {
const spy = jest.spyOn(component, "open");
refs.forEach((ref) => spy.mockReturnValueOnce(ref));
return spy;
}
describe("UpgradeFlowService", () => {
let sut: UpgradeFlowService;
let dialogService: MockProxy<DialogService>;
let accountService: MockProxy<AccountService>;
let router: MockProxy<Router>;
// 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<DialogService>();
accountService = mock<AccountService>();
router = mock<Router>();
// 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<UpgradeAccountDialogResult>({
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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>({
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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Families,
});
const organizationId = "mock-org-id";
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>({
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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>({
status: UpgradePaymentDialogStatus.Back,
organizationId: null,
});
// Setup mock dialog for second cycle (when user cancels)
const mockSecondUpgradeDialogRef = createMockDialogRef<UpgradeAccountDialogResult>({
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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>({
status: UpgradePaymentDialogStatus.Back,
organizationId: null,
});
// Setup mock dialog for second cycle (when user selects families plan)
const mockSecondUpgradeDialogRef = createMockDialogRef<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Families,
});
const mockSecondPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>({
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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>(
"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<UpgradeAccountDialogResult>(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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>(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<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>({
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();
});
});
});

View File

@@ -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<typeof UpgradeFlowResult>;
/**
* Service to manage the account upgrade flow through multiple dialogs
*/
@Injectable({ providedIn: "root" })
export class UpgradeFlowService {
// References to open dialogs
private upgradeToPremiumDialogRef?: DialogRef<UpgradeAccountDialogResult>;
private upgradePaymentDialogRef?: DialogRef<UpgradePaymentDialogResult>;
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<UpgradeFlowResult> {
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<UpgradeAccountDialogResult | undefined> {
this.upgradeToPremiumDialogRef = UpgradeAccountDialogComponent.open(this.dialogService);
const result = await lastValueFrom(this.upgradeToPremiumDialogRef.closed);
this.upgradeToPremiumDialogRef = undefined;
return result;
}
private async openUpgradePaymentDialog(
plan: any,
): Promise<UpgradePaymentDialogResult | undefined> {
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<UpgradeFlowResult> {
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;
}
}