mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 15:23:33 +00:00
[AC-2959] ACH Direct Debit POC (#10746)
* (No Logic) Fix typo in billing-api-service.abstraction file name * (Cleanup) Remove payment method components and API methods from provider portal Product team decided not to have a payment method page in the provider portal for consolidated billing. This just removes all the unused components and API methods. * Add organization endpoints to support new payment method behavior * Add payment-v2.component This component existed in the libs folder because we used it for the provider portal, but since we've removed payment functionality from the provider portal, I moved it into web in this commit. * (No Logic) Move existing payment.component into new payment component folder * Add verify-bank-account.component This component existed in the libs folder because we used it for the provider portal, but since we've removed payment functionality from the provider portal, I moved it into web in this commit. * Add adjust-payment-dialog-v2.component * (No Logic) Move existing adjust-payment-dialog.component into new adjust-payment-dialog component folder * Add organization-payment-method.component * Add feature flag: AC-2476-deprecate-stripe-sources-api * Pivot organization payment method route on new feature flag * Fix broken test
This commit is contained in:
@@ -32,7 +32,7 @@ import {
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@@ -37,7 +37,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PaymentComponent } from "../shared/payment.component";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
import { TaxInfoComponent } from "../shared/tax-info.component";
|
||||
|
||||
type ChangePlanDialogParams = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
|
||||
import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard";
|
||||
@@ -11,6 +13,7 @@ import { PaymentMethodComponent } from "../shared";
|
||||
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
|
||||
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
|
||||
import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component";
|
||||
import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -25,17 +28,21 @@ const routes: Routes = [
|
||||
: OrganizationSubscriptionCloudComponent,
|
||||
data: { titleId: "subscription" },
|
||||
},
|
||||
{
|
||||
path: "payment-method",
|
||||
component: PaymentMethodComponent,
|
||||
canActivate: [
|
||||
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
|
||||
organizationIsUnmanaged,
|
||||
],
|
||||
data: {
|
||||
titleId: "paymentMethod",
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: PaymentMethodComponent,
|
||||
flaggedComponent: OrganizationPaymentMethodComponent,
|
||||
featureFlag: FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
|
||||
routeOptions: {
|
||||
path: "payment-method",
|
||||
canActivate: [
|
||||
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
|
||||
organizationIsUnmanaged,
|
||||
],
|
||||
data: {
|
||||
titleId: "paymentMethod",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: "history",
|
||||
component: OrgBillingHistoryViewComponent,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { OrganizationBillingRoutingModule } from "./organization-billing-routing
|
||||
import { OrganizationPlansComponent } from "./organization-plans.component";
|
||||
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
|
||||
import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component";
|
||||
import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component";
|
||||
import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component";
|
||||
import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component";
|
||||
import { SubscriptionHiddenComponent } from "./subscription-hidden.component";
|
||||
@@ -42,6 +43,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
|
||||
SubscriptionHiddenComponent,
|
||||
SubscriptionStatusComponent,
|
||||
ChangePlanDialogComponent,
|
||||
OrganizationPaymentMethodComponent,
|
||||
],
|
||||
})
|
||||
export class OrganizationBillingModule {}
|
||||
|
||||
@@ -41,7 +41,7 @@ import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
|
||||
import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared";
|
||||
import { PaymentComponent } from "../shared/payment.component";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
import { TaxInfoComponent } from "../shared/tax-info.component";
|
||||
|
||||
interface OnSuccessArgs {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<app-header></app-header>
|
||||
<bit-container>
|
||||
<ng-container *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!loading">
|
||||
<!-- Account Credit -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">
|
||||
{{ accountCreditHeaderText }}
|
||||
</h2>
|
||||
<p class="tw-text-lg tw-font-bold">{{ Math.abs(accountCredit) | currency: "$" }}</p>
|
||||
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
|
||||
{{ "addCredit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
<!-- Payment Method -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
|
||||
<ng-container *ngIf="paymentSource">
|
||||
<app-verify-bank-account
|
||||
*ngIf="paymentSource.needsVerification"
|
||||
[onSubmit]="verifyBankAccount"
|
||||
(submitted)="load()"
|
||||
>
|
||||
</app-verify-bank-account>
|
||||
<p>
|
||||
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
|
||||
{{ paymentSource.description }}
|
||||
<span *ngIf="paymentSource.needsVerification">- {{ "unverified" | i18n }}</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="updatePaymentMethod">
|
||||
{{ updatePaymentSourceButtonText }}
|
||||
</button>
|
||||
<p *ngIf="subscriptionIsUnpaid" bitTypography="body1">
|
||||
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
|
||||
</p>
|
||||
</bit-section>
|
||||
<!-- Tax Information -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "taxInformation" | i18n }}</h2>
|
||||
<p bitTypography="body1">{{ "taxInformationDesc" | i18n }}</p>
|
||||
<app-tax-info></app-tax-info>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
[bitAction]="updateTaxInformation"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
</ng-container>
|
||||
</bit-container>
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Component, ViewChild } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { from, lastValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
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 { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { TaxInfoComponent } from "../../shared";
|
||||
import {
|
||||
AddCreditDialogResult,
|
||||
openAddCreditDialog,
|
||||
} from "../../shared/add-credit-dialog.component";
|
||||
import {
|
||||
AdjustPaymentDialogV2Component,
|
||||
AdjustPaymentDialogV2ResultType,
|
||||
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog-v2.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./organization-payment-method.component.html",
|
||||
})
|
||||
export class OrganizationPaymentMethodComponent {
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
|
||||
organizationId: string;
|
||||
accountCredit: number;
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
subscriptionStatus?: string;
|
||||
|
||||
loading = true;
|
||||
|
||||
protected readonly Math = Math;
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
this.activatedRoute.params
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
switchMap(({ organizationId }) => {
|
||||
if (this.platformUtilsService.isSelfHost()) {
|
||||
return from(this.router.navigate(["/settings/subscription"]));
|
||||
}
|
||||
|
||||
this.organizationId = organizationId;
|
||||
return from(this.load());
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
protected addAccountCredit = async (): Promise<void> => {
|
||||
const dialogRef = openAddCreditDialog(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === AddCreditDialogResult.Added) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
protected load = async (): Promise<void> => {
|
||||
this.loading = true;
|
||||
const { accountCredit, paymentSource, subscriptionStatus } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
this.loading = false;
|
||||
};
|
||||
|
||||
protected updatePaymentMethod = async (): Promise<void> => {
|
||||
const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, {
|
||||
data: {
|
||||
initialPaymentMethod: this.paymentSource?.type,
|
||||
organizationId: this.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === AdjustPaymentDialogV2ResultType.Submitted) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
protected updateTaxInformation = async (): Promise<void> => {
|
||||
this.taxInfoComponent.taxFormGroup.updateValueAndValidity();
|
||||
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
|
||||
|
||||
if (this.taxInfoComponent.taxFormGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new ExpandedTaxInfoUpdateRequest();
|
||||
request.country = this.taxInfoComponent.country;
|
||||
request.postalCode = this.taxInfoComponent.postalCode;
|
||||
request.taxId = this.taxInfoComponent.taxId;
|
||||
request.line1 = this.taxInfoComponent.line1;
|
||||
request.line2 = this.taxInfoComponent.line2;
|
||||
request.city = this.taxInfoComponent.city;
|
||||
request.state = this.taxInfoComponent.state;
|
||||
|
||||
await this.billingApiService.updateOrganizationTaxInformation(this.organizationId, request);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("taxInfoUpdated"),
|
||||
});
|
||||
};
|
||||
|
||||
protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise<void> => {
|
||||
await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("verifiedBankAccount"),
|
||||
});
|
||||
};
|
||||
|
||||
protected get accountCreditHeaderText(): string {
|
||||
const key = this.accountCredit <= 0 ? "accountBalance" : "accountCredit";
|
||||
return this.i18nService.t(key);
|
||||
}
|
||||
|
||||
protected get paymentSourceClasses() {
|
||||
if (this.paymentSource == null) {
|
||||
return [];
|
||||
}
|
||||
switch (this.paymentSource.type) {
|
||||
case PaymentMethodType.Card:
|
||||
return ["bwi-credit-card"];
|
||||
case PaymentMethodType.BankAccount:
|
||||
return ["bwi-bank"];
|
||||
case PaymentMethodType.Check:
|
||||
return ["bwi-money"];
|
||||
case PaymentMethodType.PayPal:
|
||||
return ["bwi-paypal text-primary"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected get subscriptionIsUnpaid(): boolean {
|
||||
return this.subscriptionStatus === "unpaid";
|
||||
}
|
||||
|
||||
protected get updatePaymentSourceButtonText(): string {
|
||||
const key = this.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod";
|
||||
return this.i18nService.t(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<bit-dialog dialogSize="large" [title]="dialogHeader">
|
||||
<ng-container bitDialogContent>
|
||||
<app-payment-v2
|
||||
[showAccountCredit]="false"
|
||||
[showBankAccount]="!!organizationId"
|
||||
[initialPaymentMethod]="initialPaymentMethod"
|
||||
></app-payment-v2>
|
||||
<app-tax-info (onCountryChanged)="onCountryChanged()"></app-tax-info>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" [bitAction]="submit">
|
||||
{{ "submit" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="ResultType.Closed"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { forwardRef, Component, Inject, ViewChild } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { TaxInfoComponent } from "../";
|
||||
import { PaymentV2Component } from "../payment/payment-v2.component";
|
||||
|
||||
export interface AdjustPaymentDialogV2Params {
|
||||
initialPaymentMethod?: PaymentMethodType;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export enum AdjustPaymentDialogV2ResultType {
|
||||
Closed = "closed",
|
||||
Submitted = "submitted",
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "./adjust-payment-dialog-v2.component.html",
|
||||
})
|
||||
export class AdjustPaymentDialogV2Component {
|
||||
@ViewChild(PaymentV2Component) paymentComponent: PaymentV2Component;
|
||||
@ViewChild(forwardRef(() => TaxInfoComponent)) taxInfoComponent: TaxInfoComponent;
|
||||
|
||||
protected readonly PaymentMethodType = PaymentMethodType;
|
||||
protected readonly ResultType = AdjustPaymentDialogV2ResultType;
|
||||
|
||||
protected dialogHeader: string;
|
||||
protected initialPaymentMethod: PaymentMethodType;
|
||||
protected organizationId?: string;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogV2Params,
|
||||
private dialogRef: DialogRef<AdjustPaymentDialogV2ResultType>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
const key = this.dialogParams.initialPaymentMethod ? "changePaymentMethod" : "addPaymentMethod";
|
||||
this.dialogHeader = this.i18nService.t(key);
|
||||
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
|
||||
this.organizationId = this.dialogParams.organizationId;
|
||||
}
|
||||
|
||||
onCountryChanged = () => {
|
||||
if (this.taxInfoComponent.taxInfo.country === "US") {
|
||||
this.paymentComponent.showBankAccount = !!this.organizationId;
|
||||
} else {
|
||||
this.paymentComponent.showBankAccount = false;
|
||||
if (this.paymentComponent.selected === PaymentMethodType.BankAccount) {
|
||||
this.paymentComponent.select(PaymentMethodType.Card);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
this.taxInfoComponent.taxFormGroup.updateValueAndValidity();
|
||||
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
|
||||
if (this.taxInfoComponent.taxFormGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.organizationId) {
|
||||
await this.updatePremiumUserPaymentMethod();
|
||||
} else {
|
||||
await this.updateOrganizationPaymentMethod();
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("updatedPaymentMethod"),
|
||||
});
|
||||
|
||||
this.dialogRef.close(AdjustPaymentDialogV2ResultType.Submitted);
|
||||
};
|
||||
|
||||
private updateOrganizationPaymentMethod = async () => {
|
||||
const paymentSource = await this.paymentComponent.tokenize();
|
||||
|
||||
const request = new UpdatePaymentMethodRequest();
|
||||
request.paymentSource = paymentSource;
|
||||
request.taxInformation = {
|
||||
country: this.taxInfoComponent.country,
|
||||
postalCode: this.taxInfoComponent.postalCode,
|
||||
taxId: this.taxInfoComponent.taxId,
|
||||
line1: this.taxInfoComponent.line1,
|
||||
line2: this.taxInfoComponent.line2,
|
||||
city: this.taxInfoComponent.city,
|
||||
state: this.taxInfoComponent.state,
|
||||
};
|
||||
|
||||
await this.billingApiService.updateOrganizationPaymentMethod(this.organizationId, request);
|
||||
};
|
||||
|
||||
private updatePremiumUserPaymentMethod = async () => {
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
|
||||
const request = new PaymentRequest();
|
||||
request.paymentMethodType = type;
|
||||
request.paymentToken = token;
|
||||
request.country = this.taxInfoComponent.country;
|
||||
request.postalCode = this.taxInfoComponent.postalCode;
|
||||
await this.apiService.postAccountPayment(request);
|
||||
};
|
||||
|
||||
static open = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<AdjustPaymentDialogV2Params>,
|
||||
) =>
|
||||
dialogService.open<AdjustPaymentDialogV2ResultType>(
|
||||
AdjustPaymentDialogV2Component,
|
||||
dialogConfig,
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PaymentComponent } from "./payment.component";
|
||||
import { TaxInfoComponent } from "./tax-info.component";
|
||||
import { PaymentComponent } from "../payment/payment.component";
|
||||
import { TaxInfoComponent } from "../tax-info.component";
|
||||
|
||||
export interface AdjustPaymentDialogData {
|
||||
organizationId: string;
|
||||
@@ -12,7 +12,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PaymentComponent } from "./payment.component";
|
||||
import { PaymentComponent } from "./payment/payment.component";
|
||||
|
||||
export interface AdjustStorageDialogData {
|
||||
storageGbPrice: number;
|
||||
|
||||
@@ -4,19 +4,29 @@ import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { AddCreditDialogComponent } from "./add-credit-dialog.component";
|
||||
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog.component";
|
||||
import { AdjustPaymentDialogV2Component } from "./adjust-payment-dialog/adjust-payment-dialog-v2.component";
|
||||
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog/adjust-payment-dialog.component";
|
||||
import { AdjustStorageComponent } from "./adjust-storage.component";
|
||||
import { BillingHistoryComponent } from "./billing-history.component";
|
||||
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
|
||||
import { PaymentV2Component } from "./payment/payment-v2.component";
|
||||
import { PaymentComponent } from "./payment/payment.component";
|
||||
import { PaymentMethodComponent } from "./payment-method.component";
|
||||
import { PaymentComponent } from "./payment.component";
|
||||
import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component";
|
||||
import { TaxInfoComponent } from "./tax-info.component";
|
||||
import { UpdateLicenseDialogComponent } from "./update-license-dialog.component";
|
||||
import { UpdateLicenseComponent } from "./update-license.component";
|
||||
import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, PaymentComponent, TaxInfoComponent, HeaderModule],
|
||||
imports: [
|
||||
SharedModule,
|
||||
PaymentComponent,
|
||||
TaxInfoComponent,
|
||||
HeaderModule,
|
||||
PaymentV2Component,
|
||||
VerifyBankAccountComponent,
|
||||
],
|
||||
declarations: [
|
||||
AddCreditDialogComponent,
|
||||
AdjustPaymentDialogComponent,
|
||||
@@ -27,6 +37,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
|
||||
UpdateLicenseComponent,
|
||||
UpdateLicenseDialogComponent,
|
||||
OffboardingSurveyComponent,
|
||||
AdjustPaymentDialogV2Component,
|
||||
],
|
||||
exports: [
|
||||
SharedModule,
|
||||
@@ -38,6 +49,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
|
||||
UpdateLicenseComponent,
|
||||
UpdateLicenseDialogComponent,
|
||||
OffboardingSurveyComponent,
|
||||
VerifyBankAccountComponent,
|
||||
],
|
||||
})
|
||||
export class BillingSharedModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./billing-shared.module";
|
||||
export * from "./payment-method.component";
|
||||
export * from "./payment.component";
|
||||
export * from "./payment/payment.component";
|
||||
export * from "./sm-subscribe.component";
|
||||
export * from "./tax-info.component";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.
|
||||
import {
|
||||
AdjustPaymentDialogResult,
|
||||
openAdjustPaymentDialog,
|
||||
} from "./adjust-payment-dialog.component";
|
||||
} from "./adjust-payment-dialog/adjust-payment-dialog.component";
|
||||
import { TaxInfoComponent } from "./tax-info.component";
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<div class="tw-mb-4 tw-text-lg">
|
||||
<bit-radio-group formControlName="paymentMethod">
|
||||
<bit-radio-button id="card-payment-method" [value]="PaymentMethodType.Card">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
|
||||
{{ "creditCard" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button
|
||||
id="bank-payment-method"
|
||||
[value]="PaymentMethodType.BankAccount"
|
||||
*ngIf="showBankAccount"
|
||||
>
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-bank" aria-hidden="true"></i>
|
||||
{{ "bankAccount" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button
|
||||
id="paypal-payment-method"
|
||||
[value]="PaymentMethodType.PayPal"
|
||||
*ngIf="showPayPal"
|
||||
>
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
|
||||
{{ "payPal" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button
|
||||
id="credit-payment-method"
|
||||
[value]="PaymentMethodType.Credit"
|
||||
*ngIf="showAccountCredit"
|
||||
>
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
|
||||
{{ "accountCredit" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
</div>
|
||||
<!-- Card -->
|
||||
<ng-container *ngIf="usingCard">
|
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4">
|
||||
<div class="tw-col-span-1">
|
||||
<label for="stripe-card-number">
|
||||
{{ "number" | i18n }}
|
||||
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
|
||||
</label>
|
||||
<div id="stripe-card-number" class="form-control stripe-form-control"></div>
|
||||
</div>
|
||||
<div class="tw-col-span-1 tw-flex tw-items-end">
|
||||
<img
|
||||
src="../../../images/cards.png"
|
||||
alt="Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay"
|
||||
class="tw-max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="tw-col-span-1">
|
||||
<label for="stripe-card-expiry">
|
||||
{{ "expiration" | i18n }}
|
||||
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
|
||||
</label>
|
||||
<div id="stripe-card-expiry" class="form-control stripe-form-control"></div>
|
||||
</div>
|
||||
<div class="tw-col-span-1">
|
||||
<div class="tw-flex">
|
||||
<label for="stripe-card-cvc">
|
||||
{{ "securityCode" | i18n }}
|
||||
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
|
||||
</label>
|
||||
<a
|
||||
href="https://www.cvvnumber.com/cvv.html"
|
||||
tabindex="-1"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ml-auto"
|
||||
appA11yTitle="{{ 'learnMore' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div id="stripe-card-cvc" class="form-control stripe-form-control"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Bank Account -->
|
||||
<ng-container *ngIf="showBankAccount && usingBankAccount">
|
||||
<app-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
{{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
|
||||
</app-callout>
|
||||
<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-label>{{ "routingNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
id="routingNumber"
|
||||
type="text"
|
||||
formControlName="routingNumber"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
id="accountNumber"
|
||||
type="text"
|
||||
formControlName="accountNumber"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
|
||||
<input
|
||||
id="accountHolderName"
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="accountHolderName"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
|
||||
<bit-select id="accountHolderType" formControlName="accountHolderType" required>
|
||||
<bit-option [value]="''" label="-- {{ 'select' | i18n }} --"></bit-option>
|
||||
<bit-option
|
||||
[value]="'company'"
|
||||
label="{{ 'bankAccountTypeCompany' | i18n }}"
|
||||
></bit-option>
|
||||
<bit-option
|
||||
[value]="'individual'"
|
||||
label="{{ 'bankAccountTypeIndividual' | i18n }}"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- PayPal -->
|
||||
<ng-container *ngIf="showPayPal && usingPayPal">
|
||||
<div class="tw-mb-3">
|
||||
<div id="braintree-container" class="tw-mb-1 tw-content-center"></div>
|
||||
<small class="tw-text-muted">{{ "paypalClickSubmit" | i18n }}</small>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Account Credit -->
|
||||
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
|
||||
<app-callout type="info">
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</app-callout>
|
||||
</ng-container>
|
||||
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
192
apps/web/src/app/billing/shared/payment/payment-v2.component.ts
Normal file
192
apps/web/src/app/billing/shared/payment/payment-v2.component.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { Subject } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
|
||||
import {
|
||||
BillingApiServiceAbstraction,
|
||||
BraintreeServiceAbstraction,
|
||||
StripeServiceAbstraction,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
/**
|
||||
* Render a form that allows the user to enter their payment method, tokenize it against one of our payment providers and,
|
||||
* optionally, submit it using the {@link onSubmit} function if it is provided.
|
||||
*
|
||||
* This component is meant to replace the existing {@link PaymentComponent} which is using the deprecated Stripe Sources API.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-payment-v2",
|
||||
templateUrl: "./payment-v2.component.html",
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class PaymentV2Component implements OnInit, OnDestroy {
|
||||
/** Show account credit as a payment option. */
|
||||
@Input() showAccountCredit: boolean = true;
|
||||
/** Show bank account as a payment option. */
|
||||
@Input() showBankAccount: boolean = true;
|
||||
/** Show PayPal as a payment option. */
|
||||
@Input() showPayPal: boolean = true;
|
||||
|
||||
/** The payment method selected by default when the component renders. */
|
||||
@Input() private initialPaymentMethod: PaymentMethodType = PaymentMethodType.Card;
|
||||
/** If provided, will be invoked with the tokenized payment source during form submission. */
|
||||
@Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise<void>;
|
||||
|
||||
@Output() submitted = new EventEmitter<PaymentMethodType>();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
paymentMethod: new FormControl<PaymentMethodType>(null),
|
||||
bankInformation: new FormGroup({
|
||||
routingNumber: new FormControl<string>("", [Validators.required]),
|
||||
accountNumber: new FormControl<string>("", [Validators.required]),
|
||||
accountHolderName: new FormControl<string>("", [Validators.required]),
|
||||
accountHolderType: new FormControl<string>("", [Validators.required]),
|
||||
}),
|
||||
});
|
||||
|
||||
protected PaymentMethodType = PaymentMethodType;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private braintreeService: BraintreeServiceAbstraction,
|
||||
private stripeService: StripeServiceAbstraction,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.formGroup.controls.paymentMethod.patchValue(this.initialPaymentMethod);
|
||||
|
||||
this.stripeService.loadStripe(
|
||||
{
|
||||
cardNumber: "#stripe-card-number",
|
||||
cardExpiry: "#stripe-card-expiry",
|
||||
cardCvc: "#stripe-card-cvc",
|
||||
},
|
||||
this.initialPaymentMethod === PaymentMethodType.Card,
|
||||
);
|
||||
|
||||
if (this.showPayPal) {
|
||||
this.braintreeService.loadBraintree(
|
||||
"#braintree-container",
|
||||
this.initialPaymentMethod === PaymentMethodType.PayPal,
|
||||
);
|
||||
}
|
||||
|
||||
this.formGroup
|
||||
.get("paymentMethod")
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((type) => {
|
||||
this.onPaymentMethodChange(type);
|
||||
});
|
||||
}
|
||||
|
||||
/** Programmatically select the provided payment method. */
|
||||
select = (paymentMethod: PaymentMethodType) => {
|
||||
this.formGroup.value.paymentMethod = paymentMethod;
|
||||
};
|
||||
|
||||
protected submit = async () => {
|
||||
const { type, token } = await this.tokenize();
|
||||
await this.onSubmit({ type, token });
|
||||
this.submitted.emit(type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tokenize the payment method information entered by the user against one of our payment providers.
|
||||
*
|
||||
* - {@link PaymentMethodType.Card} => [Stripe.confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup}
|
||||
* - {@link PaymentMethodType.BankAccount} => [Stripe.confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup}
|
||||
* - {@link PaymentMethodType.PayPal} => [Braintree.requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod}
|
||||
* */
|
||||
async tokenize(): Promise<{ type: PaymentMethodType; token: string }> {
|
||||
const type = this.selected;
|
||||
|
||||
if (this.usingStripe) {
|
||||
const clientSecret = await this.billingApiService.createSetupIntent(type);
|
||||
|
||||
if (this.usingBankAccount) {
|
||||
const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, {
|
||||
accountHolderName: this.formGroup.value.bankInformation.accountHolderName,
|
||||
routingNumber: this.formGroup.value.bankInformation.routingNumber,
|
||||
accountNumber: this.formGroup.value.bankInformation.accountNumber,
|
||||
accountHolderType: this.formGroup.value.bankInformation.accountHolderType,
|
||||
});
|
||||
return {
|
||||
type,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.usingCard) {
|
||||
const token = await this.stripeService.setupCardPaymentMethod(clientSecret);
|
||||
return {
|
||||
type,
|
||||
token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.usingPayPal) {
|
||||
const token = await this.braintreeService.requestPaymentMethod();
|
||||
return {
|
||||
type,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.stripeService.unloadStripe();
|
||||
if (this.showPayPal) {
|
||||
this.braintreeService.unloadBraintree();
|
||||
}
|
||||
}
|
||||
|
||||
private onPaymentMethodChange(type: PaymentMethodType): void {
|
||||
switch (type) {
|
||||
case PaymentMethodType.Card: {
|
||||
this.stripeService.mountElements();
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal: {
|
||||
this.braintreeService.createDropin();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get selected(): PaymentMethodType {
|
||||
return this.formGroup.value.paymentMethod;
|
||||
}
|
||||
|
||||
protected get usingAccountCredit(): boolean {
|
||||
return this.selected === PaymentMethodType.Credit;
|
||||
}
|
||||
|
||||
protected get usingBankAccount(): boolean {
|
||||
return this.selected === PaymentMethodType.BankAccount;
|
||||
}
|
||||
|
||||
protected get usingCard(): boolean {
|
||||
return this.selected === PaymentMethodType.Card;
|
||||
}
|
||||
|
||||
protected get usingPayPal(): boolean {
|
||||
return this.selected === PaymentMethodType.PayPal;
|
||||
}
|
||||
|
||||
private get usingStripe(): boolean {
|
||||
return this.usingBankAccount || this.usingCard;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@Component({
|
||||
selector: "app-payment",
|
||||
@@ -0,0 +1,18 @@
|
||||
<app-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
<p>{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}</p>
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-form-field class="tw-mr-2 tw-w-40">
|
||||
<bit-label>{{ "amountX" | i18n: "1" }}</bit-label>
|
||||
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount1" />
|
||||
<span bitPrefix>$0.</span>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-mr-2 tw-w-40">
|
||||
<bit-label>{{ "amountX" | i18n: "2" }}</bit-label>
|
||||
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount2" />
|
||||
<span bitPrefix>$0.</span>
|
||||
</bit-form-field>
|
||||
<button *ngIf="onSubmit" type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
</app-callout>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
|
||||
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@Component({
|
||||
selector: "app-verify-bank-account",
|
||||
templateUrl: "./verify-bank-account.component.html",
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class VerifyBankAccountComponent {
|
||||
@Input() onSubmit?: (request: VerifyBankAccountRequest) => Promise<void>;
|
||||
@Output() submitted = new EventEmitter();
|
||||
|
||||
protected formGroup = this.formBuilder.group({
|
||||
amount1: new FormControl<number>(null, [
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(99),
|
||||
]),
|
||||
amount2: new FormControl<number>(null, [
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(99),
|
||||
]),
|
||||
});
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
|
||||
submit = async () => {
|
||||
const request = new VerifyBankAccountRequest(
|
||||
this.formGroup.value.amount1,
|
||||
this.formGroup.value.amount2,
|
||||
);
|
||||
await this.onSubmit(request);
|
||||
this.submitted.emit();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user