1
0
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:
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

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

View File

@@ -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() {

View File

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

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

View File

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

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