+ @let callout = taxIdWarningCallout;
+ @if (callout) {
+
+ {{ callout.message }}
+
+ }
@@ -57,13 +77,13 @@ type DialogResult =
`,
standalone: true,
imports: [EnterBillingAddressComponent, SharedModule],
- providers: [BillingClient],
+ providers: [SubscriberBillingClient],
})
export class EditBillingAddressDialogComponent {
protected formGroup = EnterBillingAddressComponent.getFormGroup();
constructor(
- private billingClient: BillingClient,
+ private billingClient: SubscriberBillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
private dialogRef: DialogRef
,
private i18nService: I18nService,
@@ -93,7 +113,7 @@ export class EditBillingAddressDialogComponent {
: { ...addressFields, taxId: null };
const result = await this.billingClient.updateBillingAddress(
- this.dialogParams.owner,
+ this.dialogParams.subscriber,
billingAddress,
);
@@ -125,7 +145,7 @@ export class EditBillingAddressDialogComponent {
};
get supportsTaxId(): boolean {
- switch (this.dialogParams.owner.type) {
+ switch (this.dialogParams.subscriber.type) {
case "account": {
return false;
}
@@ -134,7 +154,7 @@ export class EditBillingAddressDialogComponent {
ProductTierType.TeamsStarter,
ProductTierType.Teams,
ProductTierType.Enterprise,
- ].includes(this.dialogParams.owner.data.productTierType);
+ ].includes(this.dialogParams.subscriber.data.productTierType);
}
case "provider": {
return true;
@@ -142,6 +162,37 @@ export class EditBillingAddressDialogComponent {
}
}
+ get taxIdWarningCallout(): {
+ type: CalloutTypes;
+ title: string;
+ message: string;
+ } | null {
+ if (
+ !this.supportsTaxId ||
+ !this.dialogParams.taxIdWarning ||
+ this.dialogParams.taxIdWarning === TaxIdWarningTypes.PendingVerification
+ ) {
+ return null;
+ }
+
+ switch (this.dialogParams.taxIdWarning) {
+ case TaxIdWarningTypes.Missing: {
+ return {
+ type: "warning",
+ title: this.i18nService.t("missingTaxIdCalloutTitle"),
+ message: this.i18nService.t("missingTaxIdCalloutDescription"),
+ };
+ }
+ case TaxIdWarningTypes.FailedVerification: {
+ return {
+ type: "warning",
+ title: this.i18nService.t("unverifiedTaxIdCalloutTitle"),
+ message: this.i18nService.t("unverifiedTaxIdCalloutDescription"),
+ };
+ }
+ }
+ }
+
static open = (dialogService: DialogService, dialogConfig: DialogConfig) =>
dialogService.open(EditBillingAddressDialogComponent, dialogConfig);
}
diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts
index ab59e965b4e..7659b7ed5ca 100644
--- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts
+++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts
@@ -3,9 +3,14 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { map, Observable, startWith, Subject, takeUntil } from "rxjs";
import { ControlsOf } from "@bitwarden/angular/types/controls-of";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import {
+ TaxIdWarningType,
+ TaxIdWarningTypes,
+} from "@bitwarden/web-vault/app/billing/warnings/types";
import { SharedModule } from "../../../shared";
-import { BillingAddress, selectableCountries, taxIdTypes } from "../types";
+import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types";
export interface BillingAddressControls {
country: string;
@@ -28,6 +33,7 @@ type Scenario =
type: "update";
existing?: BillingAddress;
supportsTaxId: boolean;
+ taxIdWarning?: TaxIdWarningType;
};
@Component({
@@ -110,7 +116,7 @@ type Scenario =
@if (supportsTaxId$ | async) {
-
+
{{ "taxIdNumber" | i18n }}
+ @let hint = taxIdWarningHint;
+ @if (hint) {
+ {{ hint }}
+ }
}
@@ -137,6 +154,8 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
private destroy$ = new Subject
();
+ constructor(private i18nService: I18nService) {}
+
ngOnInit() {
switch (this.scenario.type) {
case "checkout": {
@@ -185,6 +204,40 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
this.group.controls.state.disable();
};
+ get taxIdWarningHint() {
+ if (
+ this.scenario.type === "checkout" ||
+ !this.scenario.supportsTaxId ||
+ !this.group.value.country ||
+ this.scenario.taxIdWarning !== TaxIdWarningTypes.FailedVerification
+ ) {
+ return null;
+ }
+
+ const taxIdType = getTaxIdTypeForCountry(this.group.value.country);
+
+ if (!taxIdType) {
+ return null;
+ }
+
+ const checkInputFormat = this.i18nService.t("checkInputFormat");
+
+ switch (taxIdType.code) {
+ case "au_abn": {
+ const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "ABN", taxIdType.example);
+ return `${checkInputFormat} ${exampleFormat}`;
+ }
+ case "eu_vat": {
+ const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "EU VAT", taxIdType.example);
+ return `${checkInputFormat} ${exampleFormat}`;
+ }
+ case "gb_vat": {
+ const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "GB VAT", taxIdType.example);
+ return `${checkInputFormat} ${exampleFormat}`;
+ }
+ }
+ }
+
static getFormGroup = (): BillingAddressFormGroup =>
new FormGroup({
country: new FormControl("", {
diff --git a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts
index 72585badca0..b1ca1922775 100644
--- a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts
+++ b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts
@@ -9,10 +9,10 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared";
-import { BillingClient } from "../../services";
-import { BillableEntity } from "../../types";
+import { BitwardenSubscriber } from "../../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
import {
@@ -21,7 +21,7 @@ import {
} from "./submit-payment-method-dialog.component";
type DialogParams = {
- owner: BillableEntity;
+ subscriber: BitwardenSubscriber;
callout: {
type: CalloutTypes;
title: string;
@@ -53,20 +53,20 @@ type DialogParams = {
`,
standalone: true,
imports: [EnterPaymentMethodComponent, SharedModule],
- providers: [BillingClient],
+ providers: [SubscriberBillingClient],
})
export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
- protected override owner: BillableEntity;
+ protected override subscriber: BitwardenSubscriber;
constructor(
- billingClient: BillingClient,
+ billingClient: SubscriberBillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
dialogRef: DialogRef,
i18nService: I18nService,
toastService: ToastService,
) {
super(billingClient, dialogRef, i18nService, toastService);
- this.owner = this.dialogParams.owner;
+ this.subscriber = this.dialogParams.subscriber;
}
static open = (dialogService: DialogService, dialogConfig: DialogConfig) =>
diff --git a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts
index 0a0a5bf26d9..62d2b775eb5 100644
--- a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts
+++ b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts
@@ -2,9 +2,9 @@ import { Component, ViewChild } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, ToastService } from "@bitwarden/components";
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
-import { BillingClient } from "../../services";
-import { BillableEntity } from "../../types";
+import { BitwardenSubscriber } from "../../types";
import { MaskedPaymentMethod } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
@@ -20,10 +20,10 @@ export abstract class SubmitPaymentMethodDialogComponent {
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
- protected abstract owner: BillableEntity;
+ protected abstract subscriber: BitwardenSubscriber;
protected constructor(
- protected billingClient: BillingClient,
+ protected billingClient: SubscriberBillingClient,
protected dialogRef: DialogRef,
protected i18nService: I18nService,
protected toastService: ToastService,
@@ -43,7 +43,7 @@ export abstract class SubmitPaymentMethodDialogComponent {
: null;
const result = await this.billingClient.updatePaymentMethod(
- this.owner,
+ this.subscriber,
paymentMethod,
billingAddress,
);
diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts
index f79e9a1b5fc..b1a2814daf2 100644
--- a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts
+++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts
@@ -3,10 +3,10 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared";
-import { BillingClient } from "../../services";
-import { BillableEntity } from "../../types";
+import { BitwardenSubscriber } from "../../types";
import { MaskedPaymentMethod } from "../types";
@Component({
@@ -32,10 +32,10 @@ import { MaskedPaymentMethod } from "../types";
`,
standalone: true,
imports: [SharedModule],
- providers: [BillingClient],
+ providers: [SubscriberBillingClient],
})
export class VerifyBankAccountComponent {
- @Input({ required: true }) owner!: BillableEntity;
+ @Input({ required: true }) subscriber!: BitwardenSubscriber;
@Output() verified = new EventEmitter();
protected formGroup = new FormGroup({
@@ -47,7 +47,7 @@ export class VerifyBankAccountComponent {
});
constructor(
- private billingClient: BillingClient,
+ private billingClient: SubscriberBillingClient,
private i18nService: I18nService,
private toastService: ToastService,
) {}
@@ -60,7 +60,7 @@ export class VerifyBankAccountComponent {
}
const result = await this.billingClient.verifyBankAccount(
- this.owner,
+ this.subscriber,
this.formGroup.value.descriptorCode!,
);
diff --git a/apps/web/src/app/billing/services/index.ts b/apps/web/src/app/billing/services/index.ts
index dcd2c05034a..e291ca6a454 100644
--- a/apps/web/src/app/billing/services/index.ts
+++ b/apps/web/src/app/billing/services/index.ts
@@ -1,4 +1,3 @@
-export * from "./billing.client";
export * from "./billing-services.module";
export * from "./braintree.service";
export * from "./stripe.service";
diff --git a/apps/web/src/app/billing/types/billable-entity.ts b/apps/web/src/app/billing/types/bitwarden-subscriber.ts
similarity index 67%
rename from apps/web/src/app/billing/types/billable-entity.ts
rename to apps/web/src/app/billing/types/bitwarden-subscriber.ts
index 79ed12a4161..3454d6a9651 100644
--- a/apps/web/src/app/billing/types/billable-entity.ts
+++ b/apps/web/src/app/billing/types/bitwarden-subscriber.ts
@@ -4,12 +4,14 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
-export type BillableEntity =
+export type BitwardenSubscriber =
| { type: "account"; data: Account }
| { type: "organization"; data: Organization }
| { type: "provider"; data: Provider };
-export const accountToBillableEntity = map((account) => {
+export type NonIndividualSubscriber = Exclude;
+
+export const mapAccountToSubscriber = map((account) => {
if (!account) {
throw new Error("Account not found");
}
@@ -19,7 +21,7 @@ export const accountToBillableEntity = map((acco
};
});
-export const organizationToBillableEntity = map(
+export const mapOrganizationToSubscriber = map(
(organization) => {
if (!organization) {
throw new Error("Organization not found");
@@ -31,7 +33,7 @@ export const organizationToBillableEntity = map((provider) => {
+export const mapProviderToSubscriber = map((provider) => {
if (!provider) {
throw new Error("Organization not found");
}
diff --git a/apps/web/src/app/billing/types/index.ts b/apps/web/src/app/billing/types/index.ts
index 1278e0f2e14..50c007677f3 100644
--- a/apps/web/src/app/billing/types/index.ts
+++ b/apps/web/src/app/billing/types/index.ts
@@ -1,2 +1,2 @@
-export * from "./billable-entity";
+export * from "./bitwarden-subscriber";
export * from "./free-trial";
diff --git a/apps/web/src/app/billing/warnings/components/index.ts b/apps/web/src/app/billing/warnings/components/index.ts
index 1e1e0682e62..5edefadb1ee 100644
--- a/apps/web/src/app/billing/warnings/components/index.ts
+++ b/apps/web/src/app/billing/warnings/components/index.ts
@@ -1,2 +1 @@
-export * from "./organization-free-trial-warning.component";
-export * from "./organization-reseller-renewal-warning.component";
+export * from "./tax-id-warning.component";
diff --git a/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts
new file mode 100644
index 00000000000..7527ef8f0b7
--- /dev/null
+++ b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts
@@ -0,0 +1,286 @@
+import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
+import {
+ BehaviorSubject,
+ combineLatest,
+ filter,
+ firstValueFrom,
+ lastValueFrom,
+ map,
+ Observable,
+ switchMap,
+} from "rxjs";
+
+import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { BannerModule, DialogService } from "@bitwarden/components";
+import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
+import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components";
+import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
+import {
+ TaxIdWarningType,
+ TaxIdWarningTypes,
+} from "@bitwarden/web-vault/app/billing/warnings/types";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+type DismissalCounts = {
+ [TaxIdWarningTypes.Missing]?: number;
+ [TaxIdWarningTypes.FailedVerification]?: number;
+};
+
+const DISMISSALS_COUNT_KEY = new UserKeyDefinition(
+ BILLING_DISK,
+ "taxIdWarningDismissalCounts",
+ {
+ deserializer: (dismissalCounts) => dismissalCounts,
+ clearOn: [],
+ },
+);
+
+type DismissedThisSession = {
+ [TaxIdWarningTypes.Missing]?: boolean;
+ [TaxIdWarningTypes.FailedVerification]?: boolean;
+};
+
+const DISMISSED_THIS_SESSION_KEY = new UserKeyDefinition(
+ BILLING_DISK,
+ "taxIdWarningDismissedThisSession",
+ {
+ deserializer: (dismissedThisSession) => dismissedThisSession,
+ clearOn: ["logout"],
+ },
+);
+
+type Dismissals = {
+ [TaxIdWarningTypes.Missing]: {
+ count: number;
+ dismissedThisSession: boolean;
+ };
+ [TaxIdWarningTypes.FailedVerification]: {
+ count: number;
+ dismissedThisSession: boolean;
+ };
+};
+
+const shouldShowWarning = (
+ warning: Exclude,
+ dismissals: Dismissals,
+) => {
+ const dismissalsForType = dismissals[warning];
+ if (dismissalsForType.dismissedThisSession) {
+ return false;
+ }
+ return dismissalsForType.count < 3;
+};
+
+type View = {
+ message: string;
+ callToAction: string;
+};
+
+type GetWarning$ = () => Observable;
+
+@Component({
+ selector: "app-tax-id-warning",
+ template: `
+ @if (enableTaxIdWarning$ | async) {
+ @let view = view$ | async;
+
+ @if (view) {
+
+ {{ view.message }}
+
+ {{ view.callToAction }}
+
+
+ }
+ }
+ `,
+ imports: [BannerModule, SharedModule],
+})
+export class TaxIdWarningComponent implements OnInit {
+ @Input({ required: true }) subscriber!: NonIndividualSubscriber;
+ @Input({ required: true }) getWarning$!: GetWarning$;
+ @Output() billingAddressUpdated = new EventEmitter();
+
+ protected enableTaxIdWarning$ = this.configService.getFeatureFlag$(
+ FeatureFlag.PM22415_TaxIDWarnings,
+ );
+
+ protected userId$ = this.accountService.activeAccount$.pipe(
+ filter((account): account is Account => account !== null),
+ getUserId,
+ );
+
+ protected dismissals$: Observable = this.userId$.pipe(
+ switchMap((userId) =>
+ combineLatest([
+ this.stateProvider.getUser(userId, DISMISSALS_COUNT_KEY).state$.pipe(
+ map((dismissalCounts) => {
+ if (!dismissalCounts) {
+ return {
+ [TaxIdWarningTypes.Missing]: 0,
+ [TaxIdWarningTypes.FailedVerification]: 0,
+ };
+ }
+ return {
+ [TaxIdWarningTypes.Missing]: dismissalCounts[TaxIdWarningTypes.Missing] ?? 0,
+ [TaxIdWarningTypes.FailedVerification]:
+ dismissalCounts[TaxIdWarningTypes.FailedVerification] ?? 0,
+ };
+ }),
+ ),
+ this.stateProvider.getUser(userId, DISMISSED_THIS_SESSION_KEY).state$.pipe(
+ map((dismissedThisSession) => {
+ if (!dismissedThisSession) {
+ return {
+ [TaxIdWarningTypes.Missing]: false,
+ [TaxIdWarningTypes.FailedVerification]: false,
+ };
+ }
+ return {
+ [TaxIdWarningTypes.Missing]: dismissedThisSession[TaxIdWarningTypes.Missing] ?? false,
+ [TaxIdWarningTypes.FailedVerification]:
+ dismissedThisSession[TaxIdWarningTypes.FailedVerification] ?? false,
+ };
+ }),
+ ),
+ ]),
+ ),
+ map(([dismissalCounts, dismissedThisSession]) => ({
+ [TaxIdWarningTypes.Missing]: {
+ count: dismissalCounts[TaxIdWarningTypes.Missing],
+ dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.Missing],
+ },
+ [TaxIdWarningTypes.FailedVerification]: {
+ count: dismissalCounts[TaxIdWarningTypes.FailedVerification],
+ dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.FailedVerification],
+ },
+ })),
+ );
+
+ protected getWarningSubject = new BehaviorSubject(null);
+
+ protected warning$ = this.getWarningSubject.pipe(switchMap(() => this.getWarning$()));
+
+ protected view$: Observable = combineLatest([this.warning$, this.dismissals$]).pipe(
+ map(([warning, dismissals]) => {
+ if (!warning || warning === TaxIdWarningTypes.PendingVerification) {
+ return null;
+ }
+
+ if (!shouldShowWarning(warning, dismissals)) {
+ return null;
+ }
+
+ switch (warning) {
+ case TaxIdWarningTypes.Missing: {
+ return {
+ message: this.i18nService.t("missingTaxIdWarning"),
+ callToAction: this.i18nService.t("addTaxId"),
+ };
+ }
+ case TaxIdWarningTypes.FailedVerification: {
+ return {
+ message: this.i18nService.t("unverifiedTaxIdWarning"),
+ callToAction: this.i18nService.t("editTaxId"),
+ };
+ }
+ }
+ }),
+ );
+
+ constructor(
+ private accountService: AccountService,
+ private configService: ConfigService,
+ private dialogService: DialogService,
+ private i18nService: I18nService,
+ private subscriberBillingClient: SubscriberBillingClient,
+ private stateProvider: StateProvider,
+ ) {}
+
+ ngOnInit() {
+ this.getWarningSubject.next(this.getWarning$);
+ }
+
+ editBillingAddress = async () => {
+ const billingAddress = await this.subscriberBillingClient.getBillingAddress(this.subscriber);
+ const warning = (await firstValueFrom(this.warning$)) ?? undefined;
+
+ const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, {
+ data: {
+ subscriber: this.subscriber,
+ billingAddress,
+ taxIdWarning: warning,
+ },
+ });
+
+ const result = await lastValueFrom(dialogRef.closed);
+
+ if (result?.type === "success") {
+ this.billingAddressUpdated.emit();
+ }
+ };
+
+ trackDismissal = async () => {
+ const warning = await firstValueFrom(this.warning$);
+ if (!warning || warning === TaxIdWarningTypes.PendingVerification) {
+ return;
+ }
+ const userId = await firstValueFrom(this.userId$);
+ const updateDismissalCounts = this.stateProvider
+ .getUser(userId, DISMISSALS_COUNT_KEY)
+ .update((dismissalCounts) => {
+ if (!dismissalCounts) {
+ return {
+ [warning]: 1,
+ };
+ }
+ const dismissalsByType = dismissalCounts[warning];
+ if (!dismissalsByType) {
+ return {
+ ...dismissalCounts,
+ [warning]: 1,
+ };
+ }
+ return {
+ ...dismissalCounts,
+ [warning]: dismissalsByType + 1,
+ };
+ });
+ const updateDismissedThisSession = this.stateProvider
+ .getUser(userId, DISMISSED_THIS_SESSION_KEY)
+ .update((dismissedThisSession) => {
+ if (!dismissedThisSession) {
+ return {
+ [warning]: true,
+ };
+ }
+ const dismissedThisSessionByType = dismissedThisSession[warning];
+ if (!dismissedThisSessionByType) {
+ return {
+ ...dismissedThisSession,
+ };
+ }
+ return {
+ ...dismissedThisSession,
+ [warning]: dismissedThisSessionByType,
+ };
+ });
+ await Promise.all([updateDismissalCounts, updateDismissedThisSession]);
+ };
+}
diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts
deleted file mode 100644
index c75dde0c9e5..00000000000
--- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts
+++ /dev/null
@@ -1,358 +0,0 @@
-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";
-
-// Skipped since Angular complains about `TypeError: Cannot read properties of undefined (reading 'ngModule')`
-// which is typically a sign of circular dependencies. The problem seems to be originating from `ChangePlanDialogComponent`.
-describe.skip("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/warnings/types/index.ts b/apps/web/src/app/billing/warnings/types/index.ts
index fc0c7d278ed..1d7b17fcd28 100644
--- a/apps/web/src/app/billing/warnings/types/index.ts
+++ b/apps/web/src/app/billing/warnings/types/index.ts
@@ -1 +1 @@
-export * from "./organization-warnings";
+export * from "./tax-id-warning-type";
diff --git a/apps/web/src/app/billing/warnings/types/organization-warnings.ts b/apps/web/src/app/billing/warnings/types/organization-warnings.ts
deleted file mode 100644
index 96bf5aff6f1..00000000000
--- a/apps/web/src/app/billing/warnings/types/organization-warnings.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
-
-export type OrganizationFreeTrialWarning = {
- organization: Pick;
- message: string;
-};
-
-export type OrganizationResellerRenewalWarning = {
- type: "info" | "warning";
- message: string;
-};
diff --git a/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts
new file mode 100644
index 00000000000..86bc76708aa
--- /dev/null
+++ b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts
@@ -0,0 +1,19 @@
+import { BaseResponse } from "@bitwarden/common/models/response/base.response";
+
+export const TaxIdWarningTypes = {
+ Missing: "tax_id_missing",
+ PendingVerification: "tax_id_pending_verification",
+ FailedVerification: "tax_id_failed_verification",
+} as const;
+
+export type TaxIdWarningType = (typeof TaxIdWarningTypes)[keyof typeof TaxIdWarningTypes];
+
+export class TaxIdWarningResponse extends BaseResponse {
+ type: TaxIdWarningType;
+
+ constructor(response: any) {
+ super(response);
+
+ this.type = this.getResponseProperty("Type");
+ }
+}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index fb85d4e3dd9..bbd89c1d288 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -11016,5 +11016,52 @@
},
"showLess": {
"message": "Show less"
+ },
+ "missingTaxId": {
+ "message": "Missing Tax ID"
+ },
+ "missingTaxIdWarning": {
+ "message": "Action required: You're missing a Tax ID number in payment details. If a Tax ID is not added, your invoices may include additional tax."
+ },
+ "addTaxId": {
+ "message": "Add a Tax ID"
+ },
+ "missingTaxIdCalloutTitle": {
+ "message": "Action required: Missing Tax ID"
+ },
+ "missingTaxIdCalloutDescription": {
+ "message": "If a Tax ID is not added, your invoices may include additional tax."
+ },
+ "unverifiedTaxIdWarning": {
+ "message": "Action required: Your Tax ID number is unverified. If your Tax ID is left unverified, your invoices may include additional tax."
+ },
+ "editTaxId": {
+ "message": "Edit your Tax ID"
+ },
+ "unverifiedTaxIdCalloutTitle": {
+ "message": "Tax ID unverified"
+ },
+ "unverifiedTaxIdCalloutDescription": {
+ "message": "Check your Tax ID to verify the format is correct and there are no typos."
+ },
+ "pendingVerification": {
+ "message": "Pending verification"
+ },
+ "checkInputFormat": {
+ "message": "Check input format for typos."
+ },
+ "exampleTaxIdFormat": {
+ "message": "Example $CODE$ format: $EXAMPLE$",
+ "placeholders": {
+ "code": {
+ "content": "$1",
+ "example": "ABN"
+ },
+ "example": {
+ "content": "$2",
+ "example": "92873837267"
+ }
+ }
}
}
+
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html
index b61b1ce7840..31e56836375 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html
+++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html
@@ -55,5 +55,14 @@
>
+
+
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts
index 52260168d4c..5e2e9a14f2d 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts
@@ -18,15 +18,24 @@ import {
ProviderPortalLogo,
BusinessUnitPortalLogo,
} from "@bitwarden/components";
+import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
+import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components";
+import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types";
import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
-import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service";
+import { ProviderWarningsService } from "../../billing/providers/warnings/services";
@Component({
selector: "providers-layout",
templateUrl: "providers-layout.component.html",
- imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule],
- providers: [ProviderWarningsService],
+ imports: [
+ CommonModule,
+ RouterModule,
+ JslibModule,
+ WebLayoutModule,
+ IconModule,
+ TaxIdWarningComponent,
+ ],
})
export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected readonly logo = ProviderPortalLogo;
@@ -43,6 +52,9 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected managePaymentDetailsOutsideCheckout$: Observable;
protected providerPortalTakeover$: Observable;
+ protected subscriber$: Observable;
+ protected getTaxIdWarning$: () => Observable;
+
constructor(
private route: ActivatedRoute,
private providerService: ProviderService,
@@ -90,10 +102,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
- providerId$
+ this.provider$
.pipe(
- switchMap((providerId) =>
- this.providerWarningsService.showProviderSuspendedDialog$(providerId),
+ switchMap((provider) =>
+ this.providerWarningsService.showProviderSuspendedDialog$(provider),
),
takeUntil(this.destroy$),
)
@@ -102,6 +114,18 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
this.providerPortalTakeover$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21821_ProviderPortalTakeover,
);
+
+ this.subscriber$ = this.provider$.pipe(
+ map((provider) => ({
+ type: "provider",
+ data: provider,
+ })),
+ );
+
+ this.getTaxIdWarning$ = () =>
+ this.provider$.pipe(
+ switchMap((provider) => this.providerWarningsService.getTaxIdWarning$(provider)),
+ );
}
ngOnDestroy() {
@@ -116,4 +140,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
showSettingsTab(provider: Provider) {
return provider.isProviderAdmin;
}
+
+ refreshTaxIdWarning = () => this.providerWarningsService.refreshTaxIdWarning();
}
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts
index 01f1facfc15..263b90f5b32 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts
@@ -21,6 +21,7 @@ import {
} from "../../billing/providers";
import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component";
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component";
+import { ProviderWarningsModule } from "../../billing/providers/warnings/provider-warnings.module";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
@@ -55,6 +56,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
CardComponent,
ScrollLayoutDirective,
PaymentComponent,
+ ProviderWarningsModule,
],
declarations: [
AcceptProviderComponent,
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html
index 375faab8d34..fa45bbb32d3 100644
--- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html
+++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html
@@ -13,19 +13,20 @@
} @else {
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts
index dbf948518a2..7782c89a5bd 100644
--- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts
+++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts
@@ -1,22 +1,30 @@
-import { Component } from "@angular/core";
+import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import {
BehaviorSubject,
+ combineLatest,
EMPTY,
filter,
+ firstValueFrom,
from,
map,
merge,
Observable,
+ of,
shareReplay,
+ Subject,
switchMap,
+ take,
+ takeUntil,
tap,
} from "rxjs";
import { catchError } from "rxjs/operators";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
+import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import {
DisplayAccountCreditComponent,
DisplayBillingAddressComponent,
@@ -26,11 +34,16 @@ import {
BillingAddress,
MaskedPaymentMethod,
} from "@bitwarden/web-vault/app/billing/payment/types";
-import { BillingClient } from "@bitwarden/web-vault/app/billing/services";
-import { BillableEntity, providerToBillableEntity } from "@bitwarden/web-vault/app/billing/types";
+import {
+ BitwardenSubscriber,
+ mapProviderToSubscriber,
+} from "@bitwarden/web-vault/app/billing/types";
+import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
+import { ProviderWarningsService } from "../warnings/services";
+
class RedirectError {
constructor(
public path: string[],
@@ -39,29 +52,31 @@ class RedirectError {
}
type View = {
- provider: BillableEntity;
+ provider: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null;
billingAddress: BillingAddress | null;
credit: number | null;
+ taxIdWarning: TaxIdWarningType | null;
};
@Component({
templateUrl: "./provider-payment-details.component.html",
- standalone: true,
imports: [
- DisplayBillingAddressComponent,
DisplayAccountCreditComponent,
+ DisplayBillingAddressComponent,
DisplayPaymentMethodComponent,
HeaderModule,
SharedModule,
],
- providers: [BillingClient],
})
-export class ProviderPaymentDetailsComponent {
+export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy {
private viewState$ = new BehaviorSubject(null);
- private load$: Observable = this.activatedRoute.params.pipe(
+ private provider$ = this.activatedRoute.params.pipe(
switchMap(({ providerId }) => this.providerService.get$(providerId)),
+ );
+
+ private load$: Observable = this.provider$.pipe(
switchMap((provider) =>
this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
@@ -74,12 +89,17 @@ export class ProviderPaymentDetailsComponent {
}),
),
),
- providerToBillableEntity,
+ mapProviderToSubscriber,
switchMap(async (provider) => {
- const [paymentMethod, billingAddress, credit] = await Promise.all([
+ const getTaxIdWarning = firstValueFrom(
+ this.providerWarningsService.getTaxIdWarning$(provider.data as Provider),
+ );
+
+ const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([
this.billingClient.getPaymentMethod(provider),
this.billingClient.getBillingAddress(provider),
this.billingClient.getCredit(provider),
+ getTaxIdWarning,
]);
return {
@@ -87,6 +107,7 @@ export class ProviderPaymentDetailsComponent {
paymentMethod,
billingAddress,
credit,
+ taxIdWarning,
};
}),
shareReplay({ bufferSize: 1, refCount: false }),
@@ -105,16 +126,64 @@ export class ProviderPaymentDetailsComponent {
this.viewState$.pipe(filter((view): view is View => view !== null)),
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ private destroy$ = new Subject();
+
+ protected enableTaxIdWarning!: boolean;
+
constructor(
private activatedRoute: ActivatedRoute,
- private billingClient: BillingClient,
+ private billingClient: SubscriberBillingClient,
private configService: ConfigService,
private providerService: ProviderService,
+ private providerWarningsService: ProviderWarningsService,
private router: Router,
+ private subscriberBillingClient: SubscriberBillingClient,
) {}
+ async ngOnInit() {
+ this.enableTaxIdWarning = await this.configService.getFeatureFlag(
+ FeatureFlag.PM22415_TaxIDWarnings,
+ );
+
+ if (this.enableTaxIdWarning) {
+ this.providerWarningsService.taxIdWarningRefreshed$
+ .pipe(
+ switchMap((warning) =>
+ combineLatest([
+ of(warning),
+ this.provider$.pipe(take(1)).pipe(
+ mapProviderToSubscriber,
+ switchMap((provider) => this.subscriberBillingClient.getBillingAddress(provider)),
+ ),
+ ]),
+ ),
+ takeUntil(this.destroy$),
+ )
+ .subscribe(([taxIdWarning, billingAddress]) => {
+ if (this.viewState$.value) {
+ this.viewState$.next({
+ ...this.viewState$.value,
+ taxIdWarning,
+ billingAddress,
+ });
+ }
+ });
+ }
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
setBillingAddress = (billingAddress: BillingAddress) => {
if (this.viewState$.value) {
+ if (
+ this.enableTaxIdWarning &&
+ this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId
+ ) {
+ this.providerWarningsService.refreshTaxIdWarning();
+ }
this.viewState$.next({
...this.viewState$.value,
billingAddress,
@@ -122,11 +191,16 @@ export class ProviderPaymentDetailsComponent {
}
};
- setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => {
+ setPaymentMethod = async (paymentMethod: MaskedPaymentMethod) => {
if (this.viewState$.value) {
+ const billingAddress =
+ this.viewState$.value.billingAddress ??
+ (await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.provider));
+
this.viewState$.next({
...this.viewState$.value,
paymentMethod,
+ billingAddress,
});
}
};
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts
deleted file mode 100644
index b2b92c26e1f..00000000000
--- a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import { TestBed } from "@angular/core/testing";
-import { ActivatedRoute, Router } from "@angular/router";
-import { mock, MockProxy } from "jest-mock-extended";
-import { of } from "rxjs";
-
-import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
-import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
-import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
-import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
-import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
-import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
-import { SyncService } from "@bitwarden/common/platform/sync";
-import { DialogRef, DialogService } from "@bitwarden/components";
-import {
- RequirePaymentMethodDialogComponent,
- SubmitPaymentMethodDialogResult,
-} from "@bitwarden/web-vault/app/billing/payment/components";
-
-import { ProviderWarningsService } from "./provider-warnings.service";
-
-describe("ProviderWarningsService", () => {
- let service: ProviderWarningsService;
- let configService: MockProxy;
- let dialogService: MockProxy;
- let providerService: MockProxy;
- let billingApiService: MockProxy;
- let i18nService: MockProxy;
- let router: MockProxy;
- let syncService: MockProxy;
-
- beforeEach(() => {
- billingApiService = mock();
- configService = mock();
- dialogService = mock();
- i18nService = mock();
- providerService = mock();
- router = mock();
- syncService = mock();
-
- TestBed.configureTestingModule({
- providers: [
- ProviderWarningsService,
- { provide: ActivatedRoute, useValue: {} },
- { provide: BillingApiServiceAbstraction, useValue: billingApiService },
- { provide: ConfigService, useValue: configService },
- { provide: DialogService, useValue: dialogService },
- { provide: I18nService, useValue: i18nService },
- { provide: ProviderService, useValue: providerService },
- { provide: Router, useValue: router },
- { provide: SyncService, useValue: syncService },
- ],
- });
-
- service = TestBed.inject(ProviderWarningsService);
- });
-
- it("should create the service", () => {
- expect(service).toBeTruthy();
- });
-
- describe("showProviderSuspendedDialog$", () => {
- const providerId = "test-provider-id";
-
- it("should not show any dialog when the 'pm-21821-provider-portal-takeover' flag is disabled", (done) => {
- const provider = { enabled: false } as Provider;
- const subscription = { status: "unpaid" } as ProviderSubscriptionResponse;
-
- providerService.get$.mockReturnValue(of(provider));
- billingApiService.getProviderSubscription.mockResolvedValue(subscription);
- configService.getFeatureFlag$.mockReturnValue(of(false));
-
- const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn(
- RequirePaymentMethodDialogComponent,
- "open",
- );
-
- service.showProviderSuspendedDialog$(providerId).subscribe(() => {
- expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
- expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled();
- done();
- });
- });
-
- it("should not show any dialog when the provider is enabled", (done) => {
- const provider = { enabled: true } as Provider;
- const subscription = { status: "unpaid" } as ProviderSubscriptionResponse;
-
- providerService.get$.mockReturnValue(of(provider));
- billingApiService.getProviderSubscription.mockResolvedValue(subscription);
- configService.getFeatureFlag$.mockReturnValue(of(true));
-
- const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn(
- RequirePaymentMethodDialogComponent,
- "open",
- );
-
- service.showProviderSuspendedDialog$(providerId).subscribe(() => {
- expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
- expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled();
- done();
- });
- });
-
- it("should show the require payment method dialog for an admin of a provider with an unpaid subscription", (done) => {
- const provider = {
- enabled: false,
- type: ProviderUserType.ProviderAdmin,
- name: "Test Provider",
- } as Provider;
- const subscription = {
- status: "unpaid",
- cancelAt: "2024-12-31",
- } as ProviderSubscriptionResponse;
-
- providerService.get$.mockReturnValue(of(provider));
- billingApiService.getProviderSubscription.mockResolvedValue(subscription);
- configService.getFeatureFlag$.mockReturnValue(of(true));
-
- const dialogRef = {
- closed: of({ type: "success" }),
- } as DialogRef;
- jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(dialogRef);
-
- service.showProviderSuspendedDialog$(providerId).subscribe(() => {
- expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalled();
- expect(syncService.fullSync).toHaveBeenCalled();
- expect(router.navigate).toHaveBeenCalled();
- done();
- });
- });
-
- it("should show the simple, unpaid invoices dialog for a service user of a provider with an unpaid subscription", (done) => {
- const provider = {
- enabled: false,
- type: ProviderUserType.ServiceUser,
- name: "Test Provider",
- } as Provider;
- const subscription = { status: "unpaid" } as ProviderSubscriptionResponse;
-
- providerService.get$.mockReturnValue(of(provider));
- billingApiService.getProviderSubscription.mockResolvedValue(subscription);
- dialogService.openSimpleDialog.mockResolvedValue(true);
- configService.getFeatureFlag$.mockReturnValue(of(true));
-
- i18nService.t.mockImplementation((key: string) => key);
-
- service.showProviderSuspendedDialog$(providerId).subscribe(() => {
- expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
- type: "danger",
- title: "unpaidInvoices",
- content: "unpaidInvoicesForServiceUser",
- disableClose: true,
- });
- done();
- });
- });
-
- it("should show the provider suspended dialog to all users of a provider that's suspended, but not unpaid", (done) => {
- const provider = {
- enabled: false,
- name: "Test Provider",
- } as Provider;
- const subscription = { status: "active" } as ProviderSubscriptionResponse;
-
- providerService.get$.mockReturnValue(of(provider));
- billingApiService.getProviderSubscription.mockResolvedValue(subscription);
- dialogService.openSimpleDialog.mockResolvedValue(true);
- configService.getFeatureFlag$.mockReturnValue(of(true));
-
- i18nService.t.mockImplementation((key: string) => key);
-
- service.showProviderSuspendedDialog$(providerId).subscribe(() => {
- expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
- type: "danger",
- title: "providerSuspended",
- content: "restoreProviderPortalAccessViaCustomerSupport",
- disableClose: false,
- acceptButtonText: "contactSupportShort",
- cancelButtonText: null,
- acceptAction: expect.any(Function),
- });
- done();
- });
- });
- });
-});
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts
deleted file mode 100644
index 87e6d9351ab..00000000000
--- a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Injectable } from "@angular/core";
-import { ActivatedRoute, Router } from "@angular/router";
-import { combineLatest, from, lastValueFrom, Observable, switchMap } from "rxjs";
-
-import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
-import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
-import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
-import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
-import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
-import { SyncService } from "@bitwarden/common/platform/sync";
-import { DialogService } from "@bitwarden/components";
-import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components";
-
-@Injectable()
-export class ProviderWarningsService {
- constructor(
- private activatedRoute: ActivatedRoute,
- private billingApiService: BillingApiServiceAbstraction,
- private configService: ConfigService,
- private dialogService: DialogService,
- private i18nService: I18nService,
- private providerService: ProviderService,
- private router: Router,
- private syncService: SyncService,
- ) {}
-
- showProviderSuspendedDialog$ = (providerId: string): Observable =>
- combineLatest([
- this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover),
- this.providerService.get$(providerId),
- from(this.billingApiService.getProviderSubscription(providerId)),
- ]).pipe(
- switchMap(async ([providerPortalTakeover, provider, subscription]) => {
- if (!providerPortalTakeover || provider.enabled) {
- return;
- }
-
- if (subscription.status === "unpaid") {
- switch (provider.type) {
- case ProviderUserType.ProviderAdmin: {
- const cancelAt = subscription.cancelAt
- ? new Date(subscription.cancelAt).toLocaleDateString("en-US", {
- month: "short",
- day: "2-digit",
- year: "numeric",
- })
- : null;
-
- const dialogRef = RequirePaymentMethodDialogComponent.open(this.dialogService, {
- data: {
- owner: {
- type: "provider",
- data: provider,
- },
- callout: {
- type: "danger",
- title: this.i18nService.t("unpaidInvoices"),
- message: this.i18nService.t(
- "restoreProviderPortalAccessViaPaymentMethod",
- cancelAt ?? undefined,
- ),
- },
- },
- });
-
- const result = await lastValueFrom(dialogRef.closed);
-
- if (result?.type === "success") {
- await this.syncService.fullSync(true);
- await this.router.navigate(["."], {
- relativeTo: this.activatedRoute,
- onSameUrlNavigation: "reload",
- });
- }
- break;
- }
- case ProviderUserType.ServiceUser: {
- await this.dialogService.openSimpleDialog({
- type: "danger",
- title: this.i18nService.t("unpaidInvoices"),
- content: this.i18nService.t("unpaidInvoicesForServiceUser"),
- disableClose: true,
- });
- break;
- }
- }
- } else {
- await this.dialogService.openSimpleDialog({
- type: "danger",
- title: this.i18nService.t("providerSuspended", provider.name),
- content: this.i18nService.t("restoreProviderPortalAccessViaCustomerSupport"),
- disableClose: false,
- acceptButtonText: this.i18nService.t("contactSupportShort"),
- cancelButtonText: null,
- acceptAction: async () => {
- window.open("https://bitwarden.com/contact/", "_blank");
- return Promise.resolve();
- },
- });
- }
- }),
- );
-}
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts
new file mode 100644
index 00000000000..88418d1ae08
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts
@@ -0,0 +1,10 @@
+import { NgModule } from "@angular/core";
+
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
+
+import { ProviderWarningsService } from "./services";
+
+@NgModule({
+ providers: [ProviderWarningsService, SubscriberBillingClient],
+})
+export class ProviderWarningsModule {}
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts
new file mode 100644
index 00000000000..08302c082d0
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts
@@ -0,0 +1 @@
+export * from "./provider-warnings.service";
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts
new file mode 100644
index 00000000000..0eb25bff524
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts
@@ -0,0 +1,416 @@
+import { TestBed } from "@angular/core/testing";
+import { ActivatedRoute, Router } from "@angular/router";
+import { mock, MockProxy } from "jest-mock-extended";
+import { of } from "rxjs";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SyncService } from "@bitwarden/common/platform/sync";
+import { ProviderId } from "@bitwarden/common/types/guid";
+import { DialogRef, DialogService } from "@bitwarden/components";
+import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components";
+import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types";
+
+import { ProviderWarningsResponse } from "../types/provider-warnings";
+
+import { ProviderWarningsService } from "./provider-warnings.service";
+
+describe("ProviderWarningsService", () => {
+ let service: ProviderWarningsService;
+ let activatedRoute: MockProxy;
+ let apiService: MockProxy;
+ let configService: MockProxy;
+ let dialogService: MockProxy;
+ let i18nService: MockProxy;
+ let router: MockProxy;
+ let syncService: MockProxy;
+
+ const provider = {
+ id: "provider-id-123",
+ name: "Test Provider",
+ } as Provider;
+
+ const formatDate = (date: Date): string =>
+ date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ });
+
+ beforeEach(() => {
+ activatedRoute = mock();
+ apiService = mock();
+ configService = mock();
+ dialogService = mock();
+ i18nService = mock();
+ router = mock();
+ syncService = mock();
+
+ i18nService.t.mockImplementation((key: string, ...args: any[]) => {
+ switch (key) {
+ case "unpaidInvoices":
+ return "Unpaid invoices";
+ case "restoreProviderPortalAccessViaPaymentMethod":
+ return `To restore access to the provider portal, add a valid payment method. Your subscription will be cancelled on ${args[0]}.`;
+ case "unpaidInvoicesForServiceUser":
+ return "There are unpaid invoices on this account. Contact your administrator to restore access to the provider portal.";
+ case "providerSuspended":
+ return `${args[0]} subscription suspended`;
+ case "restoreProviderPortalAccessViaCustomerSupport":
+ return "To restore access to the provider portal, contact our support team.";
+ case "contactSupportShort":
+ return "Contact Support";
+ default:
+ return key;
+ }
+ });
+
+ TestBed.configureTestingModule({
+ providers: [
+ ProviderWarningsService,
+ { provide: ActivatedRoute, useValue: activatedRoute },
+ { provide: ApiService, useValue: apiService },
+ { provide: ConfigService, useValue: configService },
+ { provide: DialogService, useValue: dialogService },
+ { provide: I18nService, useValue: i18nService },
+ { provide: Router, useValue: router },
+ { provide: SyncService, useValue: syncService },
+ ],
+ });
+
+ service = TestBed.inject(ProviderWarningsService);
+ });
+
+ describe("getTaxIdWarning$", () => {
+ it("should return null when no tax ID warning exists", (done) => {
+ apiService.send.mockResolvedValue({});
+
+ service.getTaxIdWarning$(provider).subscribe((result) => {
+ expect(result).toBeNull();
+ done();
+ });
+ });
+
+ it("should return tax_id_missing type when tax ID is missing", (done) => {
+ const warning = { Type: TaxIdWarningTypes.Missing };
+ apiService.send.mockResolvedValue({
+ TaxId: warning,
+ });
+
+ service.getTaxIdWarning$(provider).subscribe((result) => {
+ expect(result).toBe(TaxIdWarningTypes.Missing);
+ done();
+ });
+ });
+
+ it("should return tax_id_pending_verification type when tax ID verification is pending", (done) => {
+ const warning = { Type: TaxIdWarningTypes.PendingVerification };
+ apiService.send.mockResolvedValue({
+ TaxId: warning,
+ });
+
+ service.getTaxIdWarning$(provider).subscribe((result) => {
+ expect(result).toBe(TaxIdWarningTypes.PendingVerification);
+ done();
+ });
+ });
+
+ it("should return tax_id_failed_verification type when tax ID verification failed", (done) => {
+ const warning = { Type: TaxIdWarningTypes.FailedVerification };
+ apiService.send.mockResolvedValue({
+ TaxId: warning,
+ });
+
+ service.getTaxIdWarning$(provider).subscribe((result) => {
+ expect(result).toBe(TaxIdWarningTypes.FailedVerification);
+ done();
+ });
+ });
+
+ it("should refresh warning and update taxIdWarningRefreshedSubject when refreshTaxIdWarning is called", (done) => {
+ const initialWarning = { Type: TaxIdWarningTypes.Missing };
+ const refreshedWarning = { Type: TaxIdWarningTypes.FailedVerification };
+ let invocationCount = 0;
+
+ apiService.send
+ .mockResolvedValueOnce({
+ TaxId: initialWarning,
+ })
+ .mockResolvedValueOnce({
+ TaxId: refreshedWarning,
+ });
+
+ const subscription = service.getTaxIdWarning$(provider).subscribe((result) => {
+ invocationCount++;
+
+ if (invocationCount === 1) {
+ expect(result).toBe(TaxIdWarningTypes.Missing);
+ } else if (invocationCount === 2) {
+ expect(result).toBe(TaxIdWarningTypes.FailedVerification);
+ subscription.unsubscribe();
+ done();
+ }
+ });
+
+ setTimeout(() => {
+ service.refreshTaxIdWarning();
+ }, 10);
+ });
+
+ it("should update taxIdWarningRefreshedSubject with warning type when refresh returns a warning", (done) => {
+ const refreshedWarning = { Type: TaxIdWarningTypes.Missing };
+ let refreshedCount = 0;
+
+ apiService.send.mockResolvedValueOnce({}).mockResolvedValueOnce({
+ TaxId: refreshedWarning,
+ });
+
+ const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => {
+ refreshedCount++;
+ if (refreshedCount === 2) {
+ expect(refreshedType).toBe(TaxIdWarningTypes.Missing);
+ taxIdSubscription.unsubscribe();
+ done();
+ }
+ });
+
+ service.getTaxIdWarning$(provider).subscribe();
+
+ setTimeout(() => {
+ service.refreshTaxIdWarning();
+ }, 10);
+ });
+
+ it("should update taxIdWarningRefreshedSubject with null when refresh returns no warning", (done) => {
+ const initialWarning = { Type: TaxIdWarningTypes.Missing };
+ let refreshedCount = 0;
+
+ apiService.send
+ .mockResolvedValueOnce({
+ TaxId: initialWarning,
+ })
+ .mockResolvedValueOnce({});
+
+ const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => {
+ refreshedCount++;
+ if (refreshedCount === 2) {
+ expect(refreshedType).toBeNull();
+ taxIdSubscription.unsubscribe();
+ done();
+ }
+ });
+
+ service.getTaxIdWarning$(provider).subscribe();
+
+ setTimeout(() => {
+ service.refreshTaxIdWarning();
+ }, 10);
+ });
+ });
+
+ describe("showProviderSuspendedDialog$", () => {
+ it("should not show dialog when feature flag is disabled", (done) => {
+ configService.getFeatureFlag$.mockReturnValue(of(false));
+ apiService.send.mockResolvedValue({
+ Suspension: { Resolution: "add_payment_method" },
+ });
+
+ service.showProviderSuspendedDialog$(provider).subscribe({
+ complete: () => {
+ expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it("should not show dialog when no suspension warning exists", (done) => {
+ configService.getFeatureFlag$.mockReturnValue(of(true));
+ apiService.send.mockResolvedValue({});
+
+ service.showProviderSuspendedDialog$(provider).subscribe({
+ complete: () => {
+ expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it("should show add payment method dialog with cancellation date", (done) => {
+ const cancelsAt = new Date(2024, 11, 31);
+ configService.getFeatureFlag$.mockReturnValue(of(true));
+ apiService.send.mockResolvedValue({
+ Suspension: {
+ Resolution: "add_payment_method",
+ SubscriptionCancelsAt: cancelsAt.toISOString(),
+ },
+ });
+
+ const mockDialogRef = {
+ closed: of({ type: "success" }),
+ } as DialogRef;
+
+ jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(mockDialogRef);
+ syncService.fullSync.mockResolvedValue(true);
+ router.navigate.mockResolvedValue(true);
+
+ service.showProviderSuspendedDialog$(provider).subscribe({
+ complete: () => {
+ const expectedDate = formatDate(cancelsAt);
+ expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalledWith(dialogService, {
+ data: {
+ subscriber: {
+ type: "provider",
+ data: provider,
+ },
+ callout: {
+ type: "danger",
+ title: "Unpaid invoices",
+ message: `To restore access to the provider portal, add a valid payment method. Your subscription will be cancelled on ${expectedDate}.`,
+ },
+ },
+ });
+ expect(syncService.fullSync).toHaveBeenCalledWith(true);
+ expect(router.navigate).toHaveBeenCalledWith(["."], {
+ relativeTo: activatedRoute,
+ onSameUrlNavigation: "reload",
+ });
+ done();
+ },
+ });
+ });
+
+ it("should show add payment method dialog without cancellation date", (done) => {
+ configService.getFeatureFlag$.mockReturnValue(of(true));
+ apiService.send.mockResolvedValue({
+ Suspension: {
+ Resolution: "add_payment_method",
+ },
+ });
+
+ const mockDialogRef = {
+ closed: of({ type: "cancelled" }),
+ } as DialogRef;
+
+ jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(mockDialogRef);
+
+ service.showProviderSuspendedDialog$(provider).subscribe({
+ complete: () => {
+ expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalledWith(dialogService, {
+ data: {
+ subscriber: {
+ type: "provider",
+ data: provider,
+ },
+ callout: {
+ type: "danger",
+ title: "Unpaid invoices",
+ message:
+ "To restore access to the provider portal, add a valid payment method. Your subscription will be cancelled on undefined.",
+ },
+ },
+ });
+ expect(syncService.fullSync).not.toHaveBeenCalled();
+ expect(router.navigate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it("should show contact administrator dialog for contact_administrator resolution", (done) => {
+ configService.getFeatureFlag$.mockReturnValue(of(true));
+ apiService.send.mockResolvedValue({
+ Suspension: {
+ Resolution: "contact_administrator",
+ },
+ });
+
+ dialogService.openSimpleDialog.mockResolvedValue(true);
+
+ service.showProviderSuspendedDialog$(provider).subscribe({
+ complete: () => {
+ expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
+ type: "danger",
+ title: "Unpaid invoices",
+ content:
+ "There are unpaid invoices on this account. Contact your administrator to restore access to the provider portal.",
+ disableClose: true,
+ });
+ done();
+ },
+ });
+ });
+
+ it("should show contact support dialog with action for contact_support resolution", (done) => {
+ configService.getFeatureFlag$.mockReturnValue(of(true));
+ apiService.send.mockResolvedValue({
+ Suspension: {
+ Resolution: "contact_support",
+ },
+ });
+
+ dialogService.openSimpleDialog.mockResolvedValue(true);
+ const openSpy = jest.spyOn(window, "open").mockImplementation();
+
+ service.showProviderSuspendedDialog$(provider).subscribe({
+ complete: () => {
+ const dialogCall = dialogService.openSimpleDialog.mock.calls[0][0];
+ expect(dialogCall).toEqual({
+ type: "danger",
+ title: "Test Provider subscription suspended",
+ content: "To restore access to the provider portal, contact our support team.",
+ acceptButtonText: "Contact Support",
+ cancelButtonText: null,
+ acceptAction: expect.any(Function),
+ });
+
+ if (dialogCall.acceptAction) {
+ void dialogCall.acceptAction().then(() => {
+ expect(openSpy).toHaveBeenCalledWith("https://bitwarden.com/contact/", "_blank");
+ openSpy.mockRestore();
+ done();
+ });
+ } else {
+ fail("acceptAction should be defined");
+ }
+ },
+ });
+ });
+ });
+
+ describe("fetchWarnings", () => {
+ it("should fetch warnings from correct API endpoint", async () => {
+ const mockResponse = { TaxId: { Type: TaxIdWarningTypes.Missing } };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await service.fetchWarnings(provider.id as ProviderId);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "GET",
+ `/providers/${provider.id}/billing/vnext/warnings`,
+ null,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(ProviderWarningsResponse);
+ expect(result.taxId?.type).toBe(TaxIdWarningTypes.Missing);
+ });
+
+ it("should handle API response with suspension warning", async () => {
+ const cancelsAt = new Date(2024, 11, 31);
+ const mockResponse = {
+ Suspension: {
+ Resolution: "add_payment_method",
+ SubscriptionCancelsAt: cancelsAt.toISOString(),
+ },
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await service.fetchWarnings(provider.id as ProviderId);
+
+ expect(result.suspension?.resolution).toBe("add_payment_method");
+ expect(result.suspension?.subscriptionCancelsAt).toEqual(cancelsAt);
+ });
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts
new file mode 100644
index 00000000000..89ddf4b4788
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts
@@ -0,0 +1,175 @@
+import { Injectable } from "@angular/core";
+import { ActivatedRoute, Router } from "@angular/router";
+import {
+ BehaviorSubject,
+ combineLatest,
+ from,
+ lastValueFrom,
+ map,
+ merge,
+ Observable,
+ Subject,
+ switchMap,
+ take,
+ tap,
+} from "rxjs";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SyncService } from "@bitwarden/common/platform/sync";
+import { ProviderId } from "@bitwarden/common/types/guid";
+import { DialogService } from "@bitwarden/components";
+import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components";
+import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types";
+
+import { ProviderWarningsResponse } from "../types/provider-warnings";
+
+@Injectable()
+export class ProviderWarningsService {
+ private cache$ = new Map>();
+
+ private refreshTaxIdWarningTrigger = new Subject();
+
+ private taxIdWarningRefreshedSubject = new BehaviorSubject(null);
+ taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable();
+
+ constructor(
+ private activatedRoute: ActivatedRoute,
+ private apiService: ApiService,
+ private configService: ConfigService,
+ private dialogService: DialogService,
+ private i18nService: I18nService,
+ private router: Router,
+ private syncService: SyncService,
+ ) {}
+
+ getTaxIdWarning$ = (provider: Provider): Observable =>
+ merge(
+ this.getWarning$(provider, (response) => response.taxId),
+ this.refreshTaxIdWarningTrigger.pipe(
+ switchMap(() =>
+ this.getWarning$(provider, (response) => response.taxId, true).pipe(
+ tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)),
+ ),
+ ),
+ ),
+ ).pipe(map((warning) => (warning ? warning.type : null)));
+
+ refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next();
+
+ showProviderSuspendedDialog$ = (provider: Provider): Observable =>
+ combineLatest([
+ this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover),
+ this.getWarning$(provider, (response) => response.suspension),
+ ]).pipe(
+ switchMap(async ([providerPortalTakeover, warning]) => {
+ if (!providerPortalTakeover || !warning) {
+ return;
+ }
+
+ switch (warning.resolution) {
+ case "add_payment_method": {
+ const cancelAt = warning.subscriptionCancelsAt
+ ? new Date(warning.subscriptionCancelsAt).toLocaleDateString("en-US", {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ })
+ : null;
+
+ const dialogRef = RequirePaymentMethodDialogComponent.open(this.dialogService, {
+ data: {
+ subscriber: {
+ type: "provider",
+ data: provider,
+ },
+ callout: {
+ type: "danger",
+ title: this.i18nService.t("unpaidInvoices"),
+ message: this.i18nService.t(
+ "restoreProviderPortalAccessViaPaymentMethod",
+ cancelAt ?? undefined,
+ ),
+ },
+ },
+ });
+
+ const result = await lastValueFrom(dialogRef.closed);
+
+ if (result?.type === "success") {
+ await this.syncService.fullSync(true);
+ await this.router.navigate(["."], {
+ relativeTo: this.activatedRoute,
+ onSameUrlNavigation: "reload",
+ });
+ }
+ break;
+ }
+ case "contact_administrator": {
+ await this.dialogService.openSimpleDialog({
+ type: "danger",
+ title: this.i18nService.t("unpaidInvoices"),
+ content: this.i18nService.t("unpaidInvoicesForServiceUser"),
+ disableClose: true,
+ });
+ break;
+ }
+ case "contact_support": {
+ await this.dialogService.openSimpleDialog({
+ type: "danger",
+ title: this.i18nService.t("providerSuspended", provider.name),
+ content: this.i18nService.t("restoreProviderPortalAccessViaCustomerSupport"),
+ acceptButtonText: this.i18nService.t("contactSupportShort"),
+ cancelButtonText: null,
+ acceptAction: async () => {
+ window.open("https://bitwarden.com/contact/", "_blank");
+ return Promise.resolve();
+ },
+ });
+ }
+ }
+ }),
+ );
+
+ fetchWarnings = async (providerId: ProviderId): Promise => {
+ const response = await this.apiService.send(
+ "GET",
+ `/providers/${providerId}/billing/vnext/warnings`,
+ null,
+ true,
+ true,
+ );
+
+ return new ProviderWarningsResponse(response);
+ };
+
+ private readThroughWarnings$ = (
+ provider: Provider,
+ bypassCache: boolean = false,
+ ): Observable => {
+ const providerId = provider.id as ProviderId;
+ const existing = this.cache$.get(providerId);
+ if (existing && !bypassCache) {
+ return existing;
+ }
+ const response$ = from(this.fetchWarnings(providerId));
+ this.cache$.set(providerId, response$);
+ return response$;
+ };
+
+ private getWarning$ = (
+ provider: Provider,
+ extract: (response: ProviderWarningsResponse) => T | null | undefined,
+ bypassCache: boolean = false,
+ ): Observable =>
+ this.readThroughWarnings$(provider, bypassCache).pipe(
+ map((response) => {
+ const value = extract(response);
+ return value ? value : null;
+ }),
+ take(1),
+ );
+}
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts
new file mode 100644
index 00000000000..9c8700150ea
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts
@@ -0,0 +1,39 @@
+import { BaseResponse } from "@bitwarden/common/models/response/base.response";
+import { TaxIdWarningResponse } from "@bitwarden/web-vault/app/billing/warnings/types";
+
+type ProviderSuspensionResolution =
+ | "add_payment_method"
+ | "contact_administrator"
+ | "contact_support";
+
+export class ProviderWarningsResponse extends BaseResponse {
+ suspension?: SuspensionWarningResponse;
+ taxId?: TaxIdWarningResponse;
+
+ constructor(response: any) {
+ super(response);
+ const suspension = this.getResponseProperty("Suspension");
+ if (suspension) {
+ this.suspension = new SuspensionWarningResponse(suspension);
+ }
+ const taxId = this.getResponseProperty("TaxId");
+ if (taxId) {
+ this.taxId = new TaxIdWarningResponse(taxId);
+ }
+ }
+}
+
+class SuspensionWarningResponse extends BaseResponse {
+ resolution: ProviderSuspensionResolution;
+ subscriptionCancelsAt?: Date;
+
+ constructor(response: any) {
+ super(response);
+
+ this.resolution = this.getResponseProperty("Resolution");
+ const subscriptionCancelsAt = this.getResponseProperty("SubscriptionCancelsAt");
+ if (subscriptionCancelsAt) {
+ this.subscriptionCancelsAt = new Date(subscriptionCancelsAt);
+ }
+ }
+}
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 29301e626b9..f89025e7d4a 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,5 +1,4 @@
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
-import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response";
import {
BillingInvoiceResponse,
@@ -18,8 +17,6 @@ export abstract class OrganizationBillingApiServiceAbstraction {
startAfter?: string,
) => Promise;
- abstract getWarnings: (id: string) => Promise;
-
abstract setupBusinessUnit: (
id: string,
request: {
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 e9456f61026..40424c236e7 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,5 +1,4 @@
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
-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";
@@ -53,18 +52,6 @@ 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 15ecd9fb63e..5a4e5ff5dde 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -32,6 +32,7 @@ export enum FeatureFlag {
AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
+ PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -108,6 +109,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AllowTrialLengthZero]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
+ [FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,