From 91455aceac4ff6127d662f00cfe593ca9108f41c Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Tue, 17 Jun 2025 15:05:55 +0100 Subject: [PATCH] Implement a new trial payment model --- .../organization-payment-method.component.ts | 12 +- ...trial-payment-method-dialog.component.html | 836 ++++++++++++++++++ .../trial-payment-method-dialog.component.ts | 767 ++++++++++++++++ apps/web/src/locales/en/messages.json | 27 + 4 files changed, 1638 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.html create mode 100644 apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.ts diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index bcc497113eb..adbda79caf2 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -33,6 +33,10 @@ import { AdjustPaymentDialogComponent, AdjustPaymentDialogResultType, } from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component"; +import { + TrialPaymentMethodDialogComponent, + TrialPaymentMethodDialogResultType, +} from "../../shared/trial-subscription-dialog/trial-payment-method-dialog.component"; import { FreeTrial } from "../../types/free-trial"; @Component({ @@ -190,15 +194,15 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { }; changePayment = async () => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { + const dialogRef = TrialPaymentMethodDialogComponent.open(this.dialogService, { data: { - initialPaymentMethod: this.paymentSource?.type, organizationId: this.organizationId, - productTier: this.organization?.productTierType, + subscription: this.organizationSubscriptionResponse, + productTierType: this.organization.productTierType, }, }); const result = await lastValueFrom(dialogRef.closed); - if (result === AdjustPaymentDialogResultType.Submitted) { + if (result === TrialPaymentMethodDialogResultType.Submitted) { this.location.replaceState(this.location.path(), "", {}); if (this.launchPaymentModalAutomatically && !this.organization.enabled) { await this.syncService.fullSync(true); diff --git a/apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.html b/apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.html new file mode 100644 index 00000000000..cd27a443570 --- /dev/null +++ b/apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.html @@ -0,0 +1,836 @@ +
+ + + {{ "subscribetoEnterprise" | i18n: currentPlanName }} + +
+

{{ "subscribetoEnterpriseSubtitle" | i18n: currentPlanName }}

+ + +
    +
  • + + {{ "includeEnterprisePolicies" | i18n }} +
  • +
  • + + {{ "passwordLessSso" | i18n }} +
  • +
  • + + {{ "accountRecovery" | i18n }} +
  • +
  • + + {{ "customRoles" | i18n }} +
  • +
  • + + {{ "unlimitedSecretsAndProjects" | i18n }} +
  • +
+ +
    +
  • + + {{ "secureDataSharing" | i18n }} +
  • +
  • + + {{ "eventLogMonitoring" | i18n }} +
  • +
  • + + {{ "directoryIntegration" | i18n }} +
  • +
  • + + {{ "unlimitedSecretsAndProjects" | i18n }} +
  • +
+ +
    +
  • + + {{ "premiumAccounts" | i18n }} +
  • +
  • + + {{ "unlimitedSharing" | i18n }} +
  • +
  • + + {{ "createUnlimitedCollections" | i18n }} +
  • +
+ + +
+
+

{{ "selectAPlan" | i18n }}

+
+ + +
+ @for (plan of selectablePlans; let i = $index; track plan.type) { + +
+ @if (plan.isAnnual) { +
+ {{ "recommended" | i18n }} +
+ } +
+

+ {{ + plan.isAnnual ? "Annually" : "Monthly" + }} + + {{ + "upgradeDiscount" + | i18n + : (selectedInterval === planIntervals.Annually && + discountPercentageFromSub == 0 + ? this.discountPercentage + : this.discountPercentageFromSub) + }} +

+ + + + {{ + (selectedPlan.isAnnual + ? selectedPlan.PasswordManager.basePrice / 12 + : selectedPlan.PasswordManager.basePrice + ) | currency: "$" + }} + + + + {{ ("additionalUsers" | i18n).toLowerCase() }} + {{ + (selectedPlan.isAnnual + ? selectedPlan.PasswordManager.seatPrice / 12 + : selectedPlan.PasswordManager.seatPrice + ) | currency: "$" + }} + /{{ selectedPlanInterval | i18n }} + + + + + + {{ + "costPerMember" + | i18n + : (((this.sub.useSecretsManager + ? selectedPlan.SecretsManager.seatPrice + : 0) + + selectedPlan.PasswordManager.seatPrice) / + (selectedPlan.isAnnual ? 12 : 1) + | currency: "$") + }} + + /{{ "monthPerMember" | i18n }} + + {{ "freeForever" | i18n }} + +
+
+
+ } +
+
+
+ + + +

{{ "paymentMethod" | i18n }}

+ + + + + + +
+

+ {{ "total" | i18n }}: + {{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD + / {{ selectedPlanInterval | i18n }} + +

+
+ +
+ +

+ {{ "passwordManager" | i18n }} +

+

+ + {{ passwordManagerSeats }} + {{ "members" | i18n }} × + {{ + (selectedPlan.isAnnual + ? selectedPlan.PasswordManager.basePrice / 12 + : selectedPlan.PasswordManager.basePrice + ) | currency: "$" + }} + /{{ selectedPlanInterval | i18n }} + + + + {{ + selectedPlan.PasswordManager.basePrice | currency: "$" + }} + {{ "freeWithSponsorship" | i18n }} + + + {{ selectedPlan.PasswordManager.basePrice | currency: "$" }} + + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ passwordManagerSeats || 0 }}  + {{ "members" | i18n }} + × + {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + + {{ passwordManagerSeatTotal(selectedPlan) | currency: "$" }} + +

+

+ + {{ storageGb }} + {{ "additionalStorageGbMessage" | i18n }} + × + {{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + {{ additionalStorageTotal(selectedPlan) | currency: "$" }} +

+ +

+ + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ + calculateTotalAppliedDiscount( + passwordManagerSeatTotal(selectedPlan) + additionalStorageTotal(selectedPlan) + ) | currency: "$" + }} + +

+ +

+ {{ "secretsManager" | i18n }} +

+

+ + {{ sub?.smSeats }} + {{ "members" | i18n }} × + {{ + (selectedPlan.isAnnual + ? selectedPlan.SecretsManager.basePrice / 12 + : selectedPlan.SecretsManager.basePrice + ) | currency: "$" + }} + /{{ selectedPlanInterval | i18n }} + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ sub?.smSeats || 0 }}  + {{ "members" | i18n }} + × + {{ selectedPlan.SecretsManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + + {{ secretsManagerSeatTotal(selectedPlan, sub.smSeats) | currency: "$" }} + +

+

+ + {{ additionalServiceAccount }} + {{ "serviceAccounts" | i18n | lowercase }} + × + {{ selectedPlan?.SecretsManager?.additionalPricePerServiceAccount | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }} +

+ +

+ + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ + calculateTotalAppliedDiscount( + additionalServiceAccountTotal(selectedPlan) + + secretsManagerSeatTotal(selectedPlan, sub.smSeats) + ) | currency: "$" + }} + +

+
+ +

+ {{ "passwordManager" | i18n }} +

+

+ + {{ "basePrice" | i18n }}: + {{ selectedPlan.PasswordManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + + + {{ selectedPlan.PasswordManager.basePrice | currency: "$" }} + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ passwordManagerSeats }}  + {{ "members" | i18n }} + × + {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + {{ passwordManagerSeatTotal(selectedPlan) | currency: "$" }} + +

+

+ + {{ storageGb }} + {{ "additionalStorageGbMessage" | i18n }} + × + {{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + {{ + storageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" + }} +

+ +

+ + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ calculateTotalAppliedDiscount(total) | currency: "$" }} + +

+ +

+ {{ "secretsManager" | i18n }} +

+

+ + {{ "basePrice" | i18n }}: + {{ selectedPlan.SecretsManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + + + {{ selectedPlan.SecretsManager.basePrice | currency: "$" }} + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ sub?.smSeats }}  + {{ "members" | i18n }} + × + {{ selectedPlan.SecretsManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + {{ secretsManagerSeatTotal(selectedPlan, sub?.smSeats) | currency: "$" }} + +

+

+ + {{ additionalServiceAccount }} + {{ "serviceAccounts" | i18n | lowercase }} + × + {{ selectedPlan.SecretsManager.additionalPricePerServiceAccount | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }} +

+ +

+ + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ + additionalServiceAccountTotal(selectedPlan) + + secretsManagerSeatTotal(selectedPlan, sub?.smSeats) | currency: "$" + }} + +

+
+
+ +
+ + +

+ {{ "secretsManager" | i18n }} +

+

+ + {{ sub?.smSeats }} + {{ "members" | i18n }} × + {{ + (selectedPlan.isAnnual + ? selectedPlan.SecretsManager.basePrice / 12 + : selectedPlan.SecretsManager.basePrice + ) | currency: "$" + }} + /{{ selectedPlanInterval | i18n }} + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ sub?.smSeats || 0 }}  + {{ "members" | i18n }} + × + {{ selectedPlan.SecretsManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + + {{ secretsManagerSeatTotal(selectedPlan, sub.smSeats) | currency: "$" }} + +

+

+ + {{ additionalServiceAccount }} + {{ "serviceAccounts" | i18n }} + × + {{ selectedPlan.SecretsManager.additionalPricePerServiceAccount | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }} +

+ +

+ {{ "passwordManager" | i18n }} +

+

+ + {{ sub?.seats }} + {{ "members" | i18n }} × + {{ + (selectedPlan.isAnnual + ? selectedPlan.PasswordManager.basePrice / 12 + : selectedPlan.PasswordManager.basePrice + ) | currency: "$" + }} + /{{ selectedPlanInterval | i18n }} + + + + {{ + selectedPlan.PasswordManager.basePrice | currency: "$" + }} + {{ "freeWithSponsorship" | i18n }} + + + {{ selectedPlan.PasswordManager.basePrice | currency: "$" }} + + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ sub?.seats || 0 }}  + {{ "members" | i18n }} + × + {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + + {{ "freeForOneYear" | i18n }} + + + + {{ passwordManagerSeatTotal(selectedPlan) | currency: "$" }} + +

+
+ + +

+ {{ "secretsManager" | i18n }} +

+

+ + {{ "basePrice" | i18n }}: + {{ selectedPlan.SecretsManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + + + {{ selectedPlan.SecretsManager.basePrice | currency: "$" }} + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ sub?.smSeats }}  + {{ "members" | i18n }} + × + {{ selectedPlan.SecretsManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + {{ secretsManagerSeatTotal(selectedPlan, sub?.smSeats) | currency: "$" }} + +

+

+ + {{ additionalServiceAccount }} + {{ "serviceAccounts" | i18n }} + × + {{ selectedPlan.SecretsManager.additionalPricePerServiceAccount | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }} +

+ +

+ {{ "passwordManager" | i18n }} +

+

+ + {{ "basePrice" | i18n }}: + {{ selectedPlan.PasswordManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + + + {{ selectedPlan.PasswordManager.basePrice | currency: "$" }} + +

+

+ + {{ "additionalUsers" | i18n }}: + {{ sub?.seats }}  + {{ "members" | i18n }} + × + {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ selectedPlanInterval | i18n }} + + + {{ "freeForOneYear" | i18n }} + + + + {{ passwordManagerSeatTotal(selectedPlan) | currency: "$" }} + +

+
+
+ +
+ +

+ + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ + calculateTotalAppliedDiscount(total) | currency: "$" + }} + +

+
+
+ +
+ +

+ + {{ "estimatedTax" | i18n }} + + + {{ estimatedTax | currency: "USD" : "$" }} + +

+
+
+ +
+ +

+ + {{ "total" | i18n }} + + + {{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} + + / {{ selectedPlanInterval | i18n }} + +

+
+
+
+
+ + + + + + +
+
diff --git a/apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.ts b/apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.ts new file mode 100644 index 00000000000..c6800b62e0b --- /dev/null +++ b/apps/web/src/app/billing/shared/trial-subscription-dialog/trial-payment-method-dialog.component.ts @@ -0,0 +1,767 @@ +// 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 { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; + +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 { + 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 { 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, + 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 { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.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 { 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"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; + +import { BillingSharedModule } from "../billing-shared.module"; +import { PaymentComponent } from "../payment/payment.component"; + +type TrialPaymentMethodParams = { + organizationId: string; + subscription: OrganizationSubscriptionResponse; + productTierType: ProductTierType; + initialPaymentMethod?: PaymentMethodType; +}; + +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum TrialPaymentMethodDialogResultType { + 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", +} + +type PlanCard = { + name: string; + selected: boolean; +}; + +interface OnSuccessArgs { + organizationId: string; +} + +@Component({ + templateUrl: "./trial-payment-method-dialog.component.html", + imports: [BillingSharedModule], +}) +export class TrialPaymentMethodDialogComponent implements OnInit, OnDestroy { + @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; + @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + + @Input() acceptingSponsorship = false; + @Input() organizationId: string; + @Input() showFree = false; + @Input() showCancel = false; + + @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; + + @Input() + get plan(): PlanType { + return this._plan; + } + + set plan(plan: PlanType) { + this._plan = plan; + this.formGroup?.controls?.plan?.setValue(plan); + } + + private _plan = PlanType.Free; + @Input() providerId?: string; + @Output() onSuccess = new EventEmitter(); + @Output() onCanceled = new EventEmitter(); + @Output() onTrialBillingSuccess = new EventEmitter(); + + protected discountPercentage: number = 20; + protected discountPercentageFromSub: number; + protected loading = true; + protected planCards: PlanCard[]; + protected ResultType = TrialPaymentMethodDialogResultType; + protected initialPaymentMethod: PaymentMethodType; + + selfHosted = false; + productTypes = ProductTierType; + formPromise: Promise; + 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], + }); + + planType: string; + selectedPlan: PlanResponse; + selectedInterval: number = 1; + planIntervals = PlanInterval; + passwordManagerPlans: PlanResponse[]; + secretsManagerPlans: PlanResponse[]; + organization: Organization; + sub: OrganizationSubscriptionResponse; + billing: BillingResponse; + currentPlanName: string; + showPayment: boolean = false; + totalOpened: boolean = false; + currentPlan: PlanResponse; + isCardStateDisabled = false; + focusedIndex: number | null = null; + accountCredit: number; + paymentSource?: PaymentSourceResponse; + plans: ListResponse; + secretsManagerTotal: number; + + private destroy$ = new Subject(); + + protected taxInformation: TaxInformation; + + constructor( + @Inject(DIALOG_DATA) private dialogParams: TrialPaymentMethodParams, + private dialogRef: DialogRef, + private toastService: ToastService, + private apiService: ApiService, + private i18nService: I18nService, + private router: Router, + private policyService: PolicyService, + private organizationService: OrganizationService, + private formBuilder: FormBuilder, + private organizationApiService: OrganizationApiServiceAbstraction, + private billingApiService: BillingApiServiceAbstraction, + private taxService: TaxServiceAbstraction, + private accountService: AccountService, + ) { + this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; + } + + async ngOnInit(): Promise { + if (this.dialogParams.organizationId) { + this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType); + this.sub = + this.dialogParams.subscription ?? + (await this.organizationApiService.getSubscription(this.dialogParams.organizationId)); + 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.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.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); + + this.setInitialPlanIntervalSelection(); + this.loading = false; + + const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); + this.taxInformation = TaxInformation.from(taxInfo); + } + + resolveHeaderName(): string { + return this.i18nService.t( + "upgradeFreeOrganization", + this.resolvePlanName(this.dialogParams.productTierType), + ); + } + + setInitialPlanIntervalSelection() { + this.focusedIndex = this.selectablePlans.length - 1; + this.selectPlan(this.selectablePlans.find((product) => product.isAnnual)); + } + + isEnterprise() { + return this.currentPlan.productTier == ProductTierType.Enterprise; + } + + isTeams() { + return this.currentPlan.productTier == ProductTierType.Teams; + } + + isFamily() { + return this.currentPlan.productTier == ProductTierType.Families; + } + + hasSecretsManager() { + if (this.organization) { + return this.organization.canAccessSecretsManager; + } + } + + isPaymentSourceEmpty() { + return this.paymentSource === null || this.paymentSource === undefined; + } + + isSecretsManagerTrial(): boolean { + return ( + this.sub?.subscription?.items?.some((item) => + this.sub?.customerDiscount?.appliesTo?.includes(item.productId), + ) ?? false + ); + } + + protected getPlanCardContainerClasses(plan: PlanResponse, index: number): string[] { + const isSelected = plan.isAnnual; + const isDisabled = this.isCardDisabled(index); + + if (isDisabled) { + return [ + "tw-cursor-not-allowed", + "tw-bg-secondary-100", + "tw-font-normal", + "tw-bg-blur", + "tw-text-muted", + "tw-block", + "tw-rounded", + ]; + } + + return isSelected + ? [ + "tw-cursor-pointer", + "tw-block", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-border-primary-600", + "hover:tw-border-primary-700", + "focus:tw-border-2", + "focus:tw-border-primary-700", + "focus:tw-rounded-lg", + ] + : [ + "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", + ]; + } + + protected selectPlan(plan: PlanResponse) { + if ( + this.selectedInterval === PlanInterval.Monthly && + plan.productTier == ProductTierType.Families + ) { + return; + } + + this.selectedPlan = plan; + this.formGroup.patchValue({ productTier: plan.productTier }); + + try { + this.refreshSalesTax(); + } catch { + this.estimatedTax = 0; + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + get selectedPlanInterval() { + return this.currentPlan.isAnnual ? "year" : "month"; + } + + get selectablePlans() { + const result = + this.passwordManagerPlans?.filter( + (plan) => plan.productTier === this.selectedPlan.productTier && this.planIsEnabled(plan), + ) || []; + + result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder).reverse(); + 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; + } + + const result = plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); + return result; + } + + 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 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); + } + + 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 (): Promise => { + if (!this.taxComponent.validate()) { + this.taxComponent.markAllAsTouched(); + return; + } + + try { + if (this.organizationId) { + await this.updateOrganizationPaymentMethod(); + } else { + await this.updatePremiumUserPaymentMethod(); + } + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("updatedPaymentMethod"), + }); + + // Emit success event before closing dialog + this.onSuccess.emit({ organizationId: this.organizationId }); + this.dialogRef.close(TrialPaymentMethodDialogResultType.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 planIsEnabled(plan: PlanResponse) { + return !plan.disabled && !plan.legacyYear; + } + + toggleTotalOpened() { + this.totalOpened = !this.totalOpened; + } + + calculateTotalAppliedDiscount(total: number) { + const discountedTotal = total * (this.discountPercentageFromSub / 100); + return discountedTotal; + } + + 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); + } + } + + onFocus(index: number) { + this.focusedIndex = index; + this.selectPlan(this.selectablePlans[index]); + } + + isCardDisabled(index: number): boolean { + const card = this.selectablePlans[index]; + return card === (this.currentPlan || this.isCardStateDisabled); + } + + private refreshSalesTax(): void { + if ( + this.taxInformation === undefined || + !this.taxInformation.country || + !this.taxInformation.postalCode + ) { + 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, + }, + }; + + if (this.organization.useSecretsManager) { + request.secretsManager = { + seats: this.sub.smSeats, + additionalMachineAccounts: + this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, + }; + } + + 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, + }); + }); + } + + 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, + ) => + dialogService.open( + TrialPaymentMethodDialogComponent, + dialogConfig, + ); +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 33468e0b306..39174992f01 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -568,6 +568,9 @@ "cancel": { "message": "Cancel" }, + "later": { + "message": "Later" + }, "canceled": { "message": "Canceled" }, @@ -4570,6 +4573,9 @@ "receiveMarketingEmailsV2": { "message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox." }, + "subscribe": { + "message": "Subscribe" + }, "unsubscribe": { "message": "Unsubscribe" }, @@ -10650,5 +10656,26 @@ "example": "12/31/2024" } } + }, + "subscribetoEnterprise": { + "message": "Subscribe to $PLAN$", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "subscribetoEnterpriseSubtitle": { + "message": "Your 7-day $PLAN$ trial starts today. Add a payment method now to continue using continue using these features after your trial ends: ", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "unlimitedSecretsAndProjects": { + "message": "Unlimited secrets and projects" } }