1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-25463] Work towards complete usage of Payments domain (#16532)

* Use payment domain

* Fixing lint and test issue

* Fix organization plans tax issue

* PM-26297: Use existing billing address for tax calculation if it exists

* PM-26344: Check existing payment method on submit
This commit is contained in:
Alex Morask
2025-10-01 10:26:47 -05:00
committed by GitHub
parent 147b511d64
commit d9d8050998
117 changed files with 1505 additions and 5212 deletions

View File

@@ -44,9 +44,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -239,7 +237,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService,
private apiService: ApiService,
private toastService: ToastService,
private configService: ConfigService,
private cipherFormConfigService: CipherFormConfigService,
protected billingApiService: BillingApiServiceAbstraction,
private accountService: AccountService,
@@ -710,14 +707,13 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async navigateToPaymentMethod() {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
const organizationId = await firstValueFrom(this.organizationId$);
await this.router.navigate(["organizations", `${organizationId}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
await this.router.navigate(
["organizations", `${organizationId}`, "billing", "payment-details"],
{
state: { launchPaymentModalAutomatically: true },
},
);
}
addAccessToggle(e: AddAccessStatusType) {

View File

@@ -71,10 +71,9 @@
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
@let paymentDetailsPageData = paymentDetailsPageData$ | async;
<bit-nav-item
[text]="paymentDetailsPageData.textKey | i18n"
[route]="paymentDetailsPageData.route"
[text]="'paymentDetails' | i18n"
route="billing/payment-details"
></bit-nav-item>
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</ng-container>

View File

@@ -23,9 +23,6 @@ import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/
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 { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getById } from "@bitwarden/common/platform/misc";
import { BannerModule, IconModule } from "@bitwarden/components";
@@ -70,11 +67,6 @@ export class OrganizationLayoutComponent implements OnInit {
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
protected paymentDetailsPageData$: Observable<{
route: string;
textKey: string;
}>;
protected subscriber$: Observable<NonIndividualSubscriber>;
protected getTaxIdWarning$: () => Observable<TaxIdWarningType | null>;
@@ -82,12 +74,10 @@ export class OrganizationLayoutComponent implements OnInit {
private route: ActivatedRoute,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private configService: ConfigService,
private policyService: PolicyService,
private providerService: ProviderService,
private accountService: AccountService,
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private organizationWarningsService: OrganizationWarningsService,
) {}
@@ -141,16 +131,6 @@ export class OrganizationLayoutComponent implements OnInit {
this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations));
this.paymentDetailsPageData$ = this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(
map((managePaymentDetailsOutsideCheckout) =>
managePaymentDetailsOutsideCheckout
? { route: "billing/payment-details", textKey: "paymentDetails" }
: { route: "billing/payment-method", textKey: "paymentMethod" },
),
);
this.subscriber$ = this.organization$.pipe(
map((organization) => ({
type: "organization",

View File

@@ -975,12 +975,11 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async navigateToPaymentMethod(organization: Organization) {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
await this.router.navigate(
["organizations", `${organization.id}`, "billing", "payment-details"],
{
state: { launchPaymentModalAutomatically: true },
},
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${organization.id}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
}
}

View File

@@ -1,104 +0,0 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<form
#form
[formGroup]="formGroup"
[appApiAction]="formPromise"
(ngSubmit)="submit()"
*ngIf="!loading"
>
<div class="tw-container tw-mb-3">
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<div class="tw-mb-1 tw-items-center" *ngIf="annualPlan !== null">
<label class="tw- tw-block tw-text-main" for="annual">
<input
class="tw-size-4 tw-align-middle"
id="annual"
name="cadence"
type="radio"
[value]="annualCadence"
formControlName="cadence"
/>
{{ "annual" | i18n }} -
{{ getPriceFor(annualCadence) | currency: "$" }}
/{{ "yr" | i18n }}
</label>
</div>
<div class="tw-mb-1 tw-items-center" *ngIf="monthlyPlan !== null">
<label class="tw- tw-block tw-text-main" for="monthly">
<input
class="tw-size-4 tw-align-middle"
id="monthly"
name="cadence"
type="radio"
[value]="monthlyCadence"
formControlName="cadence"
/>
{{ "monthly" | i18n }} -
{{ getPriceFor(monthlyCadence) | currency: "$" }}
/{{ "monthAbbr" | i18n }}
</label>
</div>
</div>
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-payment [showAccountCredit]="false"></app-payment>
<app-manage-tax-information
[showTaxIdField]="showTaxIdField"
(taxInformationChanged)="onTaxInformationChanged()"
></app-manage-tax-information>
@if (trialLength === 0) {
@let priceLabel =
subscriptionProduct === SubscriptionProduct.PasswordManager
? "passwordManagerPlanPrice"
: "secretsManagerPlanPrice";
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-base">
{{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }}
<div>
{{ "estimatedTax" | i18n }}:
@if (fetchingTaxAmount) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
{{ taxAmount | currency: "USD $" }}
}
</div>
</div>
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
<p class="tw-text-lg">
<strong>{{ "total" | i18n }}: </strong>
@if (fetchingTaxAmount) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
{{ total | currency: "USD $" }}/{{ interval | i18n }}
}
</p>
</div>
}
</div>
<div class="tw-flex tw-space-x-2">
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
{{ (trialLength > 0 ? "startTrial" : "submit") | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
</div>
</div>
</form>
<ng-template #loadingSpinner>
<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-template>

View File

@@ -1,360 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
BillingInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
OrganizationInformation,
PaymentInformation,
PlanInformation,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import {
PaymentMethodType,
PlanType,
ProductTierType,
ProductType,
} from "@bitwarden/common/billing/enums";
import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ToastService } from "@bitwarden/components";
import { BillingSharedModule } from "../../shared";
import { PaymentComponent } from "../../shared/payment/payment.component";
export type TrialOrganizationType = Exclude<ProductTierType, ProductTierType.Free>;
export interface OrganizationInfo {
name: string;
email: string;
type: TrialOrganizationType | null;
}
export interface OrganizationCreatedEvent {
organizationId: string;
planDescription: string;
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum SubscriptionCadence {
Annual,
Monthly,
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum SubscriptionProduct {
PasswordManager,
SecretsManager,
}
@Component({
selector: "app-trial-billing-step",
templateUrl: "trial-billing-step.component.html",
imports: [BillingSharedModule],
})
export class TrialBillingStepComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent;
@Input() organizationInfo: OrganizationInfo;
@Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager;
@Input() trialLength: number;
@Output() steppedBack = new EventEmitter();
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
loading = true;
fetchingTaxAmount = false;
annualCadence = SubscriptionCadence.Annual;
monthlyCadence = SubscriptionCadence.Monthly;
formGroup = this.formBuilder.group({
cadence: [SubscriptionCadence.Annual, Validators.required],
});
formPromise: Promise<string>;
applicablePlans: PlanResponse[];
annualPlan?: PlanResponse;
monthlyPlan?: PlanResponse;
taxAmount = 0;
private destroy$ = new Subject<void>();
protected readonly SubscriptionProduct = SubscriptionProduct;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private formBuilder: FormBuilder,
private messagingService: MessagingService,
private organizationBillingService: OrganizationBillingService,
private toastService: ToastService,
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
) {}
async ngOnInit(): Promise<void> {
const plans = await this.apiService.getPlans();
this.applicablePlans = plans.data.filter(this.isApplicable);
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
if (this.trialLength === 0) {
this.formGroup.controls.cadence.valueChanges
.pipe(
switchMap((cadence) => from(this.previewTaxAmount(cadence))),
takeUntil(this.destroy$),
)
.subscribe((taxAmount) => {
this.taxAmount = taxAmount;
});
}
this.loading = false;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async submit(): Promise<void> {
if (!this.taxInfoComponent.validate()) {
return;
}
this.formPromise = this.createOrganization();
const organizationId = await this.formPromise;
const planDescription = this.getPlanDescription();
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("organizationCreated"),
message: this.i18nService.t("organizationReadyToGo"),
});
this.organizationCreated.emit({
organizationId,
planDescription,
});
// TODO: No one actually listening to this?
this.messagingService.send("organizationCreated", { organizationId });
}
async onTaxInformationChanged() {
if (this.trialLength === 0) {
this.taxAmount = await this.previewTaxAmount(this.formGroup.value.cadence);
}
this.paymentComponent.showBankAccount =
this.taxInfoComponent.getTaxInformation().country === "US";
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
protected getPriceFor(cadence: SubscriptionCadence): number {
const plan = this.findPlanFor(cadence);
return this.subscriptionProduct === SubscriptionProduct.PasswordManager
? plan.PasswordManager.basePrice === 0
? plan.PasswordManager.seatPrice
: plan.PasswordManager.basePrice
: plan.SecretsManager.basePrice === 0
? plan.SecretsManager.seatPrice
: plan.SecretsManager.basePrice;
}
protected stepBack() {
this.steppedBack.emit();
}
private async createOrganization(): Promise<string> {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const planResponse = this.findPlanFor(this.formGroup.value.cadence);
const { type, token } = await this.paymentComponent.tokenize();
const paymentMethod: [string, PaymentMethodType] = [token, type];
const organization: OrganizationInformation = {
name: this.organizationInfo.name,
billingEmail: this.organizationInfo.email,
initiationPath:
this.subscriptionProduct === SubscriptionProduct.PasswordManager
? "Password Manager trial from marketing website"
: "Secrets Manager trial from marketing website",
};
const plan: PlanInformation = {
type: planResponse.type,
passwordManagerSeats: 1,
};
if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) {
plan.subscribeToSecretsManager = true;
plan.isFromSecretsManagerTrial = true;
plan.secretsManagerSeats = 1;
}
const payment: PaymentInformation = {
paymentMethod,
billing: this.getBillingInformationFromTaxInfoComponent(),
skipTrial: this.trialLength === 0,
};
const response = await this.organizationBillingService.purchaseSubscription(
{
organization,
plan,
payment,
},
activeUserId,
);
return response.id;
}
private productTypeToPlanTypeMap: {
[productType in TrialOrganizationType]: {
[cadence in SubscriptionCadence]?: PlanType;
};
} = {
[ProductTierType.Enterprise]: {
[SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually,
[SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly,
},
[ProductTierType.Families]: {
[SubscriptionCadence.Annual]: PlanType.FamiliesAnnually,
// No monthly option for Families plan
},
[ProductTierType.Teams]: {
[SubscriptionCadence.Annual]: PlanType.TeamsAnnually,
[SubscriptionCadence.Monthly]: PlanType.TeamsMonthly,
},
[ProductTierType.TeamsStarter]: {
// No annual option for Teams Starter plan
[SubscriptionCadence.Monthly]: PlanType.TeamsStarter,
},
};
private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null {
const productType = this.organizationInfo.type;
const planType = this.productTypeToPlanTypeMap[productType]?.[cadence];
return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null;
}
protected get showTaxIdField(): boolean {
switch (this.organizationInfo.type) {
case ProductTierType.Families:
return false;
default:
return true;
}
}
private getBillingInformationFromTaxInfoComponent(): BillingInformation {
return {
postalCode: this.taxInfoComponent.getTaxInformation()?.postalCode,
country: this.taxInfoComponent.getTaxInformation()?.country,
taxId: this.taxInfoComponent.getTaxInformation()?.taxId,
addressLine1: this.taxInfoComponent.getTaxInformation()?.line1,
addressLine2: this.taxInfoComponent.getTaxInformation()?.line2,
city: this.taxInfoComponent.getTaxInformation()?.city,
state: this.taxInfoComponent.getTaxInformation()?.state,
};
}
private getPlanDescription(): string {
const plan = this.findPlanFor(this.formGroup.value.cadence);
const price =
this.subscriptionProduct === SubscriptionProduct.PasswordManager
? plan.PasswordManager.basePrice === 0
? plan.PasswordManager.seatPrice
: plan.PasswordManager.basePrice
: plan.SecretsManager.basePrice === 0
? plan.SecretsManager.seatPrice
: plan.SecretsManager.basePrice;
switch (this.formGroup.value.cadence) {
case SubscriptionCadence.Annual:
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
case SubscriptionCadence.Monthly:
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
}
}
private isApplicable(plan: PlanResponse): boolean {
const hasCorrectProductType =
plan.productTier === ProductTierType.Enterprise ||
plan.productTier === ProductTierType.Families ||
plan.productTier === ProductTierType.Teams ||
plan.productTier === ProductTierType.TeamsStarter;
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
return hasCorrectProductType && notDisabledOrLegacy;
}
private previewTaxAmount = async (cadence: SubscriptionCadence): Promise<number> => {
this.fetchingTaxAmount = true;
if (!this.taxInfoComponent.validate()) {
this.fetchingTaxAmount = false;
return 0;
}
const plan = this.findPlanFor(cadence);
const productType =
this.subscriptionProduct === SubscriptionProduct.PasswordManager
? ProductType.PasswordManager
: ProductType.SecretsManager;
const taxInformation = this.taxInfoComponent.getTaxInformation();
const request: PreviewTaxAmountForOrganizationTrialRequest = {
planType: plan.type,
productType,
taxInformation: {
...taxInformation,
},
};
const response = await this.taxService.previewTaxAmountForOrganizationTrial(request);
this.fetchingTaxAmount = false;
return response;
};
get price() {
return this.getPriceFor(this.formGroup.value.cadence);
}
get total() {
return this.price + this.taxAmount;
}
get interval() {
return this.formGroup.value.cadence === SubscriptionCadence.Annual ? "year" : "month";
}
}

View File

@@ -1,2 +1,3 @@
export * from "./organization-billing.client";
export * from "./subscriber-billing.client";
export * from "./tax.client";

View File

@@ -82,6 +82,24 @@ export class SubscriberBillingClient {
return data ? new MaskedPaymentMethodResponse(data).value : null;
};
restartSubscription = async (
subscriber: BitwardenSubscriber,
paymentMethod: TokenizedPaymentMethod,
billingAddress: BillingAddress,
): Promise<void> => {
const path = `${this.getEndpoint(subscriber)}/subscription/restart`;
await this.apiService.send(
"POST",
path,
{
paymentMethod,
billingAddress,
},
true,
false,
);
};
updateBillingAddress = async (
subscriber: BitwardenSubscriber,
billingAddress: BillingAddress,

View File

@@ -0,0 +1,131 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
class TaxAmountResponse extends BaseResponse implements TaxAmounts {
tax: number;
total: number;
constructor(response: any) {
super(response);
this.tax = this.getResponseProperty("Tax");
this.total = this.getResponseProperty("Total");
}
}
export type OrganizationSubscriptionPlan = {
tier: "families" | "teams" | "enterprise";
cadence: "annually" | "monthly";
};
export type OrganizationSubscriptionPurchase = OrganizationSubscriptionPlan & {
passwordManager: {
seats: number;
additionalStorage: number;
sponsored: boolean;
};
secretsManager?: {
seats: number;
additionalServiceAccounts: number;
standalone: boolean;
};
};
export type OrganizationSubscriptionUpdate = {
passwordManager?: {
seats?: number;
additionalStorage?: number;
};
secretsManager?: {
seats?: number;
additionalServiceAccounts?: number;
};
};
export interface TaxAmounts {
tax: number;
total: number;
}
@Injectable()
export class TaxClient {
constructor(private apiService: ApiService) {}
previewTaxForOrganizationSubscriptionPurchase = async (
purchase: OrganizationSubscriptionPurchase,
billingAddress: BillingAddress,
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
"/billing/tax/organizations/subscriptions/purchase",
{
purchase,
billingAddress,
},
true,
true,
);
return new TaxAmountResponse(json);
};
previewTaxForOrganizationSubscriptionPlanChange = async (
organizationId: string,
plan: {
tier: "families" | "teams" | "enterprise";
cadence: "annually" | "monthly";
},
billingAddress: BillingAddress | null,
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/organizations/${organizationId}/subscription/plan-change`,
{
plan,
billingAddress,
},
true,
true,
);
return new TaxAmountResponse(json);
};
previewTaxForOrganizationSubscriptionUpdate = async (
organizationId: string,
update: OrganizationSubscriptionUpdate,
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/organizations/${organizationId}/subscription/update`,
{
update,
},
true,
true,
);
return new TaxAmountResponse(json);
};
previewTaxForPremiumSubscriptionPurchase = async (
additionalStorage: number,
billingAddress: BillingAddress,
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/premium/subscriptions/purchase`,
{
additionalStorage,
billingAddress,
},
true,
true,
);
return new TaxAmountResponse(json);
};
}

View File

@@ -1,2 +1 @@
export { OrganizationPlansComponent } from "./organizations";
export { TaxInfoComponent } from "./shared";

View File

@@ -3,8 +3,6 @@ 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 { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumComponent } from "./premium/premium.component";
import { SubscriptionComponent } from "./subscription.component";
@@ -27,11 +25,6 @@ const routes: Routes = [
component: PremiumComponent,
data: { titleId: "goPremium" },
},
{
path: "payment-method",
component: PaymentMethodComponent,
data: { titleId: "paymentMethod" },
},
{
path: "payment-details",
component: AccountPaymentDetailsComponent,

View File

@@ -1,5 +1,10 @@
import { NgModule } from "@angular/core";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { BillingSharedModule } from "../shared";
@@ -10,7 +15,13 @@ import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@NgModule({
imports: [IndividualBillingRoutingModule, BillingSharedModule, HeaderModule],
imports: [
IndividualBillingRoutingModule,
BillingSharedModule,
HeaderModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
declarations: [
SubscriptionComponent,
BillingHistoryViewComponent,

View File

@@ -1,22 +1,7 @@
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 { BehaviorSubject, filter, merge, Observable, shareReplay, switchMap, tap } from "rxjs";
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";
@@ -28,13 +13,6 @@ import {
import { MaskedPaymentMethod } from "../../payment/types";
import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types";
class RedirectError {
constructor(
public path: string[],
public relativeTo: ActivatedRoute,
) {}
}
type View = {
account: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null;
@@ -56,23 +34,11 @@ 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;
}),
),
),
mapAccountToSubscriber,
switchMap(async (account) => {
const [paymentMethod, credit] = await Promise.all([
this.billingClient.getPaymentMethod(account),
this.billingClient.getCredit(account),
this.subscriberBillingClient.getPaymentMethod(account),
this.subscriberBillingClient.getCredit(account),
]);
return {
@@ -82,14 +48,6 @@ export class AccountPaymentDetailsComponent {
};
}),
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(
@@ -99,10 +57,7 @@ export class AccountPaymentDetailsComponent {
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private billingClient: SubscriberBillingClient,
private configService: ConfigService,
private router: Router,
private subscriberBillingClient: SubscriberBillingClient,
) {}
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => {

View File

@@ -70,7 +70,7 @@
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
@@ -93,15 +93,25 @@
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB &times;
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment [showBankAccount]="false"></app-payment>
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()"></app-tax-info>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>

View File

@@ -9,36 +9,34 @@ import { debounceTime } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { PaymentComponent } from "../../shared/payment/payment.component";
import { TaxInfoComponent } from "../../shared/tax-info.component";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types";
@Component({
templateUrl: "./premium.component.html",
standalone: false,
providers: [TaxClient],
})
export class PremiumComponent {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected addOnFormGroup = new FormGroup({
protected formGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
});
protected licenseFormGroup = new FormGroup({
file: new FormControl<File>(null, [Validators.required]),
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
protected cloudWebVaultURL: string;
@@ -53,16 +51,14 @@ export class PremiumComponent {
private activatedRoute: ActivatedRoute,
private apiService: ApiService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
private tokenService: TokenService,
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
private taxClient: TaxClient,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
@@ -93,11 +89,13 @@ export class PremiumComponent {
)
.subscribe();
this.addOnFormGroup.controls.additionalStorage.valueChanges
.pipe(debounceTime(1000), takeUntilDestroyed())
.subscribe(() => {
this.refreshSalesTax();
});
this.formGroup.valueChanges
.pipe(
debounceTime(1000),
switchMap(async () => await this.refreshSalesTax()),
takeUntilDestroyed(),
)
.subscribe();
}
finalizeUpgrade = async () => {
@@ -117,53 +115,21 @@ export class PremiumComponent {
navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
onLicenseFileSelected = (event: Event): void => {
const element = event.target as HTMLInputElement;
this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null;
};
submitPremiumLicense = async (): Promise<void> => {
this.licenseFormGroup.markAllAsTouched();
if (this.licenseFormGroup.invalid) {
return this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("selectFile"),
});
}
const emailVerified = await this.tokenService.getEmailVerified();
if (!emailVerified) {
return this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("verifyEmailFirst"),
});
}
const formData = new FormData();
formData.append("license", this.licenseFormGroup.value.file);
await this.apiService.postAccountLicense(formData);
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
submitPayment = async (): Promise<void> => {
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
if (this.taxInfoComponent.taxFormGroup.invalid) {
if (this.formGroup.invalid) {
return;
}
const { type, token } = await this.paymentComponent.tokenize();
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
const legacyEnum = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
const formData = new FormData();
formData.append("paymentMethodType", type.toString());
formData.append("paymentToken", token);
formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString());
formData.append("country", this.taxInfoComponent.country);
formData.append("postalCode", this.taxInfoComponent.postalCode);
formData.append("paymentMethodType", legacyEnum.toString());
formData.append("paymentToken", paymentMethod.token);
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
formData.append("country", this.formGroup.value.billingAddress.country);
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
await this.apiService.postPremium(formData);
await this.finalizeUpgrade();
@@ -171,7 +137,7 @@ export class PremiumComponent {
};
protected get additionalStorageCost(): number {
return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage;
return this.storageGBPrice * this.formGroup.value.additionalStorage;
}
protected get premiumURL(): string {
@@ -190,35 +156,18 @@ export class PremiumComponent {
await this.postFinalizeUpgrade();
}
private refreshSalesTax(): void {
if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) {
private async refreshSalesTax(): Promise<void> {
if (this.formGroup.invalid) {
return;
}
const request: PreviewIndividualInvoiceRequest = {
passwordManager: {
additionalStorage: this.addOnFormGroup.value.additionalStorage,
},
taxInformation: {
postalCode: this.taxInfoComponent.postalCode,
country: this.taxInfoComponent.country,
},
};
this.taxService
.previewIndividualInvoice(request)
.then((invoice) => {
this.estimatedTax = invoice.taxAmount;
})
.catch((error) => {
this.toastService.showToast({
title: "",
variant: "error",
message: this.i18nService.t(error.message),
});
});
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
protected onTaxInformationChanged(): void {
this.refreshSalesTax();
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
this.formGroup.value.additionalStorage,
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}
}

View File

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

View File

@@ -1,12 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { map, Observable, switchMap } from "rxjs";
import { Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.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";
@Component({
@@ -15,32 +13,16 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
})
export class SubscriptionComponent implements OnInit {
hasPremium$: Observable<boolean>;
paymentDetailsPageData$: Observable<{
route: string;
textKey: string;
}>;
selfHosted: boolean;
constructor(
private platformUtilsService: PlatformUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
private configService: ConfigService,
) {
this.hasPremium$ = accountService.activeAccount$.pipe(
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() {

View File

@@ -328,24 +328,60 @@
*ngIf="formGroup.value.productTier !== productTypes.Free || isSubscriptionCanceled"
>
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<p
*ngIf="
!showPayment && (paymentSource || billing?.paymentSource) && !isSubscriptionCanceled
"
>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ paymentSource?.description }}
<span class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer" (click)="toggleShowPayment()">
{{ "changePaymentMethod" | i18n }}
</span>
<p *ngIf="!showPayment && !!paymentMethod && !isSubscriptionCanceled">
@switch (paymentMethod.type) {
@case ("bankAccount") {
<i class="bwi bwi-fw bwi-billing"></i>
{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }}
@if (paymentMethod.hostedVerificationUrl) {
<span>- {{ "unverified" | i18n }}</span>
}
<span
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
(click)="toggleShowPayment()"
>
{{ "changePaymentMethod" | i18n }}
</span>
}
@case ("card") {
<p class="tw-flex tw-items-center tw-gap-2">
@let cardBrandIcon = getCardBrandIcon();
@if (cardBrandIcon !== null) {
<i class="bwi bwi-fw credit-card-icon {{ cardBrandIcon }}"></i>
} @else {
<i class="bwi bwi-fw bwi-credit-card"></i>
}
{{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }},
{{ paymentMethod.expiration }}
<span
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
(click)="toggleShowPayment()"
>
{{ "changePaymentMethod" | i18n }}
</span>
</p>
}
@case ("payPal") {
<i class="bwi bwi-fw bwi-paypal tw-text-primary-600"></i>
{{ paymentMethod.email }}
<span
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
(click)="toggleShowPayment()"
>
{{ "changePaymentMethod" | i18n }}
</span>
}
}
<a></a>
</p>
<ng-container *ngIf="canUpdatePaymentInformation()">
<app-payment [showAccountCredit]="false" />
<app-manage-tax-information
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
></app-manage-tax-information>
<app-enter-payment-method [group]="billingFormGroup.controls.paymentMethod">
</app-enter-payment-method>
<app-enter-billing-address
[group]="billingFormGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId }"
>
</app-enter-billing-address>
</ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">

View File

@@ -12,9 +12,9 @@ import {
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
import { debounceTime } from "rxjs/operators";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@@ -28,28 +28,8 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
BillingApiServiceAbstraction,
BillingInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
OrganizationInformation,
PaymentInformation,
PlanInformation,
} from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import {
PaymentMethodType,
PlanInterval,
PlanType,
ProductTierType,
} from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -57,6 +37,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { OrganizationId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
CardComponent,
DIALOG_DATA,
DialogConfig,
DialogRef,
@@ -64,11 +45,25 @@ import {
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import {
OrganizationSubscriptionPlan,
SubscriberBillingClient,
TaxClient,
} from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import {
BillingAddress,
getCardBrandIcon,
MaskedPaymentMethod,
} from "@bitwarden/web-vault/app/billing/payment/types";
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { BillingNotificationService } from "../services/billing-notification.service";
import { BillingSharedModule } from "../shared/billing-shared.module";
import { PaymentComponent } from "../shared/payment/payment.component";
type ChangePlanDialogParams = {
organizationId: string;
@@ -111,11 +106,16 @@ interface OnSuccessArgs {
@Component({
templateUrl: "./change-plan-dialog.component.html",
imports: [BillingSharedModule],
imports: [
BillingSharedModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
CardComponent,
],
providers: [SubscriberBillingClient, TaxClient],
})
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent;
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent;
@Input() acceptingSponsorship = false;
@Input() organizationId: string;
@@ -172,7 +172,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
clientOwnerEmail: ["", [Validators.email]],
plan: [this.plan],
productTier: [this.productTier],
// planInterval: [1],
});
billingFormGroup = this.formBuilder.group({
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
planType: string;
@@ -183,7 +187,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
secretsManagerPlans: PlanResponse[];
organization: Organization;
sub: OrganizationSubscriptionResponse;
billing: BillingResponse;
dialogHeaderName: string;
currentPlanName: string;
showPayment: boolean = false;
@@ -191,15 +194,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
currentPlan: PlanResponse;
isCardStateDisabled = false;
focusedIndex: number | null = null;
accountCredit: number;
paymentSource?: PaymentSourceResponse;
plans: ListResponse<PlanResponse>;
isSubscriptionCanceled: boolean = false;
secretsManagerTotal: number;
private destroy$ = new Subject<void>();
paymentMethod: MaskedPaymentMethod | null;
billingAddress: BillingAddress | null;
protected taxInformation: TaxInformation;
private destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams,
@@ -215,11 +217,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private messagingService: MessagingService,
private formBuilder: FormBuilder,
private organizationApiService: OrganizationApiServiceAbstraction,
private billingApiService: BillingApiServiceAbstraction,
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
private organizationBillingService: OrganizationBillingService,
private billingNotificationService: BillingNotificationService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
) {}
async ngOnInit(): Promise<void> {
@@ -242,10 +243,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
);
if (this.sub?.subscription?.status !== "canceled") {
try {
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
const [paymentMethod, billingAddress] = await Promise.all([
this.subscriberBillingClient.getPaymentMethod(subscriber),
this.subscriberBillingClient.getBillingAddress(subscriber),
]);
this.paymentMethod = paymentMethod;
this.billingAddress = billingAddress;
} catch (error) {
this.billingNotificationService.handleError(error);
}
@@ -307,15 +312,24 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
? 0
: (this.sub?.customerDiscount?.percentOff ?? 0);
this.setInitialPlanSelection();
this.loading = false;
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
this.taxInformation = TaxInformation.from(taxInfo);
await this.setInitialPlanSelection();
if (!this.isSubscriptionCanceled) {
this.refreshSalesTax();
await this.refreshSalesTax();
}
combineLatest([
this.billingFormGroup.controls.billingAddress.controls.country.valueChanges,
this.billingFormGroup.controls.billingAddress.controls.postalCode.valueChanges,
this.billingFormGroup.controls.billingAddress.controls.taxId.valueChanges,
])
.pipe(
debounceTime(1000),
switchMap(async () => await this.refreshSalesTax()),
takeUntil(this.destroy$),
)
.subscribe();
this.loading = false;
}
resolveHeaderName(subscription: OrganizationSubscriptionResponse): string {
@@ -333,10 +347,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
);
}
setInitialPlanSelection() {
async setInitialPlanSelection() {
this.focusedIndex = this.selectableProducts.length - 1;
if (!this.isSubscriptionCanceled) {
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
}
}
@@ -344,10 +358,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return this.selectableProducts.find((product) => product.productTier === productTier);
}
isPaymentSourceEmpty() {
return this.paymentSource === null || this.paymentSource === undefined;
}
isSecretsManagerTrial(): boolean {
return (
this.sub?.subscription?.items?.some((item) =>
@@ -356,13 +366,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
);
}
planTypeChanged() {
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
async planTypeChanged() {
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
}
updateInterval(event: number) {
async updateInterval(event: number) {
this.selectedInterval = event;
this.planTypeChanged();
await this.planTypeChanged();
}
protected getPlanIntervals() {
@@ -460,7 +470,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
}
protected selectPlan(plan: PlanResponse) {
protected async selectPlan(plan: PlanResponse) {
if (
this.selectedInterval === PlanInterval.Monthly &&
plan.productTier == ProductTierType.Families
@@ -475,7 +485,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.formGroup.patchValue({ productTier: plan.productTier });
try {
this.refreshSalesTax();
await this.refreshSalesTax();
} catch {
this.estimatedTax = 0;
}
@@ -489,19 +499,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
get upgradeRequiresPaymentMethod() {
const isFreeTier = this.organization?.productTierType === ProductTierType.Free;
const shouldHideFree = !this.showFree;
const hasNoPaymentSource = !this.paymentSource;
const hasNoPaymentSource = !this.paymentMethod;
return isFreeTier && shouldHideFree && hasNoPaymentSource;
}
get selectedSecretsManagerPlan() {
let planResponse: PlanResponse;
if (this.secretsManagerPlans) {
return this.secretsManagerPlans.find((plan) => plan.type === this.selectedPlan.type);
}
return planResponse;
}
get selectedPlanInterval() {
if (this.isSubscriptionCanceled) {
return this.currentPlan.isAnnual ? "year" : "month";
@@ -591,8 +593,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return 0;
}
const result = plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0);
return result;
return plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0);
}
secretsManagerSeatTotal(plan: PlanResponse, seats: number): number {
@@ -746,39 +747,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.formGroup.controls.additionalSeats.setValue(1);
}
changedCountry() {
this.paymentComponent.showBankAccount = this.taxInformation.country === "US";
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
protected taxInformationChanged(event: TaxInformation): void {
this.taxInformation = event;
this.changedCountry();
this.refreshSalesTax();
}
submit = async () => {
if (this.taxComponent !== undefined && !this.taxComponent.validate()) {
this.taxComponent.markAllAsTouched();
this.formGroup.markAllAsTouched();
this.billingFormGroup.markAllAsTouched();
if (this.formGroup.invalid || (this.billingFormGroup.invalid && !this.paymentMethod)) {
return;
}
const doSubmit = async (): Promise<string> => {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
let orgId: string = null;
let orgId: string;
const sub = this.sub?.subscription;
const isCanceled = sub?.status === "canceled";
const isCancelledDowngradedToFreeOrg =
sub?.cancelled && this.organization.productTierType === ProductTierType.Free;
if (isCanceled || isCancelledDowngradedToFreeOrg) {
await this.restartSubscription(activeUserId);
await this.restartSubscription();
orgId = this.organizationId;
} else {
orgId = await this.updateOrganization();
@@ -795,9 +779,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]);
await this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]);
}
if (this.isInTrialFlow) {
@@ -818,46 +800,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.dialogRef.close();
};
private async restartSubscription(activeUserId: UserId) {
const org = await this.organizationApiService.get(this.organizationId);
const organization: OrganizationInformation = {
name: org.name,
billingEmail: org.billingEmail,
};
const filteredPlan = this.plans.data
.filter((plan) => plan.productTier === this.selectedPlan.productTier && !plan.legacyYear)
.find((plan) => {
const isSameBillingCycle = plan.isAnnual === this.selectedPlan.isAnnual;
return isSameBillingCycle;
});
const plan: PlanInformation = {
type: filteredPlan.type,
passwordManagerSeats: org.seats,
};
if (org.useSecretsManager) {
plan.subscribeToSecretsManager = true;
plan.secretsManagerSeats = org.smSeats;
}
const { type, token } = await this.paymentComponent.tokenize();
const paymentMethod: [string, PaymentMethodType] = [token, type];
const payment: PaymentInformation = {
private async restartSubscription() {
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress);
await this.subscriberBillingClient.restartSubscription(
{ type: "organization", data: this.organization },
paymentMethod,
billing: this.getBillingInformationFromTaxInfoComponent(),
};
await this.organizationBillingService.restartSubscription(
this.organization.id,
{
organization,
plan,
payment,
},
activeUserId,
billingAddress,
);
}
@@ -875,25 +824,25 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.formGroup.controls.premiumAccessAddon.value;
request.planType = this.selectedPlan.type;
if (this.showPayment) {
request.billingAddressCountry = this.taxInformation.country;
request.billingAddressPostalCode = this.taxInformation.postalCode;
request.billingAddressCountry = this.billingFormGroup.controls.billingAddress.value.country;
request.billingAddressPostalCode =
this.billingFormGroup.controls.billingAddress.value.postalCode;
}
// Secrets Manager
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
const tokenizedPaymentSource = await this.paymentComponent.tokenize();
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource;
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
this.taxInformation,
if (this.upgradeRequiresPaymentMethod || this.showPayment || !this.paymentMethod) {
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
const billingAddress = getBillingAddressFromForm(
this.billingFormGroup.controls.billingAddress,
);
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
await Promise.all([
this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null),
this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress),
]);
}
// Backfill pub/priv key if necessary
@@ -931,18 +880,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return text;
}
private getBillingInformationFromTaxInfoComponent(): BillingInformation {
return {
country: this.taxInformation.country,
postalCode: this.taxInformation.postalCode,
taxId: this.taxInformation.taxId,
addressLine1: this.taxInformation.line1,
addressLine2: this.taxInformation.line2,
city: this.taxInformation.city,
state: this.taxInformation.state,
};
}
private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void {
request.useSecretsManager = this.organization.useSecretsManager;
if (!this.organization.useSecretsManager) {
@@ -1002,25 +939,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
calculateTotalAppliedDiscount(total: number) {
const discountedTotal = total * (this.discountPercentageFromSub / 100);
return discountedTotal;
}
get paymentSourceClasses() {
if (this.paymentSource == null) {
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
case PaymentMethodType.Check:
return ["bwi-billing"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
return total * (this.discountPercentageFromSub / 100);
}
resolvePlanName(productTier: ProductTierType) {
@@ -1064,9 +983,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
}
onFocus(index: number) {
async onFocus(index: number) {
this.focusedIndex = index;
this.selectPlan(this.selectableProducts[index]);
await this.selectPlan(this.selectableProducts[index]);
}
isCardDisabled(index: number): boolean {
@@ -1078,58 +997,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return index;
}
private refreshSalesTax(): void {
if (
this.taxInformation === undefined ||
!this.taxInformation.country ||
!this.taxInformation.postalCode
) {
private async refreshSalesTax(): Promise<void> {
if (this.billingFormGroup.controls.billingAddress.invalid && !this.billingAddress) {
return;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: this.organizationId,
passwordManager: {
additionalStorage: 0,
plan: this.selectedPlan?.type,
seats: this.sub.seats,
},
taxInformation: {
postalCode: this.taxInformation.postalCode,
country: this.taxInformation.country,
taxId: this.taxInformation.taxId,
},
const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => {
switch (planType) {
case PlanType.FamiliesAnnually:
return { tier: "families", cadence: "annually" };
case PlanType.TeamsMonthly:
return { tier: "teams", cadence: "monthly" };
case PlanType.TeamsAnnually:
return { tier: "teams", cadence: "annually" };
case PlanType.EnterpriseMonthly:
return { tier: "enterprise", cadence: "monthly" };
case PlanType.EnterpriseAnnually:
return { tier: "enterprise", cadence: "annually" };
}
};
if (this.organization.useSecretsManager) {
request.secretsManager = {
seats: this.sub.smSeats,
additionalMachineAccounts:
this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount,
};
}
const billingAddress = this.billingFormGroup.controls.billingAddress.valid
? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress)
: this.billingAddress;
this.taxService
.previewOrganizationInvoice(request)
.then((invoice) => {
this.estimatedTax = invoice.taxAmount;
})
.catch((error) => {
const translatedMessage = this.i18nService.t(error.message);
this.toastService.showToast({
title: "",
variant: "error",
message:
!translatedMessage || translatedMessage === "" ? error.message : translatedMessage,
});
});
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organizationId,
getPlanFromLegacyEnum(this.selectedPlan.type),
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}
protected canUpdatePaymentInformation(): boolean {
return (
this.upgradeRequiresPaymentMethod ||
this.showPayment ||
this.isPaymentSourceEmpty() ||
!this.paymentMethod ||
this.isSubscriptionCanceled
);
}
@@ -1146,4 +1051,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return this.i18nService.t("upgrade");
}
}
get supportsTaxId() {
return this.formGroup.value.productTier !== ProductTierType.Families;
}
getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod);
}

View File

@@ -11,7 +11,6 @@ import { WebPlatformUtilsService } from "../../core/web-platform-utils.service";
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component";
import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component";
const routes: Routes = [
{
@@ -26,17 +25,6 @@ const routes: Routes = [
: OrganizationSubscriptionCloudComponent,
data: { titleId: "subscription" },
},
{
path: "payment-method",
component: OrganizationPaymentMethodComponent,
canActivate: [
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
organizationIsUnmanaged,
],
data: {
titleId: "paymentMethod",
},
},
{
path: "payment-details",
component: OrganizationPaymentDetailsComponent,

View File

@@ -17,7 +17,6 @@ import { OrganizationBillingRoutingModule } from "./organization-billing-routing
import { OrganizationPlansComponent } from "./organization-plans.component";
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component";
import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component";
import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component";
import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component";
import { SubscriptionHiddenComponent } from "./subscription-hidden.component";
@@ -45,7 +44,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
SecretsManagerSubscribeStandaloneComponent,
SubscriptionHiddenComponent,
SubscriptionStatusComponent,
OrganizationPaymentMethodComponent,
],
})
export class OrganizationBillingModule {}

View File

@@ -404,17 +404,16 @@
<p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
{{ paymentDesc }}
</p>
<app-payment
*ngIf="createOrganization || upgradeRequiresPaymentMethod"
[showAccountCredit]="false"
>
</app-payment>
<app-manage-tax-information
@if (createOrganization || upgradeRequiresPaymentMethod) {
<app-enter-payment-method [group]="billingFormGroup.controls.paymentMethod">
</app-enter-payment-method>
}
<app-enter-billing-address
[group]="billingFormGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: showTaxIdField }"
class="tw-my-4"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="onTaxInformationChanged($event)"
/>
>
</app-enter-billing-address>
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-base">
{{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }}

View File

@@ -11,10 +11,9 @@ import {
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { firstValueFrom, merge, Subject, takeUntil } from "rxjs";
import { debounceTime, map, switchMap } from "rxjs/operators";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@@ -32,24 +31,12 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso
import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import {
PaymentMethodType,
PlanSponsorshipType,
PlanType,
ProductTierType,
} from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -59,10 +46,20 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import {
OrganizationSubscriptionPlan,
SubscriberBillingClient,
TaxClient,
} from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared";
import { PaymentComponent } from "../shared/payment/payment.component";
interface OnSuccessArgs {
organizationId: string;
@@ -78,11 +75,16 @@ const Allowed2020PlansForLegacyProviders = [
@Component({
selector: "app-organization-plans",
templateUrl: "organization-plans.component.html",
imports: [BillingSharedModule, OrganizationCreateModule],
imports: [
BillingSharedModule,
OrganizationCreateModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
providers: [SubscriberBillingClient, TaxClient],
})
export class OrganizationPlansComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent;
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
@Input() organizationId?: string;
@Input() showFree = true;
@@ -105,8 +107,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private _productTier = ProductTierType.Free;
protected taxInformation: TaxInformation;
@Input()
get plan(): PlanType {
return this._plan;
@@ -135,10 +135,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder);
selfHostedForm = this.formBuilder.group({
file: [null, [Validators.required]],
});
formGroup = this.formBuilder.group({
name: [""],
billingEmail: ["", [Validators.email]],
@@ -152,6 +148,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
secretsManager: this.secretsManagerSubscription,
});
billingFormGroup = this.formBuilder.group({
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
passwordManagerPlans: PlanResponse[];
secretsManagerPlans: PlanResponse[];
organization: Organization;
@@ -179,10 +180,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private organizationApiService: OrganizationApiServiceAbstraction,
private providerApiService: ProviderApiServiceAbstraction,
private toastService: ToastService,
private configService: ConfigService,
private billingApiService: BillingApiServiceAbstraction,
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
) {
this.selfHosted = this.platformUtilsService.isSelfHost();
}
@@ -199,9 +199,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
);
this.billing = await this.organizationApiService.getBilling(this.organizationId);
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
this.taxInformation = await this.organizationApiService.getTaxInfo(this.organizationId);
} else if (!this.selfHosted) {
this.taxInformation = await this.apiService.getTaxInfo();
const billingAddress = await this.subscriberBillingClient.getBillingAddress({
type: "organization",
data: this.organization,
});
this.billingFormGroup.controls.billingAddress.patchValue({
...billingAddress,
taxId: billingAddress?.taxId?.value,
});
}
if (!this.selfHosted) {
@@ -268,15 +273,17 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.loading = false;
this.formGroup.valueChanges.pipe(debounceTime(1000), takeUntil(this.destroy$)).subscribe(() => {
this.refreshSalesTax();
});
this.secretsManagerForm.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
this.refreshSalesTax();
});
merge(
this.formGroup.valueChanges,
this.billingFormGroup.valueChanges,
this.secretsManagerForm.valueChanges,
)
.pipe(
debounceTime(1000),
switchMap(async () => await this.refreshSalesTax()),
takeUntil(this.destroy$),
)
.subscribe();
if (this.enableSecretsManagerByDefault && this.selectedSecretsManagerPlan) {
this.secretsManagerSubscription.patchValue({
@@ -587,34 +594,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.changedProduct();
}
protected changedCountry(): void {
this.paymentComponent.showBankAccount = this.taxInformation?.country === "US";
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
protected onTaxInformationChanged(event: TaxInformation): void {
this.taxInformation = event;
this.changedCountry();
this.refreshSalesTax();
}
protected cancel(): void {
this.onCanceled.emit();
}
protected setSelectedFile(event: Event): void {
const fileInputEl = <HTMLInputElement>event.target;
this.selectedFile = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
}
submit = async () => {
if (this.taxComponent && !this.taxComponent.validate()) {
this.taxComponent.markAllAsTouched();
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
@@ -688,46 +674,54 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
}
private refreshSalesTax(): void {
if (!this.taxComponent.validate()) {
private async refreshSalesTax(): Promise<void> {
if (this.billingFormGroup.controls.billingAddress.invalid) {
return;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: this.organizationId,
passwordManager: {
additionalStorage: this.formGroup.controls.additionalStorage.value,
plan: this.formGroup.controls.plan.value,
sponsoredPlan: this.planSponsorshipType,
seats: this.formGroup.controls.additionalSeats.value,
},
taxInformation: {
postalCode: this.taxInformation.postalCode,
country: this.taxInformation.country,
taxId: this.taxInformation.taxId,
},
const getPlanFromLegacyEnum = (): OrganizationSubscriptionPlan => {
switch (this.formGroup.value.plan) {
case PlanType.FamiliesAnnually:
return { tier: "families", cadence: "annually" };
case PlanType.TeamsMonthly:
return { tier: "teams", cadence: "monthly" };
case PlanType.TeamsAnnually:
return { tier: "teams", cadence: "annually" };
case PlanType.EnterpriseMonthly:
return { tier: "enterprise", cadence: "monthly" };
case PlanType.EnterpriseAnnually:
return { tier: "enterprise", cadence: "annually" };
}
};
if (this.secretsManagerForm.controls.enabled.value === true) {
request.secretsManager = {
seats: this.secretsManagerForm.controls.userSeats.value,
additionalMachineAccounts: this.secretsManagerForm.controls.additionalServiceAccounts.value,
};
}
const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress);
this.taxService
.previewOrganizationInvoice(request)
.then((invoice) => {
this.estimatedTax = invoice.taxAmount;
this.total = invoice.totalAmount;
})
.catch((error) => {
this.toastService.showToast({
title: "",
variant: "error",
message: this.i18nService.t(error.message),
});
});
const passwordManagerSeats =
this.formGroup.value.productTier === ProductTierType.Families
? 1
: this.formGroup.value.additionalSeats;
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
{
...getPlanFromLegacyEnum(),
passwordManager: {
seats: passwordManagerSeats,
additionalStorage: this.formGroup.value.additionalStorage,
sponsored: false,
},
secretsManager: this.formGroup.value.secretsManager.enabled
? {
seats: this.secretsManagerForm.value.userSeats,
additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts,
standalone: false,
}
: undefined,
},
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
this.total = taxAmounts.total;
}
private async updateOrganization() {
@@ -738,21 +732,24 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
this.formGroup.controls.premiumAccessAddon.value;
request.planType = this.selectedPlan.type;
request.billingAddressCountry = this.taxInformation?.country;
request.billingAddressPostalCode = this.taxInformation?.postalCode;
request.billingAddressCountry = this.billingFormGroup.value.billingAddress.country;
request.billingAddressPostalCode = this.billingFormGroup.value.billingAddress.postalCode;
// Secrets Manager
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod) {
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = await this.paymentComponent.tokenize();
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
this.taxInformation,
);
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
if (this.billingFormGroup.invalid) {
return;
}
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
await this.subscriberBillingClient.updatePaymentMethod(
{ type: "organization", data: this.organization },
paymentMethod,
{
country: this.billingFormGroup.value.billingAddress.country,
postalCode: this.billingFormGroup.value.billingAddress.postalCode,
},
);
}
@@ -791,23 +788,31 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.selectedPlan.type === PlanType.Free) {
request.planType = PlanType.Free;
} else {
const { type, token } = await this.paymentComponent.tokenize();
if (this.billingFormGroup.invalid) {
return;
}
request.paymentToken = token;
request.paymentMethodType = type;
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
const billingAddress = getBillingAddressFromForm(
this.billingFormGroup.controls.billingAddress,
);
request.paymentToken = paymentMethod.token;
request.paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
request.additionalSeats = this.formGroup.controls.additionalSeats.value;
request.additionalStorageGb = this.formGroup.controls.additionalStorage.value;
request.premiumAccessAddon =
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
this.formGroup.controls.premiumAccessAddon.value;
request.planType = this.selectedPlan.type;
request.billingAddressPostalCode = this.taxInformation?.postalCode;
request.billingAddressCountry = this.taxInformation?.country;
request.taxIdNumber = this.taxInformation?.taxId;
request.billingAddressLine1 = this.taxInformation?.line1;
request.billingAddressLine2 = this.taxInformation?.line2;
request.billingAddressCity = this.taxInformation?.city;
request.billingAddressState = this.taxInformation?.state;
request.billingAddressPostalCode = billingAddress.postalCode;
request.billingAddressCountry = billingAddress.country;
request.taxIdNumber = billingAddress.taxId?.value;
request.billingAddressLine1 = billingAddress.line1;
request.billingAddressLine2 = billingAddress.line2;
request.billingAddressCity = billingAddress.city;
request.billingAddressState = billingAddress.state;
}
// Secrets Manager

View File

@@ -1,15 +1,11 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
catchError,
combineLatest,
EMPTY,
filter,
firstValueFrom,
from,
lastValueFrom,
map,
merge,
Observable,
of,
@@ -22,15 +18,13 @@ import {
withLatestFrom,
} from "rxjs";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { 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 { getById } from "@bitwarden/common/platform/misc";
import { DialogService } from "@bitwarden/components";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
@@ -54,13 +48,6 @@ import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/type
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 = {
organization: BitwardenSubscriber;
paymentMethod: MaskedPaymentMethod | null;
@@ -93,24 +80,12 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
switchMap((userId) =>
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)),
.pipe(getById(this.activatedRoute.snapshot.params.organizationId)),
),
filter((organization): organization is Organization => !!organization),
);
private load$: Observable<View> = this.organization$.pipe(
switchMap((organization) =>
this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(
map((managePaymentDetailsOutsideCheckout) => {
if (!managePaymentDetailsOutsideCheckout) {
throw new RedirectError(["../payment-method"], this.activatedRoute);
}
return organization;
}),
),
),
mapOrganizationToSubscriber,
switchMap(async (organization) => {
const getTaxIdWarning = firstValueFrom(
@@ -132,14 +107,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
taxIdWarning,
};
}),
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(
@@ -159,7 +126,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
private messageListener: MessageListener,
private organizationService: OrganizationService,
private organizationWarningsService: OrganizationWarningsService,
private router: Router,
private subscriberBillingClient: SubscriberBillingClient,
) {}

View File

@@ -1,48 +0,0 @@
<app-header></app-header>
<bit-container>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="!loading">
<!-- Account Credit -->
<bit-section>
<h2 bitTypography="h2">
{{ accountCreditHeaderText }}
</h2>
<p class="tw-text-lg tw-font-bold">{{ Math.abs(accountCredit) | currency: "$" }}</p>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
{{ "addCredit" | i18n }}
</button>
</bit-section>
<!-- Payment Method -->
<bit-section>
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
<ng-container *ngIf="paymentSource">
<app-verify-bank-account
*ngIf="paymentSource.needsVerification"
[onSubmit]="verifyBankAccount"
(submitted)="load()"
>
</app-verify-bank-account>
<p>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ paymentSource.description }}
<span *ngIf="paymentSource.needsVerification">- {{ "unverified" | i18n }}</span>
</p>
</ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="updatePaymentMethod">
{{ updatePaymentSourceButtonText }}
</button>
<p *ngIf="subscriptionIsUnpaid" bitTypography="body1">
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
</p>
</bit-section>
</ng-container>
</bit-container>

View File

@@ -1,288 +0,0 @@
import { Location } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
OrganizationService,
getOrganizationById,
} 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "../../services/billing-notification.service";
import {
AddCreditDialogResult,
openAddCreditDialog,
} from "../../shared/add-credit-dialog.component";
import {
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
import {
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
TrialPaymentDialogComponent,
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component";
@Component({
templateUrl: "./organization-payment-method.component.html",
standalone: false,
})
export class OrganizationPaymentMethodComponent implements OnDestroy {
organizationId!: string;
isUnpaid = false;
accountCredit?: number;
paymentSource?: PaymentSourceResponse;
subscriptionStatus?: string;
organization?: Organization;
organizationSubscriptionResponse?: OrganizationSubscriptionResponse;
loading = true;
protected readonly Math = Math;
launchPaymentModalAutomatically = false;
protected taxInformation?: TaxInformation;
constructor(
private activatedRoute: ActivatedRoute,
private billingApiService: BillingApiServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private toastService: ToastService,
private location: Location,
private organizationService: OrganizationService,
private accountService: AccountService,
protected syncService: SyncService,
private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
) {
combineLatest([
this.activatedRoute.params,
this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout),
])
.pipe(
switchMap(([{ organizationId }, managePaymentDetailsOutsideCheckout]) => {
if (this.platformUtilsService.isSelfHost()) {
return from(this.router.navigate(["/settings/subscription"]));
}
if (managePaymentDetailsOutsideCheckout) {
return from(
this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }),
);
}
this.organizationId = organizationId;
return from(this.load());
}),
takeUntilDestroyed(),
)
.subscribe();
const state = this.router.getCurrentNavigation()?.extras?.state;
// In case the above state is undefined or null, we use redundantState
const redundantState: any = location.getState();
const queryParam = this.activatedRoute.snapshot.queryParamMap.get(
"launchPaymentModalAutomatically",
);
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
} else if (
redundantState &&
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
) {
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
} else {
this.launchPaymentModalAutomatically = queryParam === "true";
}
}
ngOnDestroy(): void {
this.launchPaymentModalAutomatically = false;
}
protected addAccountCredit = async (): Promise<void> => {
if (this.subscriptionStatus === "trialing") {
const hasValidBillingAddress = await this.checkBillingAddressForTrialingOrg();
if (!hasValidBillingAddress) {
return;
}
}
const dialogRef = openAddCreditDialog(this.dialogService, {
data: {
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AddCreditDialogResult.Added) {
await this.load();
}
};
protected load = async (): Promise<void> => {
this.loading = true;
try {
const { accountCredit, paymentSource, subscriptionStatus, taxInformation } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus;
this.taxInformation = taxInformation;
this.isUnpaid = this.subscriptionStatus === "unpaid";
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!userId) {
throw new Error("User ID is not found");
}
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
organizationSubscriptionPromise,
organizationPromise,
]);
if (!this.organization) {
throw new Error("Organization is not found");
}
if (!this.paymentSource) {
throw new Error("Payment source is not found");
}
}
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
} catch (error) {
this.billingNotificationService.handleError(error);
} finally {
this.loading = false;
}
};
protected updatePaymentMethod = async (): Promise<void> => {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
data: {
initialPaymentMethod: this.paymentSource?.type,
organizationId: this.organizationId,
productTier: this.organization?.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResultType.Submitted) {
await this.load();
}
};
changePayment = async () => {
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organizationId,
subscription: this.organizationSubscriptionResponse!,
productTierType: this.organization!.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
await this.syncService.fullSync(true);
}
this.launchPaymentModalAutomatically = false;
await this.load();
}
};
protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise<void> => {
await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("verifiedBankAccount"),
});
};
protected get accountCreditHeaderText(): string {
const hasAccountCredit = this.accountCredit && this.accountCredit > 0;
const key = hasAccountCredit ? "accountCredit" : "accountBalance";
return this.i18nService.t(key);
}
protected get paymentSourceClasses() {
if (this.paymentSource == null) {
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
case PaymentMethodType.Check:
return ["bwi-billing"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
}
protected get subscriptionIsUnpaid(): boolean {
return this.subscriptionStatus === "unpaid";
}
protected get updatePaymentSourceButtonText(): string {
const key = this.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod";
return this.i18nService.t(key);
}
private async checkBillingAddressForTrialingOrg(): Promise<boolean> {
const hasBillingAddress = this.taxInformation != null;
if (!hasBillingAddress) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("billingAddressRequiredToAddCredit"),
});
return false;
}
return true;
}
}

View File

@@ -15,8 +15,6 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-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 { DialogRef, DialogService } from "@bitwarden/components";
import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients";
@@ -35,7 +33,6 @@ import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/typ
describe("OrganizationWarningsService", () => {
let service: OrganizationWarningsService;
let configService: MockProxy<ConfigService>;
let dialogService: MockProxy<DialogService>;
let i18nService: MockProxy<I18nService>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
@@ -57,7 +54,6 @@ describe("OrganizationWarningsService", () => {
});
beforeEach(() => {
configService = mock<ConfigService>();
dialogService = mock<DialogService>();
i18nService = mock<I18nService>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
@@ -94,7 +90,6 @@ describe("OrganizationWarningsService", () => {
TestBed.configureTestingModule({
providers: [
OrganizationWarningsService,
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
{ provide: I18nService, useValue: i18nService },
{ provide: OrganizationApiServiceAbstraction, useValue: organizationApiService },
@@ -466,7 +461,6 @@ describe("OrganizationWarningsService", () => {
} as OrganizationWarningsResponse);
dialogService.openSimpleDialog.mockResolvedValue(true);
configService.getFeatureFlag.mockResolvedValue(false);
router.navigate.mockResolvedValue(true);
service.showInactiveSubscriptionDialog$(organization).subscribe({
@@ -478,11 +472,8 @@ describe("OrganizationWarningsService", () => {
acceptButtonText: "Continue",
cancelButtonText: "Close",
});
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
expect(router.navigate).toHaveBeenCalledWith(
["organizations", "org-id-123", "billing", "payment-method"],
["organizations", "org-id-123", "billing", "payment-details"],
{ state: { launchPaymentModalAutomatically: true } },
);
done();
@@ -497,7 +488,6 @@ describe("OrganizationWarningsService", () => {
} as OrganizationWarningsResponse);
dialogService.openSimpleDialog.mockResolvedValue(true);
configService.getFeatureFlag.mockResolvedValue(true);
router.navigate.mockResolvedValue(true);
service.showInactiveSubscriptionDialog$(organization).subscribe({
@@ -522,7 +512,6 @@ describe("OrganizationWarningsService", () => {
service.showInactiveSubscriptionDialog$(organization).subscribe({
complete: () => {
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
expect(configService.getFeatureFlag).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
done();
},

View File

@@ -16,8 +16,6 @@ import { take } from "rxjs/operators";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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 { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
@@ -53,7 +51,6 @@ export class OrganizationWarningsService {
taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable();
constructor(
private configService: ConfigService,
private dialogService: DialogService,
private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction,
@@ -196,14 +193,8 @@ export class OrganizationWarningsService {
cancelButtonText: this.i18nService.t("close"),
});
if (confirmed) {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout
? "payment-details"
: "payment-method";
await this.router.navigate(
["organizations", `${organization.id}`, "billing", route],
["organizations", `${organization.id}`, "billing", "payment-details"],
{
state: { launchPaymentModalAutomatically: true },
},

View File

@@ -5,7 +5,7 @@ import { DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { BitwardenSubscriber } from "../../types";
import { MaskedPaymentMethod } from "../types";
import { getCardBrandIcon, MaskedPaymentMethod } from "../types";
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component";
@@ -40,9 +40,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial
}
@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>
@let cardBrandIcon = getCardBrandIcon();
@if (cardBrandIcon !== null) {
<i class="bwi bwi-fw credit-card-icon {{ cardBrandIcon }}"></i>
} @else {
<i class="bwi bwi-fw bwi-credit-card"></i>
}
@@ -74,16 +74,6 @@ export class DisplayPaymentMethodComponent {
@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> => {
@@ -100,13 +90,5 @@ export class DisplayPaymentMethodComponent {
}
};
protected getBrandIconForCard = (): string | null => {
if (this.paymentMethod?.type !== "card") {
return null;
}
return this.paymentMethod.brand in this.availableCardIcons
? this.availableCardIcons[this.paymentMethod.brand]
: null;
};
protected getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod);
}

View File

@@ -11,10 +11,7 @@ import {
ToastService,
} from "@bitwarden/components";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddress,
getTaxIdTypeForCountry,
} from "@bitwarden/web-vault/app/billing/payment/types";
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
import {
TaxIdWarningType,
@@ -22,7 +19,10 @@ import {
} from "@bitwarden/web-vault/app/billing/warnings/types";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { EnterBillingAddressComponent } from "./enter-billing-address.component";
import {
EnterBillingAddressComponent,
getBillingAddressFromForm,
} from "./enter-billing-address.component";
type DialogParams = {
subscriber: BitwardenSubscriber;
@@ -104,13 +104,7 @@ export class EditBillingAddressDialogComponent {
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 billingAddress = getBillingAddressFromForm(this.formGroup);
const result = await this.billingClient.updateBillingAddress(
this.dialogParams.subscriber,

View File

@@ -24,6 +24,17 @@ export interface BillingAddressControls {
export type BillingAddressFormGroup = FormGroup<ControlsOf<BillingAddressControls>>;
export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): BillingAddress =>
getBillingAddressFromControls(formGroup.getRawValue());
export const getBillingAddressFromControls = (controls: BillingAddressControls) => {
const { taxId, ...addressFields } = controls;
const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null;
return taxIdType
? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } }
: { ...addressFields, taxId: null };
};
type Scenario =
| {
type: "checkout";
@@ -67,54 +78,56 @@ type Scenario =
/>
</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"
data-testid="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"
data-testid="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"
data-testid="city"
/>
</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"
data-testid="state"
/>
</bit-form-field>
</div>
@if (scenario.type === "update") {
<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"
data-testid="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"
data-testid="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"
data-testid="city"
/>
</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"
data-testid="state"
/>
</bit-form-field>
</div>
}
@if (supportsTaxId$ | async) {
<div class="tw-col-span-12">
<bit-form-field [disableMargin]="true">
@@ -175,7 +188,7 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe(
startWith(this.group.value.country ?? this.selectableCountries[0].value),
map((country) => {
if (!this.scenario.supportsTaxId) {
if (!this.scenario.supportsTaxId || country === "US") {
return false;
}

View File

@@ -8,7 +8,6 @@ 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,
@@ -16,6 +15,8 @@ import {
TokenizedPaymentMethod,
} from "../types";
import { PaymentLabelComponent } from "./payment-label.component";
type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit";
type PaymentMethodFormGroup = FormGroup<{
@@ -102,7 +103,7 @@ type PaymentMethodFormGroup = FormGroup<{
<button
[bitPopoverTriggerFor]="cardSecurityCodePopover"
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-pr-1"
[position]="'above-end'"
>
<i class="bwi bwi-question-circle tw-text-lg" aria-hidden="true"></i>
@@ -310,7 +311,7 @@ export class EnterPaymentMethodComponent implements OnInit {
select = (paymentMethod: PaymentMethodOption) =>
this.group.controls.type.patchValue(paymentMethod);
tokenize = async (): Promise<TokenizedPaymentMethod> => {
tokenize = async (): Promise<TokenizedPaymentMethod | null> => {
const exchange = async (paymentMethod: TokenizablePaymentMethod) => {
switch (paymentMethod) {
case "bankAccount": {
@@ -351,13 +352,37 @@ export class EnterPaymentMethodComponent implements OnInit {
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;
if (error) {
this.logService.error(error);
switch (this.selected) {
case "card": {
if (
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
) {
this.toastService.showToast({
variant: "error",
title: "",
message: error.message,
});
}
return null;
}
case "payPal": {
if (typeof error === "string" && error === "No payment method is available.") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("clickPayWithPayPal"),
});
return null;
}
}
}
throw error;
}
return null;
}
};

View File

@@ -6,6 +6,7 @@ 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 "./payment-label.component";
export * from "./require-payment-method-dialog.component";
export * from "./submit-payment-method-dialog.component";
export * from "./verify-bank-account.component";

View File

@@ -13,7 +13,21 @@ import { SharedModule } from "../../../shared";
*/
@Component({
selector: "app-payment-label",
templateUrl: "./payment-label.component.html",
template: `
<ng-template #defaultContent>
<ng-content></ng-content>
</ng-template>
<div class="tw-relative tw-mt-2">
<bit-label
[attr.for]="for"
class="tw-absolute tw-bg-background tw-px-1 tw-text-sm tw-text-muted -tw-top-2.5 tw-left-3 tw-mb-0 tw-max-w-full tw-pointer-events-auto"
>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
</bit-label>
</div>
`,
imports: [FormFieldModule, SharedModule],
})
export class PaymentLabelComponent {

View File

@@ -37,6 +37,10 @@ export abstract class SubmitPaymentMethodDialogComponent {
}
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
if (!paymentMethod) {
return;
}
const billingAddress =
this.formGroup.value.type !== "payPal"
? this.formGroup.controls.billingAddress.getRawValue()

View File

@@ -21,6 +21,24 @@ export const StripeCardBrands = {
export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands];
export const cardBrandIcons: 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",
};
export const getCardBrandIcon = (paymentMethod: MaskedPaymentMethod | null): string | null => {
if (paymentMethod?.type !== "card") {
return null;
}
return paymentMethod.brand in cardBrandIcons ? cardBrandIcons[paymentMethod.brand] : null;
};
type MaskedBankAccount = {
type: BankAccountPaymentMethod;
bankName: string;

View File

@@ -1,3 +1,5 @@
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
export const TokenizablePaymentMethods = {
bankAccount: "bankAccount",
card: "card",
@@ -16,6 +18,34 @@ export const isTokenizablePaymentMethod = (value: string): value is TokenizableP
return valid.includes(value);
};
export const tokenizablePaymentMethodFromLegacyEnum = (
legacyEnum: PaymentMethodType,
): TokenizablePaymentMethod | null => {
switch (legacyEnum) {
case PaymentMethodType.BankAccount:
return "bankAccount";
case PaymentMethodType.Card:
return "card";
case PaymentMethodType.PayPal:
return "payPal";
default:
return null;
}
};
export const tokenizablePaymentMethodToLegacyEnum = (
paymentMethod: TokenizablePaymentMethod,
): PaymentMethodType => {
switch (paymentMethod) {
case "bankAccount":
return PaymentMethodType.BankAccount;
case "card":
return PaymentMethodType.Card;
case "payPal":
return PaymentMethodType.PayPal;
}
};
export type TokenizedPaymentMethod = {
type: TokenizablePaymentMethod;
token: string;

View File

@@ -1,10 +1,7 @@
import { Injectable } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
@@ -14,17 +11,13 @@ import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.co
providedIn: "root",
})
export class PricingSummaryService {
private estimatedTax: number = 0;
constructor(private taxService: TaxServiceAbstraction) {}
async getPricingSummaryData(
plan: PlanResponse,
sub: OrganizationSubscriptionResponse,
organization: Organization,
selectedInterval: PlanInterval,
taxInformation: TaxInformation,
isSecretsManagerTrial: boolean,
estimatedTax: number,
): Promise<PricingSummaryData> {
// Calculation helpers
const passwordManagerSeatTotal =
@@ -72,14 +65,9 @@ export class PricingSummaryService {
const acceptingSponsorship = false;
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation);
const total = organization?.useSecretsManager
? passwordManagerSubtotal +
additionalStorageTotal +
secretsManagerSubtotal +
this.estimatedTax
: passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax;
? passwordManagerSubtotal + additionalStorageTotal + secretsManagerSubtotal + estimatedTax
: passwordManagerSubtotal + additionalStorageTotal + estimatedTax;
return {
selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month",
@@ -104,45 +92,10 @@ export class PricingSummaryService {
additionalServiceAccount,
storageGb,
isSecretsManagerTrial,
estimatedTax: this.estimatedTax,
estimatedTax,
};
}
async getEstimatedTax(
organization: Organization,
currentPlan: PlanResponse,
sub: OrganizationSubscriptionResponse,
taxInformation: TaxInformation,
) {
if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) {
return 0;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: organization.id,
passwordManager: {
additionalStorage: 0,
plan: currentPlan?.type,
seats: sub.seats,
},
taxInformation: {
postalCode: taxInformation.postalCode,
country: taxInformation.country,
taxId: taxInformation.taxId,
},
};
if (organization.useSecretsManager) {
request.secretsManager = {
seats: sub.smSeats ?? 0,
additionalMachineAccounts:
(sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0),
};
}
const invoiceResponse = await this.taxService.previewOrganizationInvoice(request);
return invoiceResponse.taxAmount;
}
getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number {
if (!plan || !plan.SecretsManager) {
return 0;

View File

@@ -1,61 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'addCredit' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
<div class="tw-grid tw-grid-cols-2">
<bit-radio-group formControlName="method">
<bit-radio-button id="credit-method-paypal" [value]="paymentMethodType.PayPal">
<bit-label> <i class="bwi bwi-paypal"></i>PayPal</bit-label>
</bit-radio-button>
<bit-radio-button id="credit-method-bitcoin" [value]="paymentMethodType.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
type="text"
formControlName="creditAmount"
(blur)="formatAmount()"
required
/>
<span bitPrefix>$USD</span>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<form #ppButtonForm action="{{ ppButtonFormAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ ppButtonBusinessId }}" />
<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="{{ returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ returnUrl }}" />
<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="{{ formGroup.get('creditAmount').value }}" />
<input type="hidden" name="custom" value="{{ ppButtonCustomField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ subject }}" />
</form>

View File

@@ -1,191 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
export interface AddCreditDialogData {
organizationId: string;
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AddCreditDialogResult {
Added = "added",
Cancelled = "cancelled",
}
export type PayPalConfig = {
businessId?: string;
buttonAction?: string;
};
@Component({
templateUrl: "add-credit-dialog.component.html",
standalone: false,
})
export class AddCreditDialogComponent implements OnInit {
@ViewChild("ppButtonForm", { read: ElementRef, static: true }) ppButtonFormRef: ElementRef;
paymentMethodType = PaymentMethodType;
ppButtonFormAction: string;
ppButtonBusinessId: string;
ppButtonCustomField: string;
ppLoading = false;
subject: string;
returnUrl: string;
organizationId: string;
private userId: string;
private name: string;
private email: string;
private region: string;
protected DialogResult = AddCreditDialogResult;
protected formGroup = new FormGroup({
method: new FormControl(PaymentMethodType.PayPal),
creditAmount: new FormControl(null, [Validators.required]),
});
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AddCreditDialogData,
private accountService: AccountService,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private organizationService: OrganizationService,
private logService: LogService,
private configService: ConfigService,
) {
this.organizationId = data.organizationId;
const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
this.ppButtonFormAction = payPalConfig.buttonAction;
this.ppButtonBusinessId = payPalConfig.businessId;
}
async ngOnInit() {
if (this.organizationId != null) {
if (this.creditAmount == null) {
this.creditAmount = "0.00";
}
this.ppButtonCustomField = "organization_id:" + this.organizationId;
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const org = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
if (org != null) {
this.subject = org.name;
this.name = org.name;
}
} else {
if (this.creditAmount == null) {
this.creditAmount = "0.00";
}
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
this.userId = userId;
this.subject = email;
this.email = this.subject;
this.ppButtonCustomField = "user_id:" + this.userId;
}
this.region = await firstValueFrom(this.configService.cloudRegion$);
this.ppButtonCustomField += ",account_credit:1";
this.ppButtonCustomField += `,region:${this.region}`;
this.returnUrl = window.location.href;
}
get creditAmount() {
return this.formGroup.value.creditAmount;
}
set creditAmount(value: string) {
this.formGroup.get("creditAmount").setValue(value);
}
get method() {
return this.formGroup.value.method;
}
submit = async () => {
if (this.creditAmount == null || this.creditAmount === "") {
return;
}
if (this.method === PaymentMethodType.PayPal) {
this.ppButtonFormRef.nativeElement.submit();
this.ppLoading = true;
return;
}
if (this.method === PaymentMethodType.BitPay) {
const req = new BitPayInvoiceRequest();
req.email = this.email;
req.name = this.name;
req.credit = true;
req.amount = this.creditAmountNumber;
req.organizationId = this.organizationId;
req.userId = this.userId;
req.returnUrl = this.returnUrl;
const bitPayUrl: string = await this.apiService.postBitPayInvoice(req);
this.platformUtilsService.launchUri(bitPayUrl);
return;
}
this.dialogRef.close(AddCreditDialogResult.Added);
};
formatAmount() {
try {
if (this.creditAmount != null && this.creditAmount !== "") {
const floatAmount = Math.abs(parseFloat(this.creditAmount));
if (floatAmount > 0) {
this.creditAmount = parseFloat((Math.round(floatAmount * 100) / 100).toString())
.toFixed(2)
.toString();
return;
}
}
} catch (e) {
this.logService.error(e);
}
this.creditAmount = "";
}
get creditAmountNumber(): number {
if (this.creditAmount != null && this.creditAmount !== "") {
try {
return parseFloat(this.creditAmount);
} catch (e) {
this.logService.error(e);
}
}
return null;
}
}
/**
* Strongly typed helper to open a AddCreditDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAddCreditDialog(
dialogService: DialogService,
config: DialogConfig<AddCreditDialogData>,
) {
return dialogService.open<AddCreditDialogResult>(AddCreditDialogComponent, config);
}

View File

@@ -1,29 +0,0 @@
<bit-dialog dialogSize="large" [title]="dialogHeader" [loading]="loading">
<ng-container bitDialogContent>
<app-payment
[showAccountCredit]="false"
[showBankAccount]="!!organizationId || !!providerId"
[initialPaymentMethod]="initialPaymentMethod"
></app-payment>
<app-manage-tax-information
*ngIf="taxInformation"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
/>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [bitAction]="submit">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -1,225 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, forwardRef, Inject, OnInit, ViewChild } from "@angular/core";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { PaymentComponent } from "../payment/payment.component";
export interface AdjustPaymentDialogParams {
initialPaymentMethod?: PaymentMethodType | null;
organizationId?: string;
productTier?: ProductTierType;
providerId?: string;
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AdjustPaymentDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
@Component({
templateUrl: "./adjust-payment-dialog.component.html",
standalone: false,
})
export class AdjustPaymentDialogComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(forwardRef(() => ManageTaxInformationComponent))
taxInfoComponent: ManageTaxInformationComponent;
protected readonly PaymentMethodType = PaymentMethodType;
protected readonly ResultType = AdjustPaymentDialogResultType;
protected dialogHeader: string;
protected initialPaymentMethod: PaymentMethodType;
protected organizationId?: string;
protected productTier?: ProductTierType;
protected providerId?: string;
protected loading = true;
protected taxInformation: TaxInformation;
constructor(
private apiService: ApiService,
private billingApiService: BillingApiServiceAbstraction,
private organizationApiService: OrganizationApiServiceAbstraction,
@Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogParams,
private dialogRef: DialogRef<AdjustPaymentDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
) {
const key = this.dialogParams.initialPaymentMethod ? "changePaymentMethod" : "addPaymentMethod";
this.dialogHeader = this.i18nService.t(key);
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
this.organizationId = this.dialogParams.organizationId;
this.productTier = this.dialogParams.productTier;
this.providerId = this.dialogParams.providerId;
}
ngOnInit(): void {
if (this.organizationId) {
this.organizationApiService
.getTaxInfo(this.organizationId)
.then((response: TaxInfoResponse) => {
this.taxInformation = TaxInformation.from(response);
this.toggleBankAccount();
})
.catch(() => {
this.taxInformation = new TaxInformation();
})
.finally(() => {
this.loading = false;
});
} else if (this.providerId) {
this.billingApiService
.getProviderTaxInformation(this.providerId)
.then((response) => {
this.taxInformation = TaxInformation.from(response);
this.toggleBankAccount();
})
.catch(() => {
this.taxInformation = new TaxInformation();
})
.finally(() => {
this.loading = false;
});
} else {
this.apiService
.getTaxInfo()
.then((response: TaxInfoResponse) => {
this.taxInformation = TaxInformation.from(response);
})
.catch(() => {
this.taxInformation = new TaxInformation();
})
.finally(() => {
this.loading = false;
});
}
}
taxInformationChanged(event: TaxInformation) {
this.taxInformation = event;
this.toggleBankAccount();
}
toggleBankAccount = () => {
if (this.taxInformation.country === "US") {
this.paymentComponent.showBankAccount = !!this.organizationId || !!this.providerId;
} else {
this.paymentComponent.showBankAccount = false;
if (this.paymentComponent.selected === PaymentMethodType.BankAccount) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
};
submit = async (): Promise<void> => {
if (!this.taxInfoComponent.validate()) {
this.taxInfoComponent.markAllAsTouched();
return;
}
try {
if (this.organizationId) {
await this.updateOrganizationPaymentMethod();
} else if (this.providerId) {
await this.updateProviderPaymentMethod();
} else {
await this.updatePremiumUserPaymentMethod();
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedPaymentMethod"),
});
this.dialogRef.close(AdjustPaymentDialogResultType.Submitted);
} catch (error) {
const msg = typeof error == "object" ? error.message : error;
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t(msg) || msg,
});
}
};
private updateOrganizationPaymentMethod = async () => {
const paymentSource = await this.paymentComponent.tokenize();
const request = new UpdatePaymentMethodRequest();
request.paymentSource = paymentSource;
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation);
await this.billingApiService.updateOrganizationPaymentMethod(this.organizationId, request);
};
private updatePremiumUserPaymentMethod = async () => {
const { type, token } = await this.paymentComponent.tokenize();
const request = new PaymentRequest();
request.paymentMethodType = type;
request.paymentToken = token;
request.country = this.taxInformation.country;
request.postalCode = this.taxInformation.postalCode;
request.taxId = this.taxInformation.taxId;
request.state = this.taxInformation.state;
request.line1 = this.taxInformation.line1;
request.line2 = this.taxInformation.line2;
request.city = this.taxInformation.city;
request.state = this.taxInformation.state;
await this.apiService.postAccountPayment(request);
};
private updateProviderPaymentMethod = async () => {
const paymentSource = await this.paymentComponent.tokenize();
const request = new UpdatePaymentMethodRequest();
request.paymentSource = paymentSource;
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation);
await this.billingApiService.updateProviderPaymentMethod(this.providerId, request);
};
protected get showTaxIdField(): boolean {
if (this.organizationId) {
switch (this.productTier) {
case ProductTierType.Free:
case ProductTierType.Families:
return false;
default:
return true;
}
} else {
return !!this.providerId;
}
}
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<AdjustPaymentDialogParams>,
) =>
dialogService.open<AdjustPaymentDialogResultType>(AdjustPaymentDialogComponent, dialogConfig);
}

View File

@@ -1,46 +1,40 @@
import { NgModule } from "@angular/core";
import { BannerModule } from "@bitwarden/components";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
import { AddCreditDialogComponent } from "./add-credit-dialog.component";
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog/adjust-payment-dialog.component";
import { AdjustStorageDialogComponent } from "./adjust-storage-dialog/adjust-storage-dialog.component";
import { BillingHistoryComponent } from "./billing-history.component";
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
import { PaymentComponent } from "./payment/payment.component";
import { PaymentMethodComponent } from "./payment-method.component";
import { PlanCardComponent } from "./plan-card/plan-card.component";
import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component";
import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component";
import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component";
import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component";
import { TaxInfoComponent } from "./tax-info.component";
import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component";
import { UpdateLicenseDialogComponent } from "./update-license-dialog.component";
import { UpdateLicenseComponent } from "./update-license.component";
import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component";
@NgModule({
imports: [
SharedModule,
TaxInfoComponent,
HeaderModule,
BannerModule,
PaymentComponent,
VerifyBankAccountComponent,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
declarations: [
AddCreditDialogComponent,
BillingHistoryComponent,
PaymentMethodComponent,
SecretsManagerSubscribeComponent,
UpdateLicenseComponent,
UpdateLicenseDialogComponent,
OffboardingSurveyComponent,
AdjustPaymentDialogComponent,
AdjustStorageDialogComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
@@ -50,14 +44,11 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
],
exports: [
SharedModule,
TaxInfoComponent,
BillingHistoryComponent,
SecretsManagerSubscribeComponent,
UpdateLicenseComponent,
UpdateLicenseDialogComponent,
OffboardingSurveyComponent,
VerifyBankAccountComponent,
PaymentComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
],

View File

@@ -1,4 +1,2 @@
export * from "./billing-shared.module";
export * from "./payment-method.component";
export * from "./sm-subscribe.component";
export * from "./tax-info.component";

View File

@@ -1,88 +0,0 @@
<app-header *ngIf="organizationId">
<button
type="button"
bitButton
buttonType="secondary"
[bitAction]="load"
class="tw-ml-auto"
*ngIf="firstLoaded"
[disabled]="loading"
>
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i>
{{ "refresh" | i18n }}
</button>
</app-header>
<bit-container>
<!-- TODO: Organization and individual should use different "page" components -->
<h2 bitTypography="h1" *ngIf="!organizationId">{{ "paymentMethod" | i18n }}</h2>
<ng-container *ngIf="!firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="billing">
<bit-section>
<h2 bitTypography="h2">
{{ (isCreditBalance ? "accountCredit" : "accountBalance") | i18n }}
</h2>
<p class="tw-text-lg tw-font-bold">{{ creditOrBalance | currency: "$" }}</p>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<button type="button" bitButton buttonType="secondary" [bitAction]="addCredit">
{{ "addCredit" | i18n }}
</button>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
<ng-container *ngIf="paymentSource">
<bit-callout
type="warning"
title="{{ 'verifyBankAccount' | i18n }}"
*ngIf="
forOrganization &&
paymentSource.type === paymentMethodType.BankAccount &&
paymentSource.needsVerification
"
>
<p bitTypography="body1">
{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
</p>
<form
[formGroup]="verifyBankForm"
[bitSubmit]="verifyBank"
class="tw-flex tw-flex-wrap tw-items-center tw-space-x-2"
>
<bit-form-field class="tw-w-40">
<bit-label>{{ "amountX" | i18n: "1" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount1" />
<span bitPrefix>$0.</span>
</bit-form-field>
<bit-form-field class="tw-w-40">
<bit-label>{{ "amountX" | i18n: "2" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount2" />
<span bitPrefix>$0.</span>
</bit-form-field>
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "verifyBankAccount" | i18n }}
</button>
</form>
</bit-callout>
<p>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ paymentSource.description }}
</p>
</ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment">
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button>
<p *ngIf="isUnpaid" bitTypography="body1">
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
</p>
</bit-section>
</ng-container>
</bit-container>

View File

@@ -1,261 +0,0 @@
import { Location } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, lastValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
OrganizationService,
getOrganizationById,
} 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 { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-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 { 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 { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
import {
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "./adjust-payment-dialog/adjust-payment-dialog.component";
@Component({
templateUrl: "payment-method.component.html",
standalone: false,
})
export class PaymentMethodComponent implements OnInit, OnDestroy {
loading = false;
firstLoaded = false;
billing?: BillingPaymentResponse;
org?: OrganizationSubscriptionResponse;
sub?: SubscriptionResponse;
paymentMethodType = PaymentMethodType;
organizationId?: string;
isUnpaid = false;
organization?: Organization;
verifyBankForm = this.formBuilder.group({
amount1: new FormControl<number>(0, [
Validators.required,
Validators.max(99),
Validators.min(0),
]),
amount2: new FormControl<number>(0, [
Validators.required,
Validators.max(99),
Validators.min(0),
]),
});
launchPaymentModalAutomatically = false;
constructor(
protected apiService: ApiService,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
private router: Router,
private location: Location,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private dialogService: DialogService,
private toastService: ToastService,
private organizationService: OrganizationService,
private accountService: AccountService,
protected syncService: SyncService,
private configService: ConfigService,
) {
const state = this.router.getCurrentNavigation()?.extras?.state;
// In case the above state is undefined or null, we use redundantState
const redundantState: any = location.getState();
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
} else if (
redundantState &&
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
) {
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
} else {
this.launchPaymentModalAutomatically = false;
}
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.params.subscribe(async (params) => {
if (params.organizationId) {
this.organizationId = params.organizationId;
} else if (this.platformUtilsService.isSelfHost()) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/settings/subscription"]);
return;
}
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
if (managePaymentDetailsOutsideCheckout) {
await this.router.navigate(["../payment-details"], { relativeTo: this.route });
}
await this.load();
this.firstLoaded = true;
});
}
load = async () => {
if (this.loading) {
return;
}
this.loading = true;
if (this.forOrganization) {
const billingPromise = this.organizationApiService.getBilling(this.organizationId!);
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId!,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!userId) {
throw new Error("User ID is not found");
}
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId!)),
);
[this.billing, this.org, this.organization] = await Promise.all([
billingPromise,
organizationSubscriptionPromise,
organizationPromise,
]);
} else {
const billingPromise = this.apiService.getUserBillingPayment();
const subPromise = this.apiService.getUserSubscription();
[this.billing, this.sub] = await Promise.all([billingPromise, subPromise]);
}
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
this.isUnpaid = this.subscription?.status === "unpaid" ?? false;
this.loading = false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
};
addCredit = async () => {
if (this.forOrganization) {
const dialogRef = openAddCreditDialog(this.dialogService, {
data: {
organizationId: this.organizationId!,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AddCreditDialogResult.Added) {
await this.load();
}
}
};
changePayment = async () => {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organizationId,
initialPaymentMethod: this.paymentSource !== null ? this.paymentSource.type : null,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResultType.Submitted) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
await this.syncService.fullSync(true);
}
this.launchPaymentModalAutomatically = false;
await this.load();
}
};
verifyBank = async () => {
if (this.loading || !this.forOrganization) {
return;
}
const request = new VerifyBankRequest();
request.amount1 = this.verifyBankForm.value.amount1!;
request.amount2 = this.verifyBankForm.value.amount2!;
await this.organizationApiService.verifyBank(this.organizationId!, request);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("verifiedBankAccount"),
});
await this.load();
};
get isCreditBalance() {
return this.billing == null || this.billing.balance <= 0;
}
get creditOrBalance() {
return Math.abs(this.billing != null ? this.billing.balance : 0);
}
get paymentSource() {
return this.billing != null ? this.billing.paymentSource : null;
}
get forOrganization() {
return this.organizationId != null;
}
get paymentSourceClasses() {
if (this.paymentSource == null) {
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
case PaymentMethodType.Check:
return ["bwi-billing"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
}
get subscription() {
return this.sub?.subscription ?? this.org?.subscription ?? null;
}
ngOnDestroy(): void {
this.launchPaymentModalAutomatically = false;
}
}

View File

@@ -1,13 +0,0 @@
<ng-template #defaultContent>
<ng-content></ng-content>
</ng-template>
<div class="tw-relative tw-mt-2">
<bit-label
[attr.for]="for"
class="tw-absolute tw-bg-background tw-px-1 tw-text-sm tw-text-muted -tw-top-2.5 tw-left-3 tw-mb-0 tw-max-w-full tw-pointer-events-auto"
>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
</bit-label>
</div>

View File

@@ -1,149 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-mb-4 tw-text-lg">
<bit-radio-group formControlName="paymentMethod">
<bit-radio-button id="card-payment-method" [value]="PaymentMethodType.Card">
<bit-label>
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
{{ "creditCard" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="bank-payment-method"
[value]="PaymentMethodType.BankAccount"
*ngIf="showBankAccount"
>
<bit-label>
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
{{ "bankAccount" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="paypal-payment-method"
[value]="PaymentMethodType.PayPal"
*ngIf="showPayPal"
>
<bit-label>
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
{{ "payPal" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="credit-payment-method"
[value]="PaymentMethodType.Credit"
*ngIf="showAccountCredit"
>
<bit-label>
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
{{ "accountCredit" | i18n }}
</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<!-- Card -->
<ng-container *ngIf="usingCard">
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4">
<div class="tw-col-span-1">
<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 }}
<a
href="https://www.cvvnumber.com/cvv.html"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
class="hover:tw-no-underline"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</app-payment-label>
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
</div>
</div>
</ng-container>
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "requiredToVerifyBankAccountWithStripe" | i18n }}
</bit-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation">
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
<input
bitInput
id="routingNumber"
type="text"
formControlName="routingNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
<input
bitInput
id="accountNumber"
type="text"
formControlName="accountNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
<input
id="accountHolderName"
bitInput
type="text"
formControlName="accountHolderName"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
<bit-select id="accountHolderType" formControlName="accountHolderType" required>
<bit-option value="" label="-- {{ 'select' | i18n }} --"></bit-option>
<bit-option value="company" label="{{ 'bankAccountTypeCompany' | i18n }}"></bit-option>
<bit-option
value="individual"
label="{{ 'bankAccountTypeIndividual' | i18n }}"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</ng-container>
<!-- PayPal -->
<ng-container *ngIf="showPayPal && usingPayPal">
<div class="tw-mb-3">
<div id="braintree-container" class="tw-mb-1 tw-content-center"></div>
<small class="tw-text-muted">{{ "paypalClickSubmit" | i18n }}</small>
</div>
</ng-container>
<!-- Account Credit -->
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
<app-callout type="info">
{{ "makeSureEnoughCredit" | i18n }}
</app-callout>
</ng-container>
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</form>

View File

@@ -1,215 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SharedModule } from "../../../shared";
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
import { PaymentLabelComponent } from "./payment-label.component";
/**
* Render a form that allows the user to enter their payment method, tokenize it against one of our payment providers and,
* optionally, submit it using the {@link onSubmit} function if it is provided.
*/
@Component({
selector: "app-payment",
templateUrl: "./payment.component.html",
imports: [BillingServicesModule, SharedModule, PaymentLabelComponent],
})
export class PaymentComponent implements OnInit, OnDestroy {
/** Show account credit as a payment option. */
@Input() showAccountCredit: boolean = true;
/** Show bank account as a payment option. */
@Input() showBankAccount: boolean = true;
/** Show PayPal as a payment option. */
@Input() showPayPal: boolean = true;
/** The payment method selected by default when the component renders. */
@Input() private initialPaymentMethod: PaymentMethodType = PaymentMethodType.Card;
/** If provided, will be invoked with the tokenized payment source during form submission. */
@Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise<void>;
@Input() private bankAccountWarningOverride?: string;
@Output() submitted = new EventEmitter<PaymentMethodType>();
private destroy$ = new Subject<void>();
protected formGroup = new FormGroup({
paymentMethod: new FormControl<PaymentMethodType>(null),
bankInformation: new FormGroup({
routingNumber: new FormControl<string>("", [Validators.required]),
accountNumber: new FormControl<string>("", [Validators.required]),
accountHolderName: new FormControl<string>("", [Validators.required]),
accountHolderType: new FormControl<string>("", [Validators.required]),
}),
});
protected PaymentMethodType = PaymentMethodType;
constructor(
private billingApiService: BillingApiServiceAbstraction,
private braintreeService: BraintreeService,
private i18nService: I18nService,
private stripeService: StripeService,
) {}
ngOnInit(): void {
this.formGroup.controls.paymentMethod.patchValue(this.initialPaymentMethod);
this.stripeService.loadStripe(
{
cardNumber: "#stripe-card-number",
cardExpiry: "#stripe-card-expiry",
cardCvc: "#stripe-card-cvc",
},
this.initialPaymentMethod === PaymentMethodType.Card,
);
if (this.showPayPal) {
this.braintreeService.loadBraintree(
"#braintree-container",
this.initialPaymentMethod === PaymentMethodType.PayPal,
);
}
this.formGroup
.get("paymentMethod")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((type) => {
this.onPaymentMethodChange(type);
});
}
/** Programmatically select the provided payment method. */
select = (paymentMethod: PaymentMethodType) => {
this.formGroup.get("paymentMethod").patchValue(paymentMethod);
};
protected submit = async () => {
const { type, token } = await this.tokenize();
await this.onSubmit?.({ type, token });
this.submitted.emit(type);
};
validate = () => {
if (!this.usingBankAccount) {
return true;
}
this.formGroup.controls.bankInformation.markAllAsTouched();
return this.formGroup.controls.bankInformation.valid;
};
/**
* Tokenize the payment method information entered by the user against one of our payment providers.
*
* - {@link PaymentMethodType.Card} => [Stripe.confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup}
* - {@link PaymentMethodType.BankAccount} => [Stripe.confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup}
* - {@link PaymentMethodType.PayPal} => [Braintree.requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod}
* */
async tokenize(): Promise<{ type: PaymentMethodType; token: string }> {
const type = this.selected;
if (this.usingStripe) {
const clientSecret = await this.billingApiService.createSetupIntent(type);
if (this.usingBankAccount) {
this.formGroup.markAllAsTouched();
if (this.formGroup.valid) {
const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, {
accountHolderName: this.formGroup.value.bankInformation.accountHolderName,
routingNumber: this.formGroup.value.bankInformation.routingNumber,
accountNumber: this.formGroup.value.bankInformation.accountNumber,
accountHolderType: this.formGroup.value.bankInformation.accountHolderType,
});
return {
type,
token,
};
} else {
throw "Invalid input provided. Please ensure all required fields are filled out correctly and try again.";
}
}
if (this.usingCard) {
const token = await this.stripeService.setupCardPaymentMethod(clientSecret);
return {
type,
token,
};
}
}
if (this.usingPayPal) {
const token = await this.braintreeService.requestPaymentMethod();
return {
type,
token,
};
}
if (this.usingAccountCredit) {
return {
type: PaymentMethodType.Credit,
token: null,
};
}
return null;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.stripeService.unloadStripe();
if (this.showPayPal) {
this.braintreeService.unloadBraintree();
}
}
private onPaymentMethodChange(type: PaymentMethodType): void {
switch (type) {
case PaymentMethodType.Card: {
this.stripeService.mountElements();
break;
}
case PaymentMethodType.PayPal: {
this.braintreeService.createDropin();
break;
}
}
}
get selected(): PaymentMethodType {
return this.formGroup.value.paymentMethod;
}
protected get usingAccountCredit(): boolean {
return this.selected === PaymentMethodType.Credit;
}
protected get usingBankAccount(): boolean {
return this.selected === PaymentMethodType.BankAccount;
}
protected get usingCard(): boolean {
return this.selected === PaymentMethodType.Card;
}
protected get usingPayPal(): boolean {
return this.selected === PaymentMethodType.PayPal;
}
private get usingStripe(): boolean {
return this.usingBankAccount || this.usingCard;
}
}

View File

@@ -1,83 +0,0 @@
<form [formGroup]="taxFormGroup">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country" autocomplete="country" data-testid="country">
<bit-option
*ngFor="let country of countryList"
[value]="country.value"
[disabled]="country.disabled"
[label]="country.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="postalCode"
autocomplete="postal-code"
data-testid="postal-code"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label for="addressCity">{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported && showTaxIdField">
<bit-form-field>
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
</bit-form-field>
</div>
</div>
</form>

View File

@@ -1,199 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { debounceTime } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { CountryListItem } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SharedModule } from "../../shared";
/**
* @deprecated Use `ManageTaxInformationComponent` instead.
*/
@Component({
selector: "app-tax-info",
templateUrl: "tax-info.component.html",
imports: [SharedModule],
})
export class TaxInfoComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
@Input() trialFlow = false;
@Output() countryChanged = new EventEmitter();
@Output() taxInformationChanged: EventEmitter<void> = new EventEmitter<void>();
taxFormGroup = new FormGroup({
country: new FormControl<string>(null, [Validators.required]),
postalCode: new FormControl<string>(null, [Validators.required]),
taxId: new FormControl<string>(null),
line1: new FormControl<string>(null),
line2: new FormControl<string>(null),
city: new FormControl<string>(null),
state: new FormControl<string>(null),
});
protected isTaxSupported: boolean;
loading = true;
organizationId: string;
providerId: string;
countryList: CountryListItem[] = this.taxService.getCountries();
constructor(
private apiService: ApiService,
private route: ActivatedRoute,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private taxService: TaxServiceAbstraction,
) {}
get country(): string {
return this.taxFormGroup.controls.country.value;
}
get postalCode(): string {
return this.taxFormGroup.controls.postalCode.value;
}
get taxId(): string {
return this.taxFormGroup.controls.taxId.value;
}
get line1(): string {
return this.taxFormGroup.controls.line1.value;
}
get line2(): string {
return this.taxFormGroup.controls.line2.value;
}
get city(): string {
return this.taxFormGroup.controls.city.value;
}
get state(): string {
return this.taxFormGroup.controls.state.value;
}
get showTaxIdField(): boolean {
return !!this.organizationId;
}
async ngOnInit() {
// Provider setup
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.route.queryParams.subscribe((params) => {
this.providerId = params.providerId;
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent?.parent?.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
if (this.organizationId) {
try {
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
if (taxInfo) {
this.taxFormGroup.controls.taxId.setValue(taxInfo.taxId);
this.taxFormGroup.controls.state.setValue(taxInfo.state);
this.taxFormGroup.controls.line1.setValue(taxInfo.line1);
this.taxFormGroup.controls.line2.setValue(taxInfo.line2);
this.taxFormGroup.controls.city.setValue(taxInfo.city);
this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode);
this.taxFormGroup.controls.country.setValue(taxInfo.country);
}
} catch (e) {
this.logService.error(e);
}
} else {
try {
const taxInfo = await this.apiService.getTaxInfo();
if (taxInfo) {
this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode);
this.taxFormGroup.controls.country.setValue(taxInfo.country);
}
} catch (e) {
this.logService.error(e);
}
}
this.isTaxSupported = await this.taxService.isCountrySupported(
this.taxFormGroup.controls.country.value,
);
this.countryChanged.emit();
});
this.taxFormGroup.controls.country.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe((value) => {
this.taxService
.isCountrySupported(this.taxFormGroup.controls.country.value)
.then((isSupported) => {
this.isTaxSupported = isSupported;
})
.catch(() => {
this.isTaxSupported = false;
})
.finally(() => {
if (!this.isTaxSupported) {
this.taxFormGroup.controls.taxId.setValue(null);
this.taxFormGroup.controls.line1.setValue(null);
this.taxFormGroup.controls.line2.setValue(null);
this.taxFormGroup.controls.city.setValue(null);
this.taxFormGroup.controls.state.setValue(null);
}
this.countryChanged.emit();
});
this.taxInformationChanged.emit();
});
this.taxFormGroup.controls.postalCode.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
this.taxInformationChanged.emit();
});
this.taxFormGroup.controls.taxId.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
this.taxInformationChanged.emit();
});
this.loading = false;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
submitTaxInfo(): Promise<any> {
this.taxFormGroup.updateValueAndValidity();
this.taxFormGroup.markAllAsTouched();
const request = new ExpandedTaxInfoUpdateRequest();
request.country = this.country;
request.postalCode = this.postalCode;
request.taxId = this.taxId;
request.line1 = this.line1;
request.line2 = this.line2;
request.city = this.city;
request.state = this.state;
return this.organizationId
? this.organizationApiService.updateTaxInfo(
this.organizationId,
request as ExpandedTaxInfoUpdateRequest,
)
: this.apiService.putTaxInfo(request);
}
}

View File

@@ -86,17 +86,13 @@
<ng-container>
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<ng-container bitDialogContent>
<app-payment
[showAccountCredit]="false"
[showBankAccount]="!!organizationId"
[initialPaymentMethod]="initialPaymentMethod"
></app-payment>
<app-manage-tax-information
*ngIf="taxInformation"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
/>
<app-enter-payment-method [group]="formGroup.controls.paymentMethod">
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId }"
>
</app-enter-billing-address>
</ng-container>
<!-- Pricing Breakdown -->
<app-pricing-summary

View File

@@ -1,7 +1,17 @@
import { Component, EventEmitter, Inject, OnInit, Output, signal, ViewChild } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import {
Component,
EventEmitter,
Inject,
OnDestroy,
OnInit,
Output,
signal,
ViewChild,
} from "@angular/core";
import { FormGroup } from "@angular/forms";
import { combineLatest, firstValueFrom, map, Subject, takeUntil } from "rxjs";
import { debounceTime, startWith, switchMap } from "rxjs/operators";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@@ -10,14 +20,9 @@ import {
} 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { PaymentMethodType, PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
@@ -29,9 +34,15 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { PlanCardService } from "../../services/plan-card.service";
import { PaymentComponent } from "../payment/payment.component";
import { PlanCard } from "../plan-card/plan-card.component";
import { PricingSummaryData } from "../pricing-summary/pricing-summary.component";
@@ -60,10 +71,10 @@ interface OnSuccessArgs {
selector: "app-trial-payment-dialog",
templateUrl: "./trial-payment-dialog.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
})
export class TrialPaymentDialogComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent!: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxComponent!: ManageTaxInformationComponent;
export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
currentPlan!: PlanResponse;
currentPlanName!: string;
@@ -78,10 +89,16 @@ export class TrialPaymentDialogComponent implements OnInit {
@Output() onSuccess = new EventEmitter<OnSuccessArgs>();
protected initialPaymentMethod: PaymentMethodType;
protected taxInformation!: TaxInformation;
protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE;
pricingSummaryData!: PricingSummaryData;
formGroup = new FormGroup({
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
private destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams,
private dialogRef: DialogRef<TrialPaymentDialogResultType>,
@@ -93,8 +110,9 @@ export class TrialPaymentDialogComponent implements OnInit {
private pricingSummaryService: PricingSummaryService,
private apiService: ApiService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
) {
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
}
@@ -134,19 +152,48 @@ export class TrialPaymentDialogComponent implements OnInit {
: PlanInterval.Monthly;
}
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
this.taxInformation = TaxInformation.from(taxInfo);
const billingAddress = await this.subscriberBillingClient.getBillingAddress({
type: "organization",
data: this.organization,
});
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
this.currentPlan,
this.sub,
this.organization,
this.selectedInterval,
this.taxInformation,
this.isSecretsManagerTrial(),
);
if (billingAddress) {
const { taxId, ...location } = billingAddress;
this.formGroup.controls.billingAddress.patchValue({
...location,
taxId: taxId ? taxId.value : null,
});
}
await this.refreshPricingSummary();
this.plans = await this.apiService.getPlans();
combineLatest([
this.formGroup.controls.billingAddress.controls.country.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.controls.country.value),
),
this.formGroup.controls.billingAddress.controls.postalCode.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.controls.postalCode.value),
),
this.formGroup.controls.billingAddress.controls.taxId.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.controls.taxId.value),
),
])
.pipe(
debounceTime(500),
switchMap(() => {
return this.refreshPricingSummary();
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
static open = (
@@ -175,14 +222,7 @@ export class TrialPaymentDialogComponent implements OnInit {
await this.selectPlan();
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
this.currentPlan,
this.sub,
this.organization,
this.selectedInterval,
this.taxInformation,
this.isSecretsManagerTrial(),
);
await this.refreshPricingSummary();
}
protected async selectPlan() {
@@ -202,7 +242,7 @@ export class TrialPaymentDialogComponent implements OnInit {
this.currentPlan = filteredPlans[0];
}
try {
await this.refreshSalesTax();
await this.refreshPricingSummary();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const translatedMessage = this.i18nService.t(errorMessage);
@@ -214,72 +254,57 @@ export class TrialPaymentDialogComponent implements OnInit {
}
}
protected get showTaxIdField(): boolean {
switch (this.currentPlan.productTier) {
case ProductTierType.Free:
case ProductTierType.Families:
return false;
default:
return true;
}
}
private async refreshSalesTax(): Promise<void> {
if (
this.taxInformation === undefined ||
!this.taxInformation.country ||
!this.taxInformation.postalCode
) {
return;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: this.organizationId,
passwordManager: {
additionalStorage: 0,
plan: this.currentPlan?.type,
seats: this.sub.seats,
},
taxInformation: {
postalCode: this.taxInformation.postalCode,
country: this.taxInformation.country,
taxId: this.taxInformation.taxId,
},
};
if (this.organization.useSecretsManager) {
request.secretsManager = {
seats: this.sub.smSeats ?? 0,
additionalMachineAccounts:
(this.sub.smServiceAccounts ?? 0) -
(this.sub.plan.SecretsManager?.baseServiceAccount ?? 0),
};
}
private refreshPricingSummary = async () => {
const estimatedTax = await this.getEstimatedTax();
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
this.currentPlan,
this.sub,
this.organization,
this.selectedInterval,
this.taxInformation,
this.isSecretsManagerTrial(),
estimatedTax,
);
}
};
async taxInformationChanged(event: TaxInformation) {
this.taxInformation = event;
this.toggleBankAccount();
await this.refreshSalesTax();
}
private getEstimatedTax = async () => {
if (this.formGroup.controls.billingAddress.invalid) {
return 0;
}
toggleBankAccount = () => {
this.paymentComponent.showBankAccount = this.taxInformation.country === "US";
const cadence =
this.currentPlan.productTier !== ProductTierType.Families
? this.currentPlan.isAnnual
? "annually"
: "monthly"
: null;
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const getTierFromLegacyEnum = (organization: Organization) => {
switch (organization.productTierType) {
case ProductTierType.Families:
return "families";
case ProductTierType.Teams:
return "teams";
case ProductTierType.Enterprise:
return "enterprise";
}
};
const tier = getTierFromLegacyEnum(this.organization);
if (tier && cadence) {
const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organization.id,
{
tier,
cadence,
},
billingAddress,
);
return costs.tax;
} else {
return 0;
}
};
@@ -292,15 +317,24 @@ export class TrialPaymentDialogComponent implements OnInit {
}
async onSubscribe(): Promise<void> {
if (!this.taxComponent.validate()) {
this.taxComponent.markAllAsTouched();
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
try {
await this.updateOrganizationPaymentMethod(
this.organizationId,
this.paymentComponent,
this.taxInformation,
);
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
if (!paymentMethod) {
return;
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
await Promise.all([
this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null),
this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress),
]);
if (this.currentPlan.type !== this.sub.planType) {
const changePlanRequest = new ChangePlanFrequencyRequest();
@@ -332,20 +366,6 @@ export class TrialPaymentDialogComponent implements OnInit {
}
}
private async updateOrganizationPaymentMethod(
organizationId: string,
paymentComponent: PaymentComponent,
taxInformation: TaxInformation,
): Promise<void> {
const paymentSource = await paymentComponent.tokenize();
const request = new UpdatePaymentMethodRequest();
request.paymentSource = paymentSource;
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request);
}
resolvePlanName(productTier: ProductTierType): string {
switch (productTier) {
case ProductTierType.Enterprise:
@@ -362,4 +382,11 @@ export class TrialPaymentDialogComponent implements OnInit {
return this.i18nService.t("planNameFree");
}
}
get supportsTaxId() {
if (!this.organization) {
return false;
}
return this.organization.productTierType !== ProductTierType.Families;
}
}

View File

@@ -1,12 +0,0 @@
<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" formControlName="descriptorCode" />
</bit-form-field>
<button *ngIf="onSubmit" type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
</form>
</bit-callout>

View File

@@ -1,34 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { SharedModule } from "../../../shared";
@Component({
selector: "app-verify-bank-account",
templateUrl: "./verify-bank-account.component.html",
imports: [SharedModule],
})
export class VerifyBankAccountComponent {
@Input() onSubmit?: (request: VerifyBankAccountRequest) => Promise<void>;
@Output() submitted = new EventEmitter();
protected formGroup = this.formBuilder.group({
descriptorCode: new FormControl<string>(null, [
Validators.required,
Validators.minLength(6),
Validators.maxLength(6),
]),
});
constructor(private formBuilder: FormBuilder) {}
submit = async () => {
const request = new VerifyBankAccountRequest(this.formGroup.value.descriptorCode);
await this.onSubmit?.(request);
this.submitted.emit();
};
}

View File

@@ -54,17 +54,7 @@
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.value.name!,
email: orgInfoFormGroup.value.billingEmail!,
type: trialOrganizationType,
}"
[subscriptionProduct]="
product === ProductType.SecretsManager
? SubscriptionProduct.SecretsManager
: SubscriptionProduct.PasswordManager
"
[trialLength]="trialLength"
[trial]="trial"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>

View File

@@ -30,13 +30,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
import { Trial } from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { RouterService } from "../../../core/router.service";
import { OrganizationCreatedEvent } from "../trial-billing-step/trial-billing-step.component";
import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component";
export type InitiationPath =
@@ -95,7 +92,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
});
private destroy$ = new Subject<void>();
protected readonly SubscriptionProduct = SubscriptionProduct;
protected readonly ProductType = ProductType;
protected trialPaymentOptional$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
@@ -338,14 +334,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
}
}
get trialOrganizationType(): TrialOrganizationType | null {
if (this.productTier === ProductTierType.Free) {
return null;
}
return this.productTier;
}
readonly showBillingStep$ = this.trialPaymentOptional$.pipe(
map((trialPaymentOptional) => {
return (
@@ -434,4 +422,26 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
return null;
});
}
get trial(): Trial {
const product =
this.product === ProductType.PasswordManager ? "passwordManager" : "secretsManager";
const tier =
this.productTier === ProductTierType.Families
? "families"
: this.productTier === ProductTierType.Teams
? "teams"
: "enterprise";
return {
organization: {
name: this.orgInfoFormGroup.value.name!,
email: this.orgInfoFormGroup.value.billingEmail!,
},
product,
tier,
length: this.trialLength,
};
}
}

View File

@@ -0,0 +1,87 @@
@if (!(prices$ | async)) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
@let prices = prices$ | async;
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-container tw-mb-3">
<!-- Cadence -->
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<bit-radio-group [formControl]="formGroup.controls.cadence">
<div class="tw-mb-1 tw-items-center">
<bit-radio-button id="annual-cadence-button" [value]="'annually'">
<bit-label>
{{ "annual" | i18n }} -
{{ prices.annually | currency: "$" }}
/{{ "yr" | i18n }}
</bit-label>
</bit-radio-button>
</div>
@if (prices.monthly) {
<div class="tw-mb-1 tw-items-center">
<bit-radio-button id="monthly-cadence-button" [value]="'monthly'">
<bit-label>
{{ "monthly" | i18n }} -
{{ prices.monthly | currency: "$" }}
/{{ "monthAbbr" | i18n }}
</bit-label>
</bit-radio-button>
</div>
}
</bit-radio-group>
</div>
<!-- Payment -->
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
></app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: trial().tier !== 'families' }"
></app-enter-billing-address>
@if (trial().length === 0) {
@let label =
trial().product === "passwordManager"
? "passwordManagerPlanPrice"
: "secretsManagerPlanPrice";
<div id="price" class="tw-my-4">
@let selectionTaxAmounts = selectionCosts$ | async;
<div class="tw-text-muted tw-text-base">
{{ label | i18n }}: {{ selectionPrice$ | async | currency: "USD $" }}
<div>
{{ "estimatedTax" | i18n }}:
{{ selectionTaxAmounts.tax | currency: "USD $" }}
</div>
</div>
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
<p class="tw-text-lg">
<strong>{{ "total" | i18n }}: </strong>
@let interval = formGroup.value.cadence === "annually" ? "year" : "month";
{{ selectionTaxAmounts.total | currency: "USD $" }}/{{ interval | i18n }}
</p>
</div>
}
</div>
<!-- Submit -->
<div class="tw-flex tw-space-x-2">
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ (trial().length > 0 ? "startTrial" : "submit") | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">
{{ "back" | i18n }}
</button>
</div>
</div>
</form>
}
<ng-template #loadingSpinner>
<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-template>

View File

@@ -0,0 +1,160 @@
import { Component, input, OnDestroy, OnInit, output, ViewChild } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import {
combineLatest,
debounceTime,
filter,
map,
Observable,
shareReplay,
startWith,
switchMap,
Subject,
firstValueFrom,
} from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
} from "@bitwarden/web-vault/app/billing/payment/components";
import {
Cadence,
Cadences,
Prices,
Trial,
TrialBillingStepService,
} from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export interface OrganizationCreatedEvent {
organizationId: string;
planDescription: string;
}
@Component({
selector: "app-trial-billing-step",
templateUrl: "./trial-billing-step.component.html",
imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule],
providers: [TaxClient, TrialBillingStepService],
})
export class TrialBillingStepComponent implements OnInit, OnDestroy {
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected trial = input.required<Trial>();
protected steppedBack = output<void>();
protected organizationCreated = output<OrganizationCreatedEvent>();
private destroy$ = new Subject<void>();
protected prices$!: Observable<Prices>;
protected selectionPrice$!: Observable<number>;
protected selectionCosts$!: Observable<{
tax: number;
total: number;
}>;
protected selectionDescription$!: Observable<string>;
protected formGroup = new FormGroup({
cadence: new FormControl<Cadence>(Cadences.Annually, {
nonNullable: true,
}),
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
constructor(
private i18nService: I18nService,
private toastService: ToastService,
private trialBillingStepService: TrialBillingStepService,
) {}
async ngOnInit() {
const { product, tier } = this.trial();
this.prices$ = this.trialBillingStepService.getPrices$(product, tier);
const cadenceChanged = this.formGroup.controls.cadence.valueChanges.pipe(
startWith(Cadences.Annually),
);
this.selectionPrice$ = combineLatest([this.prices$, cadenceChanged]).pipe(
map(([prices, cadence]) => prices[cadence]),
filter((price): price is number => !!price),
);
this.selectionCosts$ = combineLatest([
cadenceChanged,
this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
filter(
(billingAddress): billingAddress is BillingAddressControls =>
!!billingAddress.country && !!billingAddress.postalCode,
),
),
]).pipe(
debounceTime(500),
switchMap(([cadence, billingAddress]) =>
this.trialBillingStepService.getCosts(product, tier, cadence, billingAddress),
),
startWith({
tax: 0,
total: 0,
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.selectionDescription$ = combineLatest([this.selectionPrice$, cadenceChanged]).pipe(
map(([price, cadence]) => {
switch (cadence) {
case Cadences.Annually:
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
case Cadences.Monthly:
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
}
}),
);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
submit = async (): Promise<void> => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
if (!paymentMethod) {
return;
}
const billingAddress = this.formGroup.controls.billingAddress.getRawValue();
const organization = await this.trialBillingStepService.startTrial(
this.trial(),
this.formGroup.value.cadence!,
billingAddress,
paymentMethod,
);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("organizationCreated"),
message: this.i18nService.t("organizationReadyToGo"),
});
this.organizationCreated.emit({
organizationId: organization.id,
planDescription: await firstValueFrom(this.selectionDescription$),
});
};
protected stepBack = () => this.steppedBack.emit();
}

View File

@@ -0,0 +1,209 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, from, map, shareReplay } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
OrganizationBillingServiceAbstraction,
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
getBillingAddressFromControls,
} from "@bitwarden/web-vault/app/billing/payment/components";
import {
tokenizablePaymentMethodToLegacyEnum,
TokenizedPaymentMethod,
} from "@bitwarden/web-vault/app/billing/payment/types";
export const Tiers = {
Families: "families",
Teams: "teams",
Enterprise: "enterprise",
} as const;
export const Cadences = {
Annually: "annually",
Monthly: "monthly",
} as const;
export const Products = {
PasswordManager: "passwordManager",
SecretsManager: "secretsManager",
} as const;
export type Tier = (typeof Tiers)[keyof typeof Tiers];
export type Cadence = (typeof Cadences)[keyof typeof Cadences];
export type Product = (typeof Products)[keyof typeof Products];
export type Prices = {
[Cadences.Annually]: number;
[Cadences.Monthly]?: number;
};
export interface Trial {
organization: {
name: string;
email: string;
};
product: Product;
tier: Tier;
length: number;
}
@Injectable()
export class TrialBillingStepService {
constructor(
private accountService: AccountService,
private apiService: ApiService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private taxClient: TaxClient,
) {}
private plans$ = from(this.apiService.getPlans()).pipe(
shareReplay({ bufferSize: 1, refCount: true }),
);
getPrices$ = (product: Product, tier: Tier) =>
this.plans$.pipe(
map((plans) => {
switch (tier) {
case "families": {
const annually = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually);
return {
annually: annually!.PasswordManager.basePrice,
};
}
case "teams":
case "enterprise": {
const annually = plans.data.find(
(plan) =>
plan.type ===
(tier === "teams" ? PlanType.TeamsAnnually : PlanType.EnterpriseAnnually),
);
const monthly = plans.data.find(
(plan) =>
plan.type ===
(tier === "teams" ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly),
);
switch (product) {
case "passwordManager": {
return {
annually: annually!.PasswordManager.seatPrice,
monthly: monthly!.PasswordManager.seatPrice,
};
}
case "secretsManager": {
return {
annually: annually!.SecretsManager.seatPrice,
monthly: monthly!.SecretsManager.seatPrice,
};
}
}
}
}
}),
);
getCosts = async (
product: Product,
tier: Tier,
cadence: Cadence,
billingAddressControls: BillingAddressControls,
): Promise<{
tax: number;
total: number;
}> => {
const billingAddress = getBillingAddressFromControls(billingAddressControls);
return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
{
tier,
cadence,
passwordManager: {
seats: 1,
additionalStorage: 0,
sponsored: false,
},
secretsManager:
product === "secretsManager"
? {
seats: 1,
additionalServiceAccounts: 0,
standalone: true,
}
: undefined,
},
billingAddress,
);
};
startTrial = async (
trial: Trial,
cadence: Cadence,
billingAddress: BillingAddressControls,
paymentMethod: TokenizedPaymentMethod,
): Promise<OrganizationResponse> => {
const getPlanType = async (tier: Tier, cadence: Cadence) => {
const plans = await firstValueFrom(this.plans$);
switch (tier) {
case "families":
return plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!.type;
case "teams":
return plans.data.find(
(plan) =>
plan.type ===
(cadence === "annually" ? PlanType.TeamsAnnually : PlanType.TeamsMonthly),
)!.type;
case "enterprise":
return plans.data.find(
(plan) =>
plan.type ===
(cadence === "annually" ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly),
)!.type;
}
};
const legacyPaymentMethod: [string, PaymentMethodType] = [
paymentMethod.token,
tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
];
const planType = await getPlanType(trial.tier, cadence);
const request: SubscriptionInformation = {
organization: {
name: trial.organization.name,
billingEmail: trial.organization.email,
initiationPath:
trial.product === "passwordManager"
? "Password Manager trial from marketing website"
: "Secrets Manager trial from marketing website",
},
plan:
trial.product === "passwordManager"
? { type: planType, passwordManagerSeats: 1 }
: {
type: planType,
passwordManagerSeats: 1,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
secretsManagerSeats: 1,
},
payment: {
paymentMethod: legacyPaymentMethod,
billing: {
country: billingAddress.country,
postalCode: billingAddress.postalCode,
taxId: billingAddress.taxId ?? undefined,
},
skipTrial: trial.length === 0,
},
};
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
return await this.organizationBillingService.purchaseSubscription(request, activeUserId);
};
}

View File

@@ -6,11 +6,11 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular";
import { FormFieldModule } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { SharedModule } from "../../shared";
import { CompleteTrialInitiationComponent } from "./complete-trial-initiation/complete-trial-initiation.component";
import { ConfirmationDetailsComponent } from "./confirmation-details.component";
import { TrialBillingStepComponent } from "./trial-billing-step/trial-billing-step.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@NgModule({

View File

@@ -5,8 +5,6 @@ import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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 { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { BannerModule } from "@bitwarden/components";
@@ -41,7 +39,6 @@ export class VaultBannersComponent implements OnInit {
private router: Router,
private accountService: AccountService,
private messageListener: MessageListener,
private configService: ConfigService,
) {
this.premiumBannerVisible$ = this.activeUserId$.pipe(
filter((userId): userId is UserId => userId != null),
@@ -75,16 +72,12 @@ export class VaultBannersComponent implements OnInit {
}
async navigateToPaymentMethod(organizationId: string): Promise<void> {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
const navigationExtras = {
state: { launchPaymentModalAutomatically: true },
};
await this.router.navigate(
["organizations", organizationId, "billing", route],
["organizations", organizationId, "billing", "payment-details"],
navigationExtras,
);
}