1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +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:
Alex Morask
2025-08-18 09:52:28 -05:00
committed by GitHub
parent 0c166b3f94
commit df7f1a8d49
58 changed files with 2422 additions and 976 deletions

View File

@@ -79,8 +79,11 @@ import {
DecryptionFailureDialogComponent, DecryptionFailureDialogComponent,
PasswordRepromptService, PasswordRepromptService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component"; import {
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service"; 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 { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
@@ -90,7 +93,6 @@ import {
} from "../../../billing/services/reseller-warning.service"; } from "../../../billing/services/reseller-warning.service";
import { TrialFlowService } from "../../../billing/services/trial-flow.service"; import { TrialFlowService } from "../../../billing/services/trial-flow.service";
import { FreeTrial } from "../../../billing/types/free-trial"; import { FreeTrial } from "../../../billing/types/free-trial";
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections";
import { import {
@@ -674,6 +676,15 @@ export class VaultComponent implements OnInit, OnDestroy {
) )
.subscribe(); .subscribe();
organization$
.pipe(
switchMap((organization) =>
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
),
takeUntil(this.destroy$),
)
.subscribe();
const freeTrial$ = combineLatest([ const freeTrial$ = combineLatest([
organization$, organization$,
this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)),

View File

@@ -150,6 +150,12 @@
> >
{{ "accessingUsingProvider" | i18n: organization.providerName }} {{ "accessingUsingProvider" | i18n: organization.providerName }}
</bit-banner> </bit-banner>
<app-tax-id-warning
[subscriber]="subscriber$ | async"
[getWarning$]="getTaxIdWarning$"
(billingAddressUpdated)="refreshTaxIdWarning()"
>
</app-tax-id-warning>
</ng-container> </ng-container>
<router-outlet></router-outlet> <router-outlet></router-outlet>

View File

@@ -28,6 +28,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getById } from "@bitwarden/common/platform/misc"; import { getById } from "@bitwarden/common/platform/misc";
import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components"; 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 { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
@@ -44,6 +48,8 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module";
IconModule, IconModule,
OrgSwitcherComponent, OrgSwitcherComponent,
BannerModule, BannerModule,
TaxIdWarningComponent,
TaxIdWarningComponent,
], ],
}) })
export class OrganizationLayoutComponent implements OnInit { export class OrganizationLayoutComponent implements OnInit {
@@ -58,7 +64,6 @@ export class OrganizationLayoutComponent implements OnInit {
showPaymentAndHistory$: Observable<boolean>; showPaymentAndHistory$: Observable<boolean>;
hideNewOrgButton$: Observable<boolean>; hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>; organizationIsUnmanaged$: Observable<boolean>;
enterpriseOrganization$: Observable<boolean>;
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>; protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
protected showSponsoredFamiliesDropdown$: Observable<boolean>; protected showSponsoredFamiliesDropdown$: Observable<boolean>;
@@ -69,6 +74,9 @@ export class OrganizationLayoutComponent implements OnInit {
textKey: string; textKey: string;
}>; }>;
protected subscriber$: Observable<NonIndividualSubscriber>;
protected getTaxIdWarning$: () => Observable<TaxIdWarningType | null>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private organizationService: OrganizationService, private organizationService: OrganizationService,
@@ -79,6 +87,7 @@ export class OrganizationLayoutComponent implements OnInit {
private accountService: AccountService, private accountService: AccountService,
private freeFamiliesPolicyService: FreeFamiliesPolicyService, private freeFamiliesPolicyService: FreeFamiliesPolicyService,
private organizationBillingService: OrganizationBillingServiceAbstraction, private organizationBillingService: OrganizationBillingServiceAbstraction,
private organizationWarningsService: OrganizationWarningsService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -150,6 +159,20 @@ export class OrganizationLayoutComponent implements OnInit {
: { route: "billing/payment-method", textKey: "paymentMethod" }, : { 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 { canShowVaultTab(organization: Organization): boolean {
@@ -179,4 +202,6 @@ export class OrganizationLayoutComponent implements OnInit {
getReportTabLabel(organization: Organization): string { getReportTabLabel(organization: Organization): string {
return organization.useEvents ? "reporting" : "reports"; return organization.useEvents ? "reporting" : "reports";
} }
refreshTaxIdWarning = () => this.organizationWarningsService.refreshTaxIdWarning();
} }

View File

@@ -10,10 +10,10 @@ import {
from, from,
lastValueFrom, lastValueFrom,
map, map,
merge,
Observable, Observable,
shareReplay, shareReplay,
switchMap, switchMap,
tap,
} from "rxjs"; } from "rxjs";
import { import {
@@ -57,12 +57,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { import {
ChangePlanDialogResultType, ChangePlanDialogResultType,
openChangePlanDialog, openChangePlanDialog,
} from "../../../billing/organizations/change-plan-dialog.component"; } from "../../../billing/organizations/change-plan-dialog.component";
import { OrganizationWarningsService } from "../../../billing/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component"; import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { GroupApiService } from "../core"; import { GroupApiService } from "../core";
@@ -253,11 +253,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.showUserManagementControls$ = organization$.pipe( this.showUserManagementControls$ = organization$.pipe(
map((organization) => organization.canManageUsers), map((organization) => organization.canManageUsers),
); );
organization$ organization$
.pipe( .pipe(
switchMap((organization) =>
merge(
this.organizationWarningsService.showInactiveSubscriptionDialog$(organization),
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
),
),
takeUntilDestroyed(), takeUntilDestroyed(),
tap((org) => (this.organization = org)),
switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)),
) )
.subscribe(); .subscribe();
} }

View File

@@ -4,8 +4,8 @@ import { NgModule } from "@angular/core";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components"; 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 { LooseComponentsModule } from "../../../shared";
import { SharedOrganizationModule } from "../shared"; import { SharedOrganizationModule } from "../shared";

View File

@@ -2,6 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { ScrollLayoutDirective } from "@bitwarden/components"; import { ScrollLayoutDirective } from "@bitwarden/components";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
import { LooseComponentsModule } from "../../shared"; import { LooseComponentsModule } from "../../shared";
@@ -21,6 +22,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector";
LooseComponentsModule, LooseComponentsModule,
ScrollingModule, ScrollingModule,
ScrollLayoutDirective, ScrollLayoutDirective,
OrganizationWarningsModule,
], ],
declarations: [GroupsComponent, GroupAddEditComponent], declarations: [GroupsComponent, GroupAddEditComponent],
}) })

View File

@@ -0,0 +1,2 @@
export * from "./organization-billing.client";
export * from "./subscriber-billing.client";

View File

@@ -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);
};
}

View File

@@ -10,7 +10,7 @@ import {
MaskedPaymentMethodResponse, MaskedPaymentMethodResponse,
TokenizedPaymentMethod, TokenizedPaymentMethod,
} from "../payment/types"; } from "../payment/types";
import { BillableEntity } from "../types"; import { BitwardenSubscriber } from "../types";
type Result<T> = type Result<T> =
| { | {
@@ -23,28 +23,28 @@ type Result<T> =
}; };
@Injectable() @Injectable()
export class BillingClient { export class SubscriberBillingClient {
constructor(private apiService: ApiService) {} constructor(private apiService: ApiService) {}
private getEndpoint = (entity: BillableEntity): string => { private getEndpoint = (subscriber: BitwardenSubscriber): string => {
switch (entity.type) { switch (subscriber.type) {
case "account": { case "account": {
return "/account/billing/vnext"; return "/account/billing/vnext";
} }
case "organization": { case "organization": {
return `/organizations/${entity.data.id}/billing/vnext`; return `/organizations/${subscriber.data.id}/billing/vnext`;
} }
case "provider": { case "provider": {
return `/providers/${entity.data.id}/billing/vnext`; return `/providers/${subscriber.data.id}/billing/vnext`;
} }
} }
}; };
addCreditWithBitPay = async ( addCreditWithBitPay = async (
owner: BillableEntity, subscriber: BitwardenSubscriber,
credit: { amount: number; redirectUrl: string }, credit: { amount: number; redirectUrl: string },
): Promise<Result<string>> => { ): Promise<Result<string>> => {
const path = `${this.getEndpoint(owner)}/credit/bitpay`; const path = `${this.getEndpoint(subscriber)}/credit/bitpay`;
try { try {
const data = await this.apiService.send("POST", path, credit, true, true); const data = await this.apiService.send("POST", path, credit, true, true);
return { return {
@@ -62,29 +62,31 @@ export class BillingClient {
} }
}; };
getBillingAddress = async (owner: BillableEntity): Promise<BillingAddress | null> => { getBillingAddress = async (subscriber: BitwardenSubscriber): Promise<BillingAddress | null> => {
const path = `${this.getEndpoint(owner)}/address`; const path = `${this.getEndpoint(subscriber)}/address`;
const data = await this.apiService.send("GET", path, null, true, true); const data = await this.apiService.send("GET", path, null, true, true);
return data ? new BillingAddressResponse(data) : null; return data ? new BillingAddressResponse(data) : null;
}; };
getCredit = async (owner: BillableEntity): Promise<number | null> => { getCredit = async (subscriber: BitwardenSubscriber): Promise<number | null> => {
const path = `${this.getEndpoint(owner)}/credit`; const path = `${this.getEndpoint(subscriber)}/credit`;
const data = await this.apiService.send("GET", path, null, true, true); const data = await this.apiService.send("GET", path, null, true, true);
return data ? (data as number) : null; return data ? (data as number) : null;
}; };
getPaymentMethod = async (owner: BillableEntity): Promise<MaskedPaymentMethod | null> => { getPaymentMethod = async (
const path = `${this.getEndpoint(owner)}/payment-method`; subscriber: BitwardenSubscriber,
): Promise<MaskedPaymentMethod | null> => {
const path = `${this.getEndpoint(subscriber)}/payment-method`;
const data = await this.apiService.send("GET", path, null, true, true); const data = await this.apiService.send("GET", path, null, true, true);
return data ? new MaskedPaymentMethodResponse(data).value : null; return data ? new MaskedPaymentMethodResponse(data).value : null;
}; };
updateBillingAddress = async ( updateBillingAddress = async (
owner: BillableEntity, subscriber: BitwardenSubscriber,
billingAddress: BillingAddress, billingAddress: BillingAddress,
): Promise<Result<BillingAddress>> => { ): Promise<Result<BillingAddress>> => {
const path = `${this.getEndpoint(owner)}/address`; const path = `${this.getEndpoint(subscriber)}/address`;
try { try {
const data = await this.apiService.send("PUT", path, billingAddress, true, true); const data = await this.apiService.send("PUT", path, billingAddress, true, true);
return { return {
@@ -103,11 +105,11 @@ export class BillingClient {
}; };
updatePaymentMethod = async ( updatePaymentMethod = async (
owner: BillableEntity, subscriber: BitwardenSubscriber,
paymentMethod: TokenizedPaymentMethod, paymentMethod: TokenizedPaymentMethod,
billingAddress: Pick<BillingAddress, "country" | "postalCode"> | null, billingAddress: Pick<BillingAddress, "country" | "postalCode"> | null,
): Promise<Result<MaskedPaymentMethod>> => { ): Promise<Result<MaskedPaymentMethod>> => {
const path = `${this.getEndpoint(owner)}/payment-method`; const path = `${this.getEndpoint(subscriber)}/payment-method`;
try { try {
const request = { const request = {
...paymentMethod, ...paymentMethod,
@@ -130,10 +132,10 @@ export class BillingClient {
}; };
verifyBankAccount = async ( verifyBankAccount = async (
owner: BillableEntity, subscriber: BitwardenSubscriber,
descriptorCode: string, descriptorCode: string,
): Promise<Result<MaskedPaymentMethod>> => { ): Promise<Result<MaskedPaymentMethod>> => {
const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; const path = `${this.getEndpoint(subscriber)}/payment-method/verify-bank-account`;
try { try {
const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); const data = await this.apiService.send("POST", path, { descriptorCode }, true, true);
return { return {

View File

@@ -12,13 +12,13 @@
} @else { } @else {
<ng-container> <ng-container>
<app-display-payment-method <app-display-payment-method
[owner]="view.account" [subscriber]="view.account"
[paymentMethod]="view.paymentMethod" [paymentMethod]="view.paymentMethod"
(updated)="setPaymentMethod($event)" (updated)="setPaymentMethod($event)"
></app-display-payment-method> ></app-display-payment-method>
<app-display-account-credit <app-display-account-credit
[owner]="view.account" [subscriber]="view.account"
[credit]="view.credit" [credit]="view.credit"
></app-display-account-credit> ></app-display-account-credit>
</ng-container> </ng-container>

View File

@@ -20,13 +20,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { HeaderModule } from "../../../layouts/header/header.module"; import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { SubscriberBillingClient } from "../../clients";
import { import {
DisplayAccountCreditComponent, DisplayAccountCreditComponent,
DisplayPaymentMethodComponent, DisplayPaymentMethodComponent,
} from "../../payment/components"; } from "../../payment/components";
import { MaskedPaymentMethod } from "../../payment/types"; import { MaskedPaymentMethod } from "../../payment/types";
import { BillingClient } from "../../services"; import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types";
import { accountToBillableEntity, BillableEntity } from "../../types";
class RedirectError { class RedirectError {
constructor( constructor(
@@ -36,7 +36,7 @@ class RedirectError {
} }
type View = { type View = {
account: BillableEntity; account: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null; paymentMethod: MaskedPaymentMethod | null;
credit: number | null; credit: number | null;
}; };
@@ -50,7 +50,7 @@ type View = {
HeaderModule, HeaderModule,
SharedModule, SharedModule,
], ],
providers: [BillingClient], providers: [SubscriberBillingClient],
}) })
export class AccountPaymentDetailsComponent { export class AccountPaymentDetailsComponent {
private viewState$ = new BehaviorSubject<View | null>(null); private viewState$ = new BehaviorSubject<View | null>(null);
@@ -68,7 +68,7 @@ export class AccountPaymentDetailsComponent {
}), }),
), ),
), ),
accountToBillableEntity, mapAccountToSubscriber,
switchMap(async (account) => { switchMap(async (account) => {
const [paymentMethod, credit] = await Promise.all([ const [paymentMethod, credit] = await Promise.all([
this.billingClient.getPaymentMethod(account), this.billingClient.getPaymentMethod(account),
@@ -100,7 +100,7 @@ export class AccountPaymentDetailsComponent {
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private billingClient: BillingClient, private billingClient: SubscriberBillingClient,
private configService: ConfigService, private configService: ConfigService,
private router: Router, private router: Router,
) {} ) {}

View File

@@ -21,19 +21,20 @@
} @else { } @else {
<ng-container> <ng-container>
<app-display-payment-method <app-display-payment-method
[owner]="view.organization" [subscriber]="view.organization"
[paymentMethod]="view.paymentMethod" [paymentMethod]="view.paymentMethod"
(updated)="setPaymentMethod($event)" (updated)="setPaymentMethod($event)"
></app-display-payment-method> ></app-display-payment-method>
<app-display-billing-address <app-display-billing-address
[owner]="view.organization" [subscriber]="view.organization"
[billingAddress]="view.billingAddress" [billingAddress]="view.billingAddress"
[taxIdWarning]="enableTaxIdWarning ? view.taxIdWarning : null"
(updated)="setBillingAddress($event)" (updated)="setBillingAddress($event)"
></app-display-billing-address> ></app-display-billing-address>
<app-display-account-credit <app-display-account-credit
[owner]="view.organization" [subscriber]="view.organization"
[credit]="view.credit" [credit]="view.credit"
></app-display-account-credit> ></app-display-account-credit>
</ng-container> </ng-container>

View File

@@ -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 { ActivatedRoute, Router } from "@angular/router";
import { import {
BehaviorSubject, BehaviorSubject,
catchError, catchError,
combineLatest,
EMPTY, EMPTY,
filter, filter,
firstValueFrom, firstValueFrom,
@@ -11,8 +12,12 @@ import {
map, map,
merge, merge,
Observable, Observable,
of,
shareReplay, shareReplay,
Subject,
switchMap, switchMap,
take,
takeUntil,
tap, tap,
} from "rxjs"; } 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { HeaderModule } from "../../../layouts/header/header.module"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
import { SharedModule } from "../../../shared"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { import {
ChangePaymentMethodDialogComponent, ChangePaymentMethodDialogComponent,
DisplayAccountCreditComponent, DisplayAccountCreditComponent,
DisplayBillingAddressComponent, DisplayBillingAddressComponent,
DisplayPaymentMethodComponent, DisplayPaymentMethodComponent,
} from "../../payment/components"; } from "@bitwarden/web-vault/app/billing/payment/components";
import { BillingAddress, MaskedPaymentMethod } from "../../payment/types"; import {
import { BillingClient } from "../../services"; BillingAddress,
import { BillableEntity, organizationToBillableEntity } from "../../types"; MaskedPaymentMethod,
import { OrganizationFreeTrialWarningComponent } from "../../warnings/components"; } 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 { class RedirectError {
constructor( constructor(
@@ -48,93 +60,100 @@ class RedirectError {
} }
type View = { type View = {
organization: BillableEntity; organization: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null; paymentMethod: MaskedPaymentMethod | null;
billingAddress: BillingAddress | null; billingAddress: BillingAddress | null;
credit: number | null; credit: number | null;
taxIdWarning: TaxIdWarningType | null;
}; };
@Component({ @Component({
templateUrl: "./organization-payment-details.component.html", templateUrl: "./organization-payment-details.component.html",
standalone: true, standalone: true,
imports: [ imports: [
DisplayBillingAddressComponent,
DisplayAccountCreditComponent, DisplayAccountCreditComponent,
DisplayBillingAddressComponent,
DisplayPaymentMethodComponent, DisplayPaymentMethodComponent,
HeaderModule, HeaderModule,
OrganizationFreeTrialWarningComponent, OrganizationFreeTrialWarningComponent,
SharedModule, SharedModule,
], ],
providers: [BillingClient],
}) })
export class OrganizationPaymentDetailsComponent implements OnInit { export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
@ViewChild(OrganizationFreeTrialWarningComponent)
organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent;
private viewState$ = new BehaviorSubject<View | null>(null); private viewState$ = new BehaviorSubject<View | null>(null);
private load$: Observable<View> = this.accountService.activeAccount$ protected organization$ = this.accountService.activeAccount$.pipe(
.pipe( getUserId,
getUserId, switchMap((userId) =>
switchMap((userId) => this.organizationService
this.organizationService .organizations$(userId)
.organizations$(userId) .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)),
.pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), ),
), filter((organization): organization is Organization => !!organization),
) );
.pipe(
switchMap((organization) =>
this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(
map((managePaymentDetailsOutsideCheckout) => {
if (!managePaymentDetailsOutsideCheckout) {
throw new RedirectError(["../payment-method"], this.activatedRoute);
}
return organization;
}),
),
),
organizationToBillableEntity,
switchMap(async (organization) => {
const [paymentMethod, billingAddress, credit] = await Promise.all([
this.billingClient.getPaymentMethod(organization),
this.billingClient.getBillingAddress(organization),
this.billingClient.getCredit(organization),
]);
return { private load$: Observable<View> = this.organization$.pipe(
organization, switchMap((organization) =>
paymentMethod, this.configService
billingAddress, .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
credit, .pipe(
}; map((managePaymentDetailsOutsideCheckout) => {
}), if (!managePaymentDetailsOutsideCheckout) {
catchError((error: unknown) => { throw new RedirectError(["../payment-method"], this.activatedRoute);
if (error instanceof RedirectError) { }
return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( return organization;
switchMap(() => EMPTY), }),
); ),
} ),
throw error; mapOrganizationToSubscriber,
}), switchMap(async (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 {
organization,
paymentMethod,
billingAddress,
credit,
taxIdWarning,
};
}),
catchError((error: unknown) => {
if (error instanceof RedirectError) {
return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe(
switchMap(() => EMPTY),
);
}
throw error;
}),
);
view$: Observable<View> = merge( view$: Observable<View> = merge(
this.load$.pipe(tap((view) => this.viewState$.next(view))), this.load$.pipe(tap((view) => this.viewState$.next(view))),
this.viewState$.pipe(filter((view): view is View => view !== null)), this.viewState$.pipe(filter((view): view is View => view !== null)),
).pipe(shareReplay({ bufferSize: 1, refCount: true })); ).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( constructor(
private accountService: AccountService, private accountService: AccountService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private billingClient: BillingClient,
private configService: ConfigService, private configService: ConfigService,
private dialogService: DialogService, private dialogService: DialogService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private organizationWarningsService: OrganizationWarningsService,
private router: Router, private router: Router,
private subscriberBillingClient: SubscriberBillingClient,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -145,24 +164,66 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, "");
await this.changePaymentMethod(); 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 () => { changePaymentMethod = async () => {
const view = await firstValueFrom(this.view$); const view = await firstValueFrom(this.view$);
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, {
data: { data: {
owner: view.organization, subscriber: view.organization,
}, },
}); });
const result = await lastValueFrom(dialogRef.closed); const result = await lastValueFrom(dialogRef.closed);
if (result?.type === "success") { if (result?.type === "success") {
await this.setPaymentMethod(result.paymentMethod); await this.setPaymentMethod(result.paymentMethod);
this.organizationFreeTrialWarningComponent.refresh(); this.organizationWarningsService.refreshFreeTrialWarning();
} }
}; };
setBillingAddress = (billingAddress: BillingAddress) => { setBillingAddress = (billingAddress: BillingAddress) => {
if (this.viewState$.value) { if (this.viewState$.value) {
if (
this.enableTaxIdWarning &&
this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId
) {
this.organizationWarningsService.refreshTaxIdWarning();
}
this.viewState$.next({ this.viewState$.next({
...this.viewState$.value, ...this.viewState$.value,
billingAddress, billingAddress,
@@ -174,7 +235,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit {
if (this.viewState$.value) { if (this.viewState$.value) {
const billingAddress = const billingAddress =
this.viewState$.value.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$.next({
...this.viewState$.value, ...this.viewState$.value,

View File

@@ -0,0 +1,2 @@
export * from "./organization-free-trial-warning.component";
export * from "./organization-reseller-renewal-warning.component";

View File

@@ -1,12 +1,9 @@
import { AsyncPipe } from "@angular/common"; import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Observable } from "rxjs";
import { Observable, Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components";
import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { I18nPipe } from "@bitwarden/ui-common";
import { OrganizationWarningsService } from "../services"; import { OrganizationWarningsService } from "../services";
import { OrganizationFreeTrialWarning } from "../types"; import { OrganizationFreeTrialWarning } from "../types";
@@ -37,33 +34,17 @@ import { OrganizationFreeTrialWarning } from "../types";
</bit-banner> </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; @Input({ required: true }) organization!: Organization;
@Output() clicked = new EventEmitter<void>(); @Output() clicked = new EventEmitter<void>();
warning$!: Observable<OrganizationFreeTrialWarning>; warning$!: Observable<OrganizationFreeTrialWarning | null>;
private destroy$ = new Subject<void>();
constructor(private organizationWarningsService: OrganizationWarningsService) {} constructor(private organizationWarningsService: OrganizationWarningsService) {}
ngOnInit() { ngOnInit() {
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); 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);
};
} }

View File

@@ -1,9 +1,9 @@
import { AsyncPipe } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { OrganizationWarningsService } from "../services";
import { OrganizationResellerRenewalWarning } from "../types"; import { OrganizationResellerRenewalWarning } from "../types";
@@ -25,12 +25,12 @@ import { OrganizationResellerRenewalWarning } from "../types";
</bit-banner> </bit-banner>
} }
`, `,
imports: [AsyncPipe, BannerComponent], imports: [BannerModule, SharedModule],
}) })
export class OrganizationResellerRenewalWarningComponent implements OnInit { export class OrganizationResellerRenewalWarningComponent implements OnInit {
@Input({ required: true }) organization!: Organization; @Input({ required: true }) organization!: Organization;
warning$!: Observable<OrganizationResellerRenewalWarning>; warning$!: Observable<OrganizationResellerRenewalWarning | null>;
constructor(private organizationWarningsService: OrganizationWarningsService) {} constructor(private organizationWarningsService: OrganizationWarningsService) {}

View File

@@ -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 {}

View File

@@ -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();
},
});
});
});
});

View File

@@ -1,26 +1,39 @@
import { Location } from "@angular/common";
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { Router } from "@angular/router"; 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 { take } from "rxjs/operators";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components"; 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 { import {
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
TrialPaymentDialogComponent, TrialPaymentDialogComponent,
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; } from "../../../shared/trial-payment-dialog/trial-payment-dialog.component";
import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; import { openChangePlanDialog } from "../../change-plan-dialog.component";
import {
OrganizationFreeTrialWarning,
OrganizationResellerRenewalWarning,
OrganizationWarningsResponse,
} from "../types";
const format = (date: Date) => const format = (date: Date) =>
date.toLocaleDateString("en-US", { date.toLocaleDateString("en-US", {
@@ -29,28 +42,39 @@ const format = (date: Date) =>
year: "numeric", year: "numeric",
}); });
@Injectable({ providedIn: "root" }) @Injectable()
export class OrganizationWarningsService { export class OrganizationWarningsService {
private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>(); 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( constructor(
private configService: ConfigService, private configService: ConfigService,
private dialogService: DialogService, private dialogService: DialogService,
private i18nService: I18nService, private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, private organizationBillingClient: OrganizationBillingClient,
private router: Router, private router: Router,
private location: Location,
protected syncService: SyncService,
) {} ) {}
getFreeTrialWarning$ = ( getFreeTrialWarning$ = (
organization: Organization, organization: Organization,
bypassCache: boolean = false, ): Observable<OrganizationFreeTrialWarning | null> =>
): Observable<OrganizationFreeTrialWarning> => merge(
this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe( this.getWarning$(organization, (response) => response.freeTrial),
this.refreshFreeTrialWarningTrigger.pipe(
switchMap(() => this.getWarning$(organization, (response) => response.freeTrial, true)),
),
).pipe(
map((warning) => { map((warning) => {
if (!warning) {
return null;
}
const { remainingTrialDays } = warning; const { remainingTrialDays } = warning;
if (remainingTrialDays >= 2) { if (remainingTrialDays >= 2) {
@@ -76,10 +100,12 @@ export class OrganizationWarningsService {
getResellerRenewalWarning$ = ( getResellerRenewalWarning$ = (
organization: Organization, organization: Organization,
bypassCache: boolean = false, ): Observable<OrganizationResellerRenewalWarning | null> =>
): Observable<OrganizationResellerRenewalWarning> => this.getWarning$(organization, (response) => response.resellerRenewal).pipe(
this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe( map((warning) => {
map((warning): OrganizationResellerRenewalWarning | null => { if (!warning) {
return null;
}
switch (warning.type) { switch (warning.type) {
case "upcoming": { case "upcoming": {
return { return {
@@ -114,14 +140,27 @@ export class OrganizationWarningsService {
} }
} }
}), }),
filter((result): result is NonNullable<typeof result> => result !== null),
); );
showInactiveSubscriptionDialog$ = ( getTaxIdWarning$ = (organization: Organization): Observable<TaxIdWarningType | null> =>
organization: Organization, merge(
bypassCache: boolean = false, this.getWarning$(organization, (response) => response.taxId),
): Observable<void> => this.refreshTaxIdWarningTrigger.pipe(
this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).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) => { switchMap(async (warning) => {
switch (warning.resolution) { switch (warning.resolution) {
case "contact_provider": { case "contact_provider": {
@@ -183,43 +222,43 @@ export class OrganizationWarningsService {
}); });
break; break;
} }
case "add_payment_method_optional_trial": {
const organizationSubscriptionResponse =
await this.organizationApiService.getSubscription(organization.id);
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
subscription: organizationSubscriptionResponse,
productTierType: organization?.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.refreshWarnings$.next(organization.id as OrganizationId);
}
}
} }
}), }),
); );
refreshWarningsForOrganization$(organizationId: OrganizationId): Observable<void> { showSubscribeBeforeFreeTrialEndsDialog$ = (organization: Organization): Observable<void> =>
return this.refreshWarnings$.pipe( this.getWarning$(organization, (response) => response.freeTrial).pipe(
filter((id) => id === organizationId), filter((warning) => warning !== null),
map((): void => void 0), switchMap(async () => {
); const organizationSubscriptionResponse = await this.organizationApiService.getSubscription(
} organization.id,
);
private getResponse$ = ( const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
subscription: organizationSubscriptionResponse,
productTierType: organization?.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.refreshFreeTrialWarningTrigger.next();
}
}),
);
private readThroughWarnings$ = (
organization: Organization, organization: Organization,
bypassCache: boolean = false, bypassCache: boolean = false,
): Observable<OrganizationWarningsResponse> => { ): 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) { if (existing && !bypassCache) {
return existing; return existing;
} }
const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)); const response$ = from(this.organizationBillingClient.getWarnings(organizationId));
this.cache$.set(organization.id as OrganizationId, response$); this.cache$.set(organizationId, response$);
return response$; return response$;
}; };
@@ -227,10 +266,12 @@ export class OrganizationWarningsService {
organization: Organization, organization: Organization,
extract: (response: OrganizationWarningsResponse) => T | null | undefined, extract: (response: OrganizationWarningsResponse) => T | null | undefined,
bypassCache: boolean = false, bypassCache: boolean = false,
): Observable<T> => ): Observable<T | null> =>
this.getResponse$(organization, bypassCache).pipe( this.readThroughWarnings$(organization, bypassCache).pipe(
map(extract), map((response) => {
takeWhile((warning): warning is T => !!warning), const value = extract(response);
return value ? value : null;
}),
take(1), take(1),
); );
} }

View File

@@ -0,0 +1 @@
export * from "./organization-warnings";

View File

@@ -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 { export class OrganizationWarningsResponse extends BaseResponse {
freeTrial?: FreeTrialWarningResponse; freeTrial?: FreeTrialWarningResponse;
inactiveSubscription?: InactiveSubscriptionWarningResponse; inactiveSubscription?: InactiveSubscriptionWarningResponse;
resellerRenewal?: ResellerRenewalWarningResponse; resellerRenewal?: ResellerRenewalWarningResponse;
taxId?: TaxIdWarningResponse;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@@ -21,6 +34,10 @@ export class OrganizationWarningsResponse extends BaseResponse {
if (resellerWarning) { if (resellerWarning) {
this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning);
} }
const taxIdWarning = this.getResponseProperty("TaxId");
if (taxIdWarning) {
this.taxId = new TaxIdWarningResponse(taxIdWarning);
}
} }
} }

View File

@@ -14,13 +14,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services"; import { BitwardenSubscriber } from "../../types";
import { BillableEntity } from "../../types";
type DialogParams = { type DialogParams = {
owner: BillableEntity; subscriber: BitwardenSubscriber;
}; };
type DialogResult = "cancelled" | "error" | "launched"; type DialogResult = "cancelled" | "error" | "launched";
@@ -125,7 +125,7 @@ const positiveNumberValidator =
`, `,
standalone: true, standalone: true,
imports: [SharedModule], imports: [SharedModule],
providers: [BillingClient], providers: [SubscriberBillingClient],
}) })
export class AddAccountCreditDialogComponent { export class AddAccountCreditDialogComponent {
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef;
@@ -143,22 +143,22 @@ export class AddAccountCreditDialogComponent {
protected payPalCustom$ = this.configService.cloudRegion$.pipe( protected payPalCustom$ = this.configService.cloudRegion$.pipe(
map((cloudRegion) => { map((cloudRegion) => {
switch (this.dialogParams.owner.type) { switch (this.dialogParams.subscriber.type) {
case "account": { 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": { 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": { 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( constructor(
private billingClient: BillingClient, private billingClient: SubscriberBillingClient,
private configService: ConfigService, private configService: ConfigService,
@Inject(DIALOG_DATA) private dialogParams: DialogParams, @Inject(DIALOG_DATA) private dialogParams: DialogParams,
private dialogRef: DialogRef<DialogResult>, private dialogRef: DialogRef<DialogResult>,
@@ -175,7 +175,7 @@ export class AddAccountCreditDialogComponent {
} }
if (this.formGroup.value.paymentMethod === "bitPay") { 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!, amount: this.amount!,
redirectUrl: this.redirectUrl, redirectUrl: this.redirectUrl,
}); });
@@ -225,13 +225,13 @@ export class AddAccountCreditDialogComponent {
} }
get payPalSubject(): string { get payPalSubject(): string {
switch (this.dialogParams.owner.type) { switch (this.dialogParams.subscriber.type) {
case "account": { case "account": {
return this.dialogParams.owner.data.email; return this.dialogParams.subscriber.data.email;
} }
case "organization": case "organization":
case "provider": { case "provider": {
return this.dialogParams.owner.data.name; return this.dialogParams.subscriber.data.name;
} }
} }
} }

View File

@@ -3,10 +3,10 @@ import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services"; import { BitwardenSubscriber } from "../../types";
import { BillableEntity } from "../../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
import { import {
@@ -15,7 +15,7 @@ import {
} from "./submit-payment-method-dialog.component"; } from "./submit-payment-method-dialog.component";
type DialogParams = { type DialogParams = {
owner: BillableEntity; subscriber: BitwardenSubscriber;
}; };
@Component({ @Component({
@@ -28,7 +28,7 @@ type DialogParams = {
<div bitDialogContent> <div bitDialogContent>
<app-enter-payment-method <app-enter-payment-method
[group]="formGroup" [group]="formGroup"
[showBankAccount]="dialogParams.owner.type !== 'account'" [showBankAccount]="dialogParams.subscriber.type !== 'account'"
[includeBillingAddress]="true" [includeBillingAddress]="true"
> >
</app-enter-payment-method> </app-enter-payment-method>
@@ -51,20 +51,20 @@ type DialogParams = {
`, `,
standalone: true, standalone: true,
imports: [EnterPaymentMethodComponent, SharedModule], imports: [EnterPaymentMethodComponent, SharedModule],
providers: [BillingClient], providers: [SubscriberBillingClient],
}) })
export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
protected override owner: BillableEntity; protected override subscriber: BitwardenSubscriber;
constructor( constructor(
billingClient: BillingClient, billingClient: SubscriberBillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams, @Inject(DIALOG_DATA) protected dialogParams: DialogParams,
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>, dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
i18nService: I18nService, i18nService: I18nService,
toastService: ToastService, toastService: ToastService,
) { ) {
super(billingClient, dialogRef, i18nService, toastService); super(billingClient, dialogRef, i18nService, toastService);
this.owner = this.dialogParams.owner; this.subscriber = this.dialogParams.subscriber;
} }
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) => static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>

View File

@@ -3,10 +3,10 @@ import { Component, Input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services"; import { BitwardenSubscriber } from "../../types";
import { BillableEntity } from "../../types";
import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component";
@@ -23,14 +23,14 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com
`, `,
standalone: true, standalone: true,
imports: [SharedModule], imports: [SharedModule],
providers: [BillingClient, CurrencyPipe], providers: [SubscriberBillingClient, CurrencyPipe],
}) })
export class DisplayAccountCreditComponent { export class DisplayAccountCreditComponent {
@Input({ required: true }) owner!: BillableEntity; @Input({ required: true }) subscriber!: BitwardenSubscriber;
@Input({ required: true }) credit!: number | null; @Input({ required: true }) credit!: number | null;
constructor( constructor(
private billingClient: BillingClient, private billingClient: SubscriberBillingClient,
private currencyPipe: CurrencyPipe, private currencyPipe: CurrencyPipe,
private dialogService: DialogService, private dialogService: DialogService,
private i18nService: I18nService, private i18nService: I18nService,
@@ -38,8 +38,8 @@ export class DisplayAccountCreditComponent {
) {} ) {}
addAccountCredit = async () => { addAccountCredit = async () => {
if (this.owner.type !== "account") { if (this.subscriber.type !== "account") {
const billingAddress = await this.billingClient.getBillingAddress(this.owner); const billingAddress = await this.billingClient.getBillingAddress(this.subscriber);
if (!billingAddress) { if (!billingAddress) {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
@@ -51,7 +51,7 @@ export class DisplayAccountCreditComponent {
AddAccountCreditDialogComponent.open(this.dialogService, { AddAccountCreditDialogComponent.open(this.dialogService, {
data: { data: {
owner: this.owner, subscriber: this.subscriber,
}, },
}); });
}; };

View File

@@ -2,23 +2,38 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components/edit-billing-address-dialog.component";
import { SharedModule } from "../../../shared"; import { AddressPipe } from "@bitwarden/web-vault/app/billing/payment/pipes";
import { BillableEntity } from "../../types"; import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
import { AddressPipe } from "../pipes"; import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { BillingAddress } from "../types"; import {
TaxIdWarningType,
import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component"; TaxIdWarningTypes,
} from "@bitwarden/web-vault/app/billing/warnings/types";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
@Component({ @Component({
selector: "app-display-billing-address", selector: "app-display-billing-address",
template: ` template: `
<bit-section> <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) { @if (billingAddress) {
<p>{{ billingAddress | address }}</p> <p>{{ billingAddress | address }}</p>
@if (billingAddress.taxId) { @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 { } @else {
<p>{{ "noBillingAddress" | i18n }}</p> <p>{{ "noBillingAddress" | i18n }}</p>
@@ -33,8 +48,9 @@ import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog
imports: [AddressPipe, SharedModule], imports: [AddressPipe, SharedModule],
}) })
export class DisplayBillingAddressComponent { export class DisplayBillingAddressComponent {
@Input({ required: true }) owner!: BillableEntity; @Input({ required: true }) subscriber!: BitwardenSubscriber;
@Input({ required: true }) billingAddress!: BillingAddress | null; @Input({ required: true }) billingAddress!: BillingAddress | null;
@Input() taxIdWarning?: TaxIdWarningType;
@Output() updated = new EventEmitter<BillingAddress>(); @Output() updated = new EventEmitter<BillingAddress>();
constructor(private dialogService: DialogService) {} constructor(private dialogService: DialogService) {}
@@ -42,8 +58,9 @@ export class DisplayBillingAddressComponent {
editBillingAddress = async (): Promise<void> => { editBillingAddress = async (): Promise<void> => {
const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, {
data: { data: {
owner: this.owner, subscriber: this.subscriber,
billingAddress: this.billingAddress, billingAddress: this.billingAddress,
taxIdWarning: this.taxIdWarning,
}, },
}); });
@@ -53,4 +70,22 @@ export class DisplayBillingAddressComponent {
this.updated.emit(result.billingAddress); 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
);
}
} }

View File

@@ -4,7 +4,7 @@ import { lastValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { BillableEntity } from "../../types"; import { BitwardenSubscriber } from "../../types";
import { MaskedPaymentMethod } from "../types"; import { MaskedPaymentMethod } from "../types";
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component";
@@ -19,7 +19,10 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component";
@switch (paymentMethod.type) { @switch (paymentMethod.type) {
@case ("bankAccount") { @case ("bankAccount") {
@if (!paymentMethod.verified) { @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> </app-verify-bank-account>
} }
@@ -63,7 +66,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component";
imports: [SharedModule, VerifyBankAccountComponent], imports: [SharedModule, VerifyBankAccountComponent],
}) })
export class DisplayPaymentMethodComponent { export class DisplayPaymentMethodComponent {
@Input({ required: true }) owner!: BillableEntity; @Input({ required: true }) subscriber!: BitwardenSubscriber;
@Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null;
@Output() updated = new EventEmitter<MaskedPaymentMethod>(); @Output() updated = new EventEmitter<MaskedPaymentMethod>();
@@ -82,7 +85,7 @@ export class DisplayPaymentMethodComponent {
changePaymentMethod = async (): Promise<void> => { changePaymentMethod = async (): Promise<void> => {
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, {
data: { data: {
owner: this.owner, subscriber: this.subscriber,
}, },
}); });

View File

@@ -3,18 +3,31 @@ import { Component, Inject } from "@angular/core";
import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; import {
CalloutTypes,
import { SharedModule } from "../../../shared"; DialogConfig,
import { BillingClient } from "../../services"; DialogRef,
import { BillableEntity } from "../../types"; DialogService,
import { BillingAddress, getTaxIdTypeForCountry } from "../types"; 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"; import { EnterBillingAddressComponent } from "./enter-billing-address.component";
type DialogParams = { type DialogParams = {
owner: BillableEntity; subscriber: BitwardenSubscriber;
billingAddress: BillingAddress | null; billingAddress: BillingAddress | null;
taxIdWarning?: TaxIdWarningType;
}; };
type DialogResult = type DialogResult =
@@ -30,11 +43,18 @@ type DialogResult =
{{ "editBillingAddress" | i18n }} {{ "editBillingAddress" | i18n }}
</span> </span>
<div bitDialogContent> <div bitDialogContent>
@let callout = taxIdWarningCallout;
@if (callout) {
<bit-callout [type]="callout.type" [title]="callout.title">
{{ callout.message }}
</bit-callout>
}
<app-enter-billing-address <app-enter-billing-address
[scenario]="{ [scenario]="{
type: 'update', type: 'update',
existing: dialogParams.billingAddress, existing: dialogParams.billingAddress,
supportsTaxId, supportsTaxId,
taxIdWarning: dialogParams.taxIdWarning,
}" }"
[group]="formGroup" [group]="formGroup"
></app-enter-billing-address> ></app-enter-billing-address>
@@ -57,13 +77,13 @@ type DialogResult =
`, `,
standalone: true, standalone: true,
imports: [EnterBillingAddressComponent, SharedModule], imports: [EnterBillingAddressComponent, SharedModule],
providers: [BillingClient], providers: [SubscriberBillingClient],
}) })
export class EditBillingAddressDialogComponent { export class EditBillingAddressDialogComponent {
protected formGroup = EnterBillingAddressComponent.getFormGroup(); protected formGroup = EnterBillingAddressComponent.getFormGroup();
constructor( constructor(
private billingClient: BillingClient, private billingClient: SubscriberBillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams, @Inject(DIALOG_DATA) protected dialogParams: DialogParams,
private dialogRef: DialogRef<DialogResult>, private dialogRef: DialogRef<DialogResult>,
private i18nService: I18nService, private i18nService: I18nService,
@@ -93,7 +113,7 @@ export class EditBillingAddressDialogComponent {
: { ...addressFields, taxId: null }; : { ...addressFields, taxId: null };
const result = await this.billingClient.updateBillingAddress( const result = await this.billingClient.updateBillingAddress(
this.dialogParams.owner, this.dialogParams.subscriber,
billingAddress, billingAddress,
); );
@@ -125,7 +145,7 @@ export class EditBillingAddressDialogComponent {
}; };
get supportsTaxId(): boolean { get supportsTaxId(): boolean {
switch (this.dialogParams.owner.type) { switch (this.dialogParams.subscriber.type) {
case "account": { case "account": {
return false; return false;
} }
@@ -134,7 +154,7 @@ export class EditBillingAddressDialogComponent {
ProductTierType.TeamsStarter, ProductTierType.TeamsStarter,
ProductTierType.Teams, ProductTierType.Teams,
ProductTierType.Enterprise, ProductTierType.Enterprise,
].includes(this.dialogParams.owner.data.productTierType); ].includes(this.dialogParams.subscriber.data.productTierType);
} }
case "provider": { case "provider": {
return true; 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>) => static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
dialogService.open<DialogResult>(EditBillingAddressDialogComponent, dialogConfig); dialogService.open<DialogResult>(EditBillingAddressDialogComponent, dialogConfig);
} }

View File

@@ -3,9 +3,14 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; import { map, Observable, startWith, Subject, takeUntil } from "rxjs";
import { ControlsOf } from "@bitwarden/angular/types/controls-of"; 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 { SharedModule } from "../../../shared";
import { BillingAddress, selectableCountries, taxIdTypes } from "../types"; import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types";
export interface BillingAddressControls { export interface BillingAddressControls {
country: string; country: string;
@@ -28,6 +33,7 @@ type Scenario =
type: "update"; type: "update";
existing?: BillingAddress; existing?: BillingAddress;
supportsTaxId: boolean; supportsTaxId: boolean;
taxIdWarning?: TaxIdWarningType;
}; };
@Component({ @Component({
@@ -110,7 +116,7 @@ type Scenario =
</bit-form-field> </bit-form-field>
</div> </div>
@if (supportsTaxId$ | async) { @if (supportsTaxId$ | async) {
<div class="tw-col-span-6"> <div class="tw-col-span-12">
<bit-form-field [disableMargin]="true"> <bit-form-field [disableMargin]="true">
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label> <bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input <input
@@ -119,6 +125,17 @@ type Scenario =
[formControl]="group.controls.taxId" [formControl]="group.controls.taxId"
data-testid="tax-id" 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> </bit-form-field>
</div> </div>
} }
@@ -137,6 +154,8 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
constructor(private i18nService: I18nService) {}
ngOnInit() { ngOnInit() {
switch (this.scenario.type) { switch (this.scenario.type) {
case "checkout": { case "checkout": {
@@ -185,6 +204,40 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
this.group.controls.state.disable(); 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 => static getFormGroup = (): BillingAddressFormGroup =>
new FormGroup({ new FormGroup({
country: new FormControl<string>("", { country: new FormControl<string>("", {

View File

@@ -9,10 +9,10 @@ import {
DialogService, DialogService,
ToastService, ToastService,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services"; import { BitwardenSubscriber } from "../../types";
import { BillableEntity } from "../../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
import { import {
@@ -21,7 +21,7 @@ import {
} from "./submit-payment-method-dialog.component"; } from "./submit-payment-method-dialog.component";
type DialogParams = { type DialogParams = {
owner: BillableEntity; subscriber: BitwardenSubscriber;
callout: { callout: {
type: CalloutTypes; type: CalloutTypes;
title: string; title: string;
@@ -53,20 +53,20 @@ type DialogParams = {
`, `,
standalone: true, standalone: true,
imports: [EnterPaymentMethodComponent, SharedModule], imports: [EnterPaymentMethodComponent, SharedModule],
providers: [BillingClient], providers: [SubscriberBillingClient],
}) })
export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
protected override owner: BillableEntity; protected override subscriber: BitwardenSubscriber;
constructor( constructor(
billingClient: BillingClient, billingClient: SubscriberBillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams, @Inject(DIALOG_DATA) protected dialogParams: DialogParams,
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>, dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
i18nService: I18nService, i18nService: I18nService,
toastService: ToastService, toastService: ToastService,
) { ) {
super(billingClient, dialogRef, i18nService, toastService); super(billingClient, dialogRef, i18nService, toastService);
this.owner = this.dialogParams.owner; this.subscriber = this.dialogParams.subscriber;
} }
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) => static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>

View File

@@ -2,9 +2,9 @@ import { Component, ViewChild } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, ToastService } from "@bitwarden/components"; import { DialogRef, ToastService } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { BillingClient } from "../../services"; import { BitwardenSubscriber } from "../../types";
import { BillableEntity } from "../../types";
import { MaskedPaymentMethod } from "../types"; import { MaskedPaymentMethod } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
@@ -20,10 +20,10 @@ export abstract class SubmitPaymentMethodDialogComponent {
private enterPaymentMethodComponent!: EnterPaymentMethodComponent; private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected formGroup = EnterPaymentMethodComponent.getFormGroup(); protected formGroup = EnterPaymentMethodComponent.getFormGroup();
protected abstract owner: BillableEntity; protected abstract subscriber: BitwardenSubscriber;
protected constructor( protected constructor(
protected billingClient: BillingClient, protected billingClient: SubscriberBillingClient,
protected dialogRef: DialogRef<SubmitPaymentMethodDialogResult>, protected dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
protected i18nService: I18nService, protected i18nService: I18nService,
protected toastService: ToastService, protected toastService: ToastService,
@@ -43,7 +43,7 @@ export abstract class SubmitPaymentMethodDialogComponent {
: null; : null;
const result = await this.billingClient.updatePaymentMethod( const result = await this.billingClient.updatePaymentMethod(
this.owner, this.subscriber,
paymentMethod, paymentMethod,
billingAddress, billingAddress,
); );

View File

@@ -3,10 +3,10 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services"; import { BitwardenSubscriber } from "../../types";
import { BillableEntity } from "../../types";
import { MaskedPaymentMethod } from "../types"; import { MaskedPaymentMethod } from "../types";
@Component({ @Component({
@@ -32,10 +32,10 @@ import { MaskedPaymentMethod } from "../types";
`, `,
standalone: true, standalone: true,
imports: [SharedModule], imports: [SharedModule],
providers: [BillingClient], providers: [SubscriberBillingClient],
}) })
export class VerifyBankAccountComponent { export class VerifyBankAccountComponent {
@Input({ required: true }) owner!: BillableEntity; @Input({ required: true }) subscriber!: BitwardenSubscriber;
@Output() verified = new EventEmitter<MaskedPaymentMethod>(); @Output() verified = new EventEmitter<MaskedPaymentMethod>();
protected formGroup = new FormGroup({ protected formGroup = new FormGroup({
@@ -47,7 +47,7 @@ export class VerifyBankAccountComponent {
}); });
constructor( constructor(
private billingClient: BillingClient, private billingClient: SubscriberBillingClient,
private i18nService: I18nService, private i18nService: I18nService,
private toastService: ToastService, private toastService: ToastService,
) {} ) {}
@@ -60,7 +60,7 @@ export class VerifyBankAccountComponent {
} }
const result = await this.billingClient.verifyBankAccount( const result = await this.billingClient.verifyBankAccount(
this.owner, this.subscriber,
this.formGroup.value.descriptorCode!, this.formGroup.value.descriptorCode!,
); );

View File

@@ -1,4 +1,3 @@
export * from "./billing.client";
export * from "./billing-services.module"; export * from "./billing-services.module";
export * from "./braintree.service"; export * from "./braintree.service";
export * from "./stripe.service"; export * from "./stripe.service";

View File

@@ -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 { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
export type BillableEntity = export type BitwardenSubscriber =
| { type: "account"; data: Account } | { type: "account"; data: Account }
| { type: "organization"; data: Organization } | { type: "organization"; data: Organization }
| { type: "provider"; data: Provider }; | { 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) { if (!account) {
throw new Error("Account not found"); 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) => { (organization) => {
if (!organization) { if (!organization) {
throw new Error("Organization not found"); 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) { if (!provider) {
throw new Error("Organization not found"); throw new Error("Organization not found");
} }

View File

@@ -1,2 +1,2 @@
export * from "./billable-entity"; export * from "./bitwarden-subscriber";
export * from "./free-trial"; export * from "./free-trial";

View File

@@ -1,2 +1 @@
export * from "./organization-free-trial-warning.component"; export * from "./tax-id-warning.component";
export * from "./organization-reseller-renewal-warning.component";

View File

@@ -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]);
};
}

View File

@@ -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 },
},
);
});
});
});

View File

@@ -1 +1 @@
export * from "./organization-warnings"; export * from "./tax-id-warning-type";

View File

@@ -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;
};

View File

@@ -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");
}
}

View File

@@ -11016,5 +11016,52 @@
}, },
"showLess": { "showLess": {
"message": "Show less" "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"
}
}
} }
} }

View File

@@ -55,5 +55,14 @@
></bit-nav-item> ></bit-nav-item>
</app-side-nav> </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> <router-outlet></router-outlet>
</app-layout> </app-layout>

View File

@@ -18,15 +18,24 @@ import {
ProviderPortalLogo, ProviderPortalLogo,
BusinessUnitPortalLogo, BusinessUnitPortalLogo,
} from "@bitwarden/components"; } 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 { 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({ @Component({
selector: "providers-layout", selector: "providers-layout",
templateUrl: "providers-layout.component.html", templateUrl: "providers-layout.component.html",
imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule], imports: [
providers: [ProviderWarningsService], CommonModule,
RouterModule,
JslibModule,
WebLayoutModule,
IconModule,
TaxIdWarningComponent,
],
}) })
export class ProvidersLayoutComponent implements OnInit, OnDestroy { export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected readonly logo = ProviderPortalLogo; protected readonly logo = ProviderPortalLogo;
@@ -43,6 +52,9 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected managePaymentDetailsOutsideCheckout$: Observable<boolean>; protected managePaymentDetailsOutsideCheckout$: Observable<boolean>;
protected providerPortalTakeover$: Observable<boolean>; protected providerPortalTakeover$: Observable<boolean>;
protected subscriber$: Observable<NonIndividualSubscriber>;
protected getTaxIdWarning$: () => Observable<TaxIdWarningType>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private providerService: ProviderService, private providerService: ProviderService,
@@ -90,10 +102,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
); );
providerId$ this.provider$
.pipe( .pipe(
switchMap((providerId) => switchMap((provider) =>
this.providerWarningsService.showProviderSuspendedDialog$(providerId), this.providerWarningsService.showProviderSuspendedDialog$(provider),
), ),
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
@@ -102,6 +114,18 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
this.providerPortalTakeover$ = this.configService.getFeatureFlag$( this.providerPortalTakeover$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21821_ProviderPortalTakeover, 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() { ngOnDestroy() {
@@ -116,4 +140,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
showSettingsTab(provider: Provider) { showSettingsTab(provider: Provider) {
return provider.isProviderAdmin; return provider.isProviderAdmin;
} }
refreshTaxIdWarning = () => this.providerWarningsService.refreshTaxIdWarning();
} }

View File

@@ -21,6 +21,7 @@ import {
} from "../../billing/providers"; } from "../../billing/providers";
import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component"; import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component";
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.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 { AddOrganizationComponent } from "./clients/add-organization.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component";
@@ -55,6 +56,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
CardComponent, CardComponent,
ScrollLayoutDirective, ScrollLayoutDirective,
PaymentComponent, PaymentComponent,
ProviderWarningsModule,
], ],
declarations: [ declarations: [
AcceptProviderComponent, AcceptProviderComponent,

View File

@@ -13,19 +13,20 @@
} @else { } @else {
<ng-container> <ng-container>
<app-display-payment-method <app-display-payment-method
[owner]="view.provider" [subscriber]="view.provider"
[paymentMethod]="view.paymentMethod" [paymentMethod]="view.paymentMethod"
(updated)="setPaymentMethod($event)" (updated)="setPaymentMethod($event)"
></app-display-payment-method> ></app-display-payment-method>
<app-display-billing-address <app-display-billing-address
[owner]="view.provider" [subscriber]="view.provider"
[billingAddress]="view.billingAddress" [billingAddress]="view.billingAddress"
[taxIdWarning]="enableTaxIdWarning ? view.taxIdWarning : null"
(updated)="setBillingAddress($event)" (updated)="setBillingAddress($event)"
></app-display-billing-address> ></app-display-billing-address>
<app-display-account-credit <app-display-account-credit
[owner]="view.provider" [subscriber]="view.provider"
[credit]="view.credit" [credit]="view.credit"
></app-display-account-credit> ></app-display-account-credit>
</ng-container> </ng-container>

View File

@@ -1,22 +1,30 @@
import { Component } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest,
EMPTY, EMPTY,
filter, filter,
firstValueFrom,
from, from,
map, map,
merge, merge,
Observable, Observable,
of,
shareReplay, shareReplay,
Subject,
switchMap, switchMap,
take,
takeUntil,
tap, tap,
} from "rxjs"; } from "rxjs";
import { catchError } from "rxjs/operators"; import { catchError } from "rxjs/operators";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { import {
DisplayAccountCreditComponent, DisplayAccountCreditComponent,
DisplayBillingAddressComponent, DisplayBillingAddressComponent,
@@ -26,11 +34,16 @@ import {
BillingAddress, BillingAddress,
MaskedPaymentMethod, MaskedPaymentMethod,
} from "@bitwarden/web-vault/app/billing/payment/types"; } from "@bitwarden/web-vault/app/billing/payment/types";
import { BillingClient } from "@bitwarden/web-vault/app/billing/services"; import {
import { BillableEntity, providerToBillableEntity } from "@bitwarden/web-vault/app/billing/types"; 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 { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { ProviderWarningsService } from "../warnings/services";
class RedirectError { class RedirectError {
constructor( constructor(
public path: string[], public path: string[],
@@ -39,29 +52,31 @@ class RedirectError {
} }
type View = { type View = {
provider: BillableEntity; provider: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null; paymentMethod: MaskedPaymentMethod | null;
billingAddress: BillingAddress | null; billingAddress: BillingAddress | null;
credit: number | null; credit: number | null;
taxIdWarning: TaxIdWarningType | null;
}; };
@Component({ @Component({
templateUrl: "./provider-payment-details.component.html", templateUrl: "./provider-payment-details.component.html",
standalone: true,
imports: [ imports: [
DisplayBillingAddressComponent,
DisplayAccountCreditComponent, DisplayAccountCreditComponent,
DisplayBillingAddressComponent,
DisplayPaymentMethodComponent, DisplayPaymentMethodComponent,
HeaderModule, HeaderModule,
SharedModule, SharedModule,
], ],
providers: [BillingClient],
}) })
export class ProviderPaymentDetailsComponent { export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy {
private viewState$ = new BehaviorSubject<View | null>(null); 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)), switchMap(({ providerId }) => this.providerService.get$(providerId)),
);
private load$: Observable<View> = this.provider$.pipe(
switchMap((provider) => switchMap((provider) =>
this.configService this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
@@ -74,12 +89,17 @@ export class ProviderPaymentDetailsComponent {
}), }),
), ),
), ),
providerToBillableEntity, mapProviderToSubscriber,
switchMap(async (provider) => { 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.getPaymentMethod(provider),
this.billingClient.getBillingAddress(provider), this.billingClient.getBillingAddress(provider),
this.billingClient.getCredit(provider), this.billingClient.getCredit(provider),
getTaxIdWarning,
]); ]);
return { return {
@@ -87,6 +107,7 @@ export class ProviderPaymentDetailsComponent {
paymentMethod, paymentMethod,
billingAddress, billingAddress,
credit, credit,
taxIdWarning,
}; };
}), }),
shareReplay({ bufferSize: 1, refCount: false }), shareReplay({ bufferSize: 1, refCount: false }),
@@ -105,16 +126,64 @@ export class ProviderPaymentDetailsComponent {
this.viewState$.pipe(filter((view): view is View => view !== null)), this.viewState$.pipe(filter((view): view is View => view !== null)),
).pipe(shareReplay({ bufferSize: 1, refCount: true })); ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
private destroy$ = new Subject<void>();
protected enableTaxIdWarning!: boolean;
constructor( constructor(
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private billingClient: BillingClient, private billingClient: SubscriberBillingClient,
private configService: ConfigService, private configService: ConfigService,
private providerService: ProviderService, private providerService: ProviderService,
private providerWarningsService: ProviderWarningsService,
private router: Router, 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) => { setBillingAddress = (billingAddress: BillingAddress) => {
if (this.viewState$.value) { if (this.viewState$.value) {
if (
this.enableTaxIdWarning &&
this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId
) {
this.providerWarningsService.refreshTaxIdWarning();
}
this.viewState$.next({ this.viewState$.next({
...this.viewState$.value, ...this.viewState$.value,
billingAddress, billingAddress,
@@ -122,11 +191,16 @@ export class ProviderPaymentDetailsComponent {
} }
}; };
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { setPaymentMethod = async (paymentMethod: MaskedPaymentMethod) => {
if (this.viewState$.value) { if (this.viewState$.value) {
const billingAddress =
this.viewState$.value.billingAddress ??
(await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.provider));
this.viewState$.next({ this.viewState$.next({
...this.viewState$.value, ...this.viewState$.value,
paymentMethod, paymentMethod,
billingAddress,
}); });
} }
}; };

View File

@@ -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();
});
});
});
});

View File

@@ -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();
},
});
}
}),
);
}

View File

@@ -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 {}

View File

@@ -0,0 +1 @@
export * from "./provider-warnings.service";

View File

@@ -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);
});
});
});

View File

@@ -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),
);
}

View File

@@ -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);
}
}
}

View File

@@ -1,5 +1,4 @@
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response";
import { import {
BillingInvoiceResponse, BillingInvoiceResponse,
@@ -18,8 +17,6 @@ export abstract class OrganizationBillingApiServiceAbstraction {
startAfter?: string, startAfter?: string,
) => Promise<BillingTransactionResponse[]>; ) => Promise<BillingTransactionResponse[]>;
abstract getWarnings: (id: string) => Promise<OrganizationWarningsResponse>;
abstract setupBusinessUnit: ( abstract setupBusinessUnit: (
id: string, id: string,
request: { request: {

View File

@@ -1,5 +1,4 @@
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; 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 { ApiService } from "../../../abstractions/api.service";
import { OrganizationBillingApiServiceAbstraction } from "../../abstractions/organizations/organization-billing-api.service.abstraction"; 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)) || []; 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( async setupBusinessUnit(
id: string, id: string,
request: { request: {

View File

@@ -32,6 +32,7 @@ export enum FeatureFlag {
AllowTrialLengthZero = "pm-20322-allow-trial-length-0", AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
/* Key Management */ /* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration", PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -108,6 +109,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AllowTrialLengthZero]: FALSE, [FeatureFlag.AllowTrialLengthZero]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
/* Key Management */ /* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.PrivateKeyRegeneration]: FALSE,