mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +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:
@@ -79,8 +79,11 @@ import {
|
||||
DecryptionFailureDialogComponent,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service";
|
||||
import {
|
||||
OrganizationFreeTrialWarningComponent,
|
||||
OrganizationResellerRenewalWarningComponent,
|
||||
} from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component";
|
||||
|
||||
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
|
||||
@@ -90,7 +93,6 @@ import {
|
||||
} from "../../../billing/services/reseller-warning.service";
|
||||
import { TrialFlowService } from "../../../billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "../../../billing/types/free-trial";
|
||||
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections";
|
||||
import {
|
||||
@@ -674,6 +676,15 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
organization$
|
||||
.pipe(
|
||||
switchMap((organization) =>
|
||||
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
const freeTrial$ = combineLatest([
|
||||
organization$,
|
||||
this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)),
|
||||
|
||||
@@ -150,6 +150,12 @@
|
||||
>
|
||||
{{ "accessingUsingProvider" | i18n: organization.providerName }}
|
||||
</bit-banner>
|
||||
<app-tax-id-warning
|
||||
[subscriber]="subscriber$ | async"
|
||||
[getWarning$]="getTaxIdWarning$"
|
||||
(billingAddressUpdated)="refreshTaxIdWarning()"
|
||||
>
|
||||
</app-tax-id-warning>
|
||||
</ng-container>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -28,6 +28,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
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 { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service";
|
||||
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
|
||||
@@ -44,6 +48,8 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module";
|
||||
IconModule,
|
||||
OrgSwitcherComponent,
|
||||
BannerModule,
|
||||
TaxIdWarningComponent,
|
||||
TaxIdWarningComponent,
|
||||
],
|
||||
})
|
||||
export class OrganizationLayoutComponent implements OnInit {
|
||||
@@ -58,7 +64,6 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
showPaymentAndHistory$: Observable<boolean>;
|
||||
hideNewOrgButton$: Observable<boolean>;
|
||||
organizationIsUnmanaged$: Observable<boolean>;
|
||||
enterpriseOrganization$: Observable<boolean>;
|
||||
|
||||
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
|
||||
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
|
||||
@@ -69,6 +74,9 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
textKey: string;
|
||||
}>;
|
||||
|
||||
protected subscriber$: Observable<NonIndividualSubscriber>;
|
||||
protected getTaxIdWarning$: () => Observable<TaxIdWarningType | null>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
@@ -79,6 +87,7 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
private accountService: AccountService,
|
||||
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -150,6 +159,20 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
: { route: "billing/payment-method", textKey: "paymentMethod" },
|
||||
),
|
||||
);
|
||||
|
||||
this.subscriber$ = this.organization$.pipe(
|
||||
map((organization) => ({
|
||||
type: "organization",
|
||||
data: organization,
|
||||
})),
|
||||
);
|
||||
|
||||
this.getTaxIdWarning$ = () =>
|
||||
this.organization$.pipe(
|
||||
switchMap((organization) =>
|
||||
this.organizationWarningsService.getTaxIdWarning$(organization),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
canShowVaultTab(organization: Organization): boolean {
|
||||
@@ -179,4 +202,6 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
getReportTabLabel(organization: Organization): string {
|
||||
return organization.useEvents ? "reporting" : "reports";
|
||||
}
|
||||
|
||||
refreshTaxIdWarning = () => this.organizationWarningsService.refreshTaxIdWarning();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
@@ -57,12 +57,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
|
||||
import {
|
||||
ChangePlanDialogResultType,
|
||||
openChangePlanDialog,
|
||||
} from "../../../billing/organizations/change-plan-dialog.component";
|
||||
import { OrganizationWarningsService } from "../../../billing/warnings/services";
|
||||
import { BaseMembersComponent } from "../../common/base-members.component";
|
||||
import { PeopleTableDataSource } from "../../common/people-table-data-source";
|
||||
import { GroupApiService } from "../core";
|
||||
@@ -253,11 +253,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
this.showUserManagementControls$ = organization$.pipe(
|
||||
map((organization) => organization.canManageUsers),
|
||||
);
|
||||
|
||||
organization$
|
||||
.pipe(
|
||||
switchMap((organization) =>
|
||||
merge(
|
||||
this.organizationWarningsService.showInactiveSubscriptionDialog$(organization),
|
||||
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
|
||||
),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
tap((org) => (this.organization = org)),
|
||||
switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { NgModule } from "@angular/core";
|
||||
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
|
||||
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
|
||||
import { ScrollLayoutDirective } from "@bitwarden/components";
|
||||
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
|
||||
|
||||
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components";
|
||||
import { LooseComponentsModule } from "../../../shared";
|
||||
import { SharedOrganizationModule } from "../shared";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { ScrollLayoutDirective } from "@bitwarden/components";
|
||||
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||
|
||||
import { LooseComponentsModule } from "../../shared";
|
||||
|
||||
@@ -21,6 +22,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector";
|
||||
LooseComponentsModule,
|
||||
ScrollingModule,
|
||||
ScrollLayoutDirective,
|
||||
OrganizationWarningsModule,
|
||||
],
|
||||
declarations: [GroupsComponent, GroupAddEditComponent],
|
||||
})
|
||||
|
||||
2
apps/web/src/app/billing/clients/index.ts
Normal file
2
apps/web/src/app/billing/clients/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./organization-billing.client";
|
||||
export * from "./subscriber-billing.client";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationBillingClient {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
getWarnings = async (organizationId: OrganizationId): Promise<OrganizationWarningsResponse> => {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
`/organizations/${organizationId}/billing/vnext/warnings`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new OrganizationWarningsResponse(response);
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
MaskedPaymentMethodResponse,
|
||||
TokenizedPaymentMethod,
|
||||
} from "../payment/types";
|
||||
import { BillableEntity } from "../types";
|
||||
import { BitwardenSubscriber } from "../types";
|
||||
|
||||
type Result<T> =
|
||||
| {
|
||||
@@ -23,28 +23,28 @@ type Result<T> =
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BillingClient {
|
||||
export class SubscriberBillingClient {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
private getEndpoint = (entity: BillableEntity): string => {
|
||||
switch (entity.type) {
|
||||
private getEndpoint = (subscriber: BitwardenSubscriber): string => {
|
||||
switch (subscriber.type) {
|
||||
case "account": {
|
||||
return "/account/billing/vnext";
|
||||
}
|
||||
case "organization": {
|
||||
return `/organizations/${entity.data.id}/billing/vnext`;
|
||||
return `/organizations/${subscriber.data.id}/billing/vnext`;
|
||||
}
|
||||
case "provider": {
|
||||
return `/providers/${entity.data.id}/billing/vnext`;
|
||||
return `/providers/${subscriber.data.id}/billing/vnext`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addCreditWithBitPay = async (
|
||||
owner: BillableEntity,
|
||||
subscriber: BitwardenSubscriber,
|
||||
credit: { amount: number; redirectUrl: string },
|
||||
): Promise<Result<string>> => {
|
||||
const path = `${this.getEndpoint(owner)}/credit/bitpay`;
|
||||
const path = `${this.getEndpoint(subscriber)}/credit/bitpay`;
|
||||
try {
|
||||
const data = await this.apiService.send("POST", path, credit, true, true);
|
||||
return {
|
||||
@@ -62,29 +62,31 @@ export class BillingClient {
|
||||
}
|
||||
};
|
||||
|
||||
getBillingAddress = async (owner: BillableEntity): Promise<BillingAddress | null> => {
|
||||
const path = `${this.getEndpoint(owner)}/address`;
|
||||
getBillingAddress = async (subscriber: BitwardenSubscriber): Promise<BillingAddress | null> => {
|
||||
const path = `${this.getEndpoint(subscriber)}/address`;
|
||||
const data = await this.apiService.send("GET", path, null, true, true);
|
||||
return data ? new BillingAddressResponse(data) : null;
|
||||
};
|
||||
|
||||
getCredit = async (owner: BillableEntity): Promise<number | null> => {
|
||||
const path = `${this.getEndpoint(owner)}/credit`;
|
||||
getCredit = async (subscriber: BitwardenSubscriber): Promise<number | null> => {
|
||||
const path = `${this.getEndpoint(subscriber)}/credit`;
|
||||
const data = await this.apiService.send("GET", path, null, true, true);
|
||||
return data ? (data as number) : null;
|
||||
};
|
||||
|
||||
getPaymentMethod = async (owner: BillableEntity): Promise<MaskedPaymentMethod | null> => {
|
||||
const path = `${this.getEndpoint(owner)}/payment-method`;
|
||||
getPaymentMethod = async (
|
||||
subscriber: BitwardenSubscriber,
|
||||
): Promise<MaskedPaymentMethod | null> => {
|
||||
const path = `${this.getEndpoint(subscriber)}/payment-method`;
|
||||
const data = await this.apiService.send("GET", path, null, true, true);
|
||||
return data ? new MaskedPaymentMethodResponse(data).value : null;
|
||||
};
|
||||
|
||||
updateBillingAddress = async (
|
||||
owner: BillableEntity,
|
||||
subscriber: BitwardenSubscriber,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<Result<BillingAddress>> => {
|
||||
const path = `${this.getEndpoint(owner)}/address`;
|
||||
const path = `${this.getEndpoint(subscriber)}/address`;
|
||||
try {
|
||||
const data = await this.apiService.send("PUT", path, billingAddress, true, true);
|
||||
return {
|
||||
@@ -103,11 +105,11 @@ export class BillingClient {
|
||||
};
|
||||
|
||||
updatePaymentMethod = async (
|
||||
owner: BillableEntity,
|
||||
subscriber: BitwardenSubscriber,
|
||||
paymentMethod: TokenizedPaymentMethod,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode"> | null,
|
||||
): Promise<Result<MaskedPaymentMethod>> => {
|
||||
const path = `${this.getEndpoint(owner)}/payment-method`;
|
||||
const path = `${this.getEndpoint(subscriber)}/payment-method`;
|
||||
try {
|
||||
const request = {
|
||||
...paymentMethod,
|
||||
@@ -130,10 +132,10 @@ export class BillingClient {
|
||||
};
|
||||
|
||||
verifyBankAccount = async (
|
||||
owner: BillableEntity,
|
||||
subscriber: BitwardenSubscriber,
|
||||
descriptorCode: string,
|
||||
): Promise<Result<MaskedPaymentMethod>> => {
|
||||
const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`;
|
||||
const path = `${this.getEndpoint(subscriber)}/payment-method/verify-bank-account`;
|
||||
try {
|
||||
const data = await this.apiService.send("POST", path, { descriptorCode }, true, true);
|
||||
return {
|
||||
@@ -12,13 +12,13 @@
|
||||
} @else {
|
||||
<ng-container>
|
||||
<app-display-payment-method
|
||||
[owner]="view.account"
|
||||
[subscriber]="view.account"
|
||||
[paymentMethod]="view.paymentMethod"
|
||||
(updated)="setPaymentMethod($event)"
|
||||
></app-display-payment-method>
|
||||
|
||||
<app-display-account-credit
|
||||
[owner]="view.account"
|
||||
[subscriber]="view.account"
|
||||
[credit]="view.credit"
|
||||
></app-display-account-credit>
|
||||
</ng-container>
|
||||
|
||||
@@ -20,13 +20,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { SubscriberBillingClient } from "../../clients";
|
||||
import {
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
} from "../../payment/components";
|
||||
import { MaskedPaymentMethod } from "../../payment/types";
|
||||
import { BillingClient } from "../../services";
|
||||
import { accountToBillableEntity, BillableEntity } from "../../types";
|
||||
import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types";
|
||||
|
||||
class RedirectError {
|
||||
constructor(
|
||||
@@ -36,7 +36,7 @@ class RedirectError {
|
||||
}
|
||||
|
||||
type View = {
|
||||
account: BillableEntity;
|
||||
account: BitwardenSubscriber;
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
credit: number | null;
|
||||
};
|
||||
@@ -50,7 +50,7 @@ type View = {
|
||||
HeaderModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [BillingClient],
|
||||
providers: [SubscriberBillingClient],
|
||||
})
|
||||
export class AccountPaymentDetailsComponent {
|
||||
private viewState$ = new BehaviorSubject<View | null>(null);
|
||||
@@ -68,7 +68,7 @@ export class AccountPaymentDetailsComponent {
|
||||
}),
|
||||
),
|
||||
),
|
||||
accountToBillableEntity,
|
||||
mapAccountToSubscriber,
|
||||
switchMap(async (account) => {
|
||||
const [paymentMethod, credit] = await Promise.all([
|
||||
this.billingClient.getPaymentMethod(account),
|
||||
@@ -100,7 +100,7 @@ export class AccountPaymentDetailsComponent {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingClient: BillingClient,
|
||||
private billingClient: SubscriberBillingClient,
|
||||
private configService: ConfigService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
@@ -21,19 +21,20 @@
|
||||
} @else {
|
||||
<ng-container>
|
||||
<app-display-payment-method
|
||||
[owner]="view.organization"
|
||||
[subscriber]="view.organization"
|
||||
[paymentMethod]="view.paymentMethod"
|
||||
(updated)="setPaymentMethod($event)"
|
||||
></app-display-payment-method>
|
||||
|
||||
<app-display-billing-address
|
||||
[owner]="view.organization"
|
||||
[subscriber]="view.organization"
|
||||
[billingAddress]="view.billingAddress"
|
||||
[taxIdWarning]="enableTaxIdWarning ? view.taxIdWarning : null"
|
||||
(updated)="setBillingAddress($event)"
|
||||
></app-display-billing-address>
|
||||
|
||||
<app-display-account-credit
|
||||
[owner]="view.organization"
|
||||
[subscriber]="view.organization"
|
||||
[credit]="view.credit"
|
||||
></app-display-account-credit>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
@@ -11,8 +12,12 @@ import {
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
Subject,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -26,19 +31,26 @@ 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 { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
import {
|
||||
ChangePaymentMethodDialogComponent,
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayBillingAddressComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
} from "../../payment/components";
|
||||
import { BillingAddress, MaskedPaymentMethod } from "../../payment/types";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity, organizationToBillableEntity } from "../../types";
|
||||
import { OrganizationFreeTrialWarningComponent } from "../../warnings/components";
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import {
|
||||
BillingAddress,
|
||||
MaskedPaymentMethod,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import {
|
||||
BitwardenSubscriber,
|
||||
mapOrganizationToSubscriber,
|
||||
} 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";
|
||||
|
||||
class RedirectError {
|
||||
constructor(
|
||||
@@ -48,41 +60,39 @@ class RedirectError {
|
||||
}
|
||||
|
||||
type View = {
|
||||
organization: BillableEntity;
|
||||
organization: BitwardenSubscriber;
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
billingAddress: BillingAddress | null;
|
||||
credit: number | null;
|
||||
taxIdWarning: TaxIdWarningType | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "./organization-payment-details.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DisplayBillingAddressComponent,
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayBillingAddressComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
HeaderModule,
|
||||
OrganizationFreeTrialWarningComponent,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
@ViewChild(OrganizationFreeTrialWarningComponent)
|
||||
organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent;
|
||||
|
||||
export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
|
||||
private viewState$ = new BehaviorSubject<View | null>(null);
|
||||
|
||||
private load$: Observable<View> = this.accountService.activeAccount$
|
||||
.pipe(
|
||||
protected organization$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
filter((organization): organization is Organization => !!organization),
|
||||
);
|
||||
|
||||
private load$: Observable<View> = this.organization$.pipe(
|
||||
switchMap((organization) =>
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
|
||||
@@ -95,12 +105,17 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
}),
|
||||
),
|
||||
),
|
||||
organizationToBillableEntity,
|
||||
mapOrganizationToSubscriber,
|
||||
switchMap(async (organization) => {
|
||||
const [paymentMethod, billingAddress, credit] = await Promise.all([
|
||||
this.billingClient.getPaymentMethod(organization),
|
||||
this.billingClient.getBillingAddress(organization),
|
||||
this.billingClient.getCredit(organization),
|
||||
const getTaxIdWarning = firstValueFrom(
|
||||
this.organizationWarningsService.getTaxIdWarning$(organization.data as Organization),
|
||||
);
|
||||
|
||||
const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([
|
||||
this.subscriberBillingClient.getPaymentMethod(organization),
|
||||
this.subscriberBillingClient.getBillingAddress(organization),
|
||||
this.subscriberBillingClient.getCredit(organization),
|
||||
getTaxIdWarning,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -108,6 +123,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
paymentMethod,
|
||||
billingAddress,
|
||||
credit,
|
||||
taxIdWarning,
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
@@ -125,16 +141,19 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
this.viewState$.pipe(filter((view): view is View => view !== null)),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
organization$ = this.view$.pipe(map((view) => view.organization.data as Organization));
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected enableTaxIdWarning!: boolean;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingClient: BillingClient,
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private organizationService: OrganizationService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private router: Router,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -145,24 +164,66 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, "");
|
||||
await this.changePaymentMethod();
|
||||
}
|
||||
|
||||
this.enableTaxIdWarning = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22415_TaxIDWarnings,
|
||||
);
|
||||
|
||||
if (this.enableTaxIdWarning) {
|
||||
this.organizationWarningsService.taxIdWarningRefreshed$
|
||||
.pipe(
|
||||
switchMap((warning) =>
|
||||
combineLatest([
|
||||
of(warning),
|
||||
this.organization$.pipe(take(1)).pipe(
|
||||
mapOrganizationToSubscriber,
|
||||
switchMap((organization) =>
|
||||
this.subscriberBillingClient.getBillingAddress(organization),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
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();
|
||||
}
|
||||
|
||||
changePaymentMethod = async () => {
|
||||
const view = await firstValueFrom(this.view$);
|
||||
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: view.organization,
|
||||
subscriber: view.organization,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result?.type === "success") {
|
||||
await this.setPaymentMethod(result.paymentMethod);
|
||||
this.organizationFreeTrialWarningComponent.refresh();
|
||||
this.organizationWarningsService.refreshFreeTrialWarning();
|
||||
}
|
||||
};
|
||||
|
||||
setBillingAddress = (billingAddress: BillingAddress) => {
|
||||
if (this.viewState$.value) {
|
||||
if (
|
||||
this.enableTaxIdWarning &&
|
||||
this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId
|
||||
) {
|
||||
this.organizationWarningsService.refreshTaxIdWarning();
|
||||
}
|
||||
this.viewState$.next({
|
||||
...this.viewState$.value,
|
||||
billingAddress,
|
||||
@@ -174,7 +235,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
if (this.viewState$.value) {
|
||||
const billingAddress =
|
||||
this.viewState$.value.billingAddress ??
|
||||
(await this.billingClient.getBillingAddress(this.viewState$.value.organization));
|
||||
(await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.organization));
|
||||
|
||||
this.viewState$.next({
|
||||
...this.viewState$.value,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./organization-free-trial-warning.component";
|
||||
export * from "./organization-reseller-renewal-warning.component";
|
||||
@@ -1,12 +1,9 @@
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { OrganizationWarningsService } from "../services";
|
||||
import { OrganizationFreeTrialWarning } from "../types";
|
||||
@@ -37,33 +34,17 @@ import { OrganizationFreeTrialWarning } from "../types";
|
||||
</bit-banner>
|
||||
}
|
||||
`,
|
||||
imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe],
|
||||
imports: [BannerModule, SharedModule],
|
||||
})
|
||||
export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy {
|
||||
export class OrganizationFreeTrialWarningComponent implements OnInit {
|
||||
@Input({ required: true }) organization!: Organization;
|
||||
@Output() clicked = new EventEmitter<void>();
|
||||
|
||||
warning$!: Observable<OrganizationFreeTrialWarning>;
|
||||
private destroy$ = new Subject<void>();
|
||||
warning$!: Observable<OrganizationFreeTrialWarning | null>;
|
||||
|
||||
constructor(private organizationWarningsService: OrganizationWarningsService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization);
|
||||
this.organizationWarningsService
|
||||
.refreshWarningsForOrganization$(this.organization.id as OrganizationId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true);
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BannerComponent } from "@bitwarden/components";
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { OrganizationWarningsService } from "../services";
|
||||
import { OrganizationResellerRenewalWarning } from "../types";
|
||||
@@ -25,12 +25,12 @@ import { OrganizationResellerRenewalWarning } from "../types";
|
||||
</bit-banner>
|
||||
}
|
||||
`,
|
||||
imports: [AsyncPipe, BannerComponent],
|
||||
imports: [BannerModule, SharedModule],
|
||||
})
|
||||
export class OrganizationResellerRenewalWarningComponent implements OnInit {
|
||||
@Input({ required: true }) organization!: Organization;
|
||||
|
||||
warning$!: Observable<OrganizationResellerRenewalWarning>;
|
||||
warning$!: Observable<OrganizationResellerRenewalWarning | null>;
|
||||
|
||||
constructor(private organizationWarningsService: OrganizationWarningsService) {}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import {
|
||||
OrganizationBillingClient,
|
||||
SubscriberBillingClient,
|
||||
} from "@bitwarden/web-vault/app/billing/clients";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
|
||||
@NgModule({
|
||||
providers: [OrganizationBillingClient, OrganizationWarningsService, SubscriberBillingClient],
|
||||
})
|
||||
export class OrganizationWarningsModule {}
|
||||
@@ -0,0 +1,682 @@
|
||||
jest.mock("@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component", () => ({
|
||||
ChangePlanDialogResultType: {
|
||||
Submitted: "submitted",
|
||||
Cancelled: "cancelled",
|
||||
},
|
||||
openChangePlanDialog: jest.fn(),
|
||||
}));
|
||||
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } 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 { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
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 { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
ChangePlanDialogResultType,
|
||||
openChangePlanDialog,
|
||||
} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services/organization-warnings.service";
|
||||
import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types";
|
||||
import {
|
||||
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
|
||||
TrialPaymentDialogComponent,
|
||||
TrialPaymentDialogResultType,
|
||||
} from "@bitwarden/web-vault/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component";
|
||||
import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types";
|
||||
|
||||
describe("OrganizationWarningsService", () => {
|
||||
let service: OrganizationWarningsService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let organizationBillingClient: MockProxy<OrganizationBillingClient>;
|
||||
let router: MockProxy<Router>;
|
||||
|
||||
const organization = {
|
||||
id: "org-id-123",
|
||||
name: "Test Organization",
|
||||
providerName: "Test Reseller Inc",
|
||||
productTierType: ProductTierType.Enterprise,
|
||||
} as Organization;
|
||||
|
||||
const format = (date: Date): string =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
organizationBillingClient = mock<OrganizationBillingClient>();
|
||||
router = mock<Router>();
|
||||
|
||||
(openChangePlanDialog as jest.Mock).mockReset();
|
||||
|
||||
i18nService.t.mockImplementation((key: string, ...args: any[]) => {
|
||||
switch (key) {
|
||||
case "freeTrialEndPromptCount":
|
||||
return `Your free trial ends in ${args[0]} days.`;
|
||||
case "freeTrialEndPromptTomorrowNoOrgName":
|
||||
return "Your free trial ends tomorrow.";
|
||||
case "freeTrialEndingTodayWithoutOrgName":
|
||||
return "Your free trial ends today.";
|
||||
case "resellerRenewalWarningMsg":
|
||||
return `Your subscription will renew soon. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`;
|
||||
case "resellerOpenInvoiceWarningMgs":
|
||||
return `An invoice for your subscription was issued on ${args[1]}. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[2]}.`;
|
||||
case "resellerPastDueWarningMsg":
|
||||
return `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`;
|
||||
case "suspendedOrganizationTitle":
|
||||
return `${args[0]} subscription suspended`;
|
||||
case "close":
|
||||
return "Close";
|
||||
case "continue":
|
||||
return "Continue";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
OrganizationWarningsService,
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: OrganizationApiServiceAbstraction, useValue: organizationApiService },
|
||||
{ provide: OrganizationBillingClient, useValue: organizationBillingClient },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(OrganizationWarningsService);
|
||||
});
|
||||
|
||||
describe("getFreeTrialWarning$", () => {
|
||||
it("should return null when no free trial warning exists", (done) => {
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse);
|
||||
|
||||
service.getFreeTrialWarning$(organization).subscribe((result) => {
|
||||
expect(result).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return warning with count message when remaining trial days >= 2", (done) => {
|
||||
const warning = { remainingTrialDays: 5 };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
freeTrial: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getFreeTrialWarning$(organization).subscribe((result) => {
|
||||
expect(result).toEqual({
|
||||
organization: organization,
|
||||
message: "Your free trial ends in 5 days.",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptCount", 5);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return warning with tomorrow message when remaining trial days = 1", (done) => {
|
||||
const warning = { remainingTrialDays: 1 };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
freeTrial: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getFreeTrialWarning$(organization).subscribe((result) => {
|
||||
expect(result).toEqual({
|
||||
organization: organization,
|
||||
message: "Your free trial ends tomorrow.",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptTomorrowNoOrgName");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return warning with today message when remaining trial days = 0", (done) => {
|
||||
const warning = { remainingTrialDays: 0 };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
freeTrial: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getFreeTrialWarning$(organization).subscribe((result) => {
|
||||
expect(result).toEqual({
|
||||
organization: organization,
|
||||
message: "Your free trial ends today.",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndingTodayWithoutOrgName");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should refresh warning when refreshFreeTrialWarning is called", (done) => {
|
||||
const initialWarning = { remainingTrialDays: 3 };
|
||||
const refreshedWarning = { remainingTrialDays: 2 };
|
||||
let invocationCount = 0;
|
||||
|
||||
organizationBillingClient.getWarnings
|
||||
.mockResolvedValueOnce({
|
||||
freeTrial: initialWarning,
|
||||
} as OrganizationWarningsResponse)
|
||||
.mockResolvedValueOnce({
|
||||
freeTrial: refreshedWarning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
const subscription = service.getFreeTrialWarning$(organization).subscribe((result) => {
|
||||
invocationCount++;
|
||||
|
||||
if (invocationCount === 1) {
|
||||
expect(result).toEqual({
|
||||
organization: organization,
|
||||
message: "Your free trial ends in 3 days.",
|
||||
});
|
||||
} else if (invocationCount === 2) {
|
||||
expect(result).toEqual({
|
||||
organization: organization,
|
||||
message: "Your free trial ends in 2 days.",
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
service.refreshFreeTrialWarning();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResellerRenewalWarning$", () => {
|
||||
it("should return null when no reseller renewal warning exists", (done) => {
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse);
|
||||
|
||||
service.getResellerRenewalWarning$(organization).subscribe((result) => {
|
||||
expect(result).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return upcoming warning with correct type and message", (done) => {
|
||||
const renewalDate = new Date(2024, 11, 31);
|
||||
const warning = {
|
||||
type: "upcoming" as const,
|
||||
upcoming: { renewalDate },
|
||||
};
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
resellerRenewal: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getResellerRenewalWarning$(organization).subscribe((result) => {
|
||||
const expectedFormattedDate = format(renewalDate);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "info",
|
||||
message: `Your subscription will renew soon. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedFormattedDate}.`,
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"resellerRenewalWarningMsg",
|
||||
"Test Reseller Inc",
|
||||
expectedFormattedDate,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return issued warning with correct type and message", (done) => {
|
||||
const issuedDate = new Date(2024, 10, 15);
|
||||
const dueDate = new Date(2024, 11, 15);
|
||||
const warning = {
|
||||
type: "issued" as const,
|
||||
issued: { issuedDate, dueDate },
|
||||
};
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
resellerRenewal: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getResellerRenewalWarning$(organization).subscribe((result) => {
|
||||
const expectedIssuedDate = format(issuedDate);
|
||||
const expectedDueDate = format(dueDate);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "info",
|
||||
message: `An invoice for your subscription was issued on ${expectedIssuedDate}. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedDueDate}.`,
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"resellerOpenInvoiceWarningMgs",
|
||||
"Test Reseller Inc",
|
||||
expectedIssuedDate,
|
||||
expectedDueDate,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return past_due warning with correct type and message", (done) => {
|
||||
const suspensionDate = new Date(2024, 11, 1);
|
||||
const warning = {
|
||||
type: "past_due" as const,
|
||||
pastDue: { suspensionDate },
|
||||
};
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
resellerRenewal: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getResellerRenewalWarning$(organization).subscribe((result) => {
|
||||
const expectedSuspensionDate = format(suspensionDate);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "warning",
|
||||
message: `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedSuspensionDate}.`,
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"resellerPastDueWarningMsg",
|
||||
"Test Reseller Inc",
|
||||
expectedSuspensionDate,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTaxIdWarning$", () => {
|
||||
it("should return null when no tax ID warning exists", (done) => {
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse);
|
||||
|
||||
service.getTaxIdWarning$(organization).subscribe((result) => {
|
||||
expect(result).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return tax_id_missing type when tax ID is missing", (done) => {
|
||||
const warning = { type: TaxIdWarningTypes.Missing };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
taxId: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getTaxIdWarning$(organization).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 };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
taxId: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getTaxIdWarning$(organization).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 };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
taxId: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
service.getTaxIdWarning$(organization).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;
|
||||
|
||||
organizationBillingClient.getWarnings
|
||||
.mockResolvedValueOnce({
|
||||
taxId: initialWarning,
|
||||
} as OrganizationWarningsResponse)
|
||||
.mockResolvedValueOnce({
|
||||
taxId: refreshedWarning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
const subscription = service.getTaxIdWarning$(organization).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;
|
||||
|
||||
organizationBillingClient.getWarnings
|
||||
.mockResolvedValueOnce({} as OrganizationWarningsResponse)
|
||||
.mockResolvedValueOnce({
|
||||
taxId: refreshedWarning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => {
|
||||
refreshedCount++;
|
||||
if (refreshedCount === 2) {
|
||||
expect(refreshedType).toBe(TaxIdWarningTypes.Missing);
|
||||
taxIdSubscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
service.getTaxIdWarning$(organization).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;
|
||||
|
||||
organizationBillingClient.getWarnings
|
||||
.mockResolvedValueOnce({
|
||||
taxId: initialWarning,
|
||||
} as OrganizationWarningsResponse)
|
||||
.mockResolvedValueOnce({} as OrganizationWarningsResponse);
|
||||
|
||||
const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => {
|
||||
refreshedCount++;
|
||||
if (refreshedCount === 2) {
|
||||
expect(refreshedType).toBeNull();
|
||||
taxIdSubscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
service.getTaxIdWarning$(organization).subscribe();
|
||||
|
||||
setTimeout(() => {
|
||||
service.refreshTaxIdWarning();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showInactiveSubscriptionDialog$", () => {
|
||||
it("should not show dialog when no inactive subscription warning exists", (done) => {
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should show contact provider dialog for contact_provider resolution", (done) => {
|
||||
const warning = { resolution: "contact_provider" };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
inactiveSubscription: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: "Test Organization subscription suspended",
|
||||
content: {
|
||||
key: "suspendedManagedOrgMessage",
|
||||
placeholders: ["Test Reseller Inc"],
|
||||
},
|
||||
type: "danger",
|
||||
acceptButtonText: "Close",
|
||||
cancelButtonText: null,
|
||||
});
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should show add payment method dialog and navigate when confirmed", (done) => {
|
||||
const warning = { resolution: "add_payment_method" };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
inactiveSubscription: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
router.navigate.mockResolvedValue(true);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: "Test Organization subscription suspended",
|
||||
content: { key: "suspendedOwnerOrgMessage" },
|
||||
type: "danger",
|
||||
acceptButtonText: "Continue",
|
||||
cancelButtonText: "Close",
|
||||
});
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
["organizations", "org-id-123", "billing", "payment-method"],
|
||||
{ state: { launchPaymentModalAutomatically: true } },
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to payment-details when feature flag is enabled", (done) => {
|
||||
const warning = { resolution: "add_payment_method" };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
inactiveSubscription: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
router.navigate.mockResolvedValue(true);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
["organizations", "org-id-123", "billing", "payment-details"],
|
||||
{ state: { launchPaymentModalAutomatically: true } },
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not navigate when add payment method dialog is cancelled", (done) => {
|
||||
const warning = { resolution: "add_payment_method" };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
inactiveSubscription: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
|
||||
expect(configService.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should open change plan dialog for resubscribe resolution", (done) => {
|
||||
const warning = { resolution: "resubscribe" };
|
||||
const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse;
|
||||
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
inactiveSubscription: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
organizationApiService.getSubscription.mockResolvedValue(subscription);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of("submitted"),
|
||||
} as DialogRef<ChangePlanDialogResultType>;
|
||||
|
||||
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id);
|
||||
expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, {
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
subscription: subscription,
|
||||
productTierType: organization.productTierType,
|
||||
},
|
||||
});
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should show contact owner dialog for contact_owner resolution", (done) => {
|
||||
const warning = { resolution: "contact_owner" };
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
inactiveSubscription: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: "Test Organization subscription suspended",
|
||||
content: { key: "suspendedUserOrgMessage" },
|
||||
type: "danger",
|
||||
acceptButtonText: "Close",
|
||||
cancelButtonText: null,
|
||||
});
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("showSubscribeBeforeFreeTrialEndsDialog$", () => {
|
||||
it("should not show dialog when no free trial warning exists", (done) => {
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse);
|
||||
|
||||
service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(organizationApiService.getSubscription).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should open trial payment dialog when free trial warning exists", (done) => {
|
||||
const warning = { remainingTrialDays: 2 };
|
||||
const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse;
|
||||
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
freeTrial: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
organizationApiService.getSubscription.mockResolvedValue(subscription);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED),
|
||||
} as DialogRef<TrialPaymentDialogResultType>;
|
||||
|
||||
const openSpy = jest
|
||||
.spyOn(TrialPaymentDialogComponent, "open")
|
||||
.mockReturnValue(mockDialogRef);
|
||||
|
||||
service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id);
|
||||
expect(openSpy).toHaveBeenCalledWith(dialogService, {
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
subscription: subscription,
|
||||
productTierType: organization.productTierType,
|
||||
},
|
||||
});
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should refresh free trial warning when dialog result is SUBMITTED", (done) => {
|
||||
const warning = { remainingTrialDays: 1 };
|
||||
const subscription = { id: "sub-456" } as OrganizationSubscriptionResponse;
|
||||
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
freeTrial: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
organizationApiService.getSubscription.mockResolvedValue(subscription);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED),
|
||||
} as DialogRef<TrialPaymentDialogResultType>;
|
||||
|
||||
jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef);
|
||||
|
||||
const refreshTriggerSpy = jest.spyOn(service["refreshFreeTrialWarningTrigger"], "next");
|
||||
|
||||
service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(refreshTriggerSpy).toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not refresh free trial warning when dialog result is CLOSED", (done) => {
|
||||
const warning = { remainingTrialDays: 3 };
|
||||
const subscription = { id: "sub-789" } as OrganizationSubscriptionResponse;
|
||||
|
||||
organizationBillingClient.getWarnings.mockResolvedValue({
|
||||
freeTrial: warning,
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
organizationApiService.getSubscription.mockResolvedValue(subscription);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED),
|
||||
} as DialogRef<TrialPaymentDialogResultType>;
|
||||
|
||||
jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef);
|
||||
const refreshSpy = jest.spyOn(service, "refreshFreeTrialWarning");
|
||||
|
||||
service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,39 @@
|
||||
import { Location } from "@angular/common";
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
filter,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { take } from "rxjs/operators";
|
||||
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response";
|
||||
import { 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 { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types";
|
||||
|
||||
import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component";
|
||||
import {
|
||||
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
|
||||
TrialPaymentDialogComponent,
|
||||
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component";
|
||||
import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types";
|
||||
} from "../../../shared/trial-payment-dialog/trial-payment-dialog.component";
|
||||
import { openChangePlanDialog } from "../../change-plan-dialog.component";
|
||||
import {
|
||||
OrganizationFreeTrialWarning,
|
||||
OrganizationResellerRenewalWarning,
|
||||
OrganizationWarningsResponse,
|
||||
} from "../types";
|
||||
|
||||
const format = (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
@@ -29,28 +42,39 @@ const format = (date: Date) =>
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
@Injectable()
|
||||
export class OrganizationWarningsService {
|
||||
private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>();
|
||||
private refreshWarnings$ = new Subject<OrganizationId>();
|
||||
|
||||
private refreshFreeTrialWarningTrigger = new Subject<void>();
|
||||
private refreshTaxIdWarningTrigger = new Subject<void>();
|
||||
|
||||
private taxIdWarningRefreshedSubject = new BehaviorSubject<TaxIdWarningType | null>(null);
|
||||
taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private organizationBillingApiService: OrganizationBillingApiServiceAbstraction,
|
||||
private organizationBillingClient: OrganizationBillingClient,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
protected syncService: SyncService,
|
||||
) {}
|
||||
|
||||
getFreeTrialWarning$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationFreeTrialWarning> =>
|
||||
this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe(
|
||||
): Observable<OrganizationFreeTrialWarning | null> =>
|
||||
merge(
|
||||
this.getWarning$(organization, (response) => response.freeTrial),
|
||||
this.refreshFreeTrialWarningTrigger.pipe(
|
||||
switchMap(() => this.getWarning$(organization, (response) => response.freeTrial, true)),
|
||||
),
|
||||
).pipe(
|
||||
map((warning) => {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { remainingTrialDays } = warning;
|
||||
|
||||
if (remainingTrialDays >= 2) {
|
||||
@@ -76,10 +100,12 @@ export class OrganizationWarningsService {
|
||||
|
||||
getResellerRenewalWarning$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationResellerRenewalWarning> =>
|
||||
this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe(
|
||||
map((warning): OrganizationResellerRenewalWarning | null => {
|
||||
): Observable<OrganizationResellerRenewalWarning | null> =>
|
||||
this.getWarning$(organization, (response) => response.resellerRenewal).pipe(
|
||||
map((warning) => {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
switch (warning.type) {
|
||||
case "upcoming": {
|
||||
return {
|
||||
@@ -114,14 +140,27 @@ export class OrganizationWarningsService {
|
||||
}
|
||||
}
|
||||
}),
|
||||
filter((result): result is NonNullable<typeof result> => result !== null),
|
||||
);
|
||||
|
||||
showInactiveSubscriptionDialog$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<void> =>
|
||||
this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe(
|
||||
getTaxIdWarning$ = (organization: Organization): Observable<TaxIdWarningType | null> =>
|
||||
merge(
|
||||
this.getWarning$(organization, (response) => response.taxId),
|
||||
this.refreshTaxIdWarningTrigger.pipe(
|
||||
switchMap(() =>
|
||||
this.getWarning$(organization, (response) => response.taxId, true).pipe(
|
||||
tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)),
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(map((warning) => (warning ? warning.type : null)));
|
||||
|
||||
refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next();
|
||||
|
||||
refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next();
|
||||
|
||||
showInactiveSubscriptionDialog$ = (organization: Organization): Observable<void> =>
|
||||
this.getWarning$(organization, (response) => response.inactiveSubscription).pipe(
|
||||
filter((warning) => warning !== null),
|
||||
switchMap(async (warning) => {
|
||||
switch (warning.resolution) {
|
||||
case "contact_provider": {
|
||||
@@ -183,9 +222,17 @@ export class OrganizationWarningsService {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "add_payment_method_optional_trial": {
|
||||
const organizationSubscriptionResponse =
|
||||
await this.organizationApiService.getSubscription(organization.id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
showSubscribeBeforeFreeTrialEndsDialog$ = (organization: Organization): Observable<void> =>
|
||||
this.getWarning$(organization, (response) => response.freeTrial).pipe(
|
||||
filter((warning) => warning !== null),
|
||||
switchMap(async () => {
|
||||
const organizationSubscriptionResponse = await this.organizationApiService.getSubscription(
|
||||
organization.id,
|
||||
);
|
||||
|
||||
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
@@ -196,30 +243,22 @@ export class OrganizationWarningsService {
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
|
||||
this.refreshWarnings$.next(organization.id as OrganizationId);
|
||||
}
|
||||
}
|
||||
this.refreshFreeTrialWarningTrigger.next();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
refreshWarningsForOrganization$(organizationId: OrganizationId): Observable<void> {
|
||||
return this.refreshWarnings$.pipe(
|
||||
filter((id) => id === organizationId),
|
||||
map((): void => void 0),
|
||||
);
|
||||
}
|
||||
|
||||
private getResponse$ = (
|
||||
private readThroughWarnings$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationWarningsResponse> => {
|
||||
const existing = this.cache$.get(organization.id as OrganizationId);
|
||||
const organizationId = organization.id as OrganizationId;
|
||||
const existing = this.cache$.get(organizationId);
|
||||
if (existing && !bypassCache) {
|
||||
return existing;
|
||||
}
|
||||
const response$ = from(this.organizationBillingApiService.getWarnings(organization.id));
|
||||
this.cache$.set(organization.id as OrganizationId, response$);
|
||||
const response$ = from(this.organizationBillingClient.getWarnings(organizationId));
|
||||
this.cache$.set(organizationId, response$);
|
||||
return response$;
|
||||
};
|
||||
|
||||
@@ -227,10 +266,12 @@ export class OrganizationWarningsService {
|
||||
organization: Organization,
|
||||
extract: (response: OrganizationWarningsResponse) => T | null | undefined,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<T> =>
|
||||
this.getResponse$(organization, bypassCache).pipe(
|
||||
map(extract),
|
||||
takeWhile((warning): warning is T => !!warning),
|
||||
): Observable<T | null> =>
|
||||
this.readThroughWarnings$(organization, bypassCache).pipe(
|
||||
map((response) => {
|
||||
const value = extract(response);
|
||||
return value ? value : null;
|
||||
}),
|
||||
take(1),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./organization-warnings";
|
||||
@@ -1,9 +1,22 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { TaxIdWarningResponse } from "@bitwarden/web-vault/app/billing/warnings/types";
|
||||
|
||||
export type OrganizationFreeTrialWarning = {
|
||||
organization: Pick<Organization, "id" & "name">;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OrganizationResellerRenewalWarning = {
|
||||
type: "info" | "warning";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export class OrganizationWarningsResponse extends BaseResponse {
|
||||
freeTrial?: FreeTrialWarningResponse;
|
||||
inactiveSubscription?: InactiveSubscriptionWarningResponse;
|
||||
resellerRenewal?: ResellerRenewalWarningResponse;
|
||||
taxId?: TaxIdWarningResponse;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -21,6 +34,10 @@ export class OrganizationWarningsResponse extends BaseResponse {
|
||||
if (resellerWarning) {
|
||||
this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning);
|
||||
}
|
||||
const taxIdWarning = this.getResponseProperty("TaxId");
|
||||
if (taxIdWarning) {
|
||||
this.taxId = new TaxIdWarningResponse(taxIdWarning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogConfig, DialogRef, 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";
|
||||
|
||||
type DialogParams = {
|
||||
owner: BillableEntity;
|
||||
subscriber: BitwardenSubscriber;
|
||||
};
|
||||
|
||||
type DialogResult = "cancelled" | "error" | "launched";
|
||||
@@ -125,7 +125,7 @@ const positiveNumberValidator =
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
providers: [BillingClient],
|
||||
providers: [SubscriberBillingClient],
|
||||
})
|
||||
export class AddAccountCreditDialogComponent {
|
||||
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef;
|
||||
@@ -143,22 +143,22 @@ export class AddAccountCreditDialogComponent {
|
||||
|
||||
protected payPalCustom$ = this.configService.cloudRegion$.pipe(
|
||||
map((cloudRegion) => {
|
||||
switch (this.dialogParams.owner.type) {
|
||||
switch (this.dialogParams.subscriber.type) {
|
||||
case "account": {
|
||||
return `user_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`;
|
||||
return `user_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`;
|
||||
}
|
||||
case "organization": {
|
||||
return `organization_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`;
|
||||
return `organization_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`;
|
||||
}
|
||||
case "provider": {
|
||||
return `provider_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`;
|
||||
return `provider_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
private billingClient: SubscriberBillingClient,
|
||||
private configService: ConfigService,
|
||||
@Inject(DIALOG_DATA) private dialogParams: DialogParams,
|
||||
private dialogRef: DialogRef<DialogResult>,
|
||||
@@ -175,7 +175,7 @@ export class AddAccountCreditDialogComponent {
|
||||
}
|
||||
|
||||
if (this.formGroup.value.paymentMethod === "bitPay") {
|
||||
const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, {
|
||||
const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.subscriber, {
|
||||
amount: this.amount!,
|
||||
redirectUrl: this.redirectUrl,
|
||||
});
|
||||
@@ -225,13 +225,13 @@ export class AddAccountCreditDialogComponent {
|
||||
}
|
||||
|
||||
get payPalSubject(): string {
|
||||
switch (this.dialogParams.owner.type) {
|
||||
switch (this.dialogParams.subscriber.type) {
|
||||
case "account": {
|
||||
return this.dialogParams.owner.data.email;
|
||||
return this.dialogParams.subscriber.data.email;
|
||||
}
|
||||
case "organization":
|
||||
case "provider": {
|
||||
return this.dialogParams.owner.data.name;
|
||||
return this.dialogParams.subscriber.data.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogConfig, DialogRef, 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 {
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./submit-payment-method-dialog.component";
|
||||
|
||||
type DialogParams = {
|
||||
owner: BillableEntity;
|
||||
subscriber: BitwardenSubscriber;
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -28,7 +28,7 @@ type DialogParams = {
|
||||
<div bitDialogContent>
|
||||
<app-enter-payment-method
|
||||
[group]="formGroup"
|
||||
[showBankAccount]="dialogParams.owner.type !== 'account'"
|
||||
[showBankAccount]="dialogParams.subscriber.type !== 'account'"
|
||||
[includeBillingAddress]="true"
|
||||
>
|
||||
</app-enter-payment-method>
|
||||
@@ -51,20 +51,20 @@ type DialogParams = {
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [EnterPaymentMethodComponent, SharedModule],
|
||||
providers: [BillingClient],
|
||||
providers: [SubscriberBillingClient],
|
||||
})
|
||||
export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
|
||||
protected override owner: BillableEntity;
|
||||
protected override subscriber: BitwardenSubscriber;
|
||||
|
||||
constructor(
|
||||
billingClient: BillingClient,
|
||||
billingClient: SubscriberBillingClient,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
|
||||
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
|
||||
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<DialogParams>) =>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Component, Input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
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 { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component";
|
||||
|
||||
@@ -23,14 +23,14 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
providers: [BillingClient, CurrencyPipe],
|
||||
providers: [SubscriberBillingClient, CurrencyPipe],
|
||||
})
|
||||
export class DisplayAccountCreditComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@Input({ required: true }) subscriber!: BitwardenSubscriber;
|
||||
@Input({ required: true }) credit!: number | null;
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
private billingClient: SubscriberBillingClient,
|
||||
private currencyPipe: CurrencyPipe,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
@@ -38,8 +38,8 @@ export class DisplayAccountCreditComponent {
|
||||
) {}
|
||||
|
||||
addAccountCredit = async () => {
|
||||
if (this.owner.type !== "account") {
|
||||
const billingAddress = await this.billingClient.getBillingAddress(this.owner);
|
||||
if (this.subscriber.type !== "account") {
|
||||
const billingAddress = await this.billingClient.getBillingAddress(this.subscriber);
|
||||
if (!billingAddress) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
@@ -51,7 +51,7 @@ export class DisplayAccountCreditComponent {
|
||||
|
||||
AddAccountCreditDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: this.owner,
|
||||
subscriber: this.subscriber,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,23 +2,38 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { AddressPipe } from "../pipes";
|
||||
import { BillingAddress } from "../types";
|
||||
|
||||
import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component";
|
||||
import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components/edit-billing-address-dialog.component";
|
||||
import { AddressPipe } from "@bitwarden/web-vault/app/billing/payment/pipes";
|
||||
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { BitwardenSubscriber } 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";
|
||||
|
||||
@Component({
|
||||
selector: "app-display-billing-address",
|
||||
template: `
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "billingAddress" | i18n }}</h2>
|
||||
<h2 bitTypography="h2">
|
||||
{{ "billingAddress" | i18n }}
|
||||
@if (showMissingTaxIdBadge) {
|
||||
<span bitBadge variant="warning">{{ "missingTaxId" | i18n }}</span>
|
||||
}
|
||||
</h2>
|
||||
@if (billingAddress) {
|
||||
<p>{{ billingAddress | address }}</p>
|
||||
@if (billingAddress.taxId) {
|
||||
<p>{{ "taxId" | i18n: billingAddress.taxId.value }}</p>
|
||||
<p class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "taxId" | i18n: billingAddress.taxId.value }}
|
||||
@if (showTaxIdPendingVerificationBadge) {
|
||||
<span bitBadge variant="secondary">{{ "pendingVerification" | i18n }}</span>
|
||||
}
|
||||
@if (showUnverifiedTaxIdBadge) {
|
||||
<span bitBadge variant="warning">{{ "unverified" | i18n }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p>{{ "noBillingAddress" | i18n }}</p>
|
||||
@@ -33,8 +48,9 @@ import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog
|
||||
imports: [AddressPipe, SharedModule],
|
||||
})
|
||||
export class DisplayBillingAddressComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@Input({ required: true }) subscriber!: BitwardenSubscriber;
|
||||
@Input({ required: true }) billingAddress!: BillingAddress | null;
|
||||
@Input() taxIdWarning?: TaxIdWarningType;
|
||||
@Output() updated = new EventEmitter<BillingAddress>();
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
@@ -42,8 +58,9 @@ export class DisplayBillingAddressComponent {
|
||||
editBillingAddress = async (): Promise<void> => {
|
||||
const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: this.owner,
|
||||
subscriber: this.subscriber,
|
||||
billingAddress: this.billingAddress,
|
||||
taxIdWarning: this.taxIdWarning,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,4 +70,22 @@ export class DisplayBillingAddressComponent {
|
||||
this.updated.emit(result.billingAddress);
|
||||
}
|
||||
};
|
||||
|
||||
get showMissingTaxIdBadge(): boolean {
|
||||
return this.subscriber.type !== "account" && this.taxIdWarning === TaxIdWarningTypes.Missing;
|
||||
}
|
||||
|
||||
get showTaxIdPendingVerificationBadge(): boolean {
|
||||
return (
|
||||
this.subscriber.type !== "account" &&
|
||||
this.taxIdWarning === TaxIdWarningTypes.PendingVerification
|
||||
);
|
||||
}
|
||||
|
||||
get showUnverifiedTaxIdBadge(): boolean {
|
||||
return (
|
||||
this.subscriber.type !== "account" &&
|
||||
this.taxIdWarning === TaxIdWarningTypes.FailedVerification
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { lastValueFrom } from "rxjs";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { BitwardenSubscriber } from "../../types";
|
||||
import { MaskedPaymentMethod } from "../types";
|
||||
|
||||
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component";
|
||||
@@ -19,7 +19,10 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component";
|
||||
@switch (paymentMethod.type) {
|
||||
@case ("bankAccount") {
|
||||
@if (!paymentMethod.verified) {
|
||||
<app-verify-bank-account [owner]="owner" (verified)="onBankAccountVerified($event)">
|
||||
<app-verify-bank-account
|
||||
[subscriber]="subscriber"
|
||||
(verified)="onBankAccountVerified($event)"
|
||||
>
|
||||
</app-verify-bank-account>
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component";
|
||||
imports: [SharedModule, VerifyBankAccountComponent],
|
||||
})
|
||||
export class DisplayPaymentMethodComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@Input({ required: true }) subscriber!: BitwardenSubscriber;
|
||||
@Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null;
|
||||
@Output() updated = new EventEmitter<MaskedPaymentMethod>();
|
||||
|
||||
@@ -82,7 +85,7 @@ export class DisplayPaymentMethodComponent {
|
||||
changePaymentMethod = async (): Promise<void> => {
|
||||
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: this.owner,
|
||||
subscriber: this.subscriber,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,18 +3,31 @@ import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { BillingAddress, getTaxIdTypeForCountry } from "../types";
|
||||
import {
|
||||
CalloutTypes,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
BillingAddress,
|
||||
getTaxIdTypeForCountry,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { BitwardenSubscriber } 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";
|
||||
|
||||
import { EnterBillingAddressComponent } from "./enter-billing-address.component";
|
||||
|
||||
type DialogParams = {
|
||||
owner: BillableEntity;
|
||||
subscriber: BitwardenSubscriber;
|
||||
billingAddress: BillingAddress | null;
|
||||
taxIdWarning?: TaxIdWarningType;
|
||||
};
|
||||
|
||||
type DialogResult =
|
||||
@@ -30,11 +43,18 @@ type DialogResult =
|
||||
{{ "editBillingAddress" | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
@let callout = taxIdWarningCallout;
|
||||
@if (callout) {
|
||||
<bit-callout [type]="callout.type" [title]="callout.title">
|
||||
{{ callout.message }}
|
||||
</bit-callout>
|
||||
}
|
||||
<app-enter-billing-address
|
||||
[scenario]="{
|
||||
type: 'update',
|
||||
existing: dialogParams.billingAddress,
|
||||
supportsTaxId,
|
||||
taxIdWarning: dialogParams.taxIdWarning,
|
||||
}"
|
||||
[group]="formGroup"
|
||||
></app-enter-billing-address>
|
||||
@@ -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<DialogResult>,
|
||||
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<DialogParams>) =>
|
||||
dialogService.open<DialogResult>(EditBillingAddressDialogComponent, dialogConfig);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
</bit-form-field>
|
||||
</div>
|
||||
@if (supportsTaxId$ | async) {
|
||||
<div class="tw-col-span-6">
|
||||
<div class="tw-col-span-12">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
@@ -119,6 +125,17 @@ type Scenario =
|
||||
[formControl]="group.controls.taxId"
|
||||
data-testid="tax-id"
|
||||
/>
|
||||
@let hint = taxIdWarningHint;
|
||||
@if (hint) {
|
||||
<bit-hint
|
||||
><i
|
||||
class="bwi bwi-exclamation-triangle tw-mr-1"
|
||||
title="{{ hint }}"
|
||||
aria-hidden="true"
|
||||
></i
|
||||
>{{ hint }}</bit-hint
|
||||
>
|
||||
}
|
||||
</bit-form-field>
|
||||
</div>
|
||||
}
|
||||
@@ -137,6 +154,8 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
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<string>("", {
|
||||
|
||||
@@ -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<SubmitPaymentMethodDialogResult>,
|
||||
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<DialogParams>) =>
|
||||
|
||||
@@ -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<SubmitPaymentMethodDialogResult>,
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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<MaskedPaymentMethod>();
|
||||
|
||||
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!,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./billing.client";
|
||||
export * from "./billing-services.module";
|
||||
export * from "./braintree.service";
|
||||
export * from "./stripe.service";
|
||||
|
||||
@@ -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 | null, BillableEntity>((account) => {
|
||||
export type NonIndividualSubscriber = Exclude<BitwardenSubscriber, { type: "account" }>;
|
||||
|
||||
export const mapAccountToSubscriber = map<Account | null, BitwardenSubscriber>((account) => {
|
||||
if (!account) {
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
@@ -19,7 +21,7 @@ export const accountToBillableEntity = map<Account | null, BillableEntity>((acco
|
||||
};
|
||||
});
|
||||
|
||||
export const organizationToBillableEntity = map<Organization | undefined, BillableEntity>(
|
||||
export const mapOrganizationToSubscriber = map<Organization | undefined, BitwardenSubscriber>(
|
||||
(organization) => {
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
@@ -31,7 +33,7 @@ export const organizationToBillableEntity = map<Organization | undefined, Billab
|
||||
},
|
||||
);
|
||||
|
||||
export const providerToBillableEntity = map<Provider | null, BillableEntity>((provider) => {
|
||||
export const mapProviderToSubscriber = map<Provider | null, BitwardenSubscriber>((provider) => {
|
||||
if (!provider) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./billable-entity";
|
||||
export * from "./bitwarden-subscriber";
|
||||
export * from "./free-trial";
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./organization-free-trial-warning.component";
|
||||
export * from "./organization-reseller-renewal-warning.component";
|
||||
export * from "./tax-id-warning.component";
|
||||
|
||||
@@ -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<DismissalCounts>(
|
||||
BILLING_DISK,
|
||||
"taxIdWarningDismissalCounts",
|
||||
{
|
||||
deserializer: (dismissalCounts) => dismissalCounts,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
type DismissedThisSession = {
|
||||
[TaxIdWarningTypes.Missing]?: boolean;
|
||||
[TaxIdWarningTypes.FailedVerification]?: boolean;
|
||||
};
|
||||
|
||||
const DISMISSED_THIS_SESSION_KEY = new UserKeyDefinition<DismissedThisSession>(
|
||||
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<TaxIdWarningType, typeof TaxIdWarningTypes.PendingVerification>,
|
||||
dismissals: Dismissals,
|
||||
) => {
|
||||
const dismissalsForType = dismissals[warning];
|
||||
if (dismissalsForType.dismissedThisSession) {
|
||||
return false;
|
||||
}
|
||||
return dismissalsForType.count < 3;
|
||||
};
|
||||
|
||||
type View = {
|
||||
message: string;
|
||||
callToAction: string;
|
||||
};
|
||||
|
||||
type GetWarning$ = () => Observable<TaxIdWarningType | null>;
|
||||
|
||||
@Component({
|
||||
selector: "app-tax-id-warning",
|
||||
template: `
|
||||
@if (enableTaxIdWarning$ | async) {
|
||||
@let view = view$ | async;
|
||||
|
||||
@if (view) {
|
||||
<bit-banner
|
||||
id="tax-id-warning-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
bannerType="warning"
|
||||
(onClose)="trackDismissal()"
|
||||
>
|
||||
{{ view.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="secondary"
|
||||
(click)="editBillingAddress()"
|
||||
class="tw-cursor-pointer"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ view.callToAction }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [BannerModule, SharedModule],
|
||||
})
|
||||
export class TaxIdWarningComponent implements OnInit {
|
||||
@Input({ required: true }) subscriber!: NonIndividualSubscriber;
|
||||
@Input({ required: true }) getWarning$!: GetWarning$;
|
||||
@Output() billingAddressUpdated = new EventEmitter<void>();
|
||||
|
||||
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<Dismissals> = 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<GetWarning$ | null>(null);
|
||||
|
||||
protected warning$ = this.getWarningSubject.pipe(switchMap(() => this.getWarning$()));
|
||||
|
||||
protected view$: Observable<View | null> = 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]);
|
||||
};
|
||||
}
|
||||
@@ -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<DialogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let organizationBillingApiService: MockProxy<OrganizationBillingApiServiceAbstraction>;
|
||||
let router: MockProxy<Router>;
|
||||
|
||||
let organizationWarningsService: OrganizationWarningsService;
|
||||
|
||||
const respond = (responseBody: any) =>
|
||||
Promise.resolve(new OrganizationWarningsResponse(responseBody));
|
||||
|
||||
const empty = () => Promise.resolve(new OrganizationWarningsResponse({}));
|
||||
|
||||
beforeEach(() => {
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
organizationBillingApiService = mock<OrganizationBillingApiServiceAbstraction>();
|
||||
router = mock<Router>();
|
||||
|
||||
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 },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export * from "./organization-warnings";
|
||||
export * from "./tax-id-warning-type";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
export type OrganizationFreeTrialWarning = {
|
||||
organization: Pick<Organization, "id" & "name">;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OrganizationResellerRenewalWarning = {
|
||||
type: "info" | "warning";
|
||||
message: string;
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BillingTransactionResponse[]>;
|
||||
|
||||
abstract getWarnings: (id: string) => Promise<OrganizationWarningsResponse>;
|
||||
|
||||
abstract setupBusinessUnit: (
|
||||
id: string,
|
||||
request: {
|
||||
|
||||
@@ -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<OrganizationWarningsResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
`/organizations/${id}/billing/warnings`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new OrganizationWarningsResponse(response);
|
||||
}
|
||||
|
||||
async setupBusinessUnit(
|
||||
id: string,
|
||||
request: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user