diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4c00c348b34d..b4f37a034e76 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1359,6 +1359,7 @@ export default class MainBackground { this.policyService, this.newPolicyService, this.autoConfirmService, + this.billingAccountProfileStateService, ); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 15e12e792e18..f9322d6a95bc 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1167,6 +1167,7 @@ const safeProviders: SafeProvider[] = [ InternalPolicyService, InternalNewPolicyService, AutomaticUserConfirmationService, + BillingAccountProfileStateService, ], }), safeProvider({ diff --git a/libs/common/src/enums/notification-type.enum.ts b/libs/common/src/enums/notification-type.enum.ts index d323dda4d744..60b0a90da603 100644 --- a/libs/common/src/enums/notification-type.enum.ts +++ b/libs/common/src/enums/notification-type.enum.ts @@ -36,4 +36,6 @@ export enum NotificationType { SyncPolicy = 25, AutoConfirmMember = 26, + + PremiumStatusChanged = 27, } diff --git a/libs/common/src/models/response/notification.response.ts b/libs/common/src/models/response/notification.response.ts index 9c538017f123..62e813188880 100644 --- a/libs/common/src/models/response/notification.response.ts +++ b/libs/common/src/models/response/notification.response.ts @@ -78,6 +78,9 @@ export class NotificationResponse extends BaseResponse { case NotificationType.AutoConfirmMember: this.payload = new AutoConfirmMemberNotification(payload); break; + case NotificationType.PremiumStatusChanged: + this.payload = new PremiumStatusChangedNotification(payload); + break; default: break; } @@ -228,3 +231,14 @@ export class AutoConfirmMemberNotification extends BaseResponse { this.organizationId = this.getResponseProperty("OrganizationId"); } } + +export class PremiumStatusChangedNotification extends BaseResponse { + userId: string; + premium: boolean; + + constructor(response: any) { + super(response); + this.userId = this.getResponseProperty("UserId"); + this.premium = this.getResponseProperty("Premium"); + } +} diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index d0cb66475de7..dd70ad02482e 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -7,6 +7,7 @@ import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { InternalNewPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/new-policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { mockAccountInfoWith } from "../../../../spec"; import { AccountService } from "../../../auth/abstractions/account.service"; @@ -40,6 +41,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { let policyService: MockProxy; let newPolicyService: MockProxy; let autoConfirmService: MockProxy; + let billingAccountProfileStateService: MockProxy; let activeUserAccount$: BehaviorSubject>; let userAccounts$: BehaviorSubject>; @@ -136,6 +138,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { policyService = mock(); newPolicyService = mock(); autoConfirmService = mock(); + billingAccountProfileStateService = mock(); defaultServerNotificationsService = new DefaultServerNotificationsService( mock(), @@ -153,6 +156,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { policyService, newPolicyService, autoConfirmService, + billingAccountProfileStateService, ); }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index c5423f878600..1f529628ef74 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -9,6 +9,7 @@ import { InternalNewPolicyService } from "@bitwarden/common/admin-console/abstra import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { awaitAsync, mockAccountInfoWith } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; @@ -49,6 +50,7 @@ describe("NotificationsService", () => { let policyService: MockProxy; let newPolicyService: MockProxy; let autoConfirmService: MockProxy; + let billingAccountProfileStateService: MockProxy; let activeAccount: BehaviorSubject>; let accounts: BehaviorSubject>; @@ -81,6 +83,7 @@ describe("NotificationsService", () => { policyService = mock(); newPolicyService = mock(); autoConfirmService = mock(); + billingAccountProfileStateService = mock(); // For these tests, use the active-user implementation (feature flag disabled) configService.getFeatureFlag$.mockReturnValue(of(true)); @@ -136,6 +139,7 @@ describe("NotificationsService", () => { policyService, newPolicyService, autoConfirmService, + billingAccountProfileStateService, ); }); @@ -541,5 +545,74 @@ describe("NotificationsService", () => { ); }); }); + + describe("NotificationType.PremiumStatusChanged", () => { + beforeEach(() => { + billingAccountProfileStateService.hasPremiumFromAnyOrganization$.mockReturnValue(of(false)); + billingAccountProfileStateService.setHasPremium.mockResolvedValue(); + }); + + it("should call setHasPremium with premium=true when notification payload is true", async () => { + const notification = new NotificationResponse({ + type: NotificationType.PremiumStatusChanged, + payload: { UserId: mockUser1, Premium: true }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(billingAccountProfileStateService.setHasPremium).toHaveBeenCalledWith( + true, + false, + mockUser1, + ); + }); + + it("should call setHasPremium with premium=false when notification payload is false", async () => { + const notification = new NotificationResponse({ + type: NotificationType.PremiumStatusChanged, + payload: { UserId: mockUser1, Premium: false }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(billingAccountProfileStateService.setHasPremium).toHaveBeenCalledWith( + false, + false, + mockUser1, + ); + }); + + it("should preserve existing hasPremiumFromAnyOrganization value", async () => { + billingAccountProfileStateService.hasPremiumFromAnyOrganization$.mockReturnValue(of(true)); + + const notification = new NotificationResponse({ + type: NotificationType.PremiumStatusChanged, + payload: { UserId: mockUser1, Premium: true }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(billingAccountProfileStateService.setHasPremium).toHaveBeenCalledWith( + true, + true, + mockUser1, + ); + }); + + it("should not trigger a full sync", async () => { + const notification = new NotificationResponse({ + type: NotificationType.PremiumStatusChanged, + payload: { UserId: mockUser1, Premium: true }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(syncService.fullSync).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 3d1eed33a578..98fe71ee17c0 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -26,10 +26,12 @@ import { trackedMerge } from "@bitwarden/common/platform/misc"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; import { NotificationType, PushNotificationLogOutReasonType } from "../../../enums"; import { LogOutNotification, NotificationResponse, + PremiumStatusChangedNotification, SyncCipherNotification, SyncFolderNotification, SyncSendNotification, @@ -75,6 +77,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly policyService: InternalPolicyService, private readonly newPolicyService: InternalNewPolicyService, private autoConfirmService: AutomaticUserConfirmationService, + private readonly billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.notifications$ = this.accountService.accounts$.pipe( map((accounts: Record): Set => { @@ -308,6 +311,20 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer notification.payload.organizationId, ); break; + case NotificationType.PremiumStatusChanged: { + const premiumPayload = notification.payload as PremiumStatusChangedNotification; + const hasPremiumFromAnyOrganization = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$( + premiumPayload.userId as UserId, + ), + ); + await this.billingAccountProfileStateService.setHasPremium( + premiumPayload.premium, + hasPremiumFromAnyOrganization, + premiumPayload.userId as UserId, + ); + break; + } default: break; }