From 6ee41343a59e22662ad3f52a9a4d5757c0ad5e71 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:49:52 -0500 Subject: [PATCH] [PM-25379] Refactor org metadata (#16759) * removing unused properties from org metadata * removing further properties from the response and replacing them with data already available * [PM-25379] new org metadata service for new endpoint * don't need strict ignore * forgot unit tests * added cache busting to metadata service not used yet - waiting for a decision on moving a portion of this to AC --- .../members/members.component.ts | 4 +- ...ganization-subscription-cloud.component.ts | 10 +- .../member-access-report.component.ts | 6 +- .../src/services/jslib-services.module.ts | 7 + .../billing-api.service.abstraction.ts | 7 +- ...ganization-metadata.service.abstraction.ts | 12 + .../organization-billing-metadata.response.ts | 23 -- .../billing/services/billing-api.service.ts | 17 +- .../organization-metadata.service.spec.ts | 276 ++++++++++++++++++ .../organization-metadata.service.ts | 74 +++++ libs/common/src/enums/feature-flag.enum.ts | 2 + 11 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts create mode 100644 libs/common/src/billing/services/organization/organization-metadata.service.spec.ts create mode 100644 libs/common/src/billing/services/organization/organization-metadata.service.ts diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index b31f1cbf35..dd1c0edf08 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent private groupService: GroupApiService, private collectionService: CollectionService, private billingApiService: BillingApiServiceAbstraction, + private organizationMetadataService: OrganizationMetadataServiceAbstraction, protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, private configService: ConfigService, private organizationUserService: OrganizationUserService, @@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe( switchMap(([_, organization]) => - this.billingApiService.getOrganizationBillingMetadata(organization.id), + this.organizationMetadataService.getOrganizationMetadata$(organization.id), ), takeUntilDestroyed(), shareReplay({ bufferSize: 1, refCount: false }), diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 6bb262152e..79d4057fdd 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner; const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser; - const metadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, - ); - this.organizationIsManagedByConsolidatedBillingMSP = - this.userOrg.hasProvider && metadata.isManaged; + this.userOrg.hasProvider && this.userOrg.hasBillableProvider; this.showSubscription = isIndependentOrganizationOwner || isResoldOrganizationOwner || (isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP); - this.showSelfHost = metadata.isEligibleForSelfHost; + this.showSelfHost = + this.userOrg.productTierType === ProductTierType.Families || + this.userOrg.productTierType === ProductTierType.Enterprise; if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts index b9cab67956..796cf212a6 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts @@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit { protected dialogService: DialogService, protected userNamePipe: UserNamePipe, protected billingApiService: BillingApiServiceAbstraction, + protected organizationMetadataService: OrganizationMetadataServiceAbstraction, ) { // Connect the search input to the table dataSource filter input this.searchControl.valueChanges @@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit { const params = await firstValueFrom(this.route.params); this.organizationId = params.organizationId; - const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, + const billingMetadata = await firstValueFrom( + this.organizationMetadataService.getOrganizationMetadata$(this.organizationId), ); this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 717a6c501c..c66c74a3ea 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -145,12 +145,14 @@ import { } from "@bitwarden/common/billing/abstractions"; import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; +import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; @@ -1414,6 +1416,11 @@ const safeProviders: SafeProvider[] = [ useClass: BillingApiService, deps: [ApiServiceAbstraction], }), + safeProvider({ + provide: OrganizationMetadataServiceAbstraction, + useClass: DefaultOrganizationMetadataService, + deps: [BillingApiServiceAbstraction, ConfigService], + }), safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index b5695e2e8a..1dbb8053e9 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { PlanResponse } from "../../billing/models/response/plan.response"; import { ListResponse } from "../../models/response/list.response"; +import { OrganizationId } from "../../types/guid"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; import { InvoicesResponse } from "../models/response/invoices.response"; @@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction { ): Promise; abstract getOrganizationBillingMetadata( - organizationId: string, + organizationId: OrganizationId, + ): Promise; + + abstract getOrganizationBillingMetadataVNext( + organizationId: OrganizationId, ): Promise; abstract getPlans(): Promise>; diff --git a/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts new file mode 100644 index 0000000000..c16d411227 --- /dev/null +++ b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts @@ -0,0 +1,12 @@ +import { Observable } from "rxjs"; + +import { OrganizationId } from "../../types/guid"; +import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response"; + +export abstract class OrganizationMetadataServiceAbstraction { + abstract getOrganizationMetadata$( + organizationId: OrganizationId, + ): Observable; + + abstract refreshMetadataCache(): void; +} diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index aa34c37bd1..366d10a1dc 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -1,35 +1,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class OrganizationBillingMetadataResponse extends BaseResponse { - isEligibleForSelfHost: boolean; - isManaged: boolean; isOnSecretsManagerStandalone: boolean; - isSubscriptionUnpaid: boolean; - hasSubscription: boolean; - hasOpenInvoice: boolean; - invoiceDueDate: Date | null; - invoiceCreatedDate: Date | null; - subPeriodEndDate: Date | null; - isSubscriptionCanceled: boolean; organizationOccupiedSeats: number; constructor(response: any) { super(response); - this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); - this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); - this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); - this.hasSubscription = this.getResponseProperty("HasSubscription"); - this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice"); - - this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate")); - this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate")); - this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate")); - this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled"); this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats"); } - - private parseDate(dateString: any): Date | null { - return dateString ? new Date(dateString) : null; - } } diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index a34809e9f0..c953d92005 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { ListResponse } from "../../models/response/list.response"; +import { OrganizationId } from "../../types/guid"; import { BillingApiServiceAbstraction } from "../abstractions"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request"; @@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction { } async getOrganizationBillingMetadata( - organizationId: string, + organizationId: OrganizationId, ): Promise { const r = await this.apiService.send( "GET", @@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingMetadataResponse(r); } + async getOrganizationBillingMetadataVNext( + organizationId: OrganizationId, + ): Promise { + const r = await this.apiService.send( + "GET", + "/organizations/" + organizationId + "/billing/vnext/metadata", + null, + true, + true, + ); + + return new OrganizationBillingMetadataResponse(r); + } + async getPlans(): Promise> { const r = await this.apiService.send("GET", "/plans", null, false, true); return new ListResponse(r, PlanResponse); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts new file mode 100644 index 0000000000..0ed60bef60 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts @@ -0,0 +1,276 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { newGuid } from "@bitwarden/guid"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { OrganizationId } from "../../../types/guid"; + +import { DefaultOrganizationMetadataService } from "./organization-metadata.service"; + +describe("DefaultOrganizationMetadataService", () => { + let service: DefaultOrganizationMetadataService; + let billingApiService: jest.Mocked; + let configService: jest.Mocked; + let featureFlagSubject: BehaviorSubject; + + const mockOrganizationId = newGuid() as OrganizationId; + const mockOrganizationId2 = newGuid() as OrganizationId; + + const createMockMetadataResponse = ( + isOnSecretsManagerStandalone = false, + organizationOccupiedSeats = 5, + ): OrganizationBillingMetadataResponse => { + return { + isOnSecretsManagerStandalone, + organizationOccupiedSeats, + } as OrganizationBillingMetadataResponse; + }; + + beforeEach(() => { + billingApiService = mock(); + configService = mock(); + featureFlagSubject = new BehaviorSubject(false); + + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable()); + + service = new DefaultOrganizationMetadataService(billingApiService, configService); + }); + + afterEach(() => { + jest.resetAllMocks(); + featureFlagSubject.complete(); + }); + + describe("getOrganizationMetadata$", () => { + describe("feature flag OFF", () => { + beforeEach(() => { + featureFlagSubject.next(false); + }); + + it("calls getOrganizationBillingMetadata when feature flag is off", async () => { + const mockResponse = createMockMetadataResponse(false, 10); + billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, + ); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("does not cache metadata when feature flag is off", async () => { + const mockResponse1 = createMockMetadataResponse(false, 10); + const mockResponse2 = createMockMetadataResponse(false, 15); + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + }); + }); + + describe("feature flag ON", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => { + const mockResponse = createMockMetadataResponse(true, 15); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("caches metadata by organization ID when feature flag is on", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + }); + + it("maintains separate cache entries for different organization IDs", async () => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( + 1, + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( + 2, + mockOrganizationId2, + ); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + expect(result3).toEqual(mockResponse1); + expect(result4).toEqual(mockResponse2); + }); + }); + + describe("shareReplay behavior", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const metadata$ = service.getOrganizationMetadata$(mockOrganizationId); + + const subscription1Promise = firstValueFrom(metadata$); + const subscription2Promise = firstValueFrom(metadata$); + const subscription3Promise = firstValueFrom(metadata$); + + const [result1, result2, result3] = await Promise.all([ + subscription1Promise, + subscription2Promise, + subscription3Promise, + ]); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + expect(result3).toEqual(mockResponse); + }); + }); + }); + + describe("refreshMetadataCache", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("refreshes cached metadata when called with feature flag on", (done) => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(true, 20); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: (result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual(mockResponse1); + } else if (invocationCount === 2) { + expect(result).toEqual(mockResponse2); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + subscription.unsubscribe(); + done(); + } + }, + error: done.fail, + }); + + setTimeout(() => { + service.refreshMetadataCache(); + }, 10); + }); + + it("does not trigger refresh when feature flag is disabled", async () => { + featureFlagSubject.next(false); + + const mockResponse1 = createMockMetadataResponse(false, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: () => { + invocationCount++; + }, + }); + + // wait for initial invocation + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(invocationCount).toBe(1); + + service.refreshMetadataCache(); + + // wait to ensure no additional invocations + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(invocationCount).toBe(1); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); + + it("bypasses cache when refreshing metadata", (done) => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(true, 20); + const mockResponse3 = createMockMetadataResponse(true, 30); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2) + .mockResolvedValueOnce(mockResponse3); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: (result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual(mockResponse1); + service.refreshMetadataCache(); + } else if (invocationCount === 2) { + expect(result).toEqual(mockResponse2); + service.refreshMetadataCache(); + } else if (invocationCount === 3) { + expect(result).toEqual(mockResponse3); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3); + subscription.unsubscribe(); + done(); + } + }, + error: done.fail, + }); + }); + }); +}); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts new file mode 100644 index 0000000000..09aaa20211 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts @@ -0,0 +1,74 @@ +import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { OrganizationId } from "../../../types/guid"; +import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response"; + +export class DefaultOrganizationMetadataService implements OrganizationMetadataServiceAbstraction { + private metadataCache = new Map< + OrganizationId, + Observable + >(); + + constructor( + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, + ) {} + private refreshMetadataTrigger = new Subject(); + + refreshMetadataCache = () => this.refreshMetadataTrigger.next(); + + getOrganizationMetadata$ = ( + organizationId: OrganizationId, + ): Observable => + this.configService + .getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure) + .pipe( + switchMap((featureFlagEnabled) => { + return merge( + this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled), + this.refreshMetadataTrigger.pipe( + filter(() => featureFlagEnabled), + switchMap(() => + this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true), + ), + ), + ); + }), + ); + + private getOrganizationMetadataInternal$( + organizationId: OrganizationId, + featureFlagEnabled: boolean, + bypassCache: boolean = false, + ): Observable { + if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) { + return this.metadataCache.get(organizationId)!; + } + + const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); + + if (featureFlagEnabled) { + this.metadataCache.set(organizationId, metadata$); + } + + return metadata$; + } + + private async fetchMetadata( + organizationId: OrganizationId, + featureFlagEnabled: boolean, + ): Promise { + if (featureFlagEnabled) { + return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId); + } + + return await this.billingApiService.getOrganizationBillingMetadata(organizationId); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8b73010daf..78113d74cb 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -25,6 +25,7 @@ export enum FeatureFlag { PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", + PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", /* Key Management */ @@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, + [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, /* Key Management */