mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +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:
@@ -31,6 +31,12 @@
|
||||
*ngIf="canAccessBilling$ | async"
|
||||
>
|
||||
<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-group>
|
||||
<bit-nav-item
|
||||
|
||||
@@ -10,6 +10,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
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 { 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";
|
||||
@@ -32,10 +34,12 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
protected canAccessBilling$: Observable<boolean>;
|
||||
|
||||
protected clientsTranslationKey$: Observable<string>;
|
||||
protected managePaymentDetailsOutsideCheckout$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private providerService: ProviderService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -69,6 +73,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
provider.providerType === ProviderType.BusinessUnit ? "businessUnits" : "clients",
|
||||
),
|
||||
);
|
||||
|
||||
this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
hasConsolidatedBilling,
|
||||
ProviderBillingHistoryComponent,
|
||||
} 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 { ClientsComponent } from "./clients/clients.component";
|
||||
@@ -142,6 +143,14 @@ const routes: Routes = [
|
||||
titleId: "subscription",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "payment-details",
|
||||
component: ProviderPaymentDetailsComponent,
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
data: {
|
||||
titleId: "paymentDetails",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "history",
|
||||
component: ProviderBillingHistoryComponent,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<app-header></app-header>
|
||||
<bit-container>
|
||||
@let view = view$ | async;
|
||||
@if (!view) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<app-display-payment-method
|
||||
[owner]="view.provider"
|
||||
[paymentMethod]="view.paymentMethod"
|
||||
(updated)="setPaymentMethod($event)"
|
||||
></app-display-payment-method>
|
||||
|
||||
<app-display-billing-address
|
||||
[owner]="view.provider"
|
||||
[billingAddress]="view.billingAddress"
|
||||
(updated)="setBillingAddress($event)"
|
||||
></app-display-billing-address>
|
||||
|
||||
<app-display-account-credit
|
||||
[owner]="view.provider"
|
||||
[credit]="view.credit"
|
||||
></app-display-account-credit>
|
||||
</ng-container>
|
||||
}
|
||||
</bit-container>
|
||||
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -62,49 +62,51 @@
|
||||
</bit-table>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Account Credit -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">
|
||||
{{ "accountCredit" | i18n }}
|
||||
</h2>
|
||||
<p class="tw-text-lg tw-font-bold">{{ subscription.accountCredit | currency: "$" }}</p>
|
||||
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
|
||||
</bit-section>
|
||||
<!-- Payment Method -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p *ngIf="!subscription.paymentSource" bitTypography="body1">
|
||||
{{ "noPaymentMethod" | i18n }}
|
||||
</p>
|
||||
<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
|
||||
>
|
||||
@if (!managePaymentDetailsOutsideCheckout) {
|
||||
<!-- Account Credit -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">
|
||||
{{ "accountCredit" | i18n }}
|
||||
</h2>
|
||||
<p class="tw-text-lg tw-font-bold">{{ subscription.accountCredit | currency: "$" }}</p>
|
||||
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
|
||||
</bit-section>
|
||||
<!-- Payment Method -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p *ngIf="!subscription.paymentSource" bitTypography="body1">
|
||||
{{ "noPaymentMethod" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<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 *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>
|
||||
</ng-container>
|
||||
<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>
|
||||
</bit-container>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ProviderPlanResponse,
|
||||
ProviderSubscriptionResponse,
|
||||
} 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 { DialogService, ToastService } from "@bitwarden/components";
|
||||
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;
|
||||
private destroy$ = new Subject<void>();
|
||||
protected totalCost: number;
|
||||
protected managePaymentDetailsOutsideCheckout: boolean;
|
||||
|
||||
protected readonly TaxInformation = TaxInformation;
|
||||
|
||||
@@ -44,6 +47,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -51,6 +55,9 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
.pipe(
|
||||
concatMap(async (params) => {
|
||||
this.providerId = params.providerId;
|
||||
this.managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
await this.load();
|
||||
this.firstLoaded = true;
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
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 { LogService } from "@bitwarden/common/platform/abstractions/log.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 organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -250,12 +253,13 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod() {
|
||||
await this.router.navigate(
|
||||
["organizations", `${this.organizationId}`, "billing", "payment-method"],
|
||||
{
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
},
|
||||
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
|
||||
await this.router.navigate(["organizations", `${this.organizationId}`, "billing", route], {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
Reference in New Issue
Block a user