mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
1087 lines
35 KiB
TypeScript
1087 lines
35 KiB
TypeScript
// FIXME: Update this file to be type safe and remove this and next line
|
|
// @ts-strict-ignore
|
|
import {
|
|
Component,
|
|
EventEmitter,
|
|
Inject,
|
|
Input,
|
|
OnDestroy,
|
|
OnInit,
|
|
Output,
|
|
ViewChild,
|
|
} from "@angular/core";
|
|
import { FormBuilder, Validators } from "@angular/forms";
|
|
import { Router } from "@angular/router";
|
|
import { combineLatest, firstValueFrom, map, Subject, switchMap, 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 {
|
|
getOrganizationById,
|
|
OrganizationService,
|
|
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
|
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
|
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 { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
|
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";
|
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
|
import {
|
|
CardComponent,
|
|
DIALOG_DATA,
|
|
DialogConfig,
|
|
DialogRef,
|
|
DialogService,
|
|
ToastService,
|
|
} from "@bitwarden/components";
|
|
import { KeyService } from "@bitwarden/key-management";
|
|
import {
|
|
OrganizationSubscriptionPlan,
|
|
SubscriberBillingClient,
|
|
TaxClient,
|
|
} from "@bitwarden/web-vault/app/billing/clients";
|
|
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
|
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";
|
|
|
|
type ChangePlanDialogParams = {
|
|
organizationId: string;
|
|
productTierType: ProductTierType;
|
|
subscription?: OrganizationSubscriptionResponse;
|
|
};
|
|
|
|
// FIXME: update to use a const object instead of a typescript enum
|
|
// eslint-disable-next-line @bitwarden/platform/no-enums
|
|
export enum ChangePlanDialogResultType {
|
|
Closed = "closed",
|
|
Submitted = "submitted",
|
|
}
|
|
|
|
// FIXME: update to use a const object instead of a typescript enum
|
|
// eslint-disable-next-line @bitwarden/platform/no-enums
|
|
export enum PlanCardState {
|
|
Selected = "selected",
|
|
NotSelected = "not_selected",
|
|
Disabled = "disabled",
|
|
}
|
|
|
|
export const openChangePlanDialog = (
|
|
dialogService: DialogService,
|
|
dialogConfig: DialogConfig<ChangePlanDialogParams>,
|
|
) =>
|
|
dialogService.open<ChangePlanDialogResultType, ChangePlanDialogParams>(
|
|
ChangePlanDialogComponent,
|
|
dialogConfig,
|
|
);
|
|
|
|
type PlanCard = {
|
|
name: string;
|
|
selected: boolean;
|
|
};
|
|
|
|
interface OnSuccessArgs {
|
|
organizationId: string;
|
|
}
|
|
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
|
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
|
@Component({
|
|
templateUrl: "./change-plan-dialog.component.html",
|
|
imports: [
|
|
BillingSharedModule,
|
|
EnterPaymentMethodComponent,
|
|
EnterBillingAddressComponent,
|
|
CardComponent,
|
|
],
|
|
providers: [SubscriberBillingClient, TaxClient],
|
|
})
|
|
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent;
|
|
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input() acceptingSponsorship = false;
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input() organizationId: string;
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input() showFree = false;
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input() showCancel = false;
|
|
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input()
|
|
get productTier(): ProductTierType {
|
|
return this._productTier;
|
|
}
|
|
|
|
set productTier(product: ProductTierType) {
|
|
this._productTier = product;
|
|
this.formGroup?.controls?.productTier?.setValue(product);
|
|
}
|
|
|
|
protected estimatedTax: number = 0;
|
|
private _productTier = ProductTierType.Free;
|
|
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input()
|
|
get plan(): PlanType {
|
|
return this._plan;
|
|
}
|
|
|
|
set plan(plan: PlanType) {
|
|
this._plan = plan;
|
|
this.formGroup?.controls?.plan?.setValue(plan);
|
|
}
|
|
|
|
private _plan = PlanType.Free;
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
@Input() providerId?: string;
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
|
@Output() onSuccess = new EventEmitter<OnSuccessArgs>();
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
|
@Output() onCanceled = new EventEmitter<void>();
|
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
|
@Output() onTrialBillingSuccess = new EventEmitter();
|
|
|
|
protected discountPercentageFromSub: number;
|
|
protected loading = true;
|
|
protected planCards: PlanCard[];
|
|
protected ResultType = ChangePlanDialogResultType;
|
|
|
|
selfHosted = false;
|
|
productTypes = ProductTierType;
|
|
formPromise: Promise<string>;
|
|
singleOrgPolicyAppliesToActiveUser = false;
|
|
isInTrialFlow = false;
|
|
discount = 0;
|
|
|
|
formGroup = this.formBuilder.group({
|
|
name: [""],
|
|
billingEmail: ["", [Validators.email]],
|
|
businessOwned: [false],
|
|
premiumAccessAddon: [false],
|
|
additionalSeats: [0, [Validators.min(0), Validators.max(100000)]],
|
|
clientOwnerEmail: ["", [Validators.email]],
|
|
plan: [this.plan],
|
|
productTier: [this.productTier],
|
|
});
|
|
|
|
billingFormGroup = this.formBuilder.group({
|
|
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
|
|
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
|
});
|
|
|
|
planType: string;
|
|
selectedPlan: PlanResponse;
|
|
selectedInterval: number = 1;
|
|
planIntervals = PlanInterval;
|
|
passwordManagerPlans: PlanResponse[];
|
|
secretsManagerPlans: PlanResponse[];
|
|
organization: Organization;
|
|
sub: OrganizationSubscriptionResponse;
|
|
dialogHeaderName: string;
|
|
currentPlanName: string;
|
|
showPayment: boolean = false;
|
|
totalOpened: boolean = false;
|
|
currentPlan: PlanResponse;
|
|
isCardStateDisabled = false;
|
|
focusedIndex: number | null = null;
|
|
plans: ListResponse<PlanResponse>;
|
|
isSubscriptionCanceled: boolean = false;
|
|
secretsManagerTotal: number;
|
|
|
|
paymentMethod: MaskedPaymentMethod | null;
|
|
billingAddress: BillingAddress | null;
|
|
|
|
private destroy$ = new Subject<void>();
|
|
|
|
constructor(
|
|
@Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams,
|
|
private dialogRef: DialogRef<ChangePlanDialogResultType>,
|
|
private toastService: ToastService,
|
|
private apiService: ApiService,
|
|
private i18nService: I18nService,
|
|
private keyService: KeyService,
|
|
private router: Router,
|
|
private syncService: SyncService,
|
|
private policyService: PolicyService,
|
|
private organizationService: OrganizationService,
|
|
private messagingService: MessagingService,
|
|
private formBuilder: FormBuilder,
|
|
private organizationApiService: OrganizationApiServiceAbstraction,
|
|
private accountService: AccountService,
|
|
private billingNotificationService: BillingNotificationService,
|
|
private subscriberBillingClient: SubscriberBillingClient,
|
|
private taxClient: TaxClient,
|
|
private organizationWarningsService: OrganizationWarningsService,
|
|
) {}
|
|
|
|
async ngOnInit(): Promise<void> {
|
|
if (this.dialogParams.organizationId) {
|
|
this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType);
|
|
this.sub =
|
|
this.dialogParams.subscription ??
|
|
(await this.organizationApiService.getSubscription(this.dialogParams.organizationId));
|
|
this.dialogHeaderName = this.resolveHeaderName(this.sub);
|
|
this.organizationId = this.dialogParams.organizationId;
|
|
this.currentPlan = this.sub?.plan;
|
|
this.selectedPlan = this.sub?.plan;
|
|
const userId = await firstValueFrom(
|
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
|
);
|
|
this.organization = await firstValueFrom(
|
|
this.organizationService
|
|
.organizations$(userId)
|
|
.pipe(getOrganizationById(this.organizationId)),
|
|
);
|
|
if (this.sub?.subscription?.status !== "canceled") {
|
|
try {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!this.selfHosted) {
|
|
this.plans = await this.apiService.getPlans();
|
|
this.passwordManagerPlans = this.plans.data.filter((plan) => !!plan.PasswordManager);
|
|
this.secretsManagerPlans = this.plans.data.filter((plan) => !!plan.SecretsManager);
|
|
|
|
if (
|
|
this.productTier === ProductTierType.Enterprise ||
|
|
this.productTier === ProductTierType.Teams
|
|
) {
|
|
this.formGroup.controls.businessOwned.setValue(true);
|
|
}
|
|
}
|
|
|
|
if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) {
|
|
const upgradedPlan = this.passwordManagerPlans.find((plan) =>
|
|
this.currentPlan.productTier === ProductTierType.Free
|
|
? plan.type === PlanType.FamiliesAnnually
|
|
: plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1,
|
|
);
|
|
|
|
this.plan = upgradedPlan.type;
|
|
this.productTier = upgradedPlan.productTier;
|
|
}
|
|
this.upgradeFlowPrefillForm();
|
|
|
|
this.accountService.activeAccount$
|
|
.pipe(
|
|
getUserId,
|
|
switchMap((userId) =>
|
|
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
|
),
|
|
takeUntil(this.destroy$),
|
|
)
|
|
.subscribe((policyAppliesToActiveUser) => {
|
|
this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser;
|
|
});
|
|
|
|
if (!this.selfHosted) {
|
|
this.changedProduct();
|
|
}
|
|
|
|
this.planCards = [
|
|
{
|
|
name: this.i18nService.t("planNameTeams"),
|
|
selected: true,
|
|
},
|
|
{
|
|
name: this.i18nService.t("planNameEnterprise"),
|
|
selected: false,
|
|
},
|
|
];
|
|
this.discountPercentageFromSub = this.isSecretsManagerTrial()
|
|
? 0
|
|
: (this.sub?.customerDiscount?.percentOff ?? 0);
|
|
|
|
await this.setInitialPlanSelection();
|
|
if (!this.isSubscriptionCanceled) {
|
|
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 {
|
|
if (subscription.subscription != null) {
|
|
this.isSubscriptionCanceled =
|
|
subscription.subscription.cancelled && this.sub?.plan.productTier !== ProductTierType.Free;
|
|
if (this.isSubscriptionCanceled) {
|
|
return this.i18nService.t("restartSubscription");
|
|
}
|
|
}
|
|
|
|
return this.i18nService.t(
|
|
"upgradeFreeOrganization",
|
|
this.resolvePlanName(this.dialogParams.productTierType),
|
|
);
|
|
}
|
|
|
|
async setInitialPlanSelection() {
|
|
this.focusedIndex = this.selectableProducts.length - 1;
|
|
if (!this.isSubscriptionCanceled) {
|
|
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
|
}
|
|
}
|
|
|
|
getPlanByType(productTier: ProductTierType) {
|
|
return this.selectableProducts.find((product) => product.productTier === productTier);
|
|
}
|
|
|
|
isSecretsManagerTrial(): boolean {
|
|
return (
|
|
this.sub?.subscription?.items?.some((item) =>
|
|
this.sub?.customerDiscount?.appliesTo?.includes(item.productId),
|
|
) ?? false
|
|
);
|
|
}
|
|
|
|
async planTypeChanged() {
|
|
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
|
}
|
|
|
|
async updateInterval(event: number) {
|
|
this.selectedInterval = event;
|
|
await this.planTypeChanged();
|
|
}
|
|
|
|
protected getPlanIntervals() {
|
|
return [
|
|
{
|
|
name: PlanInterval[PlanInterval.Annually],
|
|
value: PlanInterval.Annually,
|
|
},
|
|
{
|
|
name: PlanInterval[PlanInterval.Monthly],
|
|
value: PlanInterval.Monthly,
|
|
},
|
|
];
|
|
}
|
|
|
|
optimizedNgForRender(index: number) {
|
|
return index;
|
|
}
|
|
|
|
protected getPlanCardContainerClasses(plan: PlanResponse, index: number) {
|
|
let cardState: PlanCardState;
|
|
|
|
if (plan == this.currentPlan) {
|
|
cardState = PlanCardState.Disabled;
|
|
this.isCardStateDisabled = true;
|
|
this.focusedIndex = index;
|
|
} else if (plan == this.selectedPlan) {
|
|
cardState = PlanCardState.Selected;
|
|
this.isCardStateDisabled = false;
|
|
this.focusedIndex = index;
|
|
} else if (
|
|
this.selectedInterval === PlanInterval.Monthly &&
|
|
plan.productTier == ProductTierType.Families
|
|
) {
|
|
cardState = PlanCardState.Disabled;
|
|
this.isCardStateDisabled = true;
|
|
this.focusedIndex = this.selectableProducts.length - 1;
|
|
} else {
|
|
cardState = PlanCardState.NotSelected;
|
|
this.isCardStateDisabled = false;
|
|
}
|
|
|
|
switch (cardState) {
|
|
case PlanCardState.Selected: {
|
|
return [
|
|
"tw-cursor-pointer",
|
|
"tw-block",
|
|
"tw-rounded",
|
|
"tw-border",
|
|
"tw-border-solid",
|
|
"tw-border-primary-600",
|
|
"hover:tw-border-primary-700",
|
|
"tw-border-2",
|
|
"!tw-border-primary-700",
|
|
"tw-rounded-lg",
|
|
];
|
|
}
|
|
case PlanCardState.NotSelected: {
|
|
return [
|
|
"tw-cursor-pointer",
|
|
"tw-block",
|
|
"tw-rounded",
|
|
"tw-border",
|
|
"tw-border-solid",
|
|
"tw-border-secondary-300",
|
|
"hover:tw-border-text-main",
|
|
"focus:tw-border-2",
|
|
"focus:tw-border-primary-700",
|
|
];
|
|
}
|
|
case PlanCardState.Disabled: {
|
|
if (this.isSubscriptionCanceled) {
|
|
return [
|
|
"tw-cursor-not-allowed",
|
|
"tw-bg-secondary-100",
|
|
"tw-font-normal",
|
|
"tw-bg-blur",
|
|
"tw-text-muted",
|
|
"tw-block",
|
|
"tw-rounded",
|
|
"tw-w-80",
|
|
];
|
|
}
|
|
|
|
return [
|
|
"tw-cursor-not-allowed",
|
|
"tw-bg-secondary-100",
|
|
"tw-font-normal",
|
|
"tw-bg-blur",
|
|
"tw-text-muted",
|
|
"tw-block",
|
|
"tw-rounded",
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
protected async selectPlan(plan: PlanResponse) {
|
|
if (
|
|
this.selectedInterval === PlanInterval.Monthly &&
|
|
plan.productTier == ProductTierType.Families
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (plan === this.currentPlan && !this.isSubscriptionCanceled) {
|
|
return;
|
|
}
|
|
this.selectedPlan = plan;
|
|
this.formGroup.patchValue({ productTier: plan.productTier });
|
|
|
|
try {
|
|
await this.refreshSalesTax();
|
|
} catch {
|
|
this.estimatedTax = 0;
|
|
}
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
|
|
get upgradeRequiresPaymentMethod() {
|
|
const isFreeTier = this.organization?.productTierType === ProductTierType.Free;
|
|
const shouldHideFree = !this.showFree;
|
|
const hasNoPaymentSource = !this.paymentMethod;
|
|
|
|
return isFreeTier && shouldHideFree && hasNoPaymentSource;
|
|
}
|
|
|
|
get selectedPlanInterval() {
|
|
if (this.isSubscriptionCanceled) {
|
|
return this.currentPlan.isAnnual ? "year" : "month";
|
|
}
|
|
return this.selectedPlan.isAnnual ? "year" : "month";
|
|
}
|
|
|
|
get selectableProducts() {
|
|
if (this.isSubscriptionCanceled) {
|
|
// Return only the current plan if the subscription is canceled
|
|
return [this.currentPlan];
|
|
}
|
|
|
|
if (this.acceptingSponsorship) {
|
|
const familyPlan = this.passwordManagerPlans.find(
|
|
(plan) => plan.type === PlanType.FamiliesAnnually,
|
|
);
|
|
this.discount = familyPlan.PasswordManager.basePrice;
|
|
return [familyPlan];
|
|
}
|
|
|
|
const businessOwnedIsChecked = this.formGroup.controls.businessOwned.value;
|
|
|
|
const result = this.passwordManagerPlans.filter(
|
|
(plan) =>
|
|
plan.type !== PlanType.Custom &&
|
|
(!businessOwnedIsChecked || plan.canBeUsedByBusiness) &&
|
|
(this.showFree || plan.productTier !== ProductTierType.Free) &&
|
|
(plan.productTier === ProductTierType.Free ||
|
|
plan.productTier === ProductTierType.TeamsStarter ||
|
|
(this.selectedInterval === PlanInterval.Annually && plan.isAnnual) ||
|
|
(this.selectedInterval === PlanInterval.Monthly && !plan.isAnnual)) &&
|
|
(!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) &&
|
|
this.planIsEnabled(plan),
|
|
);
|
|
|
|
if (
|
|
this.currentPlan.productTier === ProductTierType.Free &&
|
|
this.selectedInterval === PlanInterval.Monthly &&
|
|
!this.organization.useSecretsManager
|
|
) {
|
|
const familyPlan = this.passwordManagerPlans.find(
|
|
(plan) => plan.productTier == ProductTierType.Families,
|
|
);
|
|
result.push(familyPlan);
|
|
}
|
|
|
|
if (
|
|
this.organization.useSecretsManager &&
|
|
this.currentPlan.productTier === ProductTierType.Free
|
|
) {
|
|
const familyPlanIndex = result.findIndex(
|
|
(plan) => plan.productTier === ProductTierType.Families,
|
|
);
|
|
|
|
if (familyPlanIndex !== -1) {
|
|
result.splice(familyPlanIndex, 1);
|
|
}
|
|
}
|
|
|
|
if (this.currentPlan.productTier !== ProductTierType.Free) {
|
|
result.push(this.currentPlan);
|
|
}
|
|
|
|
result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder);
|
|
|
|
return result;
|
|
}
|
|
|
|
get selectablePlans() {
|
|
const selectedProductTierType = this.formGroup.controls.productTier.value;
|
|
const result =
|
|
this.passwordManagerPlans?.filter(
|
|
(plan) => plan.productTier === selectedProductTierType && this.planIsEnabled(plan),
|
|
) || [];
|
|
|
|
result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder);
|
|
return result;
|
|
}
|
|
|
|
get storageGb() {
|
|
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
|
|
}
|
|
|
|
passwordManagerSeatTotal(plan: PlanResponse): number {
|
|
if (!plan.PasswordManager.hasAdditionalSeatsOption || this.isSecretsManagerTrial()) {
|
|
return 0;
|
|
}
|
|
|
|
return plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0);
|
|
}
|
|
|
|
secretsManagerSeatTotal(plan: PlanResponse, seats: number): number {
|
|
if (!plan.SecretsManager.hasAdditionalSeatsOption) {
|
|
return 0;
|
|
}
|
|
|
|
return plan.SecretsManager.seatPrice * Math.abs(seats || 0);
|
|
}
|
|
|
|
additionalStorageTotal(plan: PlanResponse): number {
|
|
if (!plan.PasswordManager.hasAdditionalStorageOption) {
|
|
return 0;
|
|
}
|
|
|
|
return (
|
|
plan.PasswordManager.additionalStoragePricePerGb *
|
|
// TODO: Eslint upgrade. Please resolve this since the null check does nothing
|
|
// eslint-disable-next-line no-constant-binary-expression
|
|
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
|
|
);
|
|
}
|
|
|
|
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
|
|
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
|
|
}
|
|
|
|
additionalServiceAccountTotal(plan: PlanResponse): number {
|
|
if (
|
|
!plan.SecretsManager.hasAdditionalServiceAccountOption ||
|
|
this.additionalServiceAccount == 0
|
|
) {
|
|
return 0;
|
|
}
|
|
|
|
return plan.SecretsManager.additionalPricePerServiceAccount * this.additionalServiceAccount;
|
|
}
|
|
|
|
get passwordManagerSubtotal() {
|
|
if (!this.selectedPlan || !this.selectedPlan.PasswordManager) {
|
|
return 0;
|
|
}
|
|
|
|
let subTotal = this.selectedPlan.PasswordManager.basePrice;
|
|
if (this.selectedPlan.PasswordManager.hasAdditionalSeatsOption) {
|
|
subTotal += this.passwordManagerSeatTotal(this.selectedPlan);
|
|
}
|
|
if (this.selectedPlan.PasswordManager.hasPremiumAccessOption) {
|
|
subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice;
|
|
}
|
|
return subTotal - this.discount;
|
|
}
|
|
|
|
secretsManagerSubtotal() {
|
|
const plan = this.selectedPlan;
|
|
if (!plan || !plan.SecretsManager) {
|
|
return this.secretsManagerTotal || 0;
|
|
}
|
|
|
|
if (this.secretsManagerTotal) {
|
|
return this.secretsManagerTotal;
|
|
}
|
|
|
|
this.secretsManagerTotal =
|
|
plan.SecretsManager.basePrice +
|
|
this.secretsManagerSeatTotal(plan, this.sub?.smSeats) +
|
|
this.additionalServiceAccountTotal(plan);
|
|
return this.secretsManagerTotal;
|
|
}
|
|
|
|
get passwordManagerSeats() {
|
|
if (!this.selectedPlan) {
|
|
return 0;
|
|
}
|
|
|
|
if (this.selectedPlan.productTier === ProductTierType.Families) {
|
|
return this.selectedPlan.PasswordManager.baseSeats;
|
|
}
|
|
return this.sub?.seats;
|
|
}
|
|
|
|
get total() {
|
|
if (!this.organization || !this.selectedPlan) {
|
|
return 0;
|
|
}
|
|
|
|
if (this.organization.useSecretsManager) {
|
|
return (
|
|
this.passwordManagerSubtotal +
|
|
this.additionalStorageTotal(this.selectedPlan) +
|
|
this.secretsManagerSubtotal() +
|
|
this.estimatedTax
|
|
);
|
|
}
|
|
return (
|
|
this.passwordManagerSubtotal +
|
|
this.additionalStorageTotal(this.selectedPlan) +
|
|
this.estimatedTax
|
|
);
|
|
}
|
|
|
|
get teamsStarterPlanIsAvailable() {
|
|
return this.selectablePlans.some((plan) => plan.type === PlanType.TeamsStarter);
|
|
}
|
|
|
|
get additionalServiceAccount() {
|
|
if (!this.currentPlan || !this.currentPlan.SecretsManager) {
|
|
return 0;
|
|
}
|
|
|
|
const baseServiceAccount = this.currentPlan.SecretsManager?.baseServiceAccount || 0;
|
|
const usedServiceAccounts = this.sub?.smServiceAccounts || 0;
|
|
|
|
const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts;
|
|
|
|
return additionalServiceAccounts <= 0 ? Math.abs(additionalServiceAccounts) : 0;
|
|
}
|
|
|
|
changedProduct() {
|
|
const selectedPlan = this.selectablePlans[0];
|
|
|
|
this.setPlanType(selectedPlan.type);
|
|
this.handlePremiumAddonAccess(selectedPlan.PasswordManager.hasPremiumAccessOption);
|
|
this.handleAdditionalSeats(selectedPlan.PasswordManager.hasAdditionalSeatsOption);
|
|
}
|
|
|
|
setPlanType(planType: PlanType) {
|
|
this.formGroup.controls.plan.setValue(planType);
|
|
}
|
|
|
|
handlePremiumAddonAccess(hasPremiumAccessOption: boolean) {
|
|
this.formGroup.controls.premiumAccessAddon.setValue(!hasPremiumAccessOption);
|
|
}
|
|
|
|
handleAdditionalSeats(selectedPlanHasAdditionalSeatsOption: boolean) {
|
|
if (!selectedPlanHasAdditionalSeatsOption) {
|
|
this.formGroup.controls.additionalSeats.setValue(0);
|
|
return;
|
|
}
|
|
|
|
if (this.currentPlan && !this.currentPlan.PasswordManager.hasAdditionalSeatsOption) {
|
|
this.formGroup.controls.additionalSeats.setValue(this.currentPlan.PasswordManager.baseSeats);
|
|
return;
|
|
}
|
|
|
|
if (this.organization) {
|
|
this.formGroup.controls.additionalSeats.setValue(this.organization.seats);
|
|
return;
|
|
}
|
|
|
|
this.formGroup.controls.additionalSeats.setValue(1);
|
|
}
|
|
|
|
submit = async () => {
|
|
this.formGroup.markAllAsTouched();
|
|
this.billingFormGroup.markAllAsTouched();
|
|
if (this.formGroup.invalid || (this.billingFormGroup.invalid && !this.paymentMethod)) {
|
|
return;
|
|
}
|
|
|
|
const doSubmit = async (): Promise<string> => {
|
|
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();
|
|
orgId = this.organizationId;
|
|
} else {
|
|
orgId = await this.updateOrganization();
|
|
}
|
|
this.toastService.showToast({
|
|
variant: "success",
|
|
title: null,
|
|
message: this.isSubscriptionCanceled
|
|
? this.i18nService.t("restartOrganizationSubscription")
|
|
: this.i18nService.t("organizationUpgraded"),
|
|
});
|
|
|
|
await this.apiService.refreshIdentityToken();
|
|
await this.syncService.fullSync(true);
|
|
|
|
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
|
await this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]);
|
|
}
|
|
|
|
if (this.isInTrialFlow) {
|
|
this.onTrialBillingSuccess.emit({
|
|
orgId: orgId,
|
|
subLabelText: this.billingSubLabelText(),
|
|
});
|
|
}
|
|
|
|
return orgId;
|
|
};
|
|
|
|
this.formPromise = doSubmit();
|
|
const organizationId = await this.formPromise;
|
|
this.onSuccess.emit({ organizationId: organizationId });
|
|
// TODO: No one actually listening to this message?
|
|
this.messagingService.send("organizationCreated", { organizationId });
|
|
this.dialogRef.close();
|
|
};
|
|
|
|
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,
|
|
billingAddress,
|
|
);
|
|
this.organizationWarningsService.refreshInactiveSubscriptionWarning();
|
|
}
|
|
|
|
private async updateOrganization() {
|
|
const request = new OrganizationUpgradeRequest();
|
|
if (this.selectedPlan.productTier !== ProductTierType.Families) {
|
|
request.additionalSeats = this.sub?.seats;
|
|
}
|
|
if (this.sub?.maxStorageGb > this.selectedPlan.PasswordManager.baseStorageGb) {
|
|
request.additionalStorageGb =
|
|
this.sub?.maxStorageGb - this.selectedPlan.PasswordManager.baseStorageGb;
|
|
}
|
|
request.premiumAccessAddon =
|
|
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
|
|
this.formGroup.controls.premiumAccessAddon.value;
|
|
request.planType = this.selectedPlan.type;
|
|
if (this.showPayment) {
|
|
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.paymentMethod) {
|
|
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
|
const billingAddress = getBillingAddressFromForm(
|
|
this.billingFormGroup.controls.billingAddress,
|
|
);
|
|
|
|
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
|
|
// These need to be synchronous so one of them can create the Customer in the case we're upgrading from Free.
|
|
await this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress);
|
|
await this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null);
|
|
}
|
|
|
|
// Backfill pub/priv key if necessary
|
|
if (!this.organization.hasPublicAndPrivateKeys) {
|
|
const userId = await firstValueFrom(
|
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
|
);
|
|
const orgShareKey = await firstValueFrom(
|
|
this.keyService
|
|
.orgKeys$(userId)
|
|
.pipe(map((orgKeys) => orgKeys?.[this.organizationId as OrganizationId] ?? null)),
|
|
);
|
|
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
|
|
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
|
|
}
|
|
|
|
await this.organizationApiService.upgrade(this.organizationId, request);
|
|
return this.organizationId;
|
|
}
|
|
|
|
private billingSubLabelText(): string {
|
|
const selectedPlan = this.selectedPlan;
|
|
const price =
|
|
selectedPlan.PasswordManager.basePrice === 0
|
|
? selectedPlan.PasswordManager.seatPrice
|
|
: selectedPlan.PasswordManager.basePrice;
|
|
let text = "";
|
|
|
|
if (selectedPlan.isAnnual) {
|
|
text += `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
|
|
} else {
|
|
text += `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void {
|
|
request.useSecretsManager = this.organization.useSecretsManager;
|
|
if (!this.organization.useSecretsManager) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.selectedPlan.SecretsManager.hasAdditionalSeatsOption &&
|
|
this.currentPlan.productTier === ProductTierType.Free
|
|
) {
|
|
request.additionalSmSeats = this.organization.seats;
|
|
} else {
|
|
request.additionalSmSeats = this.sub?.smSeats;
|
|
request.additionalServiceAccounts = this.additionalServiceAccount;
|
|
}
|
|
}
|
|
|
|
private upgradeFlowPrefillForm() {
|
|
if (this.acceptingSponsorship) {
|
|
this.formGroup.controls.productTier.setValue(ProductTierType.Families);
|
|
this.changedProduct();
|
|
return;
|
|
}
|
|
|
|
if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) {
|
|
const upgradedPlan = this.passwordManagerPlans.find((plan) => {
|
|
if (this.currentPlan.productTier === ProductTierType.Free) {
|
|
return plan.type === PlanType.FamiliesAnnually;
|
|
}
|
|
|
|
if (
|
|
this.currentPlan.productTier === ProductTierType.Families &&
|
|
!this.teamsStarterPlanIsAvailable
|
|
) {
|
|
return plan.type === PlanType.TeamsAnnually;
|
|
}
|
|
|
|
return plan.upgradeSortOrder === this.currentPlan.upgradeSortOrder + 1;
|
|
});
|
|
|
|
this.plan = upgradedPlan.type;
|
|
this.productTier = upgradedPlan.productTier;
|
|
this.changedProduct();
|
|
}
|
|
}
|
|
|
|
private planIsEnabled(plan: PlanResponse) {
|
|
return !plan.disabled && !plan.legacyYear;
|
|
}
|
|
|
|
toggleShowPayment() {
|
|
this.showPayment = true;
|
|
}
|
|
|
|
toggleTotalOpened() {
|
|
this.totalOpened = !this.totalOpened;
|
|
}
|
|
|
|
calculateTotalAppliedDiscount(total: number) {
|
|
return total * (this.discountPercentageFromSub / 100);
|
|
}
|
|
|
|
resolvePlanName(productTier: ProductTierType) {
|
|
switch (productTier) {
|
|
case ProductTierType.Enterprise:
|
|
return this.i18nService.t("planNameEnterprise");
|
|
case ProductTierType.Free:
|
|
return this.i18nService.t("planNameFree");
|
|
case ProductTierType.Families:
|
|
return this.i18nService.t("planNameFamilies");
|
|
case ProductTierType.Teams:
|
|
return this.i18nService.t("planNameTeams");
|
|
case ProductTierType.TeamsStarter:
|
|
return this.i18nService.t("planNameTeamsStarter");
|
|
}
|
|
}
|
|
|
|
onKeydown(event: KeyboardEvent, index: number) {
|
|
const cardElements = Array.from(document.querySelectorAll(".product-card")) as HTMLElement[];
|
|
let newIndex = index;
|
|
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
|
|
|
|
if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
|
|
do {
|
|
newIndex = (newIndex + direction + cardElements.length) % cardElements.length;
|
|
} while (this.isCardDisabled(newIndex) && newIndex !== index);
|
|
|
|
event.preventDefault();
|
|
|
|
setTimeout(() => {
|
|
const card = cardElements[newIndex];
|
|
if (
|
|
!(
|
|
card.classList.contains("tw-bg-secondary-100") &&
|
|
card.classList.contains("tw-text-muted")
|
|
)
|
|
) {
|
|
card?.focus();
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
async onFocus(index: number) {
|
|
this.focusedIndex = index;
|
|
await this.selectPlan(this.selectableProducts[index]);
|
|
}
|
|
|
|
isCardDisabled(index: number): boolean {
|
|
const card = this.selectableProducts[index];
|
|
return card === (this.currentPlan || this.isCardStateDisabled);
|
|
}
|
|
|
|
manageSelectableProduct(index: number) {
|
|
return index;
|
|
}
|
|
|
|
private async refreshSalesTax(): Promise<void> {
|
|
if (this.billingFormGroup.controls.billingAddress.invalid && !this.billingAddress) {
|
|
return;
|
|
}
|
|
|
|
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" };
|
|
}
|
|
};
|
|
|
|
const billingAddress = this.billingFormGroup.controls.billingAddress.valid
|
|
? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress)
|
|
: this.billingAddress;
|
|
|
|
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.paymentMethod ||
|
|
this.isSubscriptionCanceled
|
|
);
|
|
}
|
|
|
|
get submitButtonLabel(): string {
|
|
if (
|
|
this.organization &&
|
|
this.sub &&
|
|
this.organization.productTierType !== ProductTierType.Free &&
|
|
this.sub.subscription?.status === "canceled"
|
|
) {
|
|
return this.i18nService.t("restart");
|
|
} else {
|
|
return this.i18nService.t("upgrade");
|
|
}
|
|
}
|
|
|
|
get supportsTaxId() {
|
|
return this.formGroup.value.productTier !== ProductTierType.Families;
|
|
}
|
|
|
|
getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod);
|
|
}
|