From df7f1a8d49bc833c6d551f330c1788d798564d4b Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:52:28 -0500 Subject: [PATCH] [PM-22415] Tax ID notifications for Organizations and Providers (#15996) * [NO LOGIC] Rename BillableEntity to BitwardenSubscriber This helps us maintain paraody with server where we call this choice type ISubscriber. I chose BitwardenSubscriber to avoid overlap with RxJS * [NO LOGIC] Move subscriber-billing.client to clients folder * [NO LOGIC] Move organization warnings under organization folder * Move getWarnings from OrganizationBillingApiService to new OrganizationBillingClient I'd like us to move away from stashing so much in libs and utilizing the JsLibServicesModule when it's not necessary to do so. These are invocations used exclusively by the Web Vault and, until that changes, they should be treated as such * Refactor OrganizationWarningsService There was a case added to the Inactive Subscription warning for a free trial, but free trials do not represent inactive subscriptions so this was semantically incorrect. This creates another method that pulls the free trial warning and shows a dialog asking the user to subscribe if they're on one. * Implement Tax ID Warnings throughout Admin Console and Provider Portal * Fix linting error * Jimmy's feedback --- .../collections/vault.component.ts | 17 +- .../organization-layout.component.html | 6 + .../layouts/organization-layout.component.ts | 27 +- .../members/members.component.ts | 13 +- .../organizations/members/members.module.ts | 2 +- .../organizations/organization.module.ts | 2 + apps/web/src/app/billing/clients/index.ts | 2 + .../clients/organization-billing.client.ts | 22 + .../subscriber-billing.client.ts} | 42 +- .../account-payment-details.component.html | 4 +- .../account-payment-details.component.ts | 12 +- ...rganization-payment-details.component.html | 7 +- .../organization-payment-details.component.ts | 193 +++-- .../warnings/components/index.ts | 2 + ...ganization-free-trial-warning.component.ts | 33 +- ...tion-reseller-renewal-warning.component.ts | 8 +- .../warnings/organization-warnings.module.ts | 12 + .../warnings/services/index.ts | 0 .../organization-warnings.service.spec.ts | 682 ++++++++++++++++++ .../services/organization-warnings.service.ts | 153 ++-- .../organizations/warnings/types/index.ts | 1 + .../warnings/types/organization-warnings.ts | 19 +- .../add-account-credit-dialog.component.ts | 26 +- .../change-payment-method-dialog.component.ts | 16 +- .../display-account-credit.component.ts | 16 +- .../display-billing-address.component.ts | 57 +- .../display-payment-method.component.ts | 11 +- .../edit-billing-address-dialog.component.ts | 75 +- .../enter-billing-address.component.ts | 57 +- ...require-payment-method-dialog.component.ts | 14 +- .../submit-payment-method-dialog.component.ts | 10 +- .../verify-bank-account.component.ts | 12 +- apps/web/src/app/billing/services/index.ts | 1 - ...able-entity.ts => bitwarden-subscriber.ts} | 10 +- apps/web/src/app/billing/types/index.ts | 2 +- .../app/billing/warnings/components/index.ts | 3 +- .../components/tax-id-warning.component.ts | 286 ++++++++ .../organization-warnings.service.spec.ts | 358 --------- .../src/app/billing/warnings/types/index.ts | 2 +- .../warnings/types/organization-warnings.ts | 11 - .../warnings/types/tax-id-warning-type.ts | 19 + apps/web/src/locales/en/messages.json | 47 ++ .../providers/providers-layout.component.html | 9 + .../providers/providers-layout.component.ts | 38 +- .../providers/providers.module.ts | 2 + .../provider-payment-details.component.html | 7 +- .../provider-payment-details.component.ts | 100 ++- .../provider-warnings.service.spec.ts | 187 ----- .../services/provider-warnings.service.ts | 104 --- .../warnings/provider-warnings.module.ts | 10 + .../providers/warnings/services/index.ts | 1 + .../provider-warnings.service.spec.ts | 416 +++++++++++ .../services/provider-warnings.service.ts | 175 +++++ .../billing/providers/warnings/types/index.ts | 0 .../warnings/types/provider-warnings.ts | 39 + ...ization-billing-api.service.abstraction.ts | 3 - .../organization-billing-api.service.ts | 13 - libs/common/src/enums/feature-flag.enum.ts | 2 + 58 files changed, 2422 insertions(+), 976 deletions(-) create mode 100644 apps/web/src/app/billing/clients/index.ts create mode 100644 apps/web/src/app/billing/clients/organization-billing.client.ts rename apps/web/src/app/billing/{services/billing.client.ts => clients/subscriber-billing.client.ts} (72%) create mode 100644 apps/web/src/app/billing/organizations/warnings/components/index.ts rename apps/web/src/app/billing/{ => organizations}/warnings/components/organization-free-trial-warning.component.ts (54%) rename apps/web/src/app/billing/{ => organizations}/warnings/components/organization-reseller-renewal-warning.component.ts (82%) create mode 100644 apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts rename apps/web/src/app/billing/{ => organizations}/warnings/services/index.ts (100%) create mode 100644 apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts rename apps/web/src/app/billing/{ => organizations}/warnings/services/organization-warnings.service.ts (63%) create mode 100644 apps/web/src/app/billing/organizations/warnings/types/index.ts rename libs/common/src/billing/models/response/organization-warnings.response.ts => apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts (80%) rename apps/web/src/app/billing/types/{billable-entity.ts => bitwarden-subscriber.ts} (67%) create mode 100644 apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts delete mode 100644 apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts delete mode 100644 apps/web/src/app/billing/warnings/types/organization-warnings.ts create mode 100644 apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts delete mode 100644 bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts delete mode 100644 bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts 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,