1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

[PM-24964] Stripe-hosted bank account verification (#16220)

* Implement bank account hosted URL verification with webhook handling notification

* [PM-25491] Create org/provider bank account warning needs to be updated
This commit is contained in:
Alex Morask
2025-09-09 12:22:45 -05:00
committed by GitHub
parent 52642056d8
commit 4907820383
11 changed files with 149 additions and 30 deletions

View File

@@ -19,6 +19,7 @@ import {
take, take,
takeUntil, takeUntil,
tap, tap,
withLatestFrom,
} from "rxjs"; } from "rxjs";
import { import {
@@ -31,6 +32,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
@@ -67,6 +69,10 @@ type View = {
taxIdWarning: TaxIdWarningType | null; taxIdWarning: TaxIdWarningType | null;
}; };
const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{ organizationId: string }>(
"organizationBankAccountVerified",
);
@Component({ @Component({
templateUrl: "./organization-payment-details.component.html", templateUrl: "./organization-payment-details.component.html",
standalone: true, standalone: true,
@@ -150,6 +156,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private configService: ConfigService, private configService: ConfigService,
private dialogService: DialogService, private dialogService: DialogService,
private messageListener: MessageListener,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private organizationWarningsService: OrganizationWarningsService, private organizationWarningsService: OrganizationWarningsService,
private router: Router, 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() { ngOnDestroy() {

View File

@@ -8,7 +8,6 @@ import { BitwardenSubscriber } from "../../types";
import { MaskedPaymentMethod } from "../types"; import { MaskedPaymentMethod } from "../types";
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component";
import { VerifyBankAccountComponent } from "./verify-bank-account.component";
@Component({ @Component({
selector: "app-display-payment-method", selector: "app-display-payment-method",
@@ -18,18 +17,23 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component";
@if (paymentMethod) { @if (paymentMethod) {
@switch (paymentMethod.type) { @switch (paymentMethod.type) {
@case ("bankAccount") { @case ("bankAccount") {
@if (!paymentMethod.verified) { @if (paymentMethod.hostedVerificationUrl) {
<app-verify-bank-account <p>
[subscriber]="subscriber" {{ "verifyBankAccountWithStripe" | i18n }}
(verified)="onBankAccountVerified($event)" <a
> bitLink
</app-verify-bank-account> rel="noreferrer"
target="_blank"
[attr.href]="paymentMethod.hostedVerificationUrl"
>{{ "verifyNow" | i18n }}</a
>
</p>
} }
<p> <p>
<i class="bwi bwi-fw bwi-billing"></i> <i class="bwi bwi-fw bwi-billing"></i>
{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} {{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }}
@if (!paymentMethod.verified) { @if (paymentMethod.hostedVerificationUrl) {
<span>- {{ "unverified" | i18n }}</span> <span>- {{ "unverified" | i18n }}</span>
} }
</p> </p>
@@ -63,7 +67,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component";
</bit-section> </bit-section>
`, `,
standalone: true, standalone: true,
imports: [SharedModule, VerifyBankAccountComponent], imports: [SharedModule],
}) })
export class DisplayPaymentMethodComponent { export class DisplayPaymentMethodComponent {
@Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) subscriber!: BitwardenSubscriber;
@@ -96,8 +100,6 @@ export class DisplayPaymentMethodComponent {
} }
}; };
onBankAccountVerified = (paymentMethod: MaskedPaymentMethod) => this.updated.emit(paymentMethod);
protected getBrandIconForCard = (): string | null => { protected getBrandIconForCard = (): string | null => {
if (this.paymentMethod?.type !== "card") { if (this.paymentMethod?.type !== "card") {
return null; return null;

View File

@@ -118,7 +118,7 @@ type PaymentMethodFormGroup = FormGroup<{
@case ("bankAccount") { @case ("bankAccount") {
<ng-container> <ng-container>
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}"> <bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountWarning" | i18n }} {{ "requiredToVerifyBankAccountWithStripe" | i18n }}
</bit-callout> </bit-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankAccount"> <div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankAccount">
<bit-form-field class="tw-col-span-1" [disableMargin]="true"> <bit-form-field class="tw-col-span-1" [disableMargin]="true">

View File

@@ -25,7 +25,7 @@ type MaskedBankAccount = {
type: BankAccountPaymentMethod; type: BankAccountPaymentMethod;
bankName: string; bankName: string;
last4: string; last4: string;
verified: boolean; hostedVerificationUrl?: string;
}; };
type MaskedCard = { type MaskedCard = {
@@ -73,7 +73,7 @@ class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccoun
type: BankAccountPaymentMethod; type: BankAccountPaymentMethod;
bankName: string; bankName: string;
last4: string; last4: string;
verified: boolean; hostedVerificationUrl?: string;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@@ -81,7 +81,7 @@ class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccoun
this.type = "bankAccount"; this.type = "bankAccount";
this.bankName = this.getResponseProperty("BankName"); this.bankName = this.getResponseProperty("BankName");
this.last4 = this.getResponseProperty("Last4"); this.last4 = this.getResponseProperty("Last4");
this.verified = this.getResponseProperty("Verified"); this.hostedVerificationUrl = this.getResponseProperty("HostedVerificationUrl");
} }
} }

View File

@@ -81,7 +81,7 @@
<!-- Bank Account --> <!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount"> <ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}"> <bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ bankAccountWarning }} {{ "requiredToVerifyBankAccountWithStripe" | i18n }}
</bit-callout> </bit-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation"> <div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation">
<bit-form-field class="tw-col-span-1" disableMargin> <bit-form-field class="tw-col-span-1" disableMargin>

View File

@@ -212,12 +212,4 @@ export class PaymentComponent implements OnInit, OnDestroy {
private get usingStripe(): boolean { private get usingStripe(): boolean {
return this.usingBankAccount || this.usingCard; return this.usingBankAccount || this.usingCard;
} }
get bankAccountWarning(): string {
if (this.bankAccountWarningOverride) {
return this.bankAccountWarningOverride;
} else {
return this.i18nService.t("verifyBankAccountWithStatementDescriptorWarning");
}
}
} }

View File

@@ -11244,5 +11244,14 @@
}, },
"confirmKeyConnectorDomain": { "confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain" "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."
} }
} }

View File

@@ -17,13 +17,18 @@ import {
take, take,
takeUntil, takeUntil,
tap, tap,
withLatestFrom,
} from "rxjs"; } from "rxjs";
import { catchError } from "rxjs/operators"; import { catchError } from "rxjs/operators";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { UserId } from "@bitwarden/user-core";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { import {
DisplayAccountCreditComponent, DisplayAccountCreditComponent,
@@ -52,6 +57,7 @@ class RedirectError {
} }
type View = { type View = {
activeUserId: UserId;
provider: BitwardenSubscriber; provider: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null; paymentMethod: MaskedPaymentMethod | null;
billingAddress: BillingAddress | null; billingAddress: BillingAddress | null;
@@ -59,6 +65,11 @@ type View = {
taxIdWarning: TaxIdWarningType | null; taxIdWarning: TaxIdWarningType | null;
}; };
const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{
providerId: string;
adminId: string;
}>("providerBankAccountVerified");
@Component({ @Component({
templateUrl: "./provider-payment-details.component.html", templateUrl: "./provider-payment-details.component.html",
imports: [ imports: [
@@ -94,15 +105,20 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy {
const getTaxIdWarning = firstValueFrom( const getTaxIdWarning = firstValueFrom(
this.providerWarningsService.getTaxIdWarning$(provider.data as Provider), this.providerWarningsService.getTaxIdWarning$(provider.data as Provider),
); );
const getActiveUserId = firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([ const [activeUserId, paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all(
this.billingClient.getPaymentMethod(provider), [
this.billingClient.getBillingAddress(provider), getActiveUserId,
this.billingClient.getCredit(provider), this.billingClient.getPaymentMethod(provider),
getTaxIdWarning, this.billingClient.getBillingAddress(provider),
]); this.billingClient.getCredit(provider),
getTaxIdWarning,
],
);
return { return {
activeUserId,
provider, provider,
paymentMethod, paymentMethod,
billingAddress, billingAddress,
@@ -131,9 +147,11 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy {
protected enableTaxIdWarning!: boolean; protected enableTaxIdWarning!: boolean;
constructor( constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private billingClient: SubscriberBillingClient, private billingClient: SubscriberBillingClient,
private configService: ConfigService, private configService: ConfigService,
private messageListener: MessageListener,
private providerService: ProviderService, private providerService: ProviderService,
private providerWarningsService: ProviderWarningsService, private providerWarningsService: ProviderWarningsService,
private router: Router, 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() { ngOnDestroy() {

View File

@@ -30,4 +30,7 @@ export enum NotificationType {
NotificationStatus = 21, NotificationStatus = 21,
RefreshSecurityTasks = 22, RefreshSecurityTasks = 22,
OrganizationBankAccountVerified = 23,
ProviderBankAccountVerified = 24,
} }

View File

@@ -63,6 +63,12 @@ export class NotificationResponse extends BaseResponse {
case NotificationType.NotificationStatus: case NotificationType.NotificationStatus:
this.payload = new EndUserNotificationResponse(payload); this.payload = new EndUserNotificationResponse(payload);
break; break;
case NotificationType.OrganizationBankAccountVerified:
this.payload = new OrganizationBankAccountVerifiedPushNotification(payload);
break;
case NotificationType.ProviderBankAccountVerified:
this.payload = new ProviderBankAccountVerifiedPushNotification(payload);
break;
default: default:
break; break;
} }
@@ -158,3 +164,23 @@ export class OrganizationCollectionSettingChangedPushNotification extends BaseRe
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion"); 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");
}
}

View File

@@ -298,6 +298,17 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
case NotificationType.SyncOrganizationCollectionSettingChanged: case NotificationType.SyncOrganizationCollectionSettingChanged:
await this.syncService.fullSync(true); await this.syncService.fullSync(true);
break; 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: default:
break; break;
} }