1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +00:00

[PM-21881] Manage payment details outside of checkout (#15458)

* 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 test
This commit is contained in:
Alex Morask
2025-07-10 08:32:40 -05:00
committed by GitHub
parent 8c3c5ab861
commit a53b1e9ffb
60 changed files with 4268 additions and 151 deletions

View File

@@ -1,14 +1,14 @@
<app-free-trial-warning <app-organization-free-trial-warning
*ngIf="useOrganizationWarningsService$ | async" *ngIf="useOrganizationWarningsService$ | async"
[organization]="organization" [organization]="organization"
(clicked)="navigateToPaymentMethod()" (clicked)="navigateToPaymentMethod()"
> >
</app-free-trial-warning> </app-organization-free-trial-warning>
<app-reseller-renewal-warning <app-organization-reseller-renewal-warning
*ngIf="useOrganizationWarningsService$ | async" *ngIf="useOrganizationWarningsService$ | async"
[organization]="organization" [organization]="organization"
> >
</app-reseller-renewal-warning> </app-organization-reseller-renewal-warning>
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial"> <ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial">
<bit-banner <bit-banner
id="free-trial-banner" id="free-trial-banner"

View File

@@ -78,8 +78,8 @@ import {
DecryptionFailureDialogComponent, DecryptionFailureDialogComponent,
PasswordRepromptService, PasswordRepromptService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/services/organization-warnings.service"; import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component";
import { ResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/reseller-renewal-warning.component"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
import { import {
@@ -88,7 +88,7 @@ import {
} from "../../../billing/services/reseller-warning.service"; } from "../../../billing/services/reseller-warning.service";
import { TrialFlowService } from "../../../billing/services/trial-flow.service"; import { TrialFlowService } from "../../../billing/services/trial-flow.service";
import { FreeTrial } from "../../../billing/types/free-trial"; import { FreeTrial } from "../../../billing/types/free-trial";
import { FreeTrialWarningComponent } from "../../../billing/warnings/free-trial-warning.component"; import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections";
import { import {
@@ -125,7 +125,7 @@ import {
BulkCollectionsDialogResult, BulkCollectionsDialogResult,
} from "./bulk-collections-dialog"; } from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { getNestedCollectionTree, getFlatCollectionTree } from "./utils"; import { getFlatCollectionTree, getNestedCollectionTree } from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -150,8 +150,8 @@ enum AddAccessStatusType {
SharedModule, SharedModule,
BannerModule, BannerModule,
NoItemsModule, NoItemsModule,
FreeTrialWarningComponent, OrganizationFreeTrialWarningComponent,
ResellerRenewalWarningComponent, OrganizationResellerRenewalWarningComponent,
], ],
providers: [ providers: [
RoutedVaultFilterService, RoutedVaultFilterService,
@@ -749,10 +749,13 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
async navigateToPaymentMethod() { async navigateToPaymentMethod() {
await this.router.navigate( const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
["organizations", `${this.organization?.id}`, "billing", "payment-method"], FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
{ state: { launchPaymentModalAutomatically: true } },
); );
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
} }
addAccessToggle(e: AddAccessStatusType) { addAccessToggle(e: AddAccessStatusType) {

View File

@@ -74,7 +74,11 @@
> >
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item> <bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)"> <ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item> @let paymentDetailsPageData = paymentDetailsPageData$ | async;
<bit-nav-item
[text]="paymentDetailsPageData.textKey | i18n"
[route]="paymentDetailsPageData.route"
></bit-nav-item>
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item> <bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</ng-container> </ng-container>
</bit-nav-group> </bit-nav-group>

View File

@@ -64,6 +64,11 @@ export class OrganizationLayoutComponent implements OnInit {
protected showSponsoredFamiliesDropdown$: Observable<boolean>; protected showSponsoredFamiliesDropdown$: Observable<boolean>;
protected canShowPoliciesTab$: Observable<boolean>; protected canShowPoliciesTab$: Observable<boolean>;
protected paymentDetailsPageData$: Observable<{
route: string;
textKey: string;
}>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private organizationService: OrganizationService, private organizationService: OrganizationService,
@@ -135,6 +140,16 @@ export class OrganizationLayoutComponent implements OnInit {
), ),
), ),
); );
this.paymentDetailsPageData$ = this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(
map((managePaymentDetailsOutsideCheckout) =>
managePaymentDetailsOutsideCheckout
? { route: "billing/payment-details", textKey: "paymentDetails" }
: { route: "billing/payment-method", textKey: "paymentMethod" },
),
);
} }
canShowVaultTab(organization: Organization): boolean { canShowVaultTab(organization: Organization): boolean {

View File

@@ -1,6 +1,8 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router"; import { RouterModule, Routes } from "@angular/router";
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
import { PaymentMethodComponent } from "../shared"; import { PaymentMethodComponent } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component";
@@ -30,6 +32,11 @@ const routes: Routes = [
component: PaymentMethodComponent, component: PaymentMethodComponent,
data: { titleId: "paymentMethod" }, data: { titleId: "paymentMethod" },
}, },
{
path: "payment-details",
component: AccountPaymentDetailsComponent,
data: { titleId: "paymentDetails" },
},
{ {
path: "billing-history", path: "billing-history",
component: BillingHistoryViewComponent, component: BillingHistoryViewComponent,

View File

@@ -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>

View File

@@ -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,
});
}
};
}

View File

@@ -3,7 +3,10 @@
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{ <bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
"subscription" | i18n "subscription" | i18n
}}</bit-tab-link> }}</bit-tab-link>
<bit-tab-link route="payment-method">{{ "paymentMethod" | i18n }}</bit-tab-link> @let paymentMethodPageData = paymentDetailsPageData$ | async;
<bit-tab-link [route]="paymentMethodPageData.route">{{
paymentMethodPageData.textKey | i18n
}}</bit-tab-link>
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link> <bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
</bit-tab-nav-bar> </bit-tab-nav-bar>
</app-header> </app-header>

View File

@@ -1,10 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { Observable, switchMap } from "rxjs"; import { map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@Component({ @Component({
@@ -13,16 +15,32 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
}) })
export class SubscriptionComponent implements OnInit { export class SubscriptionComponent implements OnInit {
hasPremium$: Observable<boolean>; hasPremium$: Observable<boolean>;
paymentDetailsPageData$: Observable<{
route: string;
textKey: string;
}>;
selfHosted: boolean; selfHosted: boolean;
constructor( constructor(
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService, accountService: AccountService,
private configService: ConfigService,
) { ) {
this.hasPremium$ = accountService.activeAccount$.pipe( this.hasPremium$ = accountService.activeAccount$.pipe(
switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)), switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)),
); );
this.paymentDetailsPageData$ = this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(
map((managePaymentDetailsOutsideCheckout) =>
managePaymentDetailsOutsideCheckout
? { route: "payment-details", textKey: "paymentDetails" }
: { route: "payment-method", textKey: "paymentMethod" },
),
);
} }
ngOnInit() { ngOnInit() {

View File

@@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router"; import { RouterModule, Routes } from "@angular/router";
import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/organizations/payment-details/organization-payment-details.component";
import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard"; import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard"; import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard";
@@ -36,6 +37,17 @@ const routes: Routes = [
titleId: "paymentMethod", titleId: "paymentMethod",
}, },
}, },
{
path: "payment-details",
component: OrganizationPaymentDetailsComponent,
canActivate: [
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
organizationIsUnmanaged,
],
data: {
titleId: "paymentDetails",
},
},
{ {
path: "history", path: "history",
component: OrgBillingHistoryViewComponent, component: OrgBillingHistoryViewComponent,

View File

@@ -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>

View File

@@ -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,
});
}
};
}

View File

@@ -4,7 +4,7 @@ import { Location } from "@angular/common";
import { Component, OnDestroy } from "@angular/core"; import { Component, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs"; import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { import {
@@ -19,6 +19,8 @@ import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
@@ -72,18 +74,28 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
private accountService: AccountService, private accountService: AccountService,
protected syncService: SyncService, protected syncService: SyncService,
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
) { ) {
this.activatedRoute.params combineLatest([
this.activatedRoute.params,
this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout),
])
.pipe( .pipe(
takeUntilDestroyed(), switchMap(([{ organizationId }, managePaymentDetailsOutsideCheckout]) => {
switchMap(({ organizationId }) => {
if (this.platformUtilsService.isSelfHost()) { if (this.platformUtilsService.isSelfHost()) {
return from(this.router.navigate(["/settings/subscription"])); return from(this.router.navigate(["/settings/subscription"]));
} }
if (managePaymentDetailsOutsideCheckout) {
return from(
this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }),
);
}
this.organizationId = organizationId; this.organizationId = organizationId;
return from(this.load()); return from(this.load());
}), }),
takeUntilDestroyed(),
) )
.subscribe(); .subscribe();

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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, "$");
}
}

View File

@@ -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);
}
};
}

View File

@@ -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;
};
}

View File

@@ -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);
}

View File

@@ -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),
});
}

View File

@@ -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],
}),
}),
});
}

View File

@@ -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";

View File

@@ -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,
});
}
}
};
}

View File

@@ -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");
});
});

View File

@@ -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(", ");
}
}

View File

@@ -0,0 +1 @@
export * from "./address.pipe";

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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");
}
}

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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;
};

View File

@@ -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;
}
};
}

View File

@@ -1,3 +1,4 @@
export * from "./billing.client";
export * from "./billing-services.module"; export * from "./billing-services.module";
export * from "./braintree.service"; export * from "./braintree.service";
export * from "./stripe.service"; export * from "./stripe.service";

View File

@@ -2,11 +2,43 @@
// @ts-strict-ignore // @ts-strict-ignore
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BankAccount } from "@bitwarden/common/billing/models/domain"; import { BankAccount } from "@bitwarden/common/billing/models/domain";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BankAccountPaymentMethod, CardPaymentMethod } from "../payment/types";
import { BillingServicesModule } from "./billing-services.module"; import { BillingServicesModule } from "./billing-services.module";
type SetupBankAccountRequest = {
payment_method: {
us_bank_account: {
routing_number: string;
account_number: string;
account_holder_type: string;
};
billing_details: {
name: string;
address?: {
country: string;
postal_code: string;
};
};
};
};
type SetupCardRequest = {
payment_method: {
card: string;
billing_details?: {
address: {
country: string;
postal_code: string;
};
};
};
};
@Injectable({ providedIn: BillingServicesModule }) @Injectable({ providedIn: BillingServicesModule })
export class StripeService { export class StripeService {
private stripe: any; private stripe: any;
@@ -17,7 +49,28 @@ export class StripeService {
cardCvc: string; cardCvc: string;
}; };
constructor(private logService: LogService) {} constructor(
private apiService: ApiService,
private logService: LogService,
) {}
createSetupIntent = async (
paymentMethod: BankAccountPaymentMethod | CardPaymentMethod,
): Promise<string> => {
const getPath = () => {
switch (paymentMethod) {
case "bankAccount": {
return "/setup-intent/bank-account";
}
case "card": {
return "/setup-intent/card";
}
}
};
const response = await this.apiService.send("POST", getPath(), null, true, true);
return response as string;
};
/** /**
* Loads [Stripe JS]{@link https://docs.stripe.com/js} in the <head> element of the current page and mounts * Loads [Stripe JS]{@link https://docs.stripe.com/js} in the <head> element of the current page and mounts
@@ -51,25 +104,28 @@ export class StripeService {
window.document.head.appendChild(script); window.document.head.appendChild(script);
} }
/** mountElements(attempt: number = 1) {
* Re-mounts previously created Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements
* specified during the {@link loadStripe} call. This is useful for when those HTML elements are removed from the DOM by Angular.
*/
mountElements(i: number = 0) {
setTimeout(() => { setTimeout(() => {
if (!document.querySelector(this.elementIds.cardNumber) && i < 10) { if (!this.elements) {
this.logService.warning("Stripe container missing, retrying..."); this.logService.warning(`Stripe elements are missing, retrying for attempt ${attempt}...`);
this.mountElements(i + 1); this.mountElements(attempt + 1);
return; } else {
} const cardNumber = this.elements.getElement("cardNumber");
const cardExpiry = this.elements.getElement("cardExpiry");
const cardCVC = this.elements.getElement("cardCvc");
const cardNumber = this.elements.getElement("cardNumber"); if ([cardNumber, cardExpiry, cardCVC].some((element) => !element)) {
const cardExpiry = this.elements.getElement("cardExpiry"); this.logService.warning(
const cardCvc = this.elements.getElement("cardCvc"); `Some Stripe card elements are missing, retrying for attempt ${attempt}...`,
cardNumber.mount(this.elementIds.cardNumber); );
cardExpiry.mount(this.elementIds.cardExpiry); this.mountElements(attempt + 1);
cardCvc.mount(this.elementIds.cardCvc); } else {
}, 50); cardNumber.mount(this.elementIds.cardNumber);
cardExpiry.mount(this.elementIds.cardExpiry);
cardCVC.mount(this.elementIds.cardCvc);
}
}
}, 100);
} }
/** /**
@@ -81,8 +137,9 @@ export class StripeService {
async setupBankAccountPaymentMethod( async setupBankAccountPaymentMethod(
clientSecret: string, clientSecret: string,
{ accountHolderName, routingNumber, accountNumber, accountHolderType }: BankAccount, { accountHolderName, routingNumber, accountNumber, accountHolderType }: BankAccount,
billingDetails?: { country: string; postalCode: string },
): Promise<string> { ): Promise<string> {
const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, { const request: SetupBankAccountRequest = {
payment_method: { payment_method: {
us_bank_account: { us_bank_account: {
routing_number: routingNumber, routing_number: routingNumber,
@@ -93,7 +150,16 @@ export class StripeService {
name: accountHolderName, name: accountHolderName,
}, },
}, },
}); };
if (billingDetails) {
request.payment_method.billing_details.address = {
country: billingDetails.country,
postal_code: billingDetails.postalCode,
};
}
const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, request);
if (result.error || (result.setupIntent && result.setupIntent.status !== "requires_action")) { if (result.error || (result.setupIntent && result.setupIntent.status !== "requires_action")) {
this.logService.error(result.error); this.logService.error(result.error);
throw result.error; throw result.error;
@@ -107,13 +173,25 @@ export class StripeService {
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}. * thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
* @returns The ID of the newly created PaymentMethod. * @returns The ID of the newly created PaymentMethod.
*/ */
async setupCardPaymentMethod(clientSecret: string): Promise<string> { async setupCardPaymentMethod(
clientSecret: string,
billingDetails?: { country: string; postalCode: string },
): Promise<string> {
const cardNumber = this.elements.getElement("cardNumber"); const cardNumber = this.elements.getElement("cardNumber");
const result = await this.stripe.confirmCardSetup(clientSecret, { const request: SetupCardRequest = {
payment_method: { payment_method: {
card: cardNumber, card: cardNumber,
}, },
}); };
if (billingDetails) {
request.payment_method.billing_details = {
address: {
country: billingDetails.country,
postal_code: billingDetails.postalCode,
},
};
}
const result = await this.stripe.confirmCardSetup(clientSecret, request);
if (result.error || (result.setupIntent && result.setupIntent.status !== "succeeded")) { if (result.error || (result.setupIntent && result.setupIntent.status !== "succeeded")) {
this.logService.error(result.error); this.logService.error(result.error);
throw result.error; throw result.error;

View File

@@ -11,6 +11,8 @@ import { BillingSourceResponse } from "@bitwarden/common/billing/models/response
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@@ -28,6 +30,7 @@ export class TrialFlowService {
private router: Router, private router: Router,
protected billingApiService: BillingApiServiceAbstraction, protected billingApiService: BillingApiServiceAbstraction,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private configService: ConfigService,
) {} ) {}
checkForOrgsWithUpcomingPaymentIssues( checkForOrgsWithUpcomingPaymentIssues(
organization: Organization, organization: Organization,
@@ -131,7 +134,11 @@ export class TrialFlowService {
} }
private async navigateToPaymentMethod(orgId: string) { private async navigateToPaymentMethod(orgId: string) {
await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${orgId}`, "billing", route], {
state: { launchPaymentModalAutomatically: true }, state: { launchPaymentModalAutomatically: true },
queryParams: { launchPaymentModalAutomatically: true }, queryParams: { launchPaymentModalAutomatically: true },
}); });

View File

@@ -18,7 +18,9 @@ import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request"; import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
@@ -79,6 +81,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService, private organizationService: OrganizationService,
private accountService: AccountService, private accountService: AccountService,
protected syncService: SyncService, protected syncService: SyncService,
private configService: ConfigService,
) { ) {
const state = this.router.getCurrentNavigation()?.extras?.state; const state = this.router.getCurrentNavigation()?.extras?.state;
// incase the above state is undefined or null we use redundantState // incase the above state is undefined or null we use redundantState
@@ -107,6 +110,14 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
return; return;
} }
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
if (managePaymentDetailsOutsideCheckout) {
await this.router.navigate(["../payment-details"], { relativeTo: this.route });
}
await this.load(); await this.load();
this.firstLoaded = true; this.firstLoaded = true;
}); });

View File

@@ -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,
};
});

View File

@@ -0,0 +1,2 @@
export * from "./billable-entity";
export * from "./free-trial";

View File

@@ -0,0 +1,2 @@
export * from "./organization-free-trial-warning.component";
export * from "./organization-reseller-renewal-warning.component";

View File

@@ -6,15 +6,13 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { import { OrganizationWarningsService } from "../services";
FreeTrialWarning, import { OrganizationFreeTrialWarning } from "../types";
OrganizationWarningsService,
} from "../services/organization-warnings.service";
@Component({ @Component({
selector: "app-free-trial-warning", selector: "app-organization-free-trial-warning",
template: ` template: `
@let warning = freeTrialWarning$ | async; @let warning = warning$ | async;
@if (warning) { @if (warning) {
<bit-banner <bit-banner
@@ -39,17 +37,19 @@ import {
`, `,
imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe],
}) })
export class FreeTrialWarningComponent implements OnInit { export class OrganizationFreeTrialWarningComponent implements OnInit {
@Input({ required: true }) organization!: Organization; @Input({ required: true }) organization!: Organization;
@Output() clicked = new EventEmitter<void>(); @Output() clicked = new EventEmitter<void>();
freeTrialWarning$!: Observable<FreeTrialWarning>; warning$!: Observable<OrganizationFreeTrialWarning>;
constructor(private organizationWarningsService: OrganizationWarningsService) {} constructor(private organizationWarningsService: OrganizationWarningsService) {}
ngOnInit() { ngOnInit() {
this.freeTrialWarning$ = this.organizationWarningsService.getFreeTrialWarning$( this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization);
this.organization,
);
} }
refresh = () => {
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true);
};
} }

View File

@@ -5,15 +5,13 @@ import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BannerComponent } from "@bitwarden/components"; import { BannerComponent } from "@bitwarden/components";
import { import { OrganizationWarningsService } from "../services";
OrganizationWarningsService, import { OrganizationResellerRenewalWarning } from "../types";
ResellerRenewalWarning,
} from "../services/organization-warnings.service";
@Component({ @Component({
selector: "app-reseller-renewal-warning", selector: "app-organization-reseller-renewal-warning",
template: ` template: `
@let warning = resellerRenewalWarning$ | async; @let warning = warning$ | async;
@if (warning) { @if (warning) {
<bit-banner <bit-banner
@@ -29,16 +27,14 @@ import {
`, `,
imports: [AsyncPipe, BannerComponent], imports: [AsyncPipe, BannerComponent],
}) })
export class ResellerRenewalWarningComponent implements OnInit { export class OrganizationResellerRenewalWarningComponent implements OnInit {
@Input({ required: true }) organization!: Organization; @Input({ required: true }) organization!: Organization;
resellerRenewalWarning$!: Observable<ResellerRenewalWarning>; warning$!: Observable<OrganizationResellerRenewalWarning>;
constructor(private organizationWarningsService: OrganizationWarningsService) {} constructor(private organizationWarningsService: OrganizationWarningsService) {}
ngOnInit() { ngOnInit() {
this.resellerRenewalWarning$ = this.organizationWarningsService.getResellerRenewalWarning$( this.warning$ = this.organizationWarningsService.getResellerRenewalWarning$(this.organization);
this.organization,
);
} }
} }

View File

@@ -0,0 +1 @@
export * from "./organization-warnings.service";

View File

@@ -1,25 +1,20 @@
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { import { filter, from, lastValueFrom, map, Observable, switchMap, takeWhile } from "rxjs";
filter,
from,
lastValueFrom,
map,
Observable,
shareReplay,
switchMap,
takeWhile,
} from "rxjs";
import { take } from "rxjs/operators"; import { take } from "rxjs/operators";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { openChangePlanDialog } from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component";
import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component";
import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types";
const format = (date: Date) => const format = (date: Date) =>
date.toLocaleDateString("en-US", { date.toLocaleDateString("en-US", {
@@ -28,21 +23,12 @@ const format = (date: Date) =>
year: "numeric", year: "numeric",
}); });
export type FreeTrialWarning = {
organization: Pick<Organization, "id" & "name">;
message: string;
};
export type ResellerRenewalWarning = {
type: "info" | "warning";
message: string;
};
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: "root" })
export class OrganizationWarningsService { export class OrganizationWarningsService {
private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>(); private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>();
constructor( constructor(
private configService: ConfigService,
private dialogService: DialogService, private dialogService: DialogService,
private i18nService: I18nService, private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
@@ -50,8 +36,11 @@ export class OrganizationWarningsService {
private router: Router, private router: Router,
) {} ) {}
getFreeTrialWarning$ = (organization: Organization): Observable<FreeTrialWarning> => getFreeTrialWarning$ = (
this.getWarning$(organization, (response) => response.freeTrial).pipe( organization: Organization,
bypassCache: boolean = false,
): Observable<OrganizationFreeTrialWarning> =>
this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe(
map((warning) => { map((warning) => {
const { remainingTrialDays } = warning; const { remainingTrialDays } = warning;
@@ -76,9 +65,12 @@ export class OrganizationWarningsService {
}), }),
); );
getResellerRenewalWarning$ = (organization: Organization): Observable<ResellerRenewalWarning> => getResellerRenewalWarning$ = (
this.getWarning$(organization, (response) => response.resellerRenewal).pipe( organization: Organization,
map((warning): ResellerRenewalWarning | null => { bypassCache: boolean = false,
): Observable<OrganizationResellerRenewalWarning> =>
this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe(
map((warning): OrganizationResellerRenewalWarning | null => {
switch (warning.type) { switch (warning.type) {
case "upcoming": { case "upcoming": {
return { return {
@@ -116,8 +108,11 @@ export class OrganizationWarningsService {
filter((result): result is NonNullable<typeof result> => result !== null), filter((result): result is NonNullable<typeof result> => result !== null),
); );
showInactiveSubscriptionDialog$ = (organization: Organization): Observable<void> => showInactiveSubscriptionDialog$ = (
this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( organization: Organization,
bypassCache: boolean = false,
): Observable<void> =>
this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe(
switchMap(async (warning) => { switchMap(async (warning) => {
switch (warning.resolution) { switch (warning.resolution) {
case "contact_provider": { case "contact_provider": {
@@ -142,8 +137,14 @@ export class OrganizationWarningsService {
cancelButtonText: this.i18nService.t("close"), cancelButtonText: this.i18nService.t("close"),
}); });
if (confirmed) { if (confirmed) {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout
? "payment-details"
: "payment-method";
await this.router.navigate( await this.router.navigate(
["organizations", `${organization.id}`, "billing", "payment-method"], ["organizations", `${organization.id}`, "billing", route],
{ {
state: { launchPaymentModalAutomatically: true }, state: { launchPaymentModalAutomatically: true },
}, },
@@ -177,14 +178,15 @@ export class OrganizationWarningsService {
}), }),
); );
private getResponse$ = (organization: Organization): Observable<OrganizationWarningsResponse> => { private getResponse$ = (
organization: Organization,
bypassCache: boolean = false,
): Observable<OrganizationWarningsResponse> => {
const existing = this.cache$.get(organization.id as OrganizationId); const existing = this.cache$.get(organization.id as OrganizationId);
if (existing) { if (existing && !bypassCache) {
return existing; return existing;
} }
const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)).pipe( const response$ = from(this.organizationBillingApiService.getWarnings(organization.id));
shareReplay({ bufferSize: 1, refCount: false }),
);
this.cache$.set(organization.id as OrganizationId, response$); this.cache$.set(organization.id as OrganizationId, response$);
return response$; return response$;
}; };
@@ -192,8 +194,9 @@ export class OrganizationWarningsService {
private getWarning$ = <T>( private getWarning$ = <T>(
organization: Organization, organization: Organization,
extract: (response: OrganizationWarningsResponse) => T | null | undefined, extract: (response: OrganizationWarningsResponse) => T | null | undefined,
bypassCache: boolean = false,
): Observable<T> => ): Observable<T> =>
this.getResponse$(organization).pipe( this.getResponse$(organization, bypassCache).pipe(
map(extract), map(extract),
takeWhile((warning): warning is T => !!warning), takeWhile((warning): warning is T => !!warning),
take(1), take(1),

View File

@@ -0,0 +1 @@
export * from "./organization-warnings";

View File

@@ -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;
};

View File

@@ -8,6 +8,7 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { MessageListener } from "@bitwarden/common/platform/messaging"; import { MessageListener } from "@bitwarden/common/platform/messaging";
@@ -87,6 +88,10 @@ describe("VaultBannersComponent", () => {
allMessages$: messageSubject.asObservable(), allMessages$: messageSubject.asObservable(),
}), }),
}, },
{
provide: ConfigService,
useValue: mock<ConfigService>(),
},
], ],
}) })
.overrideProvider(VaultBannersService, { useValue: bannerService }) .overrideProvider(VaultBannersService, { useValue: bannerService })

View File

@@ -1,9 +1,11 @@
import { Component, Input, OnInit } from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, map, Observable, switchMap, filter } from "rxjs"; import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessageListener } from "@bitwarden/common/platform/messaging"; import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
@@ -35,6 +37,7 @@ export class VaultBannersComponent implements OnInit {
private i18nService: I18nService, private i18nService: I18nService,
private accountService: AccountService, private accountService: AccountService,
private messageListener: MessageListener, private messageListener: MessageListener,
private configService: ConfigService,
) { ) {
this.premiumBannerVisible$ = this.activeUserId$.pipe( this.premiumBannerVisible$ = this.activeUserId$.pipe(
filter((userId): userId is UserId => userId != null), filter((userId): userId is UserId => userId != null),
@@ -68,12 +71,16 @@ export class VaultBannersComponent implements OnInit {
} }
async navigateToPaymentMethod(organizationId: string): Promise<void> { async navigateToPaymentMethod(organizationId: string): Promise<void> {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
const navigationExtras = { const navigationExtras = {
state: { launchPaymentModalAutomatically: true }, state: { launchPaymentModalAutomatically: true },
}; };
await this.router.navigate( await this.router.navigate(
["organizations", organizationId, "billing", "payment-method"], ["organizations", organizationId, "billing", route],
navigationExtras, navigationExtras,
); );
} }

View File

@@ -10802,5 +10802,53 @@
"billingAddressRequiredToAddCredit": { "billingAddressRequiredToAddCredit": {
"message": "Billing address required to add credit.", "message": "Billing address required to add credit.",
"description": "Error message shown when trying to add credit to a trialing organization without a billing address." "description": "Error message shown when trying to add credit to a trialing organization without a billing address."
},
"billingAddress": {
"message": "Billing address"
},
"addBillingAddress": {
"message": "Add billing address"
},
"editBillingAddress": {
"message": "Edit billing address"
},
"noBillingAddress": {
"message": "No address on file."
},
"billingAddressUpdated": {
"message": "Your billing address has been updated."
},
"paymentDetails": {
"message": "Payment details"
},
"paymentMethodUpdated": {
"message": "Your payment method has been updated."
},
"bankAccountVerified": {
"message": "Your bank account has been verified."
},
"availableCreditAppliedToInvoice": {
"message": "Any available credit will be automatically applied towards invoices generated for this account."
},
"mustBePositiveNumber": {
"message": "Must be a positive number"
},
"cardSecurityCode": {
"message": "Card security code"
},
"cardSecurityCodeDescription": {
"message": "Card security code, also known as CVV or CVC, is typically a 3 digit number printed on the back of your credit card or 4 digit number printed on the front above your card number."
},
"verifyBankAccountWarning": {
"message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the Payment Details page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended."
},
"taxId": {
"message": "Tax ID: $TAX_ID$",
"placeholders": {
"tax_id": {
"content": "$1",
"example": "12-3456789"
}
}
} }
} }

View File

@@ -31,6 +31,12 @@
*ngIf="canAccessBilling$ | async" *ngIf="canAccessBilling$ | async"
> >
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item> <bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
@if (managePaymentDetailsOutsideCheckout$ | async) {
<bit-nav-item
[text]="'paymentDetails' | i18n"
route="billing/payment-details"
></bit-nav-item>
}
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item> <bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<bit-nav-item <bit-nav-item

View File

@@ -10,6 +10,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums"; import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Icon, IconModule } from "@bitwarden/components"; import { Icon, IconModule } from "@bitwarden/components";
import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/business-unit-portal-logo.icon"; import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/business-unit-portal-logo.icon";
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo"; import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
@@ -32,10 +34,12 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected canAccessBilling$: Observable<boolean>; protected canAccessBilling$: Observable<boolean>;
protected clientsTranslationKey$: Observable<string>; protected clientsTranslationKey$: Observable<string>;
protected managePaymentDetailsOutsideCheckout$: Observable<boolean>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private providerService: ProviderService, private providerService: ProviderService,
private configService: ConfigService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -69,6 +73,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
provider.providerType === ProviderType.BusinessUnit ? "businessUnits" : "clients", provider.providerType === ProviderType.BusinessUnit ? "businessUnits" : "clients",
), ),
); );
this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
} }
ngOnDestroy() { ngOnDestroy() {

View File

@@ -13,6 +13,7 @@ import {
hasConsolidatedBilling, hasConsolidatedBilling,
ProviderBillingHistoryComponent, ProviderBillingHistoryComponent,
} from "../../billing/providers"; } from "../../billing/providers";
import { ProviderPaymentDetailsComponent } from "../../billing/providers/payment-details/provider-payment-details.component";
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component"; import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component";
import { ClientsComponent } from "./clients/clients.component"; import { ClientsComponent } from "./clients/clients.component";
@@ -142,6 +143,14 @@ const routes: Routes = [
titleId: "subscription", titleId: "subscription",
}, },
}, },
{
path: "payment-details",
component: ProviderPaymentDetailsComponent,
canActivate: [providerPermissionsGuard()],
data: {
titleId: "paymentDetails",
},
},
{ {
path: "history", path: "history",
component: ProviderBillingHistoryComponent, component: ProviderBillingHistoryComponent,

View File

@@ -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>

View File

@@ -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,
});
}
};
}

View File

@@ -62,49 +62,51 @@
</bit-table> </bit-table>
</div> </div>
</ng-container> </ng-container>
<!-- Account Credit --> @if (!managePaymentDetailsOutsideCheckout) {
<bit-section> <!-- Account Credit -->
<h2 bitTypography="h2"> <bit-section>
{{ "accountCredit" | i18n }} <h2 bitTypography="h2">
</h2> {{ "accountCredit" | i18n }}
<p class="tw-text-lg tw-font-bold">{{ subscription.accountCredit | currency: "$" }}</p> </h2>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p> <p class="tw-text-lg tw-font-bold">{{ subscription.accountCredit | currency: "$" }}</p>
</bit-section> <p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<!-- Payment Method --> </bit-section>
<bit-section> <!-- Payment Method -->
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2> <bit-section>
<p *ngIf="!subscription.paymentSource" bitTypography="body1"> <h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
{{ "noPaymentMethod" | i18n }} <p *ngIf="!subscription.paymentSource" bitTypography="body1">
</p> {{ "noPaymentMethod" | i18n }}
<ng-container *ngIf="subscription.paymentSource">
<app-verify-bank-account
*ngIf="subscription.paymentSource.needsVerification"
[onSubmit]="verifyBankAccount"
(submitted)="load()"
>
</app-verify-bank-account>
<p>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ subscription.paymentSource.description }}
<span *ngIf="subscription.paymentSource.needsVerification"
>- {{ "unverified" | i18n }}</span
>
</p> </p>
</ng-container> <ng-container *ngIf="subscription.paymentSource">
<button type="button" bitButton buttonType="secondary" [bitAction]="updatePaymentMethod"> <app-verify-bank-account
{{ updatePaymentSourceButtonText }} *ngIf="subscription.paymentSource.needsVerification"
</button> [onSubmit]="verifyBankAccount"
</bit-section> (submitted)="load()"
<!-- Tax Information --> >
<bit-section> </app-verify-bank-account>
<h2 bitTypography="h2" class="tw-mt-16">{{ "taxInformation" | i18n }}</h2> <p>
<p>{{ "taxInformationDesc" | i18n }}</p> <i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
<app-manage-tax-information {{ subscription.paymentSource.description }}
*ngIf="subscription.taxInformation" <span *ngIf="subscription.paymentSource.needsVerification"
[startWith]="TaxInformation.from(subscription.taxInformation)" >- {{ "unverified" | i18n }}</span
[onSubmit]="updateTaxInformation" >
(taxInformationUpdated)="load()" </p>
/> </ng-container>
</bit-section> <button type="button" bitButton buttonType="secondary" [bitAction]="updatePaymentMethod">
{{ updatePaymentSourceButtonText }}
</button>
</bit-section>
<!-- Tax Information -->
<bit-section>
<h2 bitTypography="h2" class="tw-mt-16">{{ "taxInformation" | i18n }}</h2>
<p>{{ "taxInformationDesc" | i18n }}</p>
<app-manage-tax-information
*ngIf="subscription.taxInformation"
[startWith]="TaxInformation.from(subscription.taxInformation)"
[onSubmit]="updateTaxInformation"
(taxInformationUpdated)="load()"
/>
</bit-section>
}
</ng-container> </ng-container>
</bit-container> </bit-container>

View File

@@ -13,6 +13,8 @@ import {
ProviderPlanResponse, ProviderPlanResponse,
ProviderSubscriptionResponse, ProviderSubscriptionResponse,
} from "@bitwarden/common/billing/models/response/provider-subscription-response"; } from "@bitwarden/common/billing/models/response/provider-subscription-response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
@@ -34,6 +36,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
protected loading: boolean; protected loading: boolean;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected totalCost: number; protected totalCost: number;
protected managePaymentDetailsOutsideCheckout: boolean;
protected readonly TaxInformation = TaxInformation; protected readonly TaxInformation = TaxInformation;
@@ -44,6 +47,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private dialogService: DialogService, private dialogService: DialogService,
private toastService: ToastService, private toastService: ToastService,
private configService: ConfigService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -51,6 +55,9 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
.pipe( .pipe(
concatMap(async (params) => { concatMap(async (params) => {
this.providerId = params.providerId; this.providerId = params.providerId;
this.managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
await this.load(); await this.load();
this.firstLoaded = true; this.firstLoaded = true;
}), }),

View File

@@ -29,6 +29,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -129,6 +131,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
private trialFlowService: TrialFlowService, private trialFlowService: TrialFlowService,
private organizationBillingService: OrganizationBillingServiceAbstraction, private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -250,12 +253,13 @@ export class OverviewComponent implements OnInit, OnDestroy {
} }
async navigateToPaymentMethod() { async navigateToPaymentMethod() {
await this.router.navigate( const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
["organizations", `${this.organizationId}`, "billing", "payment-method"], FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
{
state: { launchPaymentModalAutomatically: true },
},
); );
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${this.organizationId}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@@ -34,6 +34,7 @@ export enum FeatureFlag {
PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup", PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup",
UseOrganizationWarningsService = "use-organization-warnings-service", UseOrganizationWarningsService = "use-organization-warnings-service",
AllowTrialLengthZero = "pm-20322-allow-trial-length-0", AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
/* Data Insights and Reporting */ /* Data Insights and Reporting */
EnableRiskInsightsNotifications = "enable-risk-insights-notifications", EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
@@ -116,6 +117,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE, [FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE,
[FeatureFlag.UseOrganizationWarningsService]: FALSE, [FeatureFlag.UseOrganizationWarningsService]: FALSE,
[FeatureFlag.AllowTrialLengthZero]: FALSE, [FeatureFlag.AllowTrialLengthZero]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
/* Key Management */ /* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.PrivateKeyRegeneration]: FALSE,