diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index 604d326bf37..22da9a566f4 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -1,4 +1,15 @@ - + + + + + - + (false); protected currentSearchText$: Observable; - protected freeTrial$: Observable; - protected resellerWarning$: Observable; + protected useOrganizationWarningsService$: Observable; + protected freeTrialWhenWarningsServiceDisabled$: Observable; + protected resellerWarningWhenWarningsServiceDisabled$: Observable; protected prevCipherId: string | null = null; protected userId: UserId; /** @@ -255,6 +262,7 @@ export class VaultComponent implements OnInit, OnDestroy { private resellerWarningService: ResellerWarningService, private accountService: AccountService, private billingNotificationService: BillingNotificationService, + private organizationWarningsService: OrganizationWarningsService, ) {} async ngOnInit() { @@ -628,9 +636,23 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); - this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + // Billing Warnings + this.useOrganizationWarningsService$ = this.configService.getFeatureFlag$( + FeatureFlag.UseOrganizationWarningsService, + ); - this.freeTrial$ = combineLatest([ + this.useOrganizationWarningsService$ + .pipe( + switchMap((enabled) => + enabled + ? this.organizationWarningsService.showInactiveSubscriptionDialog$(this.organization) + : this.unpaidSubscriptionDialog$, + ), + takeUntil(this.destroy$), + ) + .subscribe(); + + const freeTrial$ = combineLatest([ organization$, this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), ]).pipe( @@ -655,7 +677,12 @@ export class VaultComponent implements OnInit, OnDestroy { filter((result) => result !== null), ); - this.resellerWarning$ = organization$.pipe( + this.freeTrialWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( + filter((enabled) => !enabled), + switchMap(() => freeTrial$), + ); + + const resellerWarning$ = organization$.pipe( filter((org) => org.isOwner), switchMap((org) => from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe( @@ -665,6 +692,12 @@ export class VaultComponent implements OnInit, OnDestroy { map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)), ); + this.resellerWarningWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( + filter((enabled) => !enabled), + switchMap(() => resellerWarning$), + ); + // End Billing Warnings + firstSetup$ .pipe( switchMap(() => this.refresh$), diff --git a/apps/web/src/app/billing/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/services/organization-warnings.service.spec.ts new file mode 100644 index 00000000000..88c571e2d67 --- /dev/null +++ b/apps/web/src/app/billing/services/organization-warnings.service.spec.ts @@ -0,0 +1,356 @@ +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, lastValueFrom } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; + +import { OrganizationWarningsService } from "./organization-warnings.service"; + +describe("OrganizationWarningsService", () => { + let dialogService: MockProxy; + let i18nService: MockProxy; + let organizationApiService: MockProxy; + let organizationBillingApiService: MockProxy; + let router: MockProxy; + + let organizationWarningsService: OrganizationWarningsService; + + const respond = (responseBody: any) => + Promise.resolve(new OrganizationWarningsResponse(responseBody)); + + const empty = () => Promise.resolve(new OrganizationWarningsResponse({})); + + beforeEach(() => { + dialogService = mock(); + i18nService = mock(); + organizationApiService = mock(); + organizationBillingApiService = mock(); + router = mock(); + + organizationWarningsService = new OrganizationWarningsService( + dialogService, + i18nService, + organizationApiService, + organizationBillingApiService, + router, + ); + }); + + describe("cache$", () => { + it("should only request warnings once for a specific organization and replay the cached result for multiple subscriptions", async () => { + const response1 = respond({ + freeTrial: { + remainingTrialDays: 1, + }, + }); + + const organization1 = { + id: "1", + name: "Test", + } as Organization; + + const response2 = respond({ + freeTrial: { + remainingTrialDays: 2, + }, + }); + + const organization2 = { + id: "2", + name: "Test", + } as Organization; + + organizationBillingApiService.getWarnings.mockImplementation((id) => { + if (id === organization1.id) { + return response1; + } + + if (id === organization2.id) { + return response2; + } + + return empty(); + }); + + const oneDayRemainingTranslation = "oneDayRemaining"; + const twoDaysRemainingTranslation = "twoDaysRemaining"; + + i18nService.t.mockImplementation((id, p1) => { + if (id === "freeTrialEndPromptTomorrowNoOrgName") { + return oneDayRemainingTranslation; + } + + if (id === "freeTrialEndPromptCount" && p1 === 2) { + return twoDaysRemainingTranslation; + } + + return ""; + }); + + const organization1Subscription1 = await firstValueFrom( + organizationWarningsService.getFreeTrialWarning$(organization1), + ); + + const organization1Subscription2 = await firstValueFrom( + organizationWarningsService.getFreeTrialWarning$(organization1), + ); + + expect(organization1Subscription1).toEqual({ + organization: organization1, + message: oneDayRemainingTranslation, + }); + + expect(organization1Subscription2).toEqual(organization1Subscription1); + + const organization2Subscription1 = await firstValueFrom( + organizationWarningsService.getFreeTrialWarning$(organization2), + ); + + const organization2Subscription2 = await firstValueFrom( + organizationWarningsService.getFreeTrialWarning$(organization2), + ); + + expect(organization2Subscription1).toEqual({ + organization: organization2, + message: twoDaysRemainingTranslation, + }); + + expect(organization2Subscription2).toEqual(organization2Subscription1); + + expect(organizationBillingApiService.getWarnings).toHaveBeenCalledTimes(2); + }); + }); + + describe("getFreeTrialWarning$", () => { + it("should not emit a free trial warning when none is included in the warnings response", (done) => { + const organization = { + id: "1", + name: "Test", + } as Organization; + + organizationBillingApiService.getWarnings.mockReturnValue(empty()); + + const warning$ = organizationWarningsService.getFreeTrialWarning$(organization); + + warning$.subscribe({ + next: () => { + fail("Observable should not emit a value."); + }, + complete: () => { + done(); + }, + }); + }); + + it("should emit a free trial warning when one is included in the warnings response", async () => { + const response = respond({ + freeTrial: { + remainingTrialDays: 1, + }, + }); + + const organization = { + id: "1", + name: "Test", + } as Organization; + + organizationBillingApiService.getWarnings.mockImplementation((id) => { + if (id === organization.id) { + return response; + } else { + return empty(); + } + }); + + const translation = "translation"; + i18nService.t.mockImplementation((id) => { + if (id === "freeTrialEndPromptTomorrowNoOrgName") { + return translation; + } else { + return ""; + } + }); + + const warning = await firstValueFrom( + organizationWarningsService.getFreeTrialWarning$(organization), + ); + + expect(warning).toEqual({ + organization, + message: translation, + }); + }); + }); + + describe("getResellerRenewalWarning$", () => { + it("should not emit a reseller renewal warning when none is included in the warnings response", (done) => { + const organization = { + id: "1", + name: "Test", + } as Organization; + + organizationBillingApiService.getWarnings.mockReturnValue(empty()); + + const warning$ = organizationWarningsService.getResellerRenewalWarning$(organization); + + warning$.subscribe({ + next: () => { + fail("Observable should not emit a value."); + }, + complete: () => { + done(); + }, + }); + }); + + it("should emit a reseller renewal warning when one is included in the warnings response", async () => { + const response = respond({ + resellerRenewal: { + type: "upcoming", + upcoming: { + renewalDate: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + const organization = { + id: "1", + name: "Test", + providerName: "Provider", + } as Organization; + + organizationBillingApiService.getWarnings.mockImplementation((id) => { + if (id === organization.id) { + return response; + } else { + return empty(); + } + }); + + const formattedDate = new Date("2026-01-01T00:00:00.000Z").toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + + const translation = "translation"; + i18nService.t.mockImplementation((id, p1, p2) => { + if ( + id === "resellerRenewalWarningMsg" && + p1 === organization.providerName && + p2 === formattedDate + ) { + return translation; + } else { + return ""; + } + }); + + const warning = await firstValueFrom( + organizationWarningsService.getResellerRenewalWarning$(organization), + ); + + expect(warning).toEqual({ + type: "info", + message: translation, + }); + }); + }); + + describe("showInactiveSubscriptionDialog$", () => { + it("should not emit the opening of a dialog for an inactive subscription warning when the warning is not included in the warnings response", (done) => { + const organization = { + id: "1", + name: "Test", + } as Organization; + + organizationBillingApiService.getWarnings.mockReturnValue(empty()); + + const warning$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); + + warning$.subscribe({ + next: () => { + fail("Observable should not emit a value."); + }, + complete: () => { + done(); + }, + }); + }); + + it("should emit the opening of a dialog for an inactive subscription warning when the warning is included in the warnings response", async () => { + const response = respond({ + inactiveSubscription: { + resolution: "add_payment_method", + }, + }); + + const organization = { + id: "1", + name: "Test", + providerName: "Provider", + } as Organization; + + organizationBillingApiService.getWarnings.mockImplementation((id) => { + if (id === organization.id) { + return response; + } else { + return empty(); + } + }); + + const titleTranslation = "title"; + const continueTranslation = "continue"; + const closeTranslation = "close"; + + i18nService.t.mockImplementation((id, param) => { + if (id === "suspendedOrganizationTitle" && param === organization.name) { + return titleTranslation; + } + if (id === "continue") { + return continueTranslation; + } + if (id === "close") { + return closeTranslation; + } + return ""; + }); + + const expectedOptions = { + title: titleTranslation, + content: { + key: "suspendedOwnerOrgMessage", + }, + type: "danger", + acceptButtonText: continueTranslation, + cancelButtonText: closeTranslation, + } as SimpleDialogOptions; + + dialogService.openSimpleDialog.mockImplementation((options) => { + if (JSON.stringify(options) == JSON.stringify(expectedOptions)) { + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } + }); + + const observable$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); + + const routerNavigateSpy = jest.spyOn(router, "navigate").mockResolvedValue(true); + + await lastValueFrom(observable$); + + expect(routerNavigateSpy).toHaveBeenCalledWith( + ["organizations", `${organization.id}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/organization-warnings.service.ts b/apps/web/src/app/billing/services/organization-warnings.service.ts new file mode 100644 index 00000000000..f75220a7744 --- /dev/null +++ b/apps/web/src/app/billing/services/organization-warnings.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; +import { + filter, + from, + lastValueFrom, + map, + Observable, + shareReplay, + switchMap, + takeWhile, +} from "rxjs"; +import { take } from "rxjs/operators"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; +import { openChangePlanDialog } from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component"; + +const format = (date: Date) => + date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + +export type FreeTrialWarning = { + organization: Pick; + message: string; +}; + +export type ResellerRenewalWarning = { + type: "info" | "warning"; + message: string; +}; + +@Injectable({ providedIn: "root" }) +export class OrganizationWarningsService { + private cache$ = new Map>(); + + constructor( + private dialogService: DialogService, + private i18nService: I18nService, + private organizationApiService: OrganizationApiServiceAbstraction, + private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, + private router: Router, + ) {} + + getFreeTrialWarning$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.freeTrial).pipe( + map((warning) => { + const { remainingTrialDays } = warning; + + if (remainingTrialDays >= 2) { + return { + organization, + message: this.i18nService.t("freeTrialEndPromptCount", remainingTrialDays), + }; + } + + if (remainingTrialDays == 1) { + return { + organization, + message: this.i18nService.t("freeTrialEndPromptTomorrowNoOrgName"), + }; + } + + return { + organization, + message: this.i18nService.t("freeTrialEndingTodayWithoutOrgName"), + }; + }), + ); + + getResellerRenewalWarning$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.resellerRenewal).pipe( + map((warning): ResellerRenewalWarning | null => { + switch (warning.type) { + case "upcoming": { + return { + type: "info", + message: this.i18nService.t( + "resellerRenewalWarningMsg", + organization.providerName, + format(warning.upcoming!.renewalDate), + ), + }; + } + case "issued": { + return { + type: "info", + message: this.i18nService.t( + "resellerOpenInvoiceWarningMgs", + organization.providerName, + format(warning.issued!.issuedDate), + format(warning.issued!.dueDate), + ), + }; + } + case "past_due": { + return { + type: "warning", + message: this.i18nService.t( + "resellerPastDueWarningMsg", + organization.providerName, + format(warning.pastDue!.suspensionDate), + ), + }; + } + } + }), + filter((result): result is NonNullable => result !== null), + ); + + showInactiveSubscriptionDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( + switchMap(async (warning) => { + switch (warning.resolution) { + case "contact_provider": { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", organization.name), + content: { + key: "suspendedManagedOrgMessage", + placeholders: [organization.providerName], + }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + break; + } + case "add_payment_method": { + const confirmed = await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", organization.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + if (confirmed) { + await this.router.navigate( + ["organizations", `${organization.id}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } + break; + } + case "resubscribe": { + const subscription = await this.organizationApiService.getSubscription(organization.id); + const dialogReference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + await lastValueFrom(dialogReference.closed); + break; + } + case "contact_owner": { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", organization.name), + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + break; + } + } + }), + ); + + private getResponse$ = (organization: Organization): Observable => { + const existing = this.cache$.get(organization.id as OrganizationId); + if (existing) { + return existing; + } + const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); + this.cache$.set(organization.id as OrganizationId, response$); + return response$; + }; + + private getWarning$ = ( + organization: Organization, + extract: (response: OrganizationWarningsResponse) => T | null | undefined, + ): Observable => + this.getResponse$(organization).pipe( + map(extract), + takeWhile((warning): warning is T => !!warning), + take(1), + ); +} diff --git a/apps/web/src/app/billing/warnings/free-trial-warning.component.ts b/apps/web/src/app/billing/warnings/free-trial-warning.component.ts new file mode 100644 index 00000000000..e83873e9d6b --- /dev/null +++ b/apps/web/src/app/billing/warnings/free-trial-warning.component.ts @@ -0,0 +1,56 @@ +import { AsyncPipe } from "@angular/common"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { + FreeTrialWarning, + OrganizationWarningsService, +} from "../services/organization-warnings.service"; + +@Component({ + selector: "app-free-trial-warning", + template: ` + @let warning = freeTrialWarning$ | async; + + @if (warning) { + + {{ warning.message }} + + {{ "clickHereToAddPaymentMethod" | i18n }} + + + } + `, + standalone: true, + imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], +}) +export class FreeTrialWarningComponent implements OnInit { + @Input({ required: true }) organization!: Organization; + @Output() clicked = new EventEmitter(); + + freeTrialWarning$!: Observable; + + constructor(private organizationWarningsService: OrganizationWarningsService) {} + + ngOnInit() { + this.freeTrialWarning$ = this.organizationWarningsService.getFreeTrialWarning$( + this.organization, + ); + } +} diff --git a/apps/web/src/app/billing/warnings/reseller-renewal-warning.component.ts b/apps/web/src/app/billing/warnings/reseller-renewal-warning.component.ts new file mode 100644 index 00000000000..fc94e85e28d --- /dev/null +++ b/apps/web/src/app/billing/warnings/reseller-renewal-warning.component.ts @@ -0,0 +1,45 @@ +import { AsyncPipe } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BannerComponent } from "@bitwarden/components"; + +import { + OrganizationWarningsService, + ResellerRenewalWarning, +} from "../services/organization-warnings.service"; + +@Component({ + selector: "app-reseller-renewal-warning", + template: ` + @let warning = resellerRenewalWarning$ | async; + + @if (warning) { + + {{ warning.message }} + + } + `, + standalone: true, + imports: [AsyncPipe, BannerComponent], +}) +export class ResellerRenewalWarningComponent implements OnInit { + @Input({ required: true }) organization!: Organization; + + resellerRenewalWarning$!: Observable; + + constructor(private organizationWarningsService: OrganizationWarningsService) {} + + ngOnInit() { + this.resellerRenewalWarning$ = this.organizationWarningsService.getResellerRenewalWarning$( + this.organization, + ); + } +} diff --git a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts index e1f7ad49012..4975da0d7d2 100644 --- a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; + import { BillingInvoiceResponse, BillingTransactionResponse, @@ -15,6 +17,8 @@ export abstract class OrganizationBillingApiServiceAbstraction { startAfter?: string, ) => Promise; + abstract getWarnings: (id: string) => Promise; + abstract setupBusinessUnit: ( id: string, request: { diff --git a/libs/common/src/billing/models/response/organization-warnings.response.ts b/libs/common/src/billing/models/response/organization-warnings.response.ts new file mode 100644 index 00000000000..ff70298101e --- /dev/null +++ b/libs/common/src/billing/models/response/organization-warnings.response.ts @@ -0,0 +1,97 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class OrganizationWarningsResponse extends BaseResponse { + freeTrial?: FreeTrialWarningResponse; + inactiveSubscription?: InactiveSubscriptionWarningResponse; + resellerRenewal?: ResellerRenewalWarningResponse; + + constructor(response: any) { + super(response); + const freeTrialWarning = this.getResponseProperty("FreeTrial"); + if (freeTrialWarning) { + this.freeTrial = new FreeTrialWarningResponse(freeTrialWarning); + } + const inactiveSubscriptionWarning = this.getResponseProperty("InactiveSubscription"); + if (inactiveSubscriptionWarning) { + this.inactiveSubscription = new InactiveSubscriptionWarningResponse( + inactiveSubscriptionWarning, + ); + } + const resellerWarning = this.getResponseProperty("ResellerRenewal"); + if (resellerWarning) { + this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); + } + } +} + +class FreeTrialWarningResponse extends BaseResponse { + remainingTrialDays: number; + + constructor(response: any) { + super(response); + this.remainingTrialDays = this.getResponseProperty("RemainingTrialDays"); + } +} + +class InactiveSubscriptionWarningResponse extends BaseResponse { + resolution: string; + + constructor(response: any) { + super(response); + this.resolution = this.getResponseProperty("Resolution"); + } +} + +class ResellerRenewalWarningResponse extends BaseResponse { + type: "upcoming" | "issued" | "past_due"; + upcoming?: UpcomingRenewal; + issued?: IssuedRenewal; + pastDue?: PastDueRenewal; + + constructor(response: any) { + super(response); + this.type = this.getResponseProperty("Type"); + switch (this.type) { + case "upcoming": { + this.upcoming = new UpcomingRenewal(this.getResponseProperty("Upcoming")); + break; + } + case "issued": { + this.issued = new IssuedRenewal(this.getResponseProperty("Issued")); + break; + } + case "past_due": { + this.pastDue = new PastDueRenewal(this.getResponseProperty("PastDue")); + } + } + } +} + +class UpcomingRenewal extends BaseResponse { + renewalDate: Date; + + constructor(response: any) { + super(response); + this.renewalDate = new Date(this.getResponseProperty("RenewalDate")); + } +} + +class IssuedRenewal extends BaseResponse { + issuedDate: Date; + dueDate: Date; + + constructor(response: any) { + super(response); + this.issuedDate = new Date(this.getResponseProperty("IssuedDate")); + this.dueDate = new Date(this.getResponseProperty("DueDate")); + } +} + +class PastDueRenewal extends BaseResponse { + suspensionDate: Date; + + constructor(response: any) { + super(response); + this.suspensionDate = new Date(this.getResponseProperty("SuspensionDate")); + } +} diff --git a/libs/common/src/billing/services/organization/organization-billing-api.service.ts b/libs/common/src/billing/services/organization/organization-billing-api.service.ts index 405bd41957f..1189316a487 100644 --- a/libs/common/src/billing/services/organization/organization-billing-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-billing-api.service.ts @@ -1,3 +1,5 @@ +import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; + import { ApiService } from "../../../abstractions/api.service"; import { OrganizationBillingApiServiceAbstraction } from "../../abstractions/organizations/organization-billing-api.service.abstraction"; import { @@ -50,6 +52,18 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ return r?.map((i: any) => new BillingTransactionResponse(i)) || []; } + async getWarnings(id: string): Promise { + const response = await this.apiService.send( + "GET", + `/organizations/${id}/billing/warnings`, + null, + true, + true, + ); + + return new OrganizationWarningsResponse(response); + } + async setupBusinessUnit( id: string, request: { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 3644ceefa9a..3e905a6253c 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -35,6 +35,7 @@ export enum FeatureFlag { PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup", + UseOrganizationWarningsService = "use-organization-warnings-service", /* Data Insights and Reporting */ CriticalApps = "pm-14466-risk-insights-critical-application", @@ -123,6 +124,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE, + [FeatureFlag.UseOrganizationWarningsService]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE,