mirror of
https://github.com/bitwarden/browser
synced 2025-12-22 19:23:52 +00:00
[PM-22415] Tax ID notifications for Organizations and Providers (#15996)
* [NO LOGIC] Rename BillableEntity to BitwardenSubscriber This helps us maintain paraody with server where we call this choice type ISubscriber. I chose BitwardenSubscriber to avoid overlap with RxJS * [NO LOGIC] Move subscriber-billing.client to clients folder * [NO LOGIC] Move organization warnings under organization folder * Move getWarnings from OrganizationBillingApiService to new OrganizationBillingClient I'd like us to move away from stashing so much in libs and utilizing the JsLibServicesModule when it's not necessary to do so. These are invocations used exclusively by the Web Vault and, until that changes, they should be treated as such * Refactor OrganizationWarningsService There was a case added to the Inactive Subscription warning for a free trial, but free trials do not represent inactive subscriptions so this was semantically incorrect. This creates another method that pulls the free trial warning and shows a dialog asking the user to subscribe if they're on one. * Implement Tax ID Warnings throughout Admin Console and Provider Portal * Fix linting error * Jimmy's feedback
This commit is contained in:
@@ -55,5 +55,14 @@
|
||||
></bit-nav-item>
|
||||
</app-side-nav>
|
||||
|
||||
<ng-container *ngIf="subscriber$ | async as subscriber">
|
||||
<app-tax-id-warning
|
||||
[subscriber]="subscriber"
|
||||
[getWarning$]="getTaxIdWarning$"
|
||||
(billingAddressUpdated)="refreshTaxIdWarning()"
|
||||
>
|
||||
</app-tax-id-warning>
|
||||
</ng-container>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</app-layout>
|
||||
|
||||
@@ -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<boolean>;
|
||||
protected providerPortalTakeover$: Observable<boolean>;
|
||||
|
||||
protected subscriber$: Observable<NonIndividualSubscriber>;
|
||||
protected getTaxIdWarning$: () => Observable<TaxIdWarningType>;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,19 +13,20 @@
|
||||
} @else {
|
||||
<ng-container>
|
||||
<app-display-payment-method
|
||||
[owner]="view.provider"
|
||||
[subscriber]="view.provider"
|
||||
[paymentMethod]="view.paymentMethod"
|
||||
(updated)="setPaymentMethod($event)"
|
||||
></app-display-payment-method>
|
||||
|
||||
<app-display-billing-address
|
||||
[owner]="view.provider"
|
||||
[subscriber]="view.provider"
|
||||
[billingAddress]="view.billingAddress"
|
||||
[taxIdWarning]="enableTaxIdWarning ? view.taxIdWarning : null"
|
||||
(updated)="setBillingAddress($event)"
|
||||
></app-display-billing-address>
|
||||
|
||||
<app-display-account-credit
|
||||
[owner]="view.provider"
|
||||
[subscriber]="view.provider"
|
||||
[credit]="view.credit"
|
||||
></app-display-account-credit>
|
||||
</ng-container>
|
||||
|
||||
@@ -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<View | null>(null);
|
||||
|
||||
private load$: Observable<View> = this.activatedRoute.params.pipe(
|
||||
private provider$ = this.activatedRoute.params.pipe(
|
||||
switchMap(({ providerId }) => this.providerService.get$(providerId)),
|
||||
);
|
||||
|
||||
private load$: Observable<View> = 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<void>();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let providerService: MockProxy<ProviderService>;
|
||||
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let router: MockProxy<Router>;
|
||||
let syncService: MockProxy<SyncService>;
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
providerService = mock<ProviderService>();
|
||||
router = mock<Router>();
|
||||
syncService = mock<SyncService>();
|
||||
|
||||
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<SubmitPaymentMethodDialogResult>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> =>
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./provider-warnings.service";
|
||||
@@ -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<ActivatedRoute>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let router: MockProxy<Router>;
|
||||
let syncService: MockProxy<SyncService>;
|
||||
|
||||
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<ActivatedRoute>();
|
||||
apiService = mock<ApiService>();
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
router = mock<Router>();
|
||||
syncService = mock<SyncService>();
|
||||
|
||||
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<any>;
|
||||
|
||||
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<any>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ProviderId, Observable<ProviderWarningsResponse>>();
|
||||
|
||||
private refreshTaxIdWarningTrigger = new Subject<void>();
|
||||
|
||||
private taxIdWarningRefreshedSubject = new BehaviorSubject<TaxIdWarningType | null>(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<TaxIdWarningType | null> =>
|
||||
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<void> =>
|
||||
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<ProviderWarningsResponse> => {
|
||||
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<ProviderWarningsResponse> => {
|
||||
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$ = <T>(
|
||||
provider: Provider,
|
||||
extract: (response: ProviderWarningsResponse) => T | null | undefined,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<T | null> =>
|
||||
this.readThroughWarnings$(provider, bypassCache).pipe(
|
||||
map((response) => {
|
||||
const value = extract(response);
|
||||
return value ? value : null;
|
||||
}),
|
||||
take(1),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user