Browse Source
* Add billable-entity * Add payment types * Add billing.client * Update stripe.service * Add payment method components * Add address.pipe * Add billing address components * Add account credit components * Add component index * Add feature flag * Re-work organization warnings code * Add organization-payment-details.component * Backfill translations * Set up organization FF routing * Add account-payment-details.component * Set up account FF routing * Add provider-payment-details.component * Set up provider FF routing * Use inline component templates for re-usable payment components * Remove errant rebase file * Removed public accessibility modifier * Fix failing testpull/15559/head
60 changed files with 4268 additions and 151 deletions
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
<bit-container> |
||||
@let view = view$ | async; |
||||
@if (!view) { |
||||
<ng-container> |
||||
<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> |
||||
} @else { |
||||
<ng-container> |
||||
<app-display-payment-method |
||||
[owner]="view.account" |
||||
[paymentMethod]="view.paymentMethod" |
||||
(updated)="setPaymentMethod($event)" |
||||
></app-display-payment-method> |
||||
|
||||
<app-display-account-credit |
||||
[owner]="view.account" |
||||
[credit]="view.credit" |
||||
></app-display-account-credit> |
||||
</ng-container> |
||||
} |
||||
</bit-container> |
||||
@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
|
||||
import { Component } from "@angular/core"; |
||||
import { ActivatedRoute, Router } from "@angular/router"; |
||||
import { |
||||
BehaviorSubject, |
||||
EMPTY, |
||||
filter, |
||||
from, |
||||
map, |
||||
merge, |
||||
Observable, |
||||
shareReplay, |
||||
switchMap, |
||||
tap, |
||||
} from "rxjs"; |
||||
import { catchError } from "rxjs/operators"; |
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; |
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; |
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; |
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module"; |
||||
import { SharedModule } from "../../../shared"; |
||||
import { |
||||
DisplayAccountCreditComponent, |
||||
DisplayPaymentMethodComponent, |
||||
} from "../../payment/components"; |
||||
import { MaskedPaymentMethod } from "../../payment/types"; |
||||
import { BillingClient } from "../../services"; |
||||
import { accountToBillableEntity, BillableEntity } from "../../types"; |
||||
|
||||
class RedirectError { |
||||
constructor( |
||||
public path: string[], |
||||
public relativeTo: ActivatedRoute, |
||||
) {} |
||||
} |
||||
|
||||
type View = { |
||||
account: BillableEntity; |
||||
paymentMethod: MaskedPaymentMethod | null; |
||||
credit: number | null; |
||||
}; |
||||
|
||||
@Component({ |
||||
templateUrl: "./account-payment-details.component.html", |
||||
standalone: true, |
||||
imports: [ |
||||
DisplayAccountCreditComponent, |
||||
DisplayPaymentMethodComponent, |
||||
HeaderModule, |
||||
SharedModule, |
||||
], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class AccountPaymentDetailsComponent { |
||||
private viewState$ = new BehaviorSubject<View | null>(null); |
||||
|
||||
private load$: Observable<View> = this.accountService.activeAccount$.pipe( |
||||
switchMap((account) => |
||||
this.configService |
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) |
||||
.pipe( |
||||
map((managePaymentDetailsOutsideCheckout) => { |
||||
if (!managePaymentDetailsOutsideCheckout) { |
||||
throw new RedirectError(["../payment-method"], this.activatedRoute); |
||||
} |
||||
return account; |
||||
}), |
||||
), |
||||
), |
||||
accountToBillableEntity, |
||||
switchMap(async (account) => { |
||||
const [paymentMethod, credit] = await Promise.all([ |
||||
this.billingClient.getPaymentMethod(account), |
||||
this.billingClient.getCredit(account), |
||||
]); |
||||
|
||||
return { |
||||
account, |
||||
paymentMethod, |
||||
credit, |
||||
}; |
||||
}), |
||||
shareReplay({ bufferSize: 1, refCount: false }), |
||||
catchError((error: unknown) => { |
||||
if (error instanceof RedirectError) { |
||||
return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( |
||||
switchMap(() => EMPTY), |
||||
); |
||||
} |
||||
throw error; |
||||
}), |
||||
); |
||||
|
||||
view$: Observable<View> = 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 })); |
||||
|
||||
constructor( |
||||
private accountService: AccountService, |
||||
private activatedRoute: ActivatedRoute, |
||||
private billingClient: BillingClient, |
||||
private configService: ConfigService, |
||||
private router: Router, |
||||
) {} |
||||
|
||||
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { |
||||
if (this.viewState$.value) { |
||||
this.viewState$.next({ |
||||
...this.viewState$.value, |
||||
paymentMethod, |
||||
}); |
||||
} |
||||
}; |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
@let organization = organization$ | async; |
||||
@if (organization) { |
||||
<app-organization-free-trial-warning |
||||
[organization]="organization" |
||||
(clicked)="changePaymentMethod()" |
||||
> |
||||
</app-organization-free-trial-warning> |
||||
} |
||||
<app-header></app-header> |
||||
<bit-container> |
||||
@let view = view$ | async; |
||||
@if (!view) { |
||||
<ng-container> |
||||
<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> |
||||
} @else { |
||||
<ng-container> |
||||
<app-display-payment-method |
||||
[owner]="view.organization" |
||||
[paymentMethod]="view.paymentMethod" |
||||
(updated)="setPaymentMethod($event)" |
||||
></app-display-payment-method> |
||||
|
||||
<app-display-billing-address |
||||
[owner]="view.organization" |
||||
[billingAddress]="view.billingAddress" |
||||
(updated)="setBillingAddress($event)" |
||||
></app-display-billing-address> |
||||
|
||||
<app-display-account-credit |
||||
[owner]="view.organization" |
||||
[credit]="view.credit" |
||||
></app-display-account-credit> |
||||
</ng-container> |
||||
} |
||||
</bit-container> |
||||
@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
|
||||
import { Component, OnInit, ViewChild } from "@angular/core"; |
||||
import { ActivatedRoute, Router } from "@angular/router"; |
||||
import { |
||||
BehaviorSubject, |
||||
catchError, |
||||
EMPTY, |
||||
filter, |
||||
firstValueFrom, |
||||
from, |
||||
lastValueFrom, |
||||
map, |
||||
merge, |
||||
Observable, |
||||
shareReplay, |
||||
switchMap, |
||||
tap, |
||||
} from "rxjs"; |
||||
|
||||
import { |
||||
getOrganizationById, |
||||
OrganizationService, |
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; |
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; |
||||
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 { DialogService } from "@bitwarden/components"; |
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module"; |
||||
import { SharedModule } from "../../../shared"; |
||||
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"; |
||||
|
||||
class RedirectError { |
||||
constructor( |
||||
public path: string[], |
||||
public relativeTo: ActivatedRoute, |
||||
) {} |
||||
} |
||||
|
||||
type View = { |
||||
organization: BillableEntity; |
||||
paymentMethod: MaskedPaymentMethod | null; |
||||
billingAddress: BillingAddress | null; |
||||
credit: number | null; |
||||
}; |
||||
|
||||
@Component({ |
||||
templateUrl: "./organization-payment-details.component.html", |
||||
standalone: true, |
||||
imports: [ |
||||
DisplayBillingAddressComponent, |
||||
DisplayAccountCreditComponent, |
||||
DisplayPaymentMethodComponent, |
||||
HeaderModule, |
||||
OrganizationFreeTrialWarningComponent, |
||||
SharedModule, |
||||
], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class OrganizationPaymentDetailsComponent implements OnInit { |
||||
@ViewChild(OrganizationFreeTrialWarningComponent) |
||||
organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent; |
||||
|
||||
private viewState$ = new BehaviorSubject<View | null>(null); |
||||
|
||||
private load$: Observable<View> = 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), |
||||
]); |
||||
|
||||
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; |
||||
}), |
||||
); |
||||
|
||||
view$: Observable<View> = 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)); |
||||
|
||||
constructor( |
||||
private accountService: AccountService, |
||||
private activatedRoute: ActivatedRoute, |
||||
private billingClient: BillingClient, |
||||
private configService: ConfigService, |
||||
private dialogService: DialogService, |
||||
private organizationService: OrganizationService, |
||||
private router: Router, |
||||
) {} |
||||
|
||||
async ngOnInit() { |
||||
const openChangePaymentMethodDialogOnStart = |
||||
(history.state?.launchPaymentModalAutomatically as boolean) ?? false; |
||||
|
||||
if (openChangePaymentMethodDialogOnStart) { |
||||
history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); |
||||
await this.changePaymentMethod(); |
||||
} |
||||
} |
||||
|
||||
changePaymentMethod = async () => { |
||||
const view = await firstValueFrom(this.view$); |
||||
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { |
||||
data: { |
||||
owner: view.organization, |
||||
}, |
||||
}); |
||||
const result = await lastValueFrom(dialogRef.closed); |
||||
if (result?.type === "success") { |
||||
this.setPaymentMethod(result.paymentMethod); |
||||
if (!view.billingAddress && result.paymentMethod.type !== "payPal") { |
||||
const billingAddress = await this.billingClient.getBillingAddress(view.organization); |
||||
if (billingAddress) { |
||||
this.setBillingAddress(billingAddress); |
||||
} |
||||
} |
||||
this.organizationFreeTrialWarningComponent.refresh(); |
||||
} |
||||
}; |
||||
|
||||
setBillingAddress = (billingAddress: BillingAddress) => { |
||||
if (this.viewState$.value) { |
||||
this.viewState$.next({ |
||||
...this.viewState$.value, |
||||
billingAddress, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { |
||||
if (this.viewState$.value) { |
||||
this.viewState$.next({ |
||||
...this.viewState$.value, |
||||
paymentMethod, |
||||
}); |
||||
} |
||||
}; |
||||
} |
||||
@ -0,0 +1,241 @@
@@ -0,0 +1,241 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog"; |
||||
import { Component, ElementRef, Inject, ViewChild } from "@angular/core"; |
||||
import { |
||||
AbstractControl, |
||||
FormControl, |
||||
FormGroup, |
||||
ValidationErrors, |
||||
ValidatorFn, |
||||
Validators, |
||||
} from "@angular/forms"; |
||||
import { map } from "rxjs"; |
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; |
||||
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 { SharedModule } from "../../../shared"; |
||||
import { BillingClient } from "../../services"; |
||||
import { BillableEntity } from "../../types"; |
||||
|
||||
type DialogParams = { |
||||
owner: BillableEntity; |
||||
}; |
||||
|
||||
type DialogResult = "cancelled" | "error" | "launched"; |
||||
|
||||
type PayPalConfig = { |
||||
businessId: string; |
||||
buttonAction: string; |
||||
}; |
||||
|
||||
declare const process: { |
||||
env: { |
||||
PAYPAL_CONFIG: PayPalConfig; |
||||
}; |
||||
}; |
||||
|
||||
const positiveNumberValidator = |
||||
(message: string): ValidatorFn => |
||||
(control: AbstractControl): ValidationErrors | null => { |
||||
if (!control.value) { |
||||
return null; |
||||
} |
||||
|
||||
const value = parseFloat(control.value); |
||||
|
||||
if (isNaN(value) || value <= 0) { |
||||
return { notPositiveNumber: { message } }; |
||||
} |
||||
|
||||
return null; |
||||
}; |
||||
|
||||
@Component({ |
||||
template: ` |
||||
<form [formGroup]="formGroup" [bitSubmit]="submit"> |
||||
<bit-dialog> |
||||
<span bitDialogTitle class="tw-font-semibold"> |
||||
{{ "addCredit" | i18n }} |
||||
</span> |
||||
<div bitDialogContent> |
||||
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p> |
||||
<div class="tw-grid tw-grid-cols-2"> |
||||
<bit-radio-group [formControl]="formGroup.controls.paymentMethod"> |
||||
<bit-radio-button id="credit-method-paypal" [value]="'payPal'"> |
||||
<bit-label> <i class="bwi bwi-paypal"></i>PayPal</bit-label> |
||||
</bit-radio-button> |
||||
<bit-radio-button id="credit-method-bitcoin" [value]="'bitPay'"> |
||||
<bit-label> <i class="bwi bwi-bitcoin"></i>Bitcoin</bit-label> |
||||
</bit-radio-button> |
||||
</bit-radio-group> |
||||
</div> |
||||
<div class="tw-grid tw-grid-cols-2"> |
||||
<bit-form-field> |
||||
<bit-label>{{ "amount" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
[formControl]="formGroup.controls.amount" |
||||
type="text" |
||||
(blur)="formatAmount()" |
||||
required |
||||
/> |
||||
<span bitPrefix>$USD</span> |
||||
</bit-form-field> |
||||
</div> |
||||
</div> |
||||
<ng-container bitDialogFooter> |
||||
<button type="submit" bitButton bitFormButton buttonType="primary"> |
||||
{{ "submit" | i18n }} |
||||
</button> |
||||
<button |
||||
type="button" |
||||
bitButton |
||||
bitFormButton |
||||
buttonType="secondary" |
||||
[bitDialogClose]="'cancelled'" |
||||
> |
||||
{{ "cancel" | i18n }} |
||||
</button> |
||||
</ng-container> |
||||
</bit-dialog> |
||||
</form> |
||||
<form #payPalForm action="{{ payPalConfig.buttonAction }}" method="post" target="_top"> |
||||
<input type="hidden" name="cmd" value="_xclick" /> |
||||
<input type="hidden" name="business" value="{{ payPalConfig.businessId }}" /> |
||||
<input type="hidden" name="button_subtype" value="services" /> |
||||
<input type="hidden" name="no_note" value="1" /> |
||||
<input type="hidden" name="no_shipping" value="1" /> |
||||
<input type="hidden" name="rm" value="1" /> |
||||
<input type="hidden" name="return" value="{{ redirectUrl }}" /> |
||||
<input type="hidden" name="cancel_return" value="{{ redirectUrl }}" /> |
||||
<input type="hidden" name="currency_code" value="USD" /> |
||||
<input |
||||
type="hidden" |
||||
name="image_url" |
||||
value="https://bitwarden.com/images/paypal-banner.png" |
||||
/> |
||||
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" /> |
||||
<input type="hidden" name="amount" value="{{ amount }}" /> |
||||
<input type="hidden" name="custom" value="{{ payPalCustom$ | async }}" /> |
||||
<input type="hidden" name="item_name" value="Bitwarden Account Credit" /> |
||||
<input type="hidden" name="item_number" value="{{ payPalSubject }}" /> |
||||
</form> |
||||
`,
|
||||
standalone: true, |
||||
imports: [SharedModule], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class AddAccountCreditDialogComponent { |
||||
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; |
||||
|
||||
protected payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; |
||||
protected redirectUrl = window.location.href; |
||||
|
||||
protected formGroup = new FormGroup({ |
||||
paymentMethod: new FormControl<"payPal" | "bitPay">("payPal"), |
||||
amount: new FormControl<string | null>("0.00", [ |
||||
Validators.required, |
||||
positiveNumberValidator(this.i18nService.t("mustBePositiveNumber")), |
||||
]), |
||||
}); |
||||
|
||||
protected payPalCustom$ = this.configService.cloudRegion$.pipe( |
||||
map((cloudRegion) => { |
||||
switch (this.dialogParams.owner.type) { |
||||
case "account": { |
||||
return `user_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; |
||||
} |
||||
case "organization": { |
||||
return `organization_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; |
||||
} |
||||
case "provider": { |
||||
return `provider_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; |
||||
} |
||||
} |
||||
}), |
||||
); |
||||
|
||||
constructor( |
||||
private billingClient: BillingClient, |
||||
private configService: ConfigService, |
||||
@Inject(DIALOG_DATA) private dialogParams: DialogParams, |
||||
private dialogRef: DialogRef<DialogResult>, |
||||
private i18nService: I18nService, |
||||
private platformUtilsService: PlatformUtilsService, |
||||
private toastService: ToastService, |
||||
) {} |
||||
|
||||
submit = async (): Promise<void> => { |
||||
this.formGroup.markAllAsTouched(); |
||||
|
||||
if (!this.formGroup.valid) { |
||||
return; |
||||
} |
||||
|
||||
if (this.formGroup.value.paymentMethod === "bitPay") { |
||||
const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, { |
||||
amount: this.amount!, |
||||
redirectUrl: this.redirectUrl, |
||||
}); |
||||
|
||||
switch (result.type) { |
||||
case "success": { |
||||
this.platformUtilsService.launchUri(result.value); |
||||
this.dialogRef.close("launched"); |
||||
break; |
||||
} |
||||
case "error": { |
||||
this.toastService.showToast({ |
||||
variant: "error", |
||||
title: "", |
||||
message: result.message, |
||||
}); |
||||
this.dialogRef.close("error"); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
this.payPalForm.nativeElement.submit(); |
||||
this.dialogRef.close("launched"); |
||||
}; |
||||
|
||||
formatAmount = (): void => { |
||||
if (this.formGroup.value.amount) { |
||||
const amount = parseFloat(this.formGroup.value.amount); |
||||
if (isNaN(amount)) { |
||||
this.formGroup.controls.amount.setValue(null); |
||||
} else { |
||||
this.formGroup.controls.amount.setValue(amount.toFixed(2).toString()); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
get amount(): number | null { |
||||
if (this.formGroup.value.amount) { |
||||
const amount = parseFloat(this.formGroup.value.amount); |
||||
if (isNaN(amount)) { |
||||
return null; |
||||
} |
||||
return amount; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
get payPalSubject(): string { |
||||
switch (this.dialogParams.owner.type) { |
||||
case "account": { |
||||
return this.dialogParams.owner.data.email; |
||||
} |
||||
case "organization": |
||||
case "provider": { |
||||
return this.dialogParams.owner.data.name; |
||||
} |
||||
} |
||||
} |
||||
|
||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) => |
||||
dialogService.open<DialogResult>(AddAccountCreditDialogComponent, dialogConfig); |
||||
} |
||||
@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog"; |
||||
import { Component, Inject, ViewChild } from "@angular/core"; |
||||
|
||||
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 { MaskedPaymentMethod } from "../types"; |
||||
|
||||
import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; |
||||
|
||||
type DialogParams = { |
||||
owner: BillableEntity; |
||||
}; |
||||
|
||||
type DialogResult = |
||||
| { type: "cancelled" } |
||||
| { type: "error" } |
||||
| { type: "success"; paymentMethod: MaskedPaymentMethod }; |
||||
|
||||
@Component({ |
||||
template: ` |
||||
<form [formGroup]="formGroup" [bitSubmit]="submit"> |
||||
<bit-dialog> |
||||
<span bitDialogTitle class="tw-font-semibold"> |
||||
{{ "changePaymentMethod" | i18n }} |
||||
</span> |
||||
<div bitDialogContent> |
||||
<app-enter-payment-method [group]="formGroup" [includeBillingAddress]="true"> |
||||
</app-enter-payment-method> |
||||
</div> |
||||
<ng-container bitDialogFooter> |
||||
<button bitButton bitFormButton buttonType="primary" type="submit"> |
||||
{{ "save" | i18n }} |
||||
</button> |
||||
<button |
||||
bitButton |
||||
buttonType="secondary" |
||||
type="button" |
||||
[bitDialogClose]="{ type: 'cancelled' }" |
||||
> |
||||
{{ "cancel" | i18n }} |
||||
</button> |
||||
</ng-container> |
||||
</bit-dialog> |
||||
</form> |
||||
`,
|
||||
standalone: true, |
||||
imports: [EnterPaymentMethodComponent, SharedModule], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class ChangePaymentMethodDialogComponent { |
||||
@ViewChild(EnterPaymentMethodComponent) |
||||
private enterPaymentMethodComponent!: EnterPaymentMethodComponent; |
||||
protected formGroup = EnterPaymentMethodComponent.getFormGroup(); |
||||
|
||||
constructor( |
||||
private billingClient: BillingClient, |
||||
@Inject(DIALOG_DATA) protected dialogParams: DialogParams, |
||||
private dialogRef: DialogRef<DialogResult>, |
||||
private i18nService: I18nService, |
||||
private toastService: ToastService, |
||||
) {} |
||||
|
||||
submit = async () => { |
||||
this.formGroup.markAllAsTouched(); |
||||
|
||||
if (!this.formGroup.valid) { |
||||
return; |
||||
} |
||||
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); |
||||
const billingAddress = |
||||
this.formGroup.value.type !== "payPal" |
||||
? this.formGroup.controls.billingAddress.getRawValue() |
||||
: null; |
||||
|
||||
const result = await this.billingClient.updatePaymentMethod( |
||||
this.dialogParams.owner, |
||||
paymentMethod, |
||||
billingAddress, |
||||
); |
||||
|
||||
switch (result.type) { |
||||
case "success": { |
||||
this.toastService.showToast({ |
||||
variant: "success", |
||||
title: "", |
||||
message: this.i18nService.t("paymentMethodUpdated"), |
||||
}); |
||||
this.dialogRef.close({ |
||||
type: "success", |
||||
paymentMethod: result.value, |
||||
}); |
||||
break; |
||||
} |
||||
case "error": { |
||||
this.toastService.showToast({ |
||||
variant: "error", |
||||
title: "", |
||||
message: result.message, |
||||
}); |
||||
this.dialogRef.close({ type: "error" }); |
||||
break; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) => |
||||
dialogService.open<DialogResult>(ChangePaymentMethodDialogComponent, dialogConfig); |
||||
} |
||||
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
import { CurrencyPipe } from "@angular/common"; |
||||
import { Component, Input } from "@angular/core"; |
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; |
||||
import { DialogService, ToastService } from "@bitwarden/components"; |
||||
|
||||
import { SharedModule } from "../../../shared"; |
||||
import { BillingClient } from "../../services"; |
||||
import { BillableEntity } from "../../types"; |
||||
|
||||
import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; |
||||
|
||||
@Component({ |
||||
selector: "app-display-account-credit", |
||||
template: ` |
||||
<bit-section> |
||||
<h2 bitTypography="h2">{{ "accountCredit" | i18n }}: {{ formattedCredit }}</h2> |
||||
<p>{{ "availableCreditAppliedToInvoice" | i18n }}</p> |
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit"> |
||||
{{ "addCredit" | i18n }} |
||||
</button> |
||||
</bit-section> |
||||
`,
|
||||
standalone: true, |
||||
imports: [SharedModule], |
||||
providers: [BillingClient, CurrencyPipe], |
||||
}) |
||||
export class DisplayAccountCreditComponent { |
||||
@Input({ required: true }) owner!: BillableEntity; |
||||
@Input({ required: true }) credit!: number | null; |
||||
|
||||
constructor( |
||||
private billingClient: BillingClient, |
||||
private currencyPipe: CurrencyPipe, |
||||
private dialogService: DialogService, |
||||
private i18nService: I18nService, |
||||
private toastService: ToastService, |
||||
) {} |
||||
|
||||
addAccountCredit = async () => { |
||||
if (this.owner.type !== "account") { |
||||
const billingAddress = await this.billingClient.getBillingAddress(this.owner); |
||||
if (!billingAddress) { |
||||
this.toastService.showToast({ |
||||
variant: "error", |
||||
title: "", |
||||
message: this.i18nService.t("billingAddressRequiredToAddCredit"), |
||||
}); |
||||
} |
||||
} |
||||
|
||||
AddAccountCreditDialogComponent.open(this.dialogService, { |
||||
data: { |
||||
owner: this.owner, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
get formattedCredit(): string | null { |
||||
const credit = this.credit ?? 0; |
||||
return this.currencyPipe.transform(credit, "$"); |
||||
} |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
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"; |
||||
|
||||
@Component({ |
||||
selector: "app-display-billing-address", |
||||
template: ` |
||||
<bit-section> |
||||
<h2 bitTypography="h2">{{ "billingAddress" | i18n }}</h2> |
||||
@if (billingAddress) { |
||||
<p>{{ billingAddress | address }}</p> |
||||
@if (billingAddress.taxId) { |
||||
<p>{{ "taxId" | i18n: billingAddress.taxId.value }}</p> |
||||
} |
||||
} @else { |
||||
<p>{{ "noBillingAddress" | i18n }}</p> |
||||
} |
||||
@let key = billingAddress ? "editBillingAddress" : "addBillingAddress"; |
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="editBillingAddress"> |
||||
{{ key | i18n }} |
||||
</button> |
||||
</bit-section> |
||||
`,
|
||||
standalone: true, |
||||
imports: [AddressPipe, SharedModule], |
||||
}) |
||||
export class DisplayBillingAddressComponent { |
||||
@Input({ required: true }) owner!: BillableEntity; |
||||
@Input({ required: true }) billingAddress!: BillingAddress | null; |
||||
@Output() updated = new EventEmitter<BillingAddress>(); |
||||
|
||||
constructor(private dialogService: DialogService) {} |
||||
|
||||
editBillingAddress = async (): Promise<void> => { |
||||
const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { |
||||
data: { |
||||
owner: this.owner, |
||||
billingAddress: this.billingAddress, |
||||
}, |
||||
}); |
||||
|
||||
const result = await lastValueFrom(dialogRef.closed); |
||||
|
||||
if (result?.type === "success") { |
||||
this.updated.emit(result.billingAddress); |
||||
} |
||||
}; |
||||
} |
||||
@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
|
||||
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 { MaskedPaymentMethod } from "../types"; |
||||
|
||||
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; |
||||
import { VerifyBankAccountComponent } from "./verify-bank-account.component"; |
||||
|
||||
@Component({ |
||||
selector: "app-display-payment-method", |
||||
template: ` |
||||
<bit-section> |
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2> |
||||
@if (paymentMethod) { |
||||
@switch (paymentMethod.type) { |
||||
@case ("bankAccount") { |
||||
@if (!paymentMethod.verified) { |
||||
<app-verify-bank-account [owner]="owner" (verified)="onBankAccountVerified($event)"> |
||||
</app-verify-bank-account> |
||||
} |
||||
|
||||
<p> |
||||
<i class="bwi bwi-fw bwi-billing"></i> |
||||
{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} |
||||
@if (!paymentMethod.verified) { |
||||
<span>- {{ "unverified" | i18n }}</span> |
||||
} |
||||
</p> |
||||
} |
||||
@case ("card") { |
||||
<p class="tw-flex tw-items-center tw-gap-2"> |
||||
@let brandIcon = getBrandIconForCard(); |
||||
@if (brandIcon !== null) { |
||||
<i class="bwi bwi-fw credit-card-icon {{ brandIcon }}"></i> |
||||
} @else { |
||||
<i class="bwi bwi-fw bwi-credit-card"></i> |
||||
} |
||||
{{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }}, |
||||
{{ paymentMethod.expiration }} |
||||
</p> |
||||
} |
||||
@case ("payPal") { |
||||
<p> |
||||
<i class="bwi bwi-fw bwi-paypal tw-text-primary-600"></i> |
||||
{{ paymentMethod.email }} |
||||
</p> |
||||
} |
||||
} |
||||
} @else { |
||||
<p bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p> |
||||
} |
||||
@let key = paymentMethod ? "changePaymentMethod" : "addPaymentMethod"; |
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="changePaymentMethod"> |
||||
{{ key | i18n }} |
||||
</button> |
||||
</bit-section> |
||||
`,
|
||||
standalone: true, |
||||
imports: [SharedModule, VerifyBankAccountComponent], |
||||
}) |
||||
export class DisplayPaymentMethodComponent { |
||||
@Input({ required: true }) owner!: BillableEntity; |
||||
@Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; |
||||
@Output() updated = new EventEmitter<MaskedPaymentMethod>(); |
||||
|
||||
protected availableCardIcons: Record<string, string> = { |
||||
amex: "card-amex", |
||||
diners: "card-diners-club", |
||||
discover: "card-discover", |
||||
jcb: "card-jcb", |
||||
mastercard: "card-mastercard", |
||||
unionpay: "card-unionpay", |
||||
visa: "card-visa", |
||||
}; |
||||
|
||||
constructor(private dialogService: DialogService) {} |
||||
|
||||
changePaymentMethod = async (): Promise<void> => { |
||||
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { |
||||
data: { |
||||
owner: this.owner, |
||||
}, |
||||
}); |
||||
|
||||
const result = await lastValueFrom(dialogRef.closed); |
||||
|
||||
if (result?.type === "success") { |
||||
this.updated.emit(result.paymentMethod); |
||||
} |
||||
}; |
||||
|
||||
onBankAccountVerified = (paymentMethod: MaskedPaymentMethod) => this.updated.emit(paymentMethod); |
||||
|
||||
protected getBrandIconForCard = (): string | null => { |
||||
if (this.paymentMethod?.type !== "card") { |
||||
return null; |
||||
} |
||||
|
||||
return this.paymentMethod.brand in this.availableCardIcons |
||||
? this.availableCardIcons[this.paymentMethod.brand] |
||||
: null; |
||||
}; |
||||
} |
||||
@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog"; |
||||
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 { EnterBillingAddressComponent } from "./enter-billing-address.component"; |
||||
|
||||
type DialogParams = { |
||||
owner: BillableEntity; |
||||
billingAddress: BillingAddress | null; |
||||
}; |
||||
|
||||
type DialogResult = |
||||
| { type: "cancelled" } |
||||
| { type: "error" } |
||||
| { type: "success"; billingAddress: BillingAddress }; |
||||
|
||||
@Component({ |
||||
template: ` |
||||
<form [formGroup]="formGroup" [bitSubmit]="submit"> |
||||
<bit-dialog> |
||||
<span bitDialogTitle class="tw-font-semibold"> |
||||
{{ "editBillingAddress" | i18n }} |
||||
</span> |
||||
<div bitDialogContent> |
||||
<app-enter-billing-address |
||||
[scenario]="{ |
||||
type: 'update', |
||||
existing: dialogParams.billingAddress, |
||||
supportsTaxId, |
||||
}" |
||||
[group]="formGroup" |
||||
></app-enter-billing-address> |
||||
</div> |
||||
<ng-container bitDialogFooter> |
||||
<button bitButton bitFormButton buttonType="primary" type="submit"> |
||||
{{ "save" | i18n }} |
||||
</button> |
||||
<button |
||||
bitButton |
||||
buttonType="secondary" |
||||
type="button" |
||||
[bitDialogClose]="{ type: 'cancelled' }" |
||||
> |
||||
{{ "cancel" | i18n }} |
||||
</button> |
||||
</ng-container> |
||||
</bit-dialog> |
||||
</form> |
||||
`,
|
||||
standalone: true, |
||||
imports: [EnterBillingAddressComponent, SharedModule], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class EditBillingAddressDialogComponent { |
||||
protected formGroup = EnterBillingAddressComponent.getFormGroup(); |
||||
|
||||
constructor( |
||||
private billingClient: BillingClient, |
||||
@Inject(DIALOG_DATA) protected dialogParams: DialogParams, |
||||
private dialogRef: DialogRef<DialogResult>, |
||||
private i18nService: I18nService, |
||||
private toastService: ToastService, |
||||
) { |
||||
if (dialogParams.billingAddress) { |
||||
this.formGroup.patchValue({ |
||||
...dialogParams.billingAddress, |
||||
taxId: dialogParams.billingAddress.taxId?.value, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
submit = async (): Promise<void> => { |
||||
this.formGroup.markAllAsTouched(); |
||||
|
||||
if (this.formGroup.invalid) { |
||||
return; |
||||
} |
||||
|
||||
const { taxId, ...addressFields } = this.formGroup.getRawValue(); |
||||
|
||||
const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; |
||||
|
||||
const billingAddress = taxIdType |
||||
? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } |
||||
: { ...addressFields, taxId: null }; |
||||
|
||||
const result = await this.billingClient.updateBillingAddress( |
||||
this.dialogParams.owner, |
||||
billingAddress, |
||||
); |
||||
|
||||
switch (result.type) { |
||||
case "success": { |
||||
this.toastService.showToast({ |
||||
variant: "success", |
||||
title: "", |
||||
message: this.i18nService.t("billingAddressUpdated"), |
||||
}); |
||||
this.dialogRef.close({ |
||||
type: "success", |
||||
billingAddress: result.value, |
||||
}); |
||||
break; |
||||
} |
||||
case "error": { |
||||
this.toastService.showToast({ |
||||
variant: "error", |
||||
title: "", |
||||
message: result.message, |
||||
}); |
||||
this.dialogRef.close({ |
||||
type: "error", |
||||
}); |
||||
break; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
get supportsTaxId(): boolean { |
||||
switch (this.dialogParams.owner.type) { |
||||
case "account": { |
||||
return false; |
||||
} |
||||
case "organization": { |
||||
return [ |
||||
ProductTierType.TeamsStarter, |
||||
ProductTierType.Teams, |
||||
ProductTierType.Enterprise, |
||||
].includes(this.dialogParams.owner.data.productTierType); |
||||
} |
||||
case "provider": { |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) => |
||||
dialogService.open<DialogResult>(EditBillingAddressDialogComponent, dialogConfig); |
||||
} |
||||
@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from "@angular/core"; |
||||
import { FormControl, FormGroup, Validators } from "@angular/forms"; |
||||
import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; |
||||
|
||||
import { ControlsOf } from "@bitwarden/angular/types/controls-of"; |
||||
|
||||
import { SharedModule } from "../../../shared"; |
||||
import { BillingAddress, selectableCountries, taxIdTypes } from "../types"; |
||||
|
||||
export interface BillingAddressControls { |
||||
country: string; |
||||
postalCode: string; |
||||
line1: string | null; |
||||
line2: string | null; |
||||
city: string | null; |
||||
state: string | null; |
||||
taxId: string | null; |
||||
} |
||||
|
||||
export type BillingAddressFormGroup = FormGroup<ControlsOf<BillingAddressControls>>; |
||||
|
||||
type Scenario = |
||||
| { |
||||
type: "checkout"; |
||||
supportsTaxId: boolean; |
||||
} |
||||
| { |
||||
type: "update"; |
||||
existing?: BillingAddress; |
||||
supportsTaxId: boolean; |
||||
}; |
||||
|
||||
@Component({ |
||||
selector: "app-enter-billing-address", |
||||
template: ` |
||||
<form [formGroup]="group"> |
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4"> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "country" | i18n }}</bit-label> |
||||
<bit-select [formControl]="group.controls.country"> |
||||
@for (selectableCountry of selectableCountries; track selectableCountry.value) { |
||||
<bit-option |
||||
[value]="selectableCountry.value" |
||||
[disabled]="selectableCountry.disabled" |
||||
[label]="selectableCountry.name" |
||||
></bit-option> |
||||
} |
||||
</bit-select> |
||||
</bit-form-field> |
||||
</div> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.postalCode" |
||||
autocomplete="postal-code" |
||||
/> |
||||
</bit-form-field> |
||||
</div> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "address1" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.line1" |
||||
autocomplete="address-line1" |
||||
/> |
||||
</bit-form-field> |
||||
</div> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "address2" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.line2" |
||||
autocomplete="address-line2" |
||||
/> |
||||
</bit-form-field> |
||||
</div> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "cityTown" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.city" |
||||
autocomplete="address-level2" |
||||
/> |
||||
</bit-form-field> |
||||
</div> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "stateProvince" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.state" |
||||
autocomplete="address-level1" |
||||
/> |
||||
</bit-form-field> |
||||
</div> |
||||
@if (supportsTaxId$ | async) { |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label> |
||||
<input bitInput type="text" [formControl]="group.controls.taxId" /> |
||||
</bit-form-field> |
||||
</div> |
||||
} |
||||
</div> |
||||
</form> |
||||
`,
|
||||
standalone: true, |
||||
imports: [SharedModule], |
||||
}) |
||||
export class EnterBillingAddressComponent implements OnInit, OnDestroy { |
||||
@Input({ required: true }) scenario!: Scenario; |
||||
@Input({ required: true }) group!: BillingAddressFormGroup; |
||||
|
||||
protected selectableCountries = selectableCountries; |
||||
protected supportsTaxId$!: Observable<boolean>; |
||||
|
||||
private destroy$ = new Subject<void>(); |
||||
|
||||
ngOnInit() { |
||||
switch (this.scenario.type) { |
||||
case "checkout": { |
||||
this.disableAddressControls(); |
||||
break; |
||||
} |
||||
case "update": { |
||||
if (this.scenario.existing) { |
||||
this.group.patchValue({ |
||||
...this.scenario.existing, |
||||
taxId: this.scenario.existing.taxId?.value, |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe( |
||||
startWith(this.group.value.country ?? this.selectableCountries[0].value), |
||||
map((country) => { |
||||
if (!this.scenario.supportsTaxId) { |
||||
return false; |
||||
} |
||||
|
||||
return taxIdTypes.filter((taxIdType) => taxIdType.iso === country).length > 0; |
||||
}), |
||||
); |
||||
|
||||
this.supportsTaxId$.pipe(takeUntil(this.destroy$)).subscribe((supportsTaxId) => { |
||||
if (supportsTaxId) { |
||||
this.group.controls.taxId.enable(); |
||||
} else { |
||||
this.group.controls.taxId.disable(); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
ngOnDestroy() { |
||||
this.destroy$.next(); |
||||
this.destroy$.complete(); |
||||
} |
||||
|
||||
disableAddressControls = () => { |
||||
this.group.controls.line1.disable(); |
||||
this.group.controls.line2.disable(); |
||||
this.group.controls.city.disable(); |
||||
this.group.controls.state.disable(); |
||||
}; |
||||
|
||||
static getFormGroup = (): BillingAddressFormGroup => |
||||
new FormGroup({ |
||||
country: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
postalCode: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
line1: new FormControl<string | null>(null), |
||||
line2: new FormControl<string | null>(null), |
||||
city: new FormControl<string | null>(null), |
||||
state: new FormControl<string | null>(null), |
||||
taxId: new FormControl<string | null>(null), |
||||
}); |
||||
} |
||||
@ -0,0 +1,408 @@
@@ -0,0 +1,408 @@
|
||||
import { Component, Input, OnInit } from "@angular/core"; |
||||
import { FormControl, FormGroup, Validators } from "@angular/forms"; |
||||
import { BehaviorSubject, startWith, Subject, takeUntil } from "rxjs"; |
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; |
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; |
||||
import { PopoverModule, ToastService } from "@bitwarden/components"; |
||||
|
||||
import { SharedModule } from "../../../shared"; |
||||
import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; |
||||
import { PaymentLabelComponent } from "../../shared/payment/payment-label.component"; |
||||
import { |
||||
isTokenizablePaymentMethod, |
||||
selectableCountries, |
||||
TokenizablePaymentMethod, |
||||
TokenizedPaymentMethod, |
||||
} from "../types"; |
||||
|
||||
type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; |
||||
|
||||
type PaymentMethodFormGroup = FormGroup<{ |
||||
type: FormControl<PaymentMethodOption>; |
||||
bankAccount: FormGroup<{ |
||||
routingNumber: FormControl<string>; |
||||
accountNumber: FormControl<string>; |
||||
accountHolderName: FormControl<string>; |
||||
accountHolderType: FormControl<"" | "company" | "individual">; |
||||
}>; |
||||
billingAddress: FormGroup<{ |
||||
country: FormControl<string>; |
||||
postalCode: FormControl<string>; |
||||
}>; |
||||
}>; |
||||
|
||||
@Component({ |
||||
selector: "app-enter-payment-method", |
||||
template: ` |
||||
@let showBillingDetails = includeBillingAddress && selected !== "payPal"; |
||||
<form [formGroup]="group"> |
||||
@if (showBillingDetails) { |
||||
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5> |
||||
} |
||||
<div class="tw-mb-4 tw-text-lg"> |
||||
<bit-radio-group [formControl]="group.controls.type"> |
||||
<bit-radio-button id="card-payment-method" [value]="'card'"> |
||||
<bit-label> |
||||
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i> |
||||
{{ "creditCard" | i18n }} |
||||
</bit-label> |
||||
</bit-radio-button> |
||||
@if (showBankAccount) { |
||||
<bit-radio-button id="bank-payment-method" [value]="'bankAccount'"> |
||||
<bit-label> |
||||
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i> |
||||
{{ "bankAccount" | i18n }} |
||||
</bit-label> |
||||
</bit-radio-button> |
||||
} |
||||
@if (showPayPal) { |
||||
<bit-radio-button id="paypal-payment-method" [value]="'payPal'"> |
||||
<bit-label> |
||||
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i> |
||||
{{ "payPal" | i18n }} |
||||
</bit-label> |
||||
</bit-radio-button> |
||||
} |
||||
@if (showAccountCredit) { |
||||
<bit-radio-button id="credit-payment-method" [value]="'accountCredit'"> |
||||
<bit-label> |
||||
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i> |
||||
{{ "accountCredit" | i18n }} |
||||
</bit-label> |
||||
</bit-radio-button> |
||||
} |
||||
</bit-radio-group> |
||||
</div> |
||||
@switch (selected) { |
||||
@case ("card") { |
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4"> |
||||
<div class="tw-col-span-1"> |
||||
<app-payment-label for="stripe-card-number" required> |
||||
{{ "number" | i18n }} |
||||
</app-payment-label> |
||||
<div id="stripe-card-number" class="tw-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"> |
||||
<app-payment-label for="stripe-card-expiry" required> |
||||
{{ "expiration" | i18n }} |
||||
</app-payment-label> |
||||
<div id="stripe-card-expiry" class="tw-stripe-form-control"></div> |
||||
</div> |
||||
<div class="tw-col-span-1"> |
||||
<app-payment-label for="stripe-card-cvc" required> |
||||
{{ "securityCodeSlashCVV" | i18n }} |
||||
<button |
||||
[bitPopoverTriggerFor]="cardSecurityCodePopover" |
||||
type="button" |
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0" |
||||
[position]="'above-end'" |
||||
> |
||||
<i class="bwi bwi-question-circle tw-text-lg" aria-hidden="true"></i> |
||||
</button> |
||||
<bit-popover [title]="'cardSecurityCode' | i18n" #cardSecurityCodePopover> |
||||
<p>{{ "cardSecurityCodeDescription" | i18n }}</p> |
||||
</bit-popover> |
||||
</app-payment-label> |
||||
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div> |
||||
</div> |
||||
</div> |
||||
} |
||||
@case ("bankAccount") { |
||||
<ng-container> |
||||
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}"> |
||||
{{ "verifyBankAccountWarning" | i18n }} |
||||
</bit-callout> |
||||
<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-label>{{ "routingNumber" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
id="routingNumber" |
||||
type="text" |
||||
[formControl]="group.controls.bankAccount.controls.routingNumber" |
||||
required |
||||
/> |
||||
</bit-form-field> |
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true"> |
||||
<bit-label>{{ "accountNumber" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
id="accountNumber" |
||||
type="text" |
||||
[formControl]="group.controls.bankAccount.controls.accountNumber" |
||||
required |
||||
/> |
||||
</bit-form-field> |
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true"> |
||||
<bit-label>{{ "accountHolderName" | i18n }}</bit-label> |
||||
<input |
||||
id="accountHolderName" |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.bankAccount.controls.accountHolderName" |
||||
required |
||||
/> |
||||
</bit-form-field> |
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true"> |
||||
<bit-label>{{ "bankAccountType" | i18n }}</bit-label> |
||||
<bit-select |
||||
id="accountHolderType" |
||||
[formControl]="group.controls.bankAccount.controls.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> |
||||
} |
||||
@case ("payPal") { |
||||
<ng-container> |
||||
<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> |
||||
} |
||||
@case ("accountCredit") { |
||||
<ng-container> |
||||
<bit-callout type="info"> |
||||
{{ "makeSureEnoughCredit" | i18n }} |
||||
</bit-callout> |
||||
</ng-container> |
||||
} |
||||
} |
||||
@if (showBillingDetails) { |
||||
<h5 bitTypography="h5">{{ "billingAddress" | i18n }}</h5> |
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4"> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "country" | i18n }}</bit-label> |
||||
<bit-select [formControl]="group.controls.billingAddress.controls.country"> |
||||
@for (selectableCountry of selectableCountries; track selectableCountry.value) { |
||||
<bit-option |
||||
[value]="selectableCountry.value" |
||||
[disabled]="selectableCountry.disabled" |
||||
[label]="selectableCountry.name" |
||||
></bit-option> |
||||
} |
||||
</bit-select> |
||||
</bit-form-field> |
||||
</div> |
||||
<div class="tw-col-span-6"> |
||||
<bit-form-field [disableMargin]="true"> |
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
[formControl]="group.controls.billingAddress.controls.postalCode" |
||||
autocomplete="postal-code" |
||||
/> |
||||
</bit-form-field> |
||||
</div> |
||||
</div> |
||||
} |
||||
</form> |
||||
`,
|
||||
standalone: true, |
||||
imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule], |
||||
}) |
||||
export class EnterPaymentMethodComponent implements OnInit { |
||||
@Input({ required: true }) group!: PaymentMethodFormGroup; |
||||
|
||||
private showBankAccountSubject = new BehaviorSubject<boolean>(true); |
||||
showBankAccount$ = this.showBankAccountSubject.asObservable(); |
||||
@Input() |
||||
set showBankAccount(value: boolean) { |
||||
this.showBankAccountSubject.next(value); |
||||
} |
||||
get showBankAccount(): boolean { |
||||
return this.showBankAccountSubject.value; |
||||
} |
||||
|
||||
@Input() showPayPal: boolean = true; |
||||
@Input() showAccountCredit: boolean = false; |
||||
@Input() includeBillingAddress: boolean = false; |
||||
|
||||
protected selectableCountries = selectableCountries; |
||||
|
||||
private destroy$ = new Subject<void>(); |
||||
|
||||
constructor( |
||||
private braintreeService: BraintreeService, |
||||
private i18nService: I18nService, |
||||
private logService: LogService, |
||||
private stripeService: StripeService, |
||||
private toastService: ToastService, |
||||
) {} |
||||
|
||||
ngOnInit() { |
||||
this.stripeService.loadStripe( |
||||
{ |
||||
cardNumber: "#stripe-card-number", |
||||
cardExpiry: "#stripe-card-expiry", |
||||
cardCvc: "#stripe-card-cvc", |
||||
}, |
||||
true, |
||||
); |
||||
|
||||
if (this.showPayPal) { |
||||
this.braintreeService.loadBraintree("#braintree-container", false); |
||||
} |
||||
|
||||
if (!this.includeBillingAddress) { |
||||
this.group.controls.billingAddress.disable(); |
||||
} |
||||
|
||||
this.group.controls.type.valueChanges |
||||
.pipe(startWith(this.group.controls.type.value), takeUntil(this.destroy$)) |
||||
.subscribe((selected) => { |
||||
if (selected === "bankAccount") { |
||||
this.group.controls.bankAccount.enable(); |
||||
if (this.includeBillingAddress) { |
||||
this.group.controls.billingAddress.enable(); |
||||
} |
||||
} else { |
||||
switch (selected) { |
||||
case "card": { |
||||
this.stripeService.mountElements(); |
||||
if (this.includeBillingAddress) { |
||||
this.group.controls.billingAddress.enable(); |
||||
} |
||||
break; |
||||
} |
||||
case "payPal": { |
||||
this.braintreeService.createDropin(); |
||||
if (this.includeBillingAddress) { |
||||
this.group.controls.billingAddress.disable(); |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
this.group.controls.bankAccount.disable(); |
||||
} |
||||
}); |
||||
|
||||
this.showBankAccount$.pipe(takeUntil(this.destroy$)).subscribe((showBankAccount) => { |
||||
if (!showBankAccount && this.selected === "bankAccount") { |
||||
this.select("card"); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
select = (paymentMethod: PaymentMethodOption) => |
||||
this.group.controls.type.patchValue(paymentMethod); |
||||
|
||||
tokenize = async (): Promise<TokenizedPaymentMethod> => { |
||||
const exchange = async (paymentMethod: TokenizablePaymentMethod) => { |
||||
switch (paymentMethod) { |
||||
case "bankAccount": { |
||||
this.group.controls.bankAccount.markAllAsTouched(); |
||||
if (!this.group.controls.bankAccount.valid) { |
||||
throw new Error("Attempted to tokenize invalid bank account information."); |
||||
} |
||||
|
||||
const bankAccount = this.group.controls.bankAccount.getRawValue(); |
||||
const clientSecret = await this.stripeService.createSetupIntent("bankAccount"); |
||||
const billingDetails = this.group.controls.billingAddress.enabled |
||||
? this.group.controls.billingAddress.getRawValue() |
||||
: undefined; |
||||
return await this.stripeService.setupBankAccountPaymentMethod( |
||||
clientSecret, |
||||
bankAccount, |
||||
billingDetails, |
||||
); |
||||
} |
||||
case "card": { |
||||
const clientSecret = await this.stripeService.createSetupIntent("card"); |
||||
const billingDetails = this.group.controls.billingAddress.enabled |
||||
? this.group.controls.billingAddress.getRawValue() |
||||
: undefined; |
||||
return this.stripeService.setupCardPaymentMethod(clientSecret, billingDetails); |
||||
} |
||||
case "payPal": { |
||||
return this.braintreeService.requestPaymentMethod(); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
if (!isTokenizablePaymentMethod(this.selected)) { |
||||
throw new Error(`Attempted to tokenize a non-tokenizable payment method: ${this.selected}`); |
||||
} |
||||
|
||||
try { |
||||
const token = await exchange(this.selected); |
||||
return { type: this.selected, token }; |
||||
} catch (error: unknown) { |
||||
this.logService.error(error); |
||||
this.toastService.showToast({ |
||||
variant: "error", |
||||
title: "", |
||||
message: this.i18nService.t("problemSubmittingPaymentMethod"), |
||||
}); |
||||
throw error; |
||||
} |
||||
}; |
||||
|
||||
validate = (): boolean => { |
||||
if (this.selected === "bankAccount") { |
||||
this.group.controls.bankAccount.markAllAsTouched(); |
||||
return this.group.controls.bankAccount.valid; |
||||
} |
||||
|
||||
return true; |
||||
}; |
||||
|
||||
get selected(): PaymentMethodOption { |
||||
return this.group.value.type!; |
||||
} |
||||
|
||||
static getFormGroup = (): PaymentMethodFormGroup => |
||||
new FormGroup({ |
||||
type: new FormControl<PaymentMethodOption>("card", { nonNullable: true }), |
||||
bankAccount: new FormGroup({ |
||||
routingNumber: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
accountNumber: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
accountHolderName: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
accountHolderType: new FormControl<"" | "company" | "individual">("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
}), |
||||
billingAddress: new FormGroup({ |
||||
country: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
postalCode: new FormControl<string>("", { |
||||
nonNullable: true, |
||||
validators: [Validators.required], |
||||
}), |
||||
}), |
||||
}); |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
export * from "./add-account-credit-dialog.component"; |
||||
export * from "./change-payment-method-dialog.component"; |
||||
export * from "./display-account-credit.component"; |
||||
export * from "./display-billing-address.component"; |
||||
export * from "./display-payment-method.component"; |
||||
export * from "./edit-billing-address-dialog.component"; |
||||
export * from "./enter-billing-address.component"; |
||||
export * from "./enter-payment-method.component"; |
||||
export * from "./verify-bank-account.component"; |
||||
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core"; |
||||
import { FormControl, FormGroup, Validators } from "@angular/forms"; |
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; |
||||
import { ToastService } from "@bitwarden/components"; |
||||
|
||||
import { SharedModule } from "../../../shared"; |
||||
import { BillingClient } from "../../services"; |
||||
import { BillableEntity } from "../../types"; |
||||
import { MaskedPaymentMethod } from "../types"; |
||||
|
||||
@Component({ |
||||
selector: "app-verify-bank-account", |
||||
template: ` |
||||
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}"> |
||||
<p>{{ "verifyBankAccountWithStatementDescriptorInstructions" | i18n }}</p> |
||||
<form [formGroup]="formGroup" [bitSubmit]="submit"> |
||||
<bit-form-field class="tw-mr-2 tw-w-48"> |
||||
<bit-label>{{ "descriptorCode" | i18n }}</bit-label> |
||||
<input |
||||
bitInput |
||||
type="text" |
||||
placeholder="SMAB12" |
||||
[formControl]="formGroup.controls.descriptorCode" |
||||
/> |
||||
</bit-form-field> |
||||
<button type="submit" bitButton bitFormButton buttonType="primary"> |
||||
{{ "submit" | i18n }} |
||||
</button> |
||||
</form> |
||||
</bit-callout> |
||||
`,
|
||||
standalone: true, |
||||
imports: [SharedModule], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class VerifyBankAccountComponent { |
||||
@Input({ required: true }) owner!: BillableEntity; |
||||
@Output() verified = new EventEmitter<MaskedPaymentMethod>(); |
||||
|
||||
protected formGroup = new FormGroup({ |
||||
descriptorCode: new FormControl<string>("", [ |
||||
Validators.required, |
||||
Validators.minLength(6), |
||||
Validators.maxLength(6), |
||||
]), |
||||
}); |
||||
|
||||
constructor( |
||||
private billingClient: BillingClient, |
||||
private i18nService: I18nService, |
||||
private toastService: ToastService, |
||||
) {} |
||||
|
||||
submit = async (): Promise<void> => { |
||||
this.formGroup.markAllAsTouched(); |
||||
|
||||
if (!this.formGroup.valid) { |
||||
return; |
||||
} |
||||
|
||||
const result = await this.billingClient.verifyBankAccount( |
||||
this.owner, |
||||
this.formGroup.value.descriptorCode!, |
||||
); |
||||
|
||||
switch (result.type) { |
||||
case "success": { |
||||
this.toastService.showToast({ |
||||
variant: "success", |
||||
title: "", |
||||
message: this.i18nService.t("bankAccountVerified"), |
||||
}); |
||||
this.verified.emit(result.value); |
||||
break; |
||||
} |
||||
case "error": { |
||||
this.toastService.showToast({ |
||||
variant: "error", |
||||
title: "", |
||||
message: result.message, |
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
import { AddressPipe } from "./address.pipe"; |
||||
|
||||
describe("AddressPipe", () => { |
||||
let pipe: AddressPipe; |
||||
|
||||
beforeEach(() => { |
||||
pipe = new AddressPipe(); |
||||
}); |
||||
|
||||
it("should format a complete address with all fields", () => { |
||||
const address = { |
||||
country: "United States", |
||||
postalCode: "10001", |
||||
line1: "123 Main St", |
||||
line2: "Apt 4B", |
||||
city: "New York", |
||||
state: "NY", |
||||
}; |
||||
|
||||
const result = pipe.transform(address); |
||||
expect(result).toBe("123 Main St, Apt 4B, New York, NY, 10001, United States"); |
||||
}); |
||||
|
||||
it("should format address without line2", () => { |
||||
const address = { |
||||
country: "United States", |
||||
postalCode: "10001", |
||||
line1: "123 Main St", |
||||
line2: null, |
||||
city: "New York", |
||||
state: "NY", |
||||
}; |
||||
|
||||
const result = pipe.transform(address); |
||||
expect(result).toBe("123 Main St, New York, NY, 10001, United States"); |
||||
}); |
||||
|
||||
it("should format address without state", () => { |
||||
const address = { |
||||
country: "United Kingdom", |
||||
postalCode: "SW1A 1AA", |
||||
line1: "123 Main St", |
||||
line2: "Apt 4B", |
||||
city: "London", |
||||
state: null, |
||||
}; |
||||
|
||||
const result = pipe.transform(address); |
||||
expect(result).toBe("123 Main St, Apt 4B, London, SW1A 1AA, United Kingdom"); |
||||
}); |
||||
|
||||
it("should format minimal address with only required fields", () => { |
||||
const address = { |
||||
country: "United States", |
||||
postalCode: "10001", |
||||
line1: null, |
||||
line2: null, |
||||
city: null, |
||||
state: null, |
||||
}; |
||||
|
||||
const result = pipe.transform(address); |
||||
expect(result).toBe("10001, United States"); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core"; |
||||
|
||||
import { BillingAddress } from "../types"; |
||||
|
||||
@Pipe({ |
||||
name: "address", |
||||
}) |
||||
export class AddressPipe implements PipeTransform { |
||||
transform(address: Omit<BillingAddress, "taxId">): string { |
||||
const parts: string[] = []; |
||||
|
||||
if (address.line1) { |
||||
parts.push(address.line1); |
||||
} |
||||
|
||||
if (address.line2) { |
||||
parts.push(address.line2); |
||||
} |
||||
|
||||
if (address.city) { |
||||
parts.push(address.city); |
||||
} |
||||
|
||||
if (address.state) { |
||||
parts.push(address.state); |
||||
} |
||||
|
||||
parts.push(address.postalCode, address.country); |
||||
|
||||
return parts.join(", "); |
||||
} |
||||
} |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
export * from "./address.pipe"; |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response"; |
||||
|
||||
import { TaxId, TaxIdResponse } from "./tax-id"; |
||||
|
||||
export type BillingAddress = { |
||||
country: string; |
||||
postalCode: string; |
||||
line1: string | null; |
||||
line2: string | null; |
||||
city: string | null; |
||||
state: string | null; |
||||
taxId: TaxId | null; |
||||
}; |
||||
|
||||
export class BillingAddressResponse extends BaseResponse implements BillingAddress { |
||||
country: string; |
||||
postalCode: string; |
||||
line1: string | null; |
||||
line2: string | null; |
||||
city: string | null; |
||||
state: string | null; |
||||
taxId: TaxId | null; |
||||
|
||||
constructor(response: any) { |
||||
super(response); |
||||
|
||||
this.country = this.getResponseProperty("Country"); |
||||
this.postalCode = this.getResponseProperty("PostalCode"); |
||||
this.line1 = this.getResponseProperty("Line1"); |
||||
this.line2 = this.getResponseProperty("Line2"); |
||||
this.city = this.getResponseProperty("City"); |
||||
this.state = this.getResponseProperty("State"); |
||||
|
||||
const taxId = this.getResponseProperty("TaxId"); |
||||
this.taxId = taxId ? new TaxIdResponse(taxId) : null; |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
export * from "./billing-address"; |
||||
export * from "./masked-payment-method"; |
||||
export * from "./selectable-country"; |
||||
export * from "./tax-id"; |
||||
export * from "./tax-id-type"; |
||||
export * from "./tokenized-payment-method"; |
||||
@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response"; |
||||
|
||||
import { |
||||
BankAccountPaymentMethod, |
||||
CardPaymentMethod, |
||||
PayPalPaymentMethod, |
||||
} from "./tokenized-payment-method"; |
||||
|
||||
export const StripeCardBrands = { |
||||
amex: "amex", |
||||
diners: "diners", |
||||
discover: "discover", |
||||
eftpos_au: "eftpos_au", |
||||
jcb: "jcb", |
||||
link: "link", |
||||
mastercard: "mastercard", |
||||
unionpay: "unionpay", |
||||
visa: "visa", |
||||
unknown: "unknown", |
||||
} as const; |
||||
|
||||
export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands]; |
||||
|
||||
type MaskedBankAccount = { |
||||
type: BankAccountPaymentMethod; |
||||
bankName: string; |
||||
last4: string; |
||||
verified: boolean; |
||||
}; |
||||
|
||||
type MaskedCard = { |
||||
type: CardPaymentMethod; |
||||
brand: StripeCardBrand; |
||||
last4: string; |
||||
expiration: string; |
||||
}; |
||||
|
||||
type MaskedPayPalAccount = { |
||||
type: PayPalPaymentMethod; |
||||
email: string; |
||||
}; |
||||
|
||||
export type MaskedPaymentMethod = MaskedBankAccount | MaskedCard | MaskedPayPalAccount; |
||||
|
||||
export class MaskedPaymentMethodResponse extends BaseResponse { |
||||
value: MaskedPaymentMethod; |
||||
|
||||
constructor(response: any) { |
||||
super(response); |
||||
|
||||
const type = this.getResponseProperty("Type"); |
||||
switch (type) { |
||||
case "card": { |
||||
this.value = new MaskedCardResponse(response); |
||||
break; |
||||
} |
||||
case "bankAccount": { |
||||
this.value = new MaskedBankAccountResponse(response); |
||||
break; |
||||
} |
||||
case "payPal": { |
||||
this.value = new MaskedPayPalAccountResponse(response); |
||||
break; |
||||
} |
||||
default: { |
||||
throw new Error(`Cannot deserialize unsupported payment method type: ${type}`); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccount { |
||||
type: BankAccountPaymentMethod; |
||||
bankName: string; |
||||
last4: string; |
||||
verified: boolean; |
||||
|
||||
constructor(response: any) { |
||||
super(response); |
||||
|
||||
this.type = "bankAccount"; |
||||
this.bankName = this.getResponseProperty("BankName"); |
||||
this.last4 = this.getResponseProperty("Last4"); |
||||
this.verified = this.getResponseProperty("Verified"); |
||||
} |
||||
} |
||||
|
||||
class MaskedCardResponse extends BaseResponse implements MaskedCard { |
||||
type: CardPaymentMethod; |
||||
brand: StripeCardBrand; |
||||
last4: string; |
||||
expiration: string; |
||||
|
||||
constructor(response: any) { |
||||
super(response); |
||||
|
||||
this.type = "card"; |
||||
this.brand = this.getResponseProperty("Brand"); |
||||
this.last4 = this.getResponseProperty("Last4"); |
||||
this.expiration = this.getResponseProperty("Expiration"); |
||||
} |
||||
} |
||||
|
||||
class MaskedPayPalAccountResponse extends BaseResponse implements MaskedPayPalAccount { |
||||
type: PayPalPaymentMethod; |
||||
email: string; |
||||
|
||||
constructor(response: any) { |
||||
super(response); |
||||
|
||||
this.type = "payPal"; |
||||
this.email = this.getResponseProperty("Email"); |
||||
} |
||||
} |
||||
@ -0,0 +1,259 @@
@@ -0,0 +1,259 @@
|
||||
type SelectableCountry = Readonly<{ |
||||
name: string; |
||||
value: string; |
||||
disabled: boolean; |
||||
}>; |
||||
|
||||
export const selectableCountries: ReadonlyArray<SelectableCountry> = [ |
||||
{ name: "-- Select --", value: "", disabled: false }, |
||||
{ name: "United States", value: "US", disabled: false }, |
||||
{ name: "China", value: "CN", disabled: false }, |
||||
{ name: "France", value: "FR", disabled: false }, |
||||
{ name: "Germany", value: "DE", disabled: false }, |
||||
{ name: "Canada", value: "CA", disabled: false }, |
||||
{ name: "United Kingdom", value: "GB", disabled: false }, |
||||
{ name: "Australia", value: "AU", disabled: false }, |
||||
{ name: "India", value: "IN", disabled: false }, |
||||
{ name: "", value: "-", disabled: true }, |
||||
{ name: "Afghanistan", value: "AF", disabled: false }, |
||||
{ name: "Åland Islands", value: "AX", disabled: false }, |
||||
{ name: "Albania", value: "AL", disabled: false }, |
||||
{ name: "Algeria", value: "DZ", disabled: false }, |
||||
{ name: "American Samoa", value: "AS", disabled: false }, |
||||
{ name: "Andorra", value: "AD", disabled: false }, |
||||
{ name: "Angola", value: "AO", disabled: false }, |
||||
{ name: "Anguilla", value: "AI", disabled: false }, |
||||
{ name: "Antarctica", value: "AQ", disabled: false }, |
||||
{ name: "Antigua and Barbuda", value: "AG", disabled: false }, |
||||
{ name: "Argentina", value: "AR", disabled: false }, |
||||
{ name: "Armenia", value: "AM", disabled: false }, |
||||
{ name: "Aruba", value: "AW", disabled: false }, |
||||
{ name: "Austria", value: "AT", disabled: false }, |
||||
{ name: "Azerbaijan", value: "AZ", disabled: false }, |
||||
{ name: "Bahamas", value: "BS", disabled: false }, |
||||
{ name: "Bahrain", value: "BH", disabled: false }, |
||||
{ name: "Bangladesh", value: "BD", disabled: false }, |
||||
{ name: "Barbados", value: "BB", disabled: false }, |
||||
{ name: "Belarus", value: "BY", disabled: false }, |
||||
{ name: "Belgium", value: "BE", disabled: false }, |
||||
{ name: "Belize", value: "BZ", disabled: false }, |
||||
{ name: "Benin", value: "BJ", disabled: false }, |
||||
{ name: "Bermuda", value: "BM", disabled: false }, |
||||
{ name: "Bhutan", value: "BT", disabled: false }, |
||||
{ name: "Bolivia, Plurinational State of", value: "BO", disabled: false }, |
||||
{ name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false }, |
||||
{ name: "Bosnia and Herzegovina", value: "BA", disabled: false }, |
||||
{ name: "Botswana", value: "BW", disabled: false }, |
||||
{ name: "Bouvet Island", value: "BV", disabled: false }, |
||||
{ name: "Brazil", value: "BR", disabled: false }, |
||||
{ name: "British Indian Ocean Territory", value: "IO", disabled: false }, |
||||
{ name: "Brunei Darussalam", value: "BN", disabled: false }, |
||||
{ name: "Bulgaria", value: "BG", disabled: false }, |
||||
{ name: "Burkina Faso", value: "BF", disabled: false }, |
||||
{ name: "Burundi", value: "BI", disabled: false }, |
||||
{ name: "Cambodia", value: "KH", disabled: false }, |
||||
{ name: "Cameroon", value: "CM", disabled: false }, |
||||
{ name: "Cape Verde", value: "CV", disabled: false }, |
||||
{ name: "Cayman Islands", value: "KY", disabled: false }, |
||||
{ name: "Central African Republic", value: "CF", disabled: false }, |
||||
{ name: "Chad", value: "TD", disabled: false }, |
||||
{ name: "Chile", value: "CL", disabled: false }, |
||||
{ name: "Christmas Island", value: "CX", disabled: false }, |
||||
{ name: "Cocos (Keeling) Islands", value: "CC", disabled: false }, |
||||
{ name: "Colombia", value: "CO", disabled: false }, |
||||
{ name: "Comoros", value: "KM", disabled: false }, |
||||
{ name: "Congo", value: "CG", disabled: false }, |
||||
{ name: "Congo, the Democratic Republic of the", value: "CD", disabled: false }, |
||||
{ name: "Cook Islands", value: "CK", disabled: false }, |
||||
{ name: "Costa Rica", value: "CR", disabled: false }, |
||||
{ name: "Côte d'Ivoire", value: "CI", disabled: false }, |
||||
{ name: "Croatia", value: "HR", disabled: false }, |
||||
{ name: "Cuba", value: "CU", disabled: false }, |
||||
{ name: "Curaçao", value: "CW", disabled: false }, |
||||
{ name: "Cyprus", value: "CY", disabled: false }, |
||||
{ name: "Czech Republic", value: "CZ", disabled: false }, |
||||
{ name: "Denmark", value: "DK", disabled: false }, |
||||
{ name: "Djibouti", value: "DJ", disabled: false }, |
||||
{ name: "Dominica", value: "DM", disabled: false }, |
||||
{ name: "Dominican Republic", value: "DO", disabled: false }, |
||||
{ name: "Ecuador", value: "EC", disabled: false }, |
||||
{ name: "Egypt", value: "EG", disabled: false }, |
||||
{ name: "El Salvador", value: "SV", disabled: false }, |
||||
{ name: "Equatorial Guinea", value: "GQ", disabled: false }, |
||||
{ name: "Eritrea", value: "ER", disabled: false }, |
||||
{ name: "Estonia", value: "EE", disabled: false }, |
||||
{ name: "Ethiopia", value: "ET", disabled: false }, |
||||
{ name: "Falkland Islands (Malvinas)", value: "FK", disabled: false }, |
||||
{ name: "Faroe Islands", value: "FO", disabled: false }, |
||||
{ name: "Fiji", value: "FJ", disabled: false }, |
||||
{ name: "Finland", value: "FI", disabled: false }, |
||||
{ name: "French Guiana", value: "GF", disabled: false }, |
||||
{ name: "French Polynesia", value: "PF", disabled: false }, |
||||
{ name: "French Southern Territories", value: "TF", disabled: false }, |
||||
{ name: "Gabon", value: "GA", disabled: false }, |
||||
{ name: "Gambia", value: "GM", disabled: false }, |
||||
{ name: "Georgia", value: "GE", disabled: false }, |
||||
{ name: "Ghana", value: "GH", disabled: false }, |
||||
{ name: "Gibraltar", value: "GI", disabled: false }, |
||||
{ name: "Greece", value: "GR", disabled: false }, |
||||
{ name: "Greenland", value: "GL", disabled: false }, |
||||
{ name: "Grenada", value: "GD", disabled: false }, |
||||
{ name: "Guadeloupe", value: "GP", disabled: false }, |
||||
{ name: "Guam", value: "GU", disabled: false }, |
||||
{ name: "Guatemala", value: "GT", disabled: false }, |
||||
{ name: "Guernsey", value: "GG", disabled: false }, |
||||
{ name: "Guinea", value: "GN", disabled: false }, |
||||
{ name: "Guinea-Bissau", value: "GW", disabled: false }, |
||||
{ name: "Guyana", value: "GY", disabled: false }, |
||||
{ name: "Haiti", value: "HT", disabled: false }, |
||||
{ name: "Heard Island and McDonald Islands", value: "HM", disabled: false }, |
||||
{ name: "Holy See (Vatican City State)", value: "VA", disabled: false }, |
||||
{ name: "Honduras", value: "HN", disabled: false }, |
||||
{ name: "Hong Kong", value: "HK", disabled: false }, |
||||
{ name: "Hungary", value: "HU", disabled: false }, |
||||
{ name: "Iceland", value: "IS", disabled: false }, |
||||
{ name: "Indonesia", value: "ID", disabled: false }, |
||||
{ name: "Iran, Islamic Republic of", value: "IR", disabled: false }, |
||||
{ name: "Iraq", value: "IQ", disabled: false }, |
||||
{ name: "Ireland", value: "IE", disabled: false }, |
||||
{ name: "Isle of Man", value: "IM", disabled: false }, |
||||
{ name: "Israel", value: "IL", disabled: false }, |
||||
{ name: "Italy", value: "IT", disabled: false }, |
||||
{ name: "Jamaica", value: "JM", disabled: false }, |
||||
{ name: "Japan", value: "JP", disabled: false }, |
||||
{ name: "Jersey", value: "JE", disabled: false }, |
||||
{ name: "Jordan", value: "JO", disabled: false }, |
||||
{ name: "Kazakhstan", value: "KZ", disabled: false }, |
||||
{ name: "Kenya", value: "KE", disabled: false }, |
||||
{ name: "Kiribati", value: "KI", disabled: false }, |
||||
{ name: "Korea, Democratic People's Republic of", value: "KP", disabled: false }, |
||||
{ name: "Korea, Republic of", value: "KR", disabled: false }, |
||||
{ name: "Kuwait", value: "KW", disabled: false }, |
||||
{ name: "Kyrgyzstan", value: "KG", disabled: false }, |
||||
{ name: "Lao People's Democratic Republic", value: "LA", disabled: false }, |
||||
{ name: "Latvia", value: "LV", disabled: false }, |
||||
{ name: "Lebanon", value: "LB", disabled: false }, |
||||
{ name: "Lesotho", value: "LS", disabled: false }, |
||||
{ name: "Liberia", value: "LR", disabled: false }, |
||||
{ name: "Libya", value: "LY", disabled: false }, |
||||
{ name: "Liechtenstein", value: "LI", disabled: false }, |
||||
{ name: "Lithuania", value: "LT", disabled: false }, |
||||
{ name: "Luxembourg", value: "LU", disabled: false }, |
||||
{ name: "Macao", value: "MO", disabled: false }, |
||||
{ name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false }, |
||||
{ name: "Madagascar", value: "MG", disabled: false }, |
||||
{ name: "Malawi", value: "MW", disabled: false }, |
||||
{ name: "Malaysia", value: "MY", disabled: false }, |
||||
{ name: "Maldives", value: "MV", disabled: false }, |
||||
{ name: "Mali", value: "ML", disabled: false }, |
||||
{ name: "Malta", value: "MT", disabled: false }, |
||||
{ name: "Marshall Islands", value: "MH", disabled: false }, |
||||
{ name: "Martinique", value: "MQ", disabled: false }, |
||||
{ name: "Mauritania", value: "MR", disabled: false }, |
||||
{ name: "Mauritius", value: "MU", disabled: false }, |
||||
{ name: "Mayotte", value: "YT", disabled: false }, |
||||
{ name: "Mexico", value: "MX", disabled: false }, |
||||
{ name: "Micronesia, Federated States of", value: "FM", disabled: false }, |
||||
{ name: "Moldova, Republic of", value: "MD", disabled: false }, |
||||
{ name: "Monaco", value: "MC", disabled: false }, |
||||
{ name: "Mongolia", value: "MN", disabled: false }, |
||||
{ name: "Montenegro", value: "ME", disabled: false }, |
||||
{ name: "Montserrat", value: "MS", disabled: false }, |
||||
{ name: "Morocco", value: "MA", disabled: false }, |
||||
{ name: "Mozambique", value: "MZ", disabled: false }, |
||||
{ name: "Myanmar", value: "MM", disabled: false }, |
||||
{ name: "Namibia", value: "NA", disabled: false }, |
||||
{ name: "Nauru", value: "NR", disabled: false }, |
||||
{ name: "Nepal", value: "NP", disabled: false }, |
||||
{ name: "Netherlands", value: "NL", disabled: false }, |
||||
{ name: "New Caledonia", value: "NC", disabled: false }, |
||||
{ name: "New Zealand", value: "NZ", disabled: false }, |
||||
{ name: "Nicaragua", value: "NI", disabled: false }, |
||||
{ name: "Niger", value: "NE", disabled: false }, |
||||
{ name: "Nigeria", value: "NG", disabled: false }, |
||||
{ name: "Niue", value: "NU", disabled: false }, |
||||
{ name: "Norfolk Island", value: "NF", disabled: false }, |
||||
{ name: "Northern Mariana Islands", value: "MP", disabled: false }, |
||||
{ name: "Norway", value: "NO", disabled: false }, |
||||
{ name: "Oman", value: "OM", disabled: false }, |
||||
{ name: "Pakistan", value: "PK", disabled: false }, |
||||
{ name: "Palau", value: "PW", disabled: false }, |
||||
{ name: "Palestinian Territory, Occupied", value: "PS", disabled: false }, |
||||
{ name: "Panama", value: "PA", disabled: false }, |
||||
{ name: "Papua New Guinea", value: "PG", disabled: false }, |
||||
{ name: "Paraguay", value: "PY", disabled: false }, |
||||
{ name: "Peru", value: "PE", disabled: false }, |
||||
{ name: "Philippines", value: "PH", disabled: false }, |
||||
{ name: "Pitcairn", value: "PN", disabled: false }, |
||||
{ name: "Poland", value: "PL", disabled: false }, |
||||
{ name: "Portugal", value: "PT", disabled: false }, |
||||
{ name: "Puerto Rico", value: "PR", disabled: false }, |
||||
{ name: "Qatar", value: "QA", disabled: false }, |
||||
{ name: "Réunion", value: "RE", disabled: false }, |
||||
{ name: "Romania", value: "RO", disabled: false }, |
||||
{ name: "Russian Federation", value: "RU", disabled: false }, |
||||
{ name: "Rwanda", value: "RW", disabled: false }, |
||||
{ name: "Saint Barthélemy", value: "BL", disabled: false }, |
||||
{ name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false }, |
||||
{ name: "Saint Kitts and Nevis", value: "KN", disabled: false }, |
||||
{ name: "Saint Lucia", value: "LC", disabled: false }, |
||||
{ name: "Saint Martin (French part)", value: "MF", disabled: false }, |
||||
{ name: "Saint Pierre and Miquelon", value: "PM", disabled: false }, |
||||
{ name: "Saint Vincent and the Grenadines", value: "VC", disabled: false }, |
||||
{ name: "Samoa", value: "WS", disabled: false }, |
||||
{ name: "San Marino", value: "SM", disabled: false }, |
||||
{ name: "Sao Tome and Principe", value: "ST", disabled: false }, |
||||
{ name: "Saudi Arabia", value: "SA", disabled: false }, |
||||
{ name: "Senegal", value: "SN", disabled: false }, |
||||
{ name: "Serbia", value: "RS", disabled: false }, |
||||
{ name: "Seychelles", value: "SC", disabled: false }, |
||||
{ name: "Sierra Leone", value: "SL", disabled: false }, |
||||
{ name: "Singapore", value: "SG", disabled: false }, |
||||
{ name: "Sint Maarten (Dutch part)", value: "SX", disabled: false }, |
||||
{ name: "Slovakia", value: "SK", disabled: false }, |
||||
{ name: "Slovenia", value: "SI", disabled: false }, |
||||
{ name: "Solomon Islands", value: "SB", disabled: false }, |
||||
{ name: "Somalia", value: "SO", disabled: false }, |
||||
{ name: "South Africa", value: "ZA", disabled: false }, |
||||
{ name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false }, |
||||
{ name: "South Sudan", value: "SS", disabled: false }, |
||||
{ name: "Spain", value: "ES", disabled: false }, |
||||
{ name: "Sri Lanka", value: "LK", disabled: false }, |
||||
{ name: "Sudan", value: "SD", disabled: false }, |
||||
{ name: "Suriname", value: "SR", disabled: false }, |
||||
{ name: "Svalbard and Jan Mayen", value: "SJ", disabled: false }, |
||||
{ name: "Swaziland", value: "SZ", disabled: false }, |
||||
{ name: "Sweden", value: "SE", disabled: false }, |
||||
{ name: "Switzerland", value: "CH", disabled: false }, |
||||
{ name: "Syrian Arab Republic", value: "SY", disabled: false }, |
||||
{ name: "Taiwan", value: "TW", disabled: false }, |
||||
{ name: "Tajikistan", value: "TJ", disabled: false }, |
||||
{ name: "Tanzania, United Republic of", value: "TZ", disabled: false }, |
||||
{ name: "Thailand", value: "TH", disabled: false }, |
||||
{ name: "Timor-Leste", value: "TL", disabled: false }, |
||||
{ name: "Togo", value: "TG", disabled: false }, |
||||
{ name: "Tokelau", value: "TK", disabled: false }, |
||||
{ name: "Tonga", value: "TO", disabled: false }, |
||||
{ name: "Trinidad and Tobago", value: "TT", disabled: false }, |
||||
{ name: "Tunisia", value: "TN", disabled: false }, |
||||
{ name: "Turkey", value: "TR", disabled: false }, |
||||
{ name: "Turkmenistan", value: "TM", disabled: false }, |
||||
{ name: "Turks and Caicos Islands", value: "TC", disabled: false }, |
||||
{ name: "Tuvalu", value: "TV", disabled: false }, |
||||
{ name: "Uganda", value: "UG", disabled: false }, |
||||
{ name: "Ukraine", value: "UA", disabled: false }, |
||||
{ name: "United Arab Emirates", value: "AE", disabled: false }, |
||||
{ name: "United States Minor Outlying Islands", value: "UM", disabled: false }, |
||||
{ name: "Uruguay", value: "UY", disabled: false }, |
||||
{ name: "Uzbekistan", value: "UZ", disabled: false }, |
||||
{ name: "Vanuatu", value: "VU", disabled: false }, |
||||
{ name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false }, |
||||
{ name: "Viet Nam", value: "VN", disabled: false }, |
||||
{ name: "Virgin Islands, British", value: "VG", disabled: false }, |
||||
{ name: "Virgin Islands, U.S.", value: "VI", disabled: false }, |
||||
{ name: "Wallis and Futuna", value: "WF", disabled: false }, |
||||
{ name: "Western Sahara", value: "EH", disabled: false }, |
||||
{ name: "Yemen", value: "YE", disabled: false }, |
||||
{ name: "Zambia", value: "ZM", disabled: false }, |
||||
{ name: "Zimbabwe", value: "ZW", disabled: false }, |
||||
]; |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response"; |
||||
|
||||
export interface TaxId { |
||||
code: string; |
||||
value: string; |
||||
} |
||||
|
||||
export class TaxIdResponse extends BaseResponse implements TaxId { |
||||
code: string; |
||||
value: string; |
||||
|
||||
constructor(response: any) { |
||||
super(response); |
||||
|
||||
this.code = this.getResponseProperty("Code"); |
||||
this.value = this.getResponseProperty("Value"); |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
export const TokenizablePaymentMethods = { |
||||
bankAccount: "bankAccount", |
||||
card: "card", |
||||
payPal: "payPal", |
||||
} as const; |
||||
|
||||
export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount; |
||||
export type CardPaymentMethod = typeof TokenizablePaymentMethods.card; |
||||
export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal; |
||||
|
||||
export type TokenizablePaymentMethod = |
||||
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods]; |
||||
|
||||
export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => { |
||||
const valid = Object.values(TokenizablePaymentMethods) as readonly string[]; |
||||
return valid.includes(value); |
||||
}; |
||||
|
||||
export type TokenizedPaymentMethod = { |
||||
type: TokenizablePaymentMethod; |
||||
token: string; |
||||
}; |
||||
@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
|
||||
import { Injectable } from "@angular/core"; |
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service"; |
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; |
||||
|
||||
import { |
||||
BillingAddress, |
||||
BillingAddressResponse, |
||||
MaskedPaymentMethod, |
||||
MaskedPaymentMethodResponse, |
||||
TokenizedPaymentMethod, |
||||
} from "../payment/types"; |
||||
import { BillableEntity } from "../types"; |
||||
|
||||
type Result<T> = |
||||
| { |
||||
type: "success"; |
||||
value: T; |
||||
} |
||||
| { |
||||
type: "error"; |
||||
message: string; |
||||
}; |
||||
|
||||
@Injectable() |
||||
export class BillingClient { |
||||
constructor(private apiService: ApiService) {} |
||||
|
||||
private getEndpoint = (entity: BillableEntity): string => { |
||||
switch (entity.type) { |
||||
case "account": { |
||||
return "/account/billing/vnext"; |
||||
} |
||||
case "organization": { |
||||
return `/organizations/${entity.data.id}/billing/vnext`; |
||||
} |
||||
case "provider": { |
||||
return `/providers/${entity.data.id}/billing/vnext`; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
addCreditWithBitPay = async ( |
||||
owner: BillableEntity, |
||||
credit: { amount: number; redirectUrl: string }, |
||||
): Promise<Result<string>> => { |
||||
const path = `${this.getEndpoint(owner)}/credit/bitpay`; |
||||
try { |
||||
const data = await this.apiService.send("POST", path, credit, true, true); |
||||
return { |
||||
type: "success", |
||||
value: data as string, |
||||
}; |
||||
} catch (error: any) { |
||||
if (error instanceof ErrorResponse) { |
||||
return { |
||||
type: "error", |
||||
message: error.message, |
||||
}; |
||||
} |
||||
throw error; |
||||
} |
||||
}; |
||||
|
||||
getBillingAddress = async (owner: BillableEntity): Promise<BillingAddress | null> => { |
||||
const path = `${this.getEndpoint(owner)}/address`; |
||||
const data = await this.apiService.send("GET", path, null, true, true); |
||||
return data ? new BillingAddressResponse(data) : null; |
||||
}; |
||||
|
||||
getCredit = async (owner: BillableEntity): Promise<number | null> => { |
||||
const path = `${this.getEndpoint(owner)}/credit`; |
||||
const data = await this.apiService.send("GET", path, null, true, true); |
||||
return data ? (data as number) : null; |
||||
}; |
||||
|
||||
getPaymentMethod = async (owner: BillableEntity): Promise<MaskedPaymentMethod | null> => { |
||||
const path = `${this.getEndpoint(owner)}/payment-method`; |
||||
const data = await this.apiService.send("GET", path, null, true, true); |
||||
return data ? new MaskedPaymentMethodResponse(data).value : null; |
||||
}; |
||||
|
||||
updateBillingAddress = async ( |
||||
owner: BillableEntity, |
||||
billingAddress: BillingAddress, |
||||
): Promise<Result<BillingAddress>> => { |
||||
const path = `${this.getEndpoint(owner)}/address`; |
||||
try { |
||||
const data = await this.apiService.send("PUT", path, billingAddress, true, true); |
||||
return { |
||||
type: "success", |
||||
value: new BillingAddressResponse(data), |
||||
}; |
||||
} catch (error: any) { |
||||
if (error instanceof ErrorResponse) { |
||||
return { |
||||
type: "error", |
||||
message: error.message, |
||||
}; |
||||
} |
||||
throw error; |
||||
} |
||||
}; |
||||
|
||||
updatePaymentMethod = async ( |
||||
owner: BillableEntity, |
||||
paymentMethod: TokenizedPaymentMethod, |
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode"> | null, |
||||
): Promise<Result<MaskedPaymentMethod>> => { |
||||
const path = `${this.getEndpoint(owner)}/payment-method`; |
||||
try { |
||||
const request = { |
||||
...paymentMethod, |
||||
billingAddress, |
||||
}; |
||||
const data = await this.apiService.send("PUT", path, request, true, true); |
||||
return { |
||||
type: "success", |
||||
value: new MaskedPaymentMethodResponse(data).value, |
||||
}; |
||||
} catch (error: any) { |
||||
if (error instanceof ErrorResponse) { |
||||
return { |
||||
type: "error", |
||||
message: error.message, |
||||
}; |
||||
} |
||||
throw error; |
||||
} |
||||
}; |
||||
|
||||
verifyBankAccount = async ( |
||||
owner: BillableEntity, |
||||
descriptorCode: string, |
||||
): Promise<Result<MaskedPaymentMethod>> => { |
||||
const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; |
||||
try { |
||||
const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); |
||||
return { |
||||
type: "success", |
||||
value: new MaskedPaymentMethodResponse(data).value, |
||||
}; |
||||
} catch (error: any) { |
||||
if (error instanceof ErrorResponse) { |
||||
return { |
||||
type: "error", |
||||
message: error.message, |
||||
}; |
||||
} |
||||
throw error; |
||||
} |
||||
}; |
||||
} |
||||
@ -1,3 +1,4 @@
@@ -1,3 +1,4 @@
|
||||
export * from "./billing.client"; |
||||
export * from "./billing-services.module"; |
||||
export * from "./braintree.service"; |
||||
export * from "./stripe.service"; |
||||
|
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
import { map } from "rxjs"; |
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; |
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; |
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service"; |
||||
|
||||
export type BillableEntity = |
||||
| { type: "account"; data: Account } |
||||
| { type: "organization"; data: Organization } |
||||
| { type: "provider"; data: Provider }; |
||||
|
||||
export const accountToBillableEntity = map<Account | null, BillableEntity>((account) => { |
||||
if (!account) { |
||||
throw new Error("Account not found"); |
||||
} |
||||
return { |
||||
type: "account", |
||||
data: account, |
||||
}; |
||||
}); |
||||
|
||||
export const organizationToBillableEntity = map<Organization | undefined, BillableEntity>( |
||||
(organization) => { |
||||
if (!organization) { |
||||
throw new Error("Organization not found"); |
||||
} |
||||
return { |
||||
type: "organization", |
||||
data: organization, |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
export const providerToBillableEntity = map<Provider | null, BillableEntity>((provider) => { |
||||
if (!provider) { |
||||
throw new Error("Organization not found"); |
||||
} |
||||
return { |
||||
type: "provider", |
||||
data: provider, |
||||
}; |
||||
}); |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export * from "./billable-entity"; |
||||
export * from "./free-trial"; |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export * from "./organization-free-trial-warning.component"; |
||||
export * from "./organization-reseller-renewal-warning.component"; |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
export * from "./organization-warnings.service"; |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
export * from "./organization-warnings"; |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; |
||||
|
||||
export type OrganizationFreeTrialWarning = { |
||||
organization: Pick<Organization, "id" & "name">; |
||||
message: string; |
||||
}; |
||||
|
||||
export type OrganizationResellerRenewalWarning = { |
||||
type: "info" | "warning"; |
||||
message: string; |
||||
}; |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
<app-header></app-header> |
||||
<bit-container> |
||||
@let view = view$ | async; |
||||
@if (!view) { |
||||
<ng-container> |
||||
<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> |
||||
} @else { |
||||
<ng-container> |
||||
<app-display-payment-method |
||||
[owner]="view.provider" |
||||
[paymentMethod]="view.paymentMethod" |
||||
(updated)="setPaymentMethod($event)" |
||||
></app-display-payment-method> |
||||
|
||||
<app-display-billing-address |
||||
[owner]="view.provider" |
||||
[billingAddress]="view.billingAddress" |
||||
(updated)="setBillingAddress($event)" |
||||
></app-display-billing-address> |
||||
|
||||
<app-display-account-credit |
||||
[owner]="view.provider" |
||||
[credit]="view.credit" |
||||
></app-display-account-credit> |
||||
</ng-container> |
||||
} |
||||
</bit-container> |
||||
@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
|
||||
import { Component } from "@angular/core"; |
||||
import { ActivatedRoute, Router } from "@angular/router"; |
||||
import { |
||||
BehaviorSubject, |
||||
EMPTY, |
||||
filter, |
||||
from, |
||||
map, |
||||
merge, |
||||
Observable, |
||||
shareReplay, |
||||
switchMap, |
||||
tap, |
||||
} from "rxjs"; |
||||
import { catchError } from "rxjs/operators"; |
||||
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; |
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; |
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; |
||||
import { |
||||
DisplayAccountCreditComponent, |
||||
DisplayBillingAddressComponent, |
||||
DisplayPaymentMethodComponent, |
||||
} from "@bitwarden/web-vault/app/billing/payment/components"; |
||||
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 { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; |
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared"; |
||||
|
||||
class RedirectError { |
||||
constructor( |
||||
public path: string[], |
||||
public relativeTo: ActivatedRoute, |
||||
) {} |
||||
} |
||||
|
||||
type View = { |
||||
provider: BillableEntity; |
||||
paymentMethod: MaskedPaymentMethod | null; |
||||
billingAddress: BillingAddress | null; |
||||
credit: number | null; |
||||
}; |
||||
|
||||
@Component({ |
||||
templateUrl: "./provider-payment-details.component.html", |
||||
standalone: true, |
||||
imports: [ |
||||
DisplayBillingAddressComponent, |
||||
DisplayAccountCreditComponent, |
||||
DisplayPaymentMethodComponent, |
||||
HeaderModule, |
||||
SharedModule, |
||||
], |
||||
providers: [BillingClient], |
||||
}) |
||||
export class ProviderPaymentDetailsComponent { |
||||
private viewState$ = new BehaviorSubject<View | null>(null); |
||||
|
||||
private load$: Observable<View> = this.activatedRoute.params.pipe( |
||||
switchMap(({ providerId }) => this.providerService.get$(providerId)), |
||||
switchMap((provider) => |
||||
this.configService |
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) |
||||
.pipe( |
||||
map((managePaymentDetailsOutsideCheckout) => { |
||||
if (!managePaymentDetailsOutsideCheckout) { |
||||
throw new RedirectError(["../subscription"], this.activatedRoute); |
||||
} |
||||
return provider; |
||||
}), |
||||
), |
||||
), |
||||
providerToBillableEntity, |
||||
switchMap(async (provider) => { |
||||
const [paymentMethod, billingAddress, credit] = await Promise.all([ |
||||
this.billingClient.getPaymentMethod(provider), |
||||
this.billingClient.getBillingAddress(provider), |
||||
this.billingClient.getCredit(provider), |
||||
]); |
||||
|
||||
return { |
||||
provider, |
||||
paymentMethod, |
||||
billingAddress, |
||||
credit, |
||||
}; |
||||
}), |
||||
shareReplay({ bufferSize: 1, refCount: false }), |
||||
catchError((error: unknown) => { |
||||
if (error instanceof RedirectError) { |
||||
return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( |
||||
switchMap(() => EMPTY), |
||||
); |
||||
} |
||||
throw error; |
||||
}), |
||||
); |
||||
|
||||
view$: Observable<View> = 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 })); |
||||
|
||||
constructor( |
||||
private activatedRoute: ActivatedRoute, |
||||
private billingClient: BillingClient, |
||||
private configService: ConfigService, |
||||
private providerService: ProviderService, |
||||
private router: Router, |
||||
) {} |
||||
|
||||
setBillingAddress = (billingAddress: BillingAddress) => { |
||||
if (this.viewState$.value) { |
||||
this.viewState$.next({ |
||||
...this.viewState$.value, |
||||
billingAddress, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { |
||||
if (this.viewState$.value) { |
||||
this.viewState$.next({ |
||||
...this.viewState$.value, |
||||
paymentMethod, |
||||
}); |
||||
} |
||||
}; |
||||
} |
||||
Loading…
Reference in new issue