From 5c33b2dc89e823be29f9513c8b6f7008cc177704 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:42:21 -0800 Subject: [PATCH] (Billing) [PM-27562] Create PremiumInterestStateService (#17139) Creates a `PremiumInterestStateService` that manages state which conveys whether or not a user intends to setup a premium subscription. Implemented in Web only. No-op for other clients. This will apply for users who began the registration process on https://bitwarden.com/go/start-premium/, which is a marketing page designed to streamline users who intend to setup a premium subscription after registration. --- ...web-premium-interest-state.service.spec.ts | 147 ++++++++++++++++++ .../web-premium-interest-state.service.ts | 44 ++++++ apps/web/src/app/core/core.module.ts | 7 + .../noop-premium-interest-state.service.ts | 14 ++ ...mium-interest-state.service.abstraction.ts | 14 ++ .../src/services/jslib-services.module.ts | 7 + libs/state/src/core/state-definitions.ts | 1 + 7 files changed, 234 insertions(+) create mode 100644 apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts create mode 100644 apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts create mode 100644 libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts create mode 100644 libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts diff --git a/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts new file mode 100644 index 00000000000..086c7504040 --- /dev/null +++ b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts @@ -0,0 +1,147 @@ +import { firstValueFrom } from "rxjs"; + +import { + FakeAccountService, + FakeStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { newGuid } from "@bitwarden/guid"; +import { UserId } from "@bitwarden/user-core"; + +import { + PREMIUM_INTEREST_KEY, + WebPremiumInterestStateService, +} from "./web-premium-interest-state.service"; + +describe("WebPremiumInterestStateService", () => { + let service: WebPremiumInterestStateService; + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + + const mockUserId = newGuid() as UserId; + const mockUserEmail = "user@example.com"; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail }); + stateProvider = new FakeStateProvider(accountService); + service = new WebPremiumInterestStateService(stateProvider); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.getPremiumInterest(null); + + await expect(promise).rejects.toThrow("UserId is required. Cannot get 'premiumInterest'."); + }); + + it("should return null when no value is set", async () => { + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBeNull(); + }); + + it("should return true when value is set to true", async () => { + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId); + + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBe(true); + }); + + it("should return false when value is set to false", async () => { + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, false, mockUserId); + + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBe(false); + }); + + it("should use getUserState$ to retrieve the value", async () => { + const getUserStateSpy = jest.spyOn(stateProvider, "getUserState$"); + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId); + + await service.getPremiumInterest(mockUserId); + + expect(getUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, mockUserId); + }); + }); + + describe("setPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.setPremiumInterest(null, true); + + await expect(promise).rejects.toThrow("UserId is required. Cannot set 'premiumInterest'."); + }); + + it("should set the value to true", async () => { + await service.setPremiumInterest(mockUserId, true); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(true); + }); + + it("should set the value to false", async () => { + await service.setPremiumInterest(mockUserId, false); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(false); + }); + + it("should update an existing value", async () => { + await service.setPremiumInterest(mockUserId, true); + await service.setPremiumInterest(mockUserId, false); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(false); + }); + + it("should use setUserState to store the value", async () => { + const setUserStateSpy = jest.spyOn(stateProvider, "setUserState"); + + await service.setPremiumInterest(mockUserId, true); + + expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, true, mockUserId); + }); + }); + + describe("clearPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.clearPremiumInterest(null); + + await expect(promise).rejects.toThrow("UserId is required. Cannot clear 'premiumInterest'."); + }); + + it("should clear the value by setting it to null", async () => { + await service.setPremiumInterest(mockUserId, true); + await service.clearPremiumInterest(mockUserId); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBeNull(); + }); + + it("should use setUserState with null to clear the value", async () => { + const setUserStateSpy = jest.spyOn(stateProvider, "setUserState"); + await service.setPremiumInterest(mockUserId, true); + + await service.clearPremiumInterest(mockUserId); + + expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, null, mockUserId); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts new file mode 100644 index 00000000000..f66fba559f4 --- /dev/null +++ b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; +import { BILLING_MEMORY, StateProvider, UserKeyDefinition } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +export const PREMIUM_INTEREST_KEY = new UserKeyDefinition( + BILLING_MEMORY, + "premiumInterest", + { + deserializer: (value: boolean) => value, + clearOn: ["lock", "logout"], + }, +); + +@Injectable() +export class WebPremiumInterestStateService implements PremiumInterestStateService { + constructor(private stateProvider: StateProvider) {} + + async getPremiumInterest(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get 'premiumInterest'."); + } + + return await firstValueFrom(this.stateProvider.getUserState$(PREMIUM_INTEREST_KEY, userId)); + } + + async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set 'premiumInterest'."); + } + + await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, premiumInterest, userId); + } + + async clearPremiumInterest(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot clear 'premiumInterest'."); + } + + await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId); + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 9619c3e23bf..72117f547d4 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -14,6 +14,7 @@ import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { CLIENT_TYPE, @@ -129,6 +130,7 @@ import { WebSetInitialPasswordService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; +import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; import { WebFileDownloadService } from "../core/web-file-download.service"; @@ -421,6 +423,11 @@ const safeProviders: SafeProvider[] = [ Router, ], }), + safeProvider({ + provide: PremiumInterestStateService, + useClass: WebPremiumInterestStateService, + deps: [StateProvider], + }), ]; @NgModule({ diff --git a/libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts b/libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts new file mode 100644 index 00000000000..f941e86e0d0 --- /dev/null +++ b/libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from "@angular/core"; + +import { UserId } from "@bitwarden/user-core"; + +import { PremiumInterestStateService } from "./premium-interest-state.service.abstraction"; + +@Injectable() +export class NoopPremiumInterestStateService implements PremiumInterestStateService { + async getPremiumInterest(userId: UserId): Promise { + return null; + } // no-op + async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise {} // no-op + async clearPremiumInterest(userId: UserId): Promise {} // no-op +} diff --git a/libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts b/libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts new file mode 100644 index 00000000000..850560df38c --- /dev/null +++ b/libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts @@ -0,0 +1,14 @@ +import { UserId } from "@bitwarden/user-core"; + +/** + * A service that manages state which conveys whether or not a user has expressed interest + * in setting up a premium subscription. This applies for users who began the registration + * process on https://bitwarden.com/go/start-premium/, which is a marketing page designed + * to streamline users who intend to setup a premium subscription after registration. + * - Implemented in Web only. No-op for other clients. + */ +export abstract class PremiumInterestStateService { + abstract getPremiumInterest(userId: UserId): Promise; + abstract setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise; + abstract clearPremiumInterest(userId: UserId): Promise; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index fc9c5b8b15c..38ce3c0fcc2 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -380,6 +380,8 @@ import { DefaultSetInitialPasswordService } from "../auth/password-management/se import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; +import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service"; +import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { DocumentLangSetter } from "../platform/i18n"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; @@ -1724,6 +1726,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: PremiumInterestStateService, + useClass: NoopPremiumInterestStateService, + deps: [], + }), ]; @NgModule({ diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 782dafe1ee2..42d7f5aaaf8 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -40,6 +40,7 @@ export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk"); // Billing export const BILLING_DISK = new StateDefinition("billing", "disk"); +export const BILLING_MEMORY = new StateDefinition("billing", "memory"); // Auth