diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 5d2460abdc1..87f309c6f66 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -79,8 +79,11 @@ import { DecryptionFailureDialogComponent, PasswordRepromptService, } from "@bitwarden/vault"; -import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component"; -import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service"; +import { + OrganizationFreeTrialWarningComponent, + OrganizationResellerRenewalWarningComponent, +} from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component"; import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; @@ -90,7 +93,6 @@ import { } from "../../../billing/services/reseller-warning.service"; import { TrialFlowService } from "../../../billing/services/trial-flow.service"; import { FreeTrial } from "../../../billing/types/free-trial"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component"; import { SharedModule } from "../../../shared"; import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; import { @@ -674,6 +676,15 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + organization$ + .pipe( + switchMap((organization) => + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + takeUntil(this.destroy$), + ) + .subscribe(); + const freeTrial$ = combineLatest([ organization$, this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index cbb4e1cf064..be9a85ffe4b 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -150,6 +150,12 @@ > {{ "accessingUsingProvider" | i18n: organization.providerName }} + + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 89f62ed8975..0123ea4fb57 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -28,6 +28,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service"; import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; @@ -44,6 +48,8 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; IconModule, OrgSwitcherComponent, BannerModule, + TaxIdWarningComponent, + TaxIdWarningComponent, ], }) export class OrganizationLayoutComponent implements OnInit { @@ -58,7 +64,6 @@ export class OrganizationLayoutComponent implements OnInit { showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; - enterpriseOrganization$: Observable; protected isBreadcrumbEventLogsEnabled$: Observable; protected showSponsoredFamiliesDropdown$: Observable; @@ -69,6 +74,9 @@ export class OrganizationLayoutComponent implements OnInit { textKey: string; }>; + protected subscriber$: Observable; + protected getTaxIdWarning$: () => Observable; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -79,6 +87,7 @@ export class OrganizationLayoutComponent implements OnInit { private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, private organizationBillingService: OrganizationBillingServiceAbstraction, + private organizationWarningsService: OrganizationWarningsService, ) {} async ngOnInit() { @@ -150,6 +159,20 @@ export class OrganizationLayoutComponent implements OnInit { : { route: "billing/payment-method", textKey: "paymentMethod" }, ), ); + + this.subscriber$ = this.organization$.pipe( + map((organization) => ({ + type: "organization", + data: organization, + })), + ); + + this.getTaxIdWarning$ = () => + this.organization$.pipe( + switchMap((organization) => + this.organizationWarningsService.getTaxIdWarning$(organization), + ), + ); } canShowVaultTab(organization: Organization): boolean { @@ -179,4 +202,6 @@ export class OrganizationLayoutComponent implements OnInit { getReportTabLabel(organization: Organization): string { return organization.useEvents ? "reporting" : "reports"; } + + refreshTaxIdWarning = () => this.organizationWarningsService.refreshTaxIdWarning(); } diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index b4542be8d26..dedf13720bf 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -10,10 +10,10 @@ import { from, lastValueFrom, map, + merge, Observable, shareReplay, switchMap, - tap, } from "rxjs"; import { @@ -57,12 +57,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePlanDialogResultType, openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; -import { OrganizationWarningsService } from "../../../billing/warnings/services"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { GroupApiService } from "../core"; @@ -253,11 +253,16 @@ export class MembersComponent extends BaseMembersComponent this.showUserManagementControls$ = organization$.pipe( map((organization) => organization.canManageUsers), ); + organization$ .pipe( + switchMap((organization) => + merge( + this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + ), takeUntilDestroyed(), - tap((org) => (this.organization = org)), - switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)), ) .subscribe(); } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index d9c5ae356a2..efc091cb335 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -4,8 +4,8 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 687361760c9..d956174149b 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -2,6 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { LooseComponentsModule } from "../../shared"; @@ -21,6 +22,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; LooseComponentsModule, ScrollingModule, ScrollLayoutDirective, + OrganizationWarningsModule, ], declarations: [GroupsComponent, GroupAddEditComponent], }) diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts new file mode 100644 index 00000000000..ff962abcbf3 --- /dev/null +++ b/apps/web/src/app/billing/clients/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-billing.client"; +export * from "./subscriber-billing.client"; diff --git a/apps/web/src/app/billing/clients/organization-billing.client.ts b/apps/web/src/app/billing/clients/organization-billing.client.ts new file mode 100644 index 00000000000..a8b3b31a4a4 --- /dev/null +++ b/apps/web/src/app/billing/clients/organization-billing.client.ts @@ -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 => { + const response = await this.apiService.send( + "GET", + `/organizations/${organizationId}/billing/vnext/warnings`, + null, + true, + true, + ); + + return new OrganizationWarningsResponse(response); + }; +} diff --git a/apps/web/src/app/billing/services/billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts similarity index 72% rename from apps/web/src/app/billing/services/billing.client.ts rename to apps/web/src/app/billing/clients/subscriber-billing.client.ts index 69f82eab19a..18ca215ef0c 100644 --- a/apps/web/src/app/billing/services/billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -10,7 +10,7 @@ import { MaskedPaymentMethodResponse, TokenizedPaymentMethod, } from "../payment/types"; -import { BillableEntity } from "../types"; +import { BitwardenSubscriber } from "../types"; type Result = | { @@ -23,28 +23,28 @@ type Result = }; @Injectable() -export class BillingClient { +export class SubscriberBillingClient { constructor(private apiService: ApiService) {} - private getEndpoint = (entity: BillableEntity): string => { - switch (entity.type) { + private getEndpoint = (subscriber: BitwardenSubscriber): string => { + switch (subscriber.type) { case "account": { return "/account/billing/vnext"; } case "organization": { - return `/organizations/${entity.data.id}/billing/vnext`; + return `/organizations/${subscriber.data.id}/billing/vnext`; } case "provider": { - return `/providers/${entity.data.id}/billing/vnext`; + return `/providers/${subscriber.data.id}/billing/vnext`; } } }; addCreditWithBitPay = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, credit: { amount: number; redirectUrl: string }, ): Promise> => { - const path = `${this.getEndpoint(owner)}/credit/bitpay`; + const path = `${this.getEndpoint(subscriber)}/credit/bitpay`; try { const data = await this.apiService.send("POST", path, credit, true, true); return { @@ -62,29 +62,31 @@ export class BillingClient { } }; - getBillingAddress = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/address`; + getBillingAddress = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/address`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new BillingAddressResponse(data) : null; }; - getCredit = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/credit`; + getCredit = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/credit`; const data = await this.apiService.send("GET", path, null, true, true); return data ? (data as number) : null; }; - getPaymentMethod = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/payment-method`; + getPaymentMethod = async ( + subscriber: BitwardenSubscriber, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/payment-method`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new MaskedPaymentMethodResponse(data).value : null; }; updateBillingAddress = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, billingAddress: BillingAddress, ): Promise> => { - const path = `${this.getEndpoint(owner)}/address`; + const path = `${this.getEndpoint(subscriber)}/address`; try { const data = await this.apiService.send("PUT", path, billingAddress, true, true); return { @@ -103,11 +105,11 @@ export class BillingClient { }; updatePaymentMethod = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, paymentMethod: TokenizedPaymentMethod, billingAddress: Pick | null, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method`; + const path = `${this.getEndpoint(subscriber)}/payment-method`; try { const request = { ...paymentMethod, @@ -130,10 +132,10 @@ export class BillingClient { }; verifyBankAccount = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, descriptorCode: string, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; + const path = `${this.getEndpoint(subscriber)}/payment-method/verify-bank-account`; try { const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); return { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html index c10590d8b1b..5bb47cd8a2e 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html @@ -12,13 +12,13 @@ } @else { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index 4a4d0f60c0b..9f46d9d3909 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -20,13 +20,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "../../clients"; import { DisplayAccountCreditComponent, DisplayPaymentMethodComponent, } from "../../payment/components"; import { MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { accountToBillableEntity, BillableEntity } from "../../types"; +import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; class RedirectError { constructor( @@ -36,7 +36,7 @@ class RedirectError { } type View = { - account: BillableEntity; + account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; credit: number | null; }; @@ -50,7 +50,7 @@ type View = { HeaderModule, SharedModule, ], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); @@ -68,7 +68,7 @@ export class AccountPaymentDetailsComponent { }), ), ), - accountToBillableEntity, + mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ this.billingClient.getPaymentMethod(account), @@ -100,7 +100,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, private router: Router, ) {} diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html index 17f4349fdd5..cd31f1f33be 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html @@ -21,19 +21,20 @@ } @else { diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts index e357444b943..d1dfea40fe2 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, catchError, + combineLatest, EMPTY, filter, firstValueFrom, @@ -11,8 +12,12 @@ import { map, merge, Observable, + of, shareReplay, + Subject, switchMap, + take, + takeUntil, tap, } from "rxjs"; @@ -26,19 +31,26 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; - -import { HeaderModule } from "../../../layouts/header/header.module"; -import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePaymentMethodDialogComponent, DisplayAccountCreditComponent, DisplayBillingAddressComponent, DisplayPaymentMethodComponent, -} from "../../payment/components"; -import { BillingAddress, MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { BillableEntity, organizationToBillableEntity } from "../../types"; -import { OrganizationFreeTrialWarningComponent } from "../../warnings/components"; +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { + BitwardenSubscriber, + mapOrganizationToSubscriber, +} from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; class RedirectError { constructor( @@ -48,93 +60,100 @@ class RedirectError { } type View = { - organization: BillableEntity; + organization: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; billingAddress: BillingAddress | null; credit: number | null; + taxIdWarning: TaxIdWarningType | null; }; @Component({ templateUrl: "./organization-payment-details.component.html", standalone: true, imports: [ - DisplayBillingAddressComponent, DisplayAccountCreditComponent, + DisplayBillingAddressComponent, DisplayPaymentMethodComponent, HeaderModule, OrganizationFreeTrialWarningComponent, SharedModule, ], - providers: [BillingClient], }) -export class OrganizationPaymentDetailsComponent implements OnInit { - @ViewChild(OrganizationFreeTrialWarningComponent) - organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent; - +export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private viewState$ = new BehaviorSubject(null); - private load$: Observable = this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), - ), - ) - .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), - ]); + protected organization$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + ), + filter((organization): organization is Organization => !!organization), + ); - return { - organization, - paymentMethod, - billingAddress, - credit, - }; - }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), - ); + private load$: Observable = this.organization$.pipe( + switchMap((organization) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../payment-method"], this.activatedRoute); + } + return organization; + }), + ), + ), + 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 = merge( this.load$.pipe(tap((view) => this.viewState$.next(view))), this.viewState$.pipe(filter((view): view is View => view !== null)), ).pipe(shareReplay({ bufferSize: 1, refCount: true })); - organization$ = this.view$.pipe(map((view) => view.organization.data as Organization)); + private destroy$ = new Subject(); + + protected enableTaxIdWarning!: boolean; constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, private configService: ConfigService, private dialogService: DialogService, private organizationService: OrganizationService, + private organizationWarningsService: OrganizationWarningsService, private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} async ngOnInit() { @@ -145,24 +164,66 @@ export class OrganizationPaymentDetailsComponent implements OnInit { history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); await this.changePaymentMethod(); } + + this.enableTaxIdWarning = await this.configService.getFeatureFlag( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + if (this.enableTaxIdWarning) { + this.organizationWarningsService.taxIdWarningRefreshed$ + .pipe( + switchMap((warning) => + combineLatest([ + of(warning), + this.organization$.pipe(take(1)).pipe( + mapOrganizationToSubscriber, + switchMap((organization) => + this.subscriberBillingClient.getBillingAddress(organization), + ), + ), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([taxIdWarning, billingAddress]) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + taxIdWarning, + billingAddress, + }); + } + }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } changePaymentMethod = async () => { const view = await firstValueFrom(this.view$); const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: view.organization, + subscriber: view.organization, }, }); const result = await lastValueFrom(dialogRef.closed); if (result?.type === "success") { await this.setPaymentMethod(result.paymentMethod); - this.organizationFreeTrialWarningComponent.refresh(); + this.organizationWarningsService.refreshFreeTrialWarning(); } }; setBillingAddress = (billingAddress: BillingAddress) => { if (this.viewState$.value) { + if ( + this.enableTaxIdWarning && + this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId + ) { + this.organizationWarningsService.refreshTaxIdWarning(); + } this.viewState$.next({ ...this.viewState$.value, billingAddress, @@ -174,7 +235,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit { if (this.viewState$.value) { const billingAddress = this.viewState$.value.billingAddress ?? - (await this.billingClient.getBillingAddress(this.viewState$.value.organization)); + (await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.organization)); this.viewState$.next({ ...this.viewState$.value, diff --git a/apps/web/src/app/billing/organizations/warnings/components/index.ts b/apps/web/src/app/billing/organizations/warnings/components/index.ts new file mode 100644 index 00000000000..1e1e0682e62 --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/components/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-free-trial-warning.component"; +export * from "./organization-reseller-renewal-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts similarity index 54% rename from apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts index a7ce53c9998..4925e4bc01d 100644 --- a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts @@ -1,12 +1,9 @@ -import { AsyncPipe } from "@angular/common"; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Observable, Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationFreeTrialWarning } from "../types"; @@ -37,33 +34,17 @@ import { OrganizationFreeTrialWarning } from "../types"; } `, - imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], + imports: [BannerModule, SharedModule], }) -export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy { +export class OrganizationFreeTrialWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; @Output() clicked = new EventEmitter(); - warning$!: Observable; - private destroy$ = new Subject(); + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); - this.organizationWarningsService - .refreshWarningsForOrganization$(this.organization.id as OrganizationId) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.refresh(); - }); } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - refresh = () => { - this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true); - }; } diff --git a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts similarity index 82% rename from apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts index f45dd443dda..4eba9f3daf5 100644 --- a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts @@ -1,9 +1,9 @@ -import { AsyncPipe } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { BannerComponent } from "@bitwarden/components"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationResellerRenewalWarning } from "../types"; @@ -25,12 +25,12 @@ import { OrganizationResellerRenewalWarning } from "../types"; } `, - imports: [AsyncPipe, BannerComponent], + imports: [BannerModule, SharedModule], }) export class OrganizationResellerRenewalWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; - warning$!: Observable; + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} diff --git a/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts new file mode 100644 index 00000000000..6defee7e78b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts @@ -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 {} diff --git a/apps/web/src/app/billing/warnings/services/index.ts b/apps/web/src/app/billing/organizations/warnings/services/index.ts similarity index 100% rename from apps/web/src/app/billing/warnings/services/index.ts rename to apps/web/src/app/billing/organizations/warnings/services/index.ts diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts new file mode 100644 index 00000000000..c7a297cc28b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -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; + let dialogService: MockProxy; + let i18nService: MockProxy; + let organizationApiService: MockProxy; + let organizationBillingClient: MockProxy; + let router: MockProxy; + + 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(); + dialogService = mock(); + i18nService = mock(); + organizationApiService = mock(); + organizationBillingClient = mock(); + router = mock(); + + (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; + + (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; + + 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; + + 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; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + const refreshSpy = jest.spyOn(service, "refreshFreeTrialWarning"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshSpy).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts similarity index 63% rename from apps/web/src/app/billing/warnings/services/organization-warnings.service.ts rename to apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index 78c17a5d384..5b466dfe41d 100644 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -1,26 +1,39 @@ -import { Location } from "@angular/common"; import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs"; +import { + BehaviorSubject, + filter, + from, + lastValueFrom, + map, + merge, + Observable, + Subject, + switchMap, + tap, +} from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; -import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; import { TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; -import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; +} from "../../../shared/trial-payment-dialog/trial-payment-dialog.component"; +import { openChangePlanDialog } from "../../change-plan-dialog.component"; +import { + OrganizationFreeTrialWarning, + OrganizationResellerRenewalWarning, + OrganizationWarningsResponse, +} from "../types"; const format = (date: Date) => date.toLocaleDateString("en-US", { @@ -29,28 +42,39 @@ const format = (date: Date) => year: "numeric", }); -@Injectable({ providedIn: "root" }) +@Injectable() export class OrganizationWarningsService { private cache$ = new Map>(); - private refreshWarnings$ = new Subject(); + + private refreshFreeTrialWarningTrigger = new Subject(); + private refreshTaxIdWarningTrigger = new Subject(); + + private taxIdWarningRefreshedSubject = new BehaviorSubject(null); + taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, - private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, + private organizationBillingClient: OrganizationBillingClient, private router: Router, - private location: Location, - protected syncService: SyncService, ) {} getFreeTrialWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe( + ): Observable => + merge( + this.getWarning$(organization, (response) => response.freeTrial), + this.refreshFreeTrialWarningTrigger.pipe( + switchMap(() => this.getWarning$(organization, (response) => response.freeTrial, true)), + ), + ).pipe( map((warning) => { + if (!warning) { + return null; + } + const { remainingTrialDays } = warning; if (remainingTrialDays >= 2) { @@ -76,10 +100,12 @@ export class OrganizationWarningsService { getResellerRenewalWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe( - map((warning): OrganizationResellerRenewalWarning | null => { + ): Observable => + this.getWarning$(organization, (response) => response.resellerRenewal).pipe( + map((warning) => { + if (!warning) { + return null; + } switch (warning.type) { case "upcoming": { return { @@ -114,14 +140,27 @@ export class OrganizationWarningsService { } } }), - filter((result): result is NonNullable => result !== null), ); - showInactiveSubscriptionDialog$ = ( - organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe( + getTaxIdWarning$ = (organization: Organization): Observable => + merge( + this.getWarning$(organization, (response) => response.taxId), + this.refreshTaxIdWarningTrigger.pipe( + switchMap(() => + this.getWarning$(organization, (response) => response.taxId, true).pipe( + tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)), + ), + ), + ), + ).pipe(map((warning) => (warning ? warning.type : null))); + + refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next(); + + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); + + showInactiveSubscriptionDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( + filter((warning) => warning !== null), switchMap(async (warning) => { switch (warning.resolution) { case "contact_provider": { @@ -183,43 +222,43 @@ export class OrganizationWarningsService { }); 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 { - return this.refreshWarnings$.pipe( - filter((id) => id === organizationId), - map((): void => void 0), - ); - } + showSubscribeBeforeFreeTrialEndsDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.freeTrial).pipe( + filter((warning) => warning !== null), + 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, bypassCache: boolean = false, ): Observable => { - const existing = this.cache$.get(organization.id as OrganizationId); + const organizationId = organization.id as OrganizationId; + const existing = this.cache$.get(organizationId); if (existing && !bypassCache) { return existing; } - const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)); - this.cache$.set(organization.id as OrganizationId, response$); + const response$ = from(this.organizationBillingClient.getWarnings(organizationId)); + this.cache$.set(organizationId, response$); return response$; }; @@ -227,10 +266,12 @@ export class OrganizationWarningsService { organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, bypassCache: boolean = false, - ): Observable => - this.getResponse$(organization, bypassCache).pipe( - map(extract), - takeWhile((warning): warning is T => !!warning), + ): Observable => + this.readThroughWarnings$(organization, bypassCache).pipe( + map((response) => { + const value = extract(response); + return value ? value : null; + }), take(1), ); } diff --git a/apps/web/src/app/billing/organizations/warnings/types/index.ts b/apps/web/src/app/billing/organizations/warnings/types/index.ts new file mode 100644 index 00000000000..fc0c7d278ed --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/types/index.ts @@ -0,0 +1 @@ +export * from "./organization-warnings"; diff --git a/libs/common/src/billing/models/response/organization-warnings.response.ts b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts similarity index 80% rename from libs/common/src/billing/models/response/organization-warnings.response.ts rename to apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts index ff70298101e..0c0097d5b09 100644 --- a/libs/common/src/billing/models/response/organization-warnings.response.ts +++ b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts @@ -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; + message: string; +}; + +export type OrganizationResellerRenewalWarning = { + type: "info" | "warning"; + message: string; +}; export class OrganizationWarningsResponse extends BaseResponse { freeTrial?: FreeTrialWarningResponse; inactiveSubscription?: InactiveSubscriptionWarningResponse; resellerRenewal?: ResellerRenewalWarningResponse; + taxId?: TaxIdWarningResponse; constructor(response: any) { super(response); @@ -21,6 +34,10 @@ export class OrganizationWarningsResponse extends BaseResponse { if (resellerWarning) { this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); } + const taxIdWarning = this.getResponseProperty("TaxId"); + if (taxIdWarning) { + this.taxId = new TaxIdWarningResponse(taxIdWarning); + } } } diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts index 90705abddd3..a83a00e8158 100644 --- a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -14,13 +14,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; type DialogResult = "cancelled" | "error" | "launched"; @@ -125,7 +125,7 @@ const positiveNumberValidator = `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AddAccountCreditDialogComponent { @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; @@ -143,22 +143,22 @@ export class AddAccountCreditDialogComponent { protected payPalCustom$ = this.configService.cloudRegion$.pipe( map((cloudRegion) => { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return `user_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `user_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "organization": { - return `organization_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `organization_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "provider": { - return `provider_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `provider_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } } }), ); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, @Inject(DIALOG_DATA) private dialogParams: DialogParams, private dialogRef: DialogRef, @@ -175,7 +175,7 @@ export class AddAccountCreditDialogComponent { } if (this.formGroup.value.paymentMethod === "bitPay") { - const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, { + const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.subscriber, { amount: this.amount!, redirectUrl: this.redirectUrl, }); @@ -225,13 +225,13 @@ export class AddAccountCreditDialogComponent { } get payPalSubject(): string { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return this.dialogParams.owner.data.email; + return this.dialogParams.subscriber.data.email; } case "organization": case "provider": { - return this.dialogParams.owner.data.name; + return this.dialogParams.subscriber.data.name; } } } diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index 15c63d8f99f..4d2fadaa894 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -3,10 +3,10 @@ import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -15,7 +15,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; @Component({ @@ -28,7 +28,7 @@ type DialogParams = {
@@ -51,20 +51,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts index 7cbe3a27f30..f6aa0ef58bb 100644 --- a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -3,10 +3,10 @@ import { Component, Input } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; @@ -23,14 +23,14 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com `, standalone: true, imports: [SharedModule], - providers: [BillingClient, CurrencyPipe], + providers: [SubscriberBillingClient, CurrencyPipe], }) export class DisplayAccountCreditComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) credit!: number | null; constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private currencyPipe: CurrencyPipe, private dialogService: DialogService, private i18nService: I18nService, @@ -38,8 +38,8 @@ export class DisplayAccountCreditComponent { ) {} addAccountCredit = async () => { - if (this.owner.type !== "account") { - const billingAddress = await this.billingClient.getBillingAddress(this.owner); + if (this.subscriber.type !== "account") { + const billingAddress = await this.billingClient.getBillingAddress(this.subscriber); if (!billingAddress) { this.toastService.showToast({ variant: "error", @@ -51,7 +51,7 @@ export class DisplayAccountCreditComponent { AddAccountCreditDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); }; diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts index f0a11321e5d..03d21a79003 100644 --- a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -2,23 +2,38 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; -import { AddressPipe } from "../pipes"; -import { BillingAddress } from "../types"; - -import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components/edit-billing-address-dialog.component"; +import { AddressPipe } from "@bitwarden/web-vault/app/billing/payment/pipes"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; @Component({ selector: "app-display-billing-address", template: ` -

{{ "billingAddress" | i18n }}

+

+ {{ "billingAddress" | i18n }} + @if (showMissingTaxIdBadge) { + {{ "missingTaxId" | i18n }} + } +

@if (billingAddress) {

{{ billingAddress | address }}

@if (billingAddress.taxId) { -

{{ "taxId" | i18n: billingAddress.taxId.value }}

+

+ {{ "taxId" | i18n: billingAddress.taxId.value }} + @if (showTaxIdPendingVerificationBadge) { + {{ "pendingVerification" | i18n }} + } + @if (showUnverifiedTaxIdBadge) { + {{ "unverified" | i18n }} + } +

} } @else {

{{ "noBillingAddress" | i18n }}

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