1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

[AC-1939] Manage provider payment information (#9415)

* Added select-payment-method.component in shared lib

Because we're going to be implementing the same functionality for providers and orgs/users, I wanted to start moving some of this shared functionality into libs so it can be accessed in both web and bit-web. Additionally, the Stripe and Braintree functionality has been moved into their own services for more central management.

* Added generalized manage-tax-information component to shared lib

* Added generalized add-account-credit-dialog component to shared libs

* Added generalized verify-bank-account component to shared libs

* Added dialog for selection of provider payment method

* Added provider-payment-method component

* Added provider-payment-method component to provider layout
This commit is contained in:
Alex Morask
2024-06-03 11:01:14 -04:00
committed by GitHub
parent aee9720a6d
commit 28de91888a
42 changed files with 1974 additions and 13 deletions

View File

@@ -33,6 +33,7 @@
*ngIf="canAccessBilling$ | async"
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item>
</bit-nav-group>
<bit-nav-item
icon="bwi-cogs"

View File

@@ -7,8 +7,12 @@ import { ProvidersComponent } from "@bitwarden/web-vault/app/admin-console/provi
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
import { ProviderSubscriptionComponent, hasConsolidatedBilling } from "../../billing/providers";
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients";
import {
ManageClientOrganizationsComponent,
ProviderSubscriptionComponent,
hasConsolidatedBilling,
ProviderPaymentMethodComponent,
} from "../../billing/providers";
import { ClientsComponent } from "./clients/clients.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
@@ -118,6 +122,13 @@ const routes: Routes = [
titleId: "subscription",
},
},
{
path: "payment-method",
component: ProviderPaymentMethodComponent,
data: {
titleId: "paymentMethod",
},
},
],
},
{

View File

@@ -9,13 +9,15 @@ import { OrganizationPlansComponent, TaxInfoComponent } from "@bitwarden/web-vau
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import { ProviderSubscriptionComponent } from "../../billing/providers";
import {
CreateClientOrganizationComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationNameComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationSubscriptionComponent,
} from "../../billing/providers/clients";
ProviderPaymentMethodComponent,
ProviderSelectPaymentMethodDialogComponent,
ProviderSubscriptionComponent,
} from "../../billing/providers";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { ClientsComponent } from "./clients/clients.component";
@@ -66,6 +68,8 @@ import { SetupComponent } from "./setup/setup.component";
ManageClientOrganizationNameComponent,
ManageClientOrganizationSubscriptionComponent,
ProviderSubscriptionComponent,
ProviderSelectPaymentMethodDialogComponent,
ProviderPaymentMethodComponent,
],
providers: [WebProviderService, ProviderPermissionsGuard],
})

View File

@@ -1,2 +1,8 @@
export * from "./clients/create-client-organization.component";
export * from "./clients/manage-client-organization-name.component";
export * from "./clients/manage-client-organization-subscription.component";
export * from "./clients/manage-client-organizations.component";
export * from "./guards/has-consolidated-billing.guard";
export * from "./provider-subscription.component";
export * from "./payment-method/provider-select-payment-method-dialog.component";
export * from "./payment-method/provider-payment-method.component";
export * from "./subscription/provider-subscription.component";

View File

@@ -0,0 +1,52 @@
<app-header></app-header>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-container *ngIf="!loading">
<!-- Account Credit -->
<ng-container>
<h2 bitTypography="h2">
{{ "accountCredit" | i18n }}
</h2>
<p class="tw-text-lg tw-font-bold">{{ accountCredit | currency: "$" }}</p>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
{{ "addCredit" | i18n }}
</button>
</ng-container>
<!-- Payment Method -->
<ng-container>
<h2 class="spaced-header">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!hasPaymentMethod">{{ "noPaymentMethod" | i18n }}</p>
<app-verify-bank-account
[onSubmit]="verifyBankAccount"
(verificationSubmitted)="onDataUpdated()"
*ngIf="hasUnverifiedPaymentMethod"
/>
<ng-container *ngIf="hasPaymentMethod">
<p>
<i class="bwi bwi-fw" [ngClass]="paymentMethodClass"></i>
{{ paymentMethodDescription }}
</p>
</ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePaymentMethod">
{{ (hasPaymentMethod ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button>
</ng-container>
<!-- Tax Information -->
<ng-container>
<h2 class="spaced-header">{{ "taxInformation" | i18n }}</h2>
<p>{{ "taxInformationDesc" | i18n }}</p>
<app-manage-tax-information
*ngIf="taxInformation"
[taxInformation]="taxInformation"
[onSubmit]="updateTaxInformation"
(taxInformationUpdated)="onDataUpdated()"
/>
</ng-container>
</bit-container>

View File

@@ -0,0 +1,140 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { from, lastValueFrom, Subject, switchMap } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { openAddAccountCreditDialog } from "@bitwarden/angular/billing/components";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { MaskedPaymentMethod, TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
openProviderSelectPaymentMethodDialog,
ProviderSelectPaymentMethodDialogResultType,
} from "./provider-select-payment-method-dialog.component";
@Component({
selector: "app-provider-payment-method",
templateUrl: "./provider-payment-method.component.html",
})
export class ProviderPaymentMethodComponent implements OnInit, OnDestroy {
protected providerId: string;
protected loading: boolean;
protected accountCredit: number;
protected maskedPaymentMethod: MaskedPaymentMethod;
protected taxInformation: TaxInformation;
private destroy$ = new Subject<void>();
constructor(
private activatedRoute: ActivatedRoute,
private billingApiService: BillingApiServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
) {}
addAccountCredit = () =>
openAddAccountCreditDialog(this.dialogService, {
data: {
providerId: this.providerId,
},
});
changePaymentMethod = async () => {
const dialogRef = openProviderSelectPaymentMethodDialog(this.dialogService, {
data: {
providerId: this.providerId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result == ProviderSelectPaymentMethodDialogResultType.Submitted) {
await this.load();
}
};
async load() {
this.loading = true;
const paymentInformation = await this.billingApiService.getProviderPaymentInformation(
this.providerId,
);
this.accountCredit = paymentInformation.accountCredit;
this.maskedPaymentMethod = MaskedPaymentMethod.from(paymentInformation.paymentMethod);
this.taxInformation = TaxInformation.from(paymentInformation.taxInformation);
this.loading = false;
}
onDataUpdated = async () => await this.load();
updateTaxInformation = async (taxInformation: TaxInformation) => {
const request = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await this.billingApiService.updateProviderTaxInformation(this.providerId, request);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedTaxInformation"),
});
};
verifyBankAccount = async (amount1: number, amount2: number) => {
const request = new VerifyBankAccountRequest(amount1, amount2);
await this.billingApiService.verifyProviderBankAccount(this.providerId, request);
};
ngOnInit() {
this.activatedRoute.params
.pipe(
switchMap(({ providerId }) => {
this.providerId = providerId;
return from(this.load());
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected get hasPaymentMethod(): boolean {
return !!this.maskedPaymentMethod;
}
protected get hasUnverifiedPaymentMethod(): boolean {
return !!this.maskedPaymentMethod && this.maskedPaymentMethod.needsVerification;
}
protected get paymentMethodClass(): string[] {
switch (this.maskedPaymentMethod.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.PayPal:
return ["bwi-paypal tw-text-primary"];
default:
return [];
}
}
protected get paymentMethodDescription(): string {
let description = this.maskedPaymentMethod.description;
if (this.maskedPaymentMethod.type === PaymentMethodType.BankAccount) {
if (this.hasUnverifiedPaymentMethod) {
description += " - " + this.i18nService.t("unverified");
} else {
description += " - " + this.i18nService.t("verified");
}
}
return description;
}
}

View File

@@ -0,0 +1,18 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large">
<span bitDialogTitle class="tw-font-semibold">
{{ "addPaymentMethod" | i18n }}
</span>
<ng-container bitDialogContent>
<app-select-payment-method [showAccountCredit]="false" />
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,60 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, EventEmitter, Inject, Output, ViewChild } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { SelectPaymentMethodComponent } from "@bitwarden/angular/billing/components";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { TokenizedPaymentMethodRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-method.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
type ProviderSelectPaymentMethodDialogParams = {
providerId: string;
};
export enum ProviderSelectPaymentMethodDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
export const openProviderSelectPaymentMethodDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<ProviderSelectPaymentMethodDialogParams>,
) =>
dialogService.open<
ProviderSelectPaymentMethodDialogResultType,
ProviderSelectPaymentMethodDialogParams
>(ProviderSelectPaymentMethodDialogComponent, dialogConfig);
@Component({
templateUrl: "provider-select-payment-method-dialog.component.html",
})
export class ProviderSelectPaymentMethodDialogComponent {
@ViewChild(SelectPaymentMethodComponent)
selectPaymentMethodComponent: SelectPaymentMethodComponent;
@Output() providerPaymentMethodUpdated = new EventEmitter();
protected readonly formGroup = new FormGroup({});
protected readonly ResultType = ProviderSelectPaymentMethodDialogResultType;
constructor(
private billingApiService: BillingApiServiceAbstraction,
@Inject(DIALOG_DATA) private dialogParams: ProviderSelectPaymentMethodDialogParams,
private dialogRef: DialogRef<ProviderSelectPaymentMethodDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
) {}
submit = async () => {
const tokenizedPaymentMethod = await this.selectPaymentMethodComponent.tokenizePaymentMethod();
const request = TokenizedPaymentMethodRequest.From(tokenizedPaymentMethod);
await this.billingApiService.updateProviderPaymentMethod(this.dialogParams.providerId, request);
this.providerPaymentMethodUpdated.emit();
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedPaymentMethod"),
});
this.dialogRef.close(this.ResultType.Submitted);
};
}