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 d1dfea40fe2..05802d5868a 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 @@ -19,6 +19,7 @@ import { take, takeUntil, tap, + withLatestFrom, } from "rxjs"; import { @@ -31,6 +32,7 @@ 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 { CommandDefinition, MessageListener } from "@bitwarden/messaging"; 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"; @@ -67,6 +69,10 @@ type View = { taxIdWarning: TaxIdWarningType | null; }; +const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{ organizationId: string }>( + "organizationBankAccountVerified", +); + @Component({ templateUrl: "./organization-payment-details.component.html", standalone: true, @@ -150,6 +156,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private activatedRoute: ActivatedRoute, private configService: ConfigService, private dialogService: DialogService, + private messageListener: MessageListener, private organizationService: OrganizationService, private organizationWarningsService: OrganizationWarningsService, private router: Router, @@ -195,6 +202,30 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { } }); } + + this.messageListener + .messages$(BANK_ACCOUNT_VERIFIED_COMMAND) + .pipe( + withLatestFrom(this.view$), + filter(([message, view]) => message.organizationId === view.organization.data.id), + switchMap( + async ([_, view]) => + await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(view.organization), + this.subscriberBillingClient.getBillingAddress(view.organization), + ]), + ), + tap(async ([paymentMethod, billingAddress]) => { + if (paymentMethod) { + await this.setPaymentMethod(paymentMethod); + } + if (billingAddress) { + this.setBillingAddress(billingAddress); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); } ngOnDestroy() { 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 df42d04b802..c33d805aed7 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 @@ -8,7 +8,6 @@ import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; -import { VerifyBankAccountComponent } from "./verify-bank-account.component"; @Component({ selector: "app-display-payment-method", @@ -18,18 +17,23 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; @if (paymentMethod) { @switch (paymentMethod.type) { @case ("bankAccount") { - @if (!paymentMethod.verified) { - - + @if (paymentMethod.hostedVerificationUrl) { +

+ {{ "verifyBankAccountWithStripe" | i18n }} + {{ "verifyNow" | i18n }} +

}

{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} - @if (!paymentMethod.verified) { + @if (paymentMethod.hostedVerificationUrl) { - {{ "unverified" | i18n }} }

@@ -63,7 +67,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; `, standalone: true, - imports: [SharedModule, VerifyBankAccountComponent], + imports: [SharedModule], }) export class DisplayPaymentMethodComponent { @Input({ required: true }) subscriber!: BitwardenSubscriber; @@ -96,8 +100,6 @@ export class DisplayPaymentMethodComponent { } }; - onBankAccountVerified = (paymentMethod: MaskedPaymentMethod) => this.updated.emit(paymentMethod); - protected getBrandIconForCard = (): string | null => { if (this.paymentMethod?.type !== "card") { return null; diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index b5d732031c0..93c45b873fe 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -118,7 +118,7 @@ type PaymentMethodFormGroup = FormGroup<{ @case ("bankAccount") { - {{ "verifyBankAccountWarning" | i18n }} + {{ "requiredToVerifyBankAccountWithStripe" | i18n }}
diff --git a/apps/web/src/app/billing/payment/types/masked-payment-method.ts b/apps/web/src/app/billing/payment/types/masked-payment-method.ts index 8d07706b14c..f57170518a1 100644 --- a/apps/web/src/app/billing/payment/types/masked-payment-method.ts +++ b/apps/web/src/app/billing/payment/types/masked-payment-method.ts @@ -25,7 +25,7 @@ type MaskedBankAccount = { type: BankAccountPaymentMethod; bankName: string; last4: string; - verified: boolean; + hostedVerificationUrl?: string; }; type MaskedCard = { @@ -73,7 +73,7 @@ class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccoun type: BankAccountPaymentMethod; bankName: string; last4: string; - verified: boolean; + hostedVerificationUrl?: string; constructor(response: any) { super(response); @@ -81,7 +81,7 @@ class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccoun this.type = "bankAccount"; this.bankName = this.getResponseProperty("BankName"); this.last4 = this.getResponseProperty("Last4"); - this.verified = this.getResponseProperty("Verified"); + this.hostedVerificationUrl = this.getResponseProperty("HostedVerificationUrl"); } } diff --git a/apps/web/src/app/billing/shared/payment/payment.component.html b/apps/web/src/app/billing/shared/payment/payment.component.html index 0d76d98e334..d1356c20854 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.html +++ b/apps/web/src/app/billing/shared/payment/payment.component.html @@ -81,7 +81,7 @@ - {{ bankAccountWarning }} + {{ "requiredToVerifyBankAccountWithStripe" | i18n }}
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.ts b/apps/web/src/app/billing/shared/payment/payment.component.ts index afb67dec883..08476e9952f 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.ts +++ b/apps/web/src/app/billing/shared/payment/payment.component.ts @@ -212,12 +212,4 @@ export class PaymentComponent implements OnInit, OnDestroy { private get usingStripe(): boolean { return this.usingBankAccount || this.usingCard; } - - get bankAccountWarning(): string { - if (this.bankAccountWarningOverride) { - return this.bankAccountWarningOverride; - } else { - return this.i18nService.t("verifyBankAccountWithStatementDescriptorWarning"); - } - } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 6e9bad50a4d..5223dff9b2d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11244,5 +11244,14 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "requiredToVerifyBankAccountWithStripe": { + "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Failure to verify the bank account will result in a missed payment and your subscription being suspended." + }, + "verifyBankAccountWithStripe": { + "message": "We have made a micro-deposit to your bank account. This may take 1-2 business days. When you see the deposit in your account, you can verify your bank account. Failure to verify your bank account will result in a missed payment and your subscription will be suspended." + }, + "verifyNow": { + "message": "Verify now." } } 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 7782c89a5bd..05cf8f55858 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 @@ -17,13 +17,18 @@ import { take, takeUntil, tap, + withLatestFrom, } 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 { 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 { CommandDefinition, MessageListener } from "@bitwarden/messaging"; +import { UserId } from "@bitwarden/user-core"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { DisplayAccountCreditComponent, @@ -52,6 +57,7 @@ class RedirectError { } type View = { + activeUserId: UserId; provider: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; billingAddress: BillingAddress | null; @@ -59,6 +65,11 @@ type View = { taxIdWarning: TaxIdWarningType | null; }; +const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{ + providerId: string; + adminId: string; +}>("providerBankAccountVerified"); + @Component({ templateUrl: "./provider-payment-details.component.html", imports: [ @@ -94,15 +105,20 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { const getTaxIdWarning = firstValueFrom( this.providerWarningsService.getTaxIdWarning$(provider.data as Provider), ); + const getActiveUserId = firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([ - this.billingClient.getPaymentMethod(provider), - this.billingClient.getBillingAddress(provider), - this.billingClient.getCredit(provider), - getTaxIdWarning, - ]); + const [activeUserId, paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all( + [ + getActiveUserId, + this.billingClient.getPaymentMethod(provider), + this.billingClient.getBillingAddress(provider), + this.billingClient.getCredit(provider), + getTaxIdWarning, + ], + ); return { + activeUserId, provider, paymentMethod, billingAddress, @@ -131,9 +147,11 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { protected enableTaxIdWarning!: boolean; constructor( + private accountService: AccountService, private activatedRoute: ActivatedRoute, private billingClient: SubscriberBillingClient, private configService: ConfigService, + private messageListener: MessageListener, private providerService: ProviderService, private providerWarningsService: ProviderWarningsService, private router: Router, @@ -169,6 +187,33 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { } }); } + + this.messageListener + .messages$(BANK_ACCOUNT_VERIFIED_COMMAND) + .pipe( + withLatestFrom(this.view$), + filter( + ([message, view]) => + message.providerId === view.provider.data.id && message.adminId === view.activeUserId, + ), + switchMap( + async ([_, view]) => + await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(view.provider), + this.subscriberBillingClient.getBillingAddress(view.provider), + ]), + ), + tap(async ([paymentMethod, billingAddress]) => { + if (paymentMethod) { + await this.setPaymentMethod(paymentMethod); + } + if (billingAddress) { + this.setBillingAddress(billingAddress); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); } ngOnDestroy() { diff --git a/libs/common/src/enums/notification-type.enum.ts b/libs/common/src/enums/notification-type.enum.ts index d362ffc841a..e43b4612203 100644 --- a/libs/common/src/enums/notification-type.enum.ts +++ b/libs/common/src/enums/notification-type.enum.ts @@ -30,4 +30,7 @@ export enum NotificationType { NotificationStatus = 21, RefreshSecurityTasks = 22, + + OrganizationBankAccountVerified = 23, + ProviderBankAccountVerified = 24, } diff --git a/libs/common/src/models/response/notification.response.ts b/libs/common/src/models/response/notification.response.ts index d1bf96b1956..56b22fd3117 100644 --- a/libs/common/src/models/response/notification.response.ts +++ b/libs/common/src/models/response/notification.response.ts @@ -63,6 +63,12 @@ export class NotificationResponse extends BaseResponse { case NotificationType.NotificationStatus: this.payload = new EndUserNotificationResponse(payload); break; + case NotificationType.OrganizationBankAccountVerified: + this.payload = new OrganizationBankAccountVerifiedPushNotification(payload); + break; + case NotificationType.ProviderBankAccountVerified: + this.payload = new ProviderBankAccountVerifiedPushNotification(payload); + break; default: break; } @@ -158,3 +164,23 @@ export class OrganizationCollectionSettingChangedPushNotification extends BaseRe this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion"); } } + +export class OrganizationBankAccountVerifiedPushNotification extends BaseResponse { + organizationId: string; + + constructor(response: any) { + super(response); + this.organizationId = this.getResponseProperty("OrganizationId"); + } +} + +export class ProviderBankAccountVerifiedPushNotification extends BaseResponse { + providerId: string; + adminId: string; + + constructor(response: any) { + super(response); + this.providerId = this.getResponseProperty("ProviderId"); + this.adminId = this.getResponseProperty("AdminId"); + } +} diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 6d0728ab65d..e934dec185d 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -298,6 +298,17 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer case NotificationType.SyncOrganizationCollectionSettingChanged: await this.syncService.fullSync(true); break; + case NotificationType.OrganizationBankAccountVerified: + this.messagingService.send("organizationBankAccountVerified", { + organizationId: notification.payload.organizationId, + }); + break; + case NotificationType.ProviderBankAccountVerified: + this.messagingService.send("providerBankAccountVerified", { + providerId: notification.payload.providerId, + adminId: notification.payload.adminId, + }); + break; default: break; }