1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

feat(billing): Add Upgrade flow service

This commit is contained in:
Stephon Brown
2025-09-24 15:53:24 -04:00
parent 000601d007
commit 9d5a7e4613
2 changed files with 458 additions and 0 deletions

View File

@@ -0,0 +1,348 @@
import { TestBed } from "@angular/core/testing";
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,
} 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>;
// 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>();
// Setup account service to return mock account
accountService.activeAccount$ = of(mockAccount);
TestBed.configureTestingModule({
providers: [
UpgradeFlowService,
{ provide: DialogService, useValue: dialogService },
{ provide: AccountService, useValue: accountService },
],
});
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", async () => {
// Arrange - Setup mock dialog references
const mockUpgradeDialogRef = createMockDialogRef<UpgradeAccountDialogResult>({
status: UpgradeAccountDialogStatus.ProceededToPayment,
plan: PersonalSubscriptionPricingTierIds.Premium,
});
const mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>(
UpgradePaymentDialogResult.UpgradedToPremium,
);
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,
}),
}),
);
});
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 mockPaymentDialogRef = createMockDialogRef<UpgradePaymentDialogResult>(
UpgradePaymentDialogResult.UpgradedToFamilies,
);
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,
}),
}),
);
});
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>(
UpgradePaymentDialogResult.Back,
);
// 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>(
UpgradePaymentDialogResult.Back,
);
// 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>(
UpgradePaymentDialogResult.UpgradedToFamilies,
);
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,
},
});
});
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 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();
});
});
});

View File

@@ -0,0 +1,110 @@
import { Injectable } from "@angular/core";
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,
} 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,
) {
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.
*
* @returns A promise resolving to the upgrade flow result
*/
async startUpgradeFlow(): Promise<UpgradeFlowResult> {
// Get subscriber information from account service
if (!this.subscriber) {
throw new Error("No active subscriber found for upgrade flow");
}
// Start the upgrade dialog flow
while (true) {
// Open the upgrade account dialog
this.upgradeToPremiumDialogRef = UpgradeAccountDialogComponent.open(this.dialogService);
const dialogResult = await lastValueFrom(this.upgradeToPremiumDialogRef.closed);
// Clear the reference to the upgrade dialog
this.upgradeToPremiumDialogRef = undefined;
if (!dialogResult) {
return UpgradeFlowResult.Cancelled;
}
// If the dialog was closed without proceeding to payment
if (dialogResult.status !== UpgradeAccountDialogStatus.ProceededToPayment) {
return UpgradeFlowResult.Cancelled;
}
// If user proceeded to payment
if (dialogResult.status === UpgradeAccountDialogStatus.ProceededToPayment) {
this.upgradePaymentDialogRef = UpgradePaymentDialogComponent.open(this.dialogService, {
data: {
plan: dialogResult.plan,
subscriber: this.subscriber,
} as UpgradePaymentDialogParams,
});
const paymentResult = await lastValueFrom(this.upgradePaymentDialogRef.closed);
this.upgradePaymentDialogRef = undefined;
if (!paymentResult) {
return UpgradeFlowResult.Cancelled;
}
// If user clicked "Back", continue the loop to reopen the first dialog
if (paymentResult === UpgradePaymentDialogResult.Back) {
continue;
}
// Handle successful payment outcomes
if (paymentResult === UpgradePaymentDialogResult.UpgradedToPremium) {
return UpgradeFlowResult.Upgraded;
} else if (paymentResult === UpgradePaymentDialogResult.UpgradedToFamilies) {
return UpgradeFlowResult.Upgraded;
} else {
return UpgradeFlowResult.Cancelled;
}
}
// Exit the loop for all other cases
return UpgradeFlowResult.Cancelled;
}
}
}