From 6896ef23927de9661c0b2f52d9634f8cfe70dfad Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 30 Jul 2024 16:01:28 +0100 Subject: [PATCH] [AC-2708] Upgrading from a password manager only subscription (#10320) * Add changes for the upgrade dialog * Resolve the free org to any org type besides Families * Resolve the pr comments on navigation * resolve family plan upgrade from free --- .../members/members.component.ts | 49 +- .../change-plan-dialog.component.html | 383 ++++++++++ .../change-plan-dialog.component.ts | 658 ++++++++++++++++++ .../organization-billing.module.ts | 2 + ...ganization-subscription-cloud.component.ts | 26 +- .../app/billing/shared/tax-info.component.ts | 2 +- apps/web/src/locales/en/messages.json | 87 +++ libs/common/src/billing/enums/index.ts | 1 + .../src/billing/enums/plan-interval.enum.ts | 4 + .../billing/enums/product-tier-type.enum.ts | 8 + libs/common/src/enums/feature-flag.enum.ts | 2 + 11 files changed, 1213 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/billing/organizations/change-plan-dialog.component.html create mode 100644 apps/web/src/app/billing/organizations/change-plan-dialog.component.ts create mode 100644 libs/common/src/billing/enums/plan-interval.enum.ts diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 809f1e3935..f5eee80d66 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -33,7 +33,9 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; -import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -45,6 +47,10 @@ import { Collection } from "@bitwarden/common/vault/models/domain/collection"; import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../../../billing/organizations/change-plan-dialog.component"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { GroupService } from "../core"; @@ -86,6 +92,10 @@ export class MembersComponent extends BaseMembersComponent protected canUseSecretsManager$: Observable; + protected EnableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableUpgradePasswordManagerSub, + ); + // Fixed sizes used for cdkVirtualScroll protected rowHeight = 62; protected rowHeightClass = `tw-h-[62px]`; @@ -112,6 +122,7 @@ export class MembersComponent extends BaseMembersComponent private collectionService: CollectionService, private billingApiService: BillingApiServiceAbstraction, private modalService: ModalService, + private configService: ConfigService, ) { super( apiService, @@ -375,6 +386,9 @@ export class MembersComponent extends BaseMembersComponent case ProductTierType.TeamsStarter: product = "teamsStarterPlan"; break; + case ProductTierType.Families: + product = "familiesPlan"; + break; default: throw new Error(`Unsupported product type: ${productType}`); } @@ -395,7 +409,7 @@ export class MembersComponent extends BaseMembersComponent const productType = this.organization.productTierType; - if (productType !== ProductTierType.Free && productType !== ProductTierType.TeamsStarter) { + if (isNotSelfUpgradable(productType)) { throw new Error(`Unsupported product type: ${productType}`); } @@ -409,7 +423,7 @@ export class MembersComponent extends BaseMembersComponent const productType = this.organization.productTierType; - if (productType !== ProductTierType.Free && productType !== ProductTierType.TeamsStarter) { + if (isNotSelfUpgradable(productType)) { throw new Error(`Unsupported product type: ${this.organization.productTierType}`); } @@ -459,11 +473,32 @@ export class MembersComponent extends BaseMembersComponent !user && this.dataSource.data.length === this.organization.seats && (this.organization.productTierType === ProductTierType.Free || - this.organization.productTierType === ProductTierType.TeamsStarter) + this.organization.productTierType === ProductTierType.TeamsStarter || + this.organization.productTierType === ProductTierType.Families) ) { - // Show org upgrade modal - await this.showSeatLimitReachedDialog(); - return; + const EnableUpgradePasswordManagerSub = await firstValueFrom( + this.EnableUpgradePasswordManagerSub$, + ); + if (EnableUpgradePasswordManagerSub) { + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: this.organization.id, + subscription: null, + productTierType: this.organization.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === ChangePlanDialogResultType.Submitted) { + await this.load(); + } + return; + } else { + // Show org upgrade modal + await this.showSeatLimitReachedDialog(); + return; + } } const dialog = openUserAddEditDialog(this.dialogService, { diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html new file mode 100644 index 0000000000..0ca913a3b7 --- /dev/null +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -0,0 +1,383 @@ +
+ + + {{ "upgradeFreeOrganization" | i18n: currentPlanName }} + +
+

{{ "upgradePlan" | i18n }}

+
+ {{ "selectAPlan" | i18n }} + + + + + {{ planInterval.name }} {{ + "upgradeDiscount" + | i18n + : (this.discountPercentageFromSub > 0 + ? discountPercentageFromSub + : this.discountPercentage) + }} + + +
+ +
+
+
+
+ {{ "selected" | i18n }} +
+
+

+ {{ selectableProduct.nameLocalizationKey | i18n }} +

+ + + + {{ + (selectableProduct.isAnnual + ? selectableProduct.PasswordManager.basePrice / 12 + : selectableProduct.PasswordManager.basePrice + ) | currency: "$" + }} + + + /{{ "month" | i18n }} + {{ "includesXMembers" | i18n: selectableProduct.PasswordManager.baseSeats }} + + {{ ("additionalUsers" | i18n).toLowerCase() }} + {{ + (selectableProduct.isAnnual + ? selectableProduct.PasswordManager.seatPrice / 12 + : selectableProduct.PasswordManager.seatPrice + ) | currency: "$" + }} + /{{ "month" | i18n }} + + + + + + {{ + "costPerMember" + | i18n + : ((selectableProduct.isAnnual + ? selectableProduct.PasswordManager.seatPrice / 12 + : selectableProduct.PasswordManager.seatPrice + ) + | currency: "$") + }} + + /{{ "monthPerMember" | i18n }} + + {{ "freeForever" | i18n }} + +
+
+ + +

{{ "upgradeEnterpriseMessage" | i18n }}

+

{{ "includeAllTeamsFeatures" | i18n }}

+
    +
  • + {{ "includeEnterprisePolicies" | i18n }} +
  • +
  • + {{ "includeSsoAuthenticationMessage" | i18n }} +
  • +
  • {{ "optionalOnPremHosting" | i18n }}
  • +
+
+ + +
    +
  • {{ "includeAllTeamsStarterFeatures" | i18n }}
  • +
  • {{ "chooseMonthlyOrAnnualBilling" | i18n }}
  • +
  • {{ "abilityToAddMoreThanNMembers" | i18n: 10 }}
  • +
+
+ +

+ {{ "upgradeTeamsMessage" | i18n }} +

+

+ {{ "upgradeFamilyMessage" | i18n }} +

+
    +
  • + {{ "limitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }} +
  • +
  • + {{ "teamsInviteMessage" | i18n }} +
  • +
  • + {{ + "chooseMonthlyOrAnnualBilling" + | i18n: selectableProduct.PasswordManager.maxCollections + }} +
  • +
  • + {{ "createUnlimitedCollections" | i18n }} +
  • +
  • + {{ "accessToCreateGroups" | i18n }} +
  • +
  • + {{ "syncGroupsAndUsersFromDirectory" | i18n }} +
  • +
  • + {{ "accessToPremiumFeatures" | i18n }} +
  • +
  • + {{ "priorityCustomerSupport" | i18n }} +
  • +
  • + {{ "optionalOnPremHosting" | i18n }} +
  • +
+
+
+
+
+
+ + + +

{{ "paymentMethod" | i18n }}

+

+ + {{ billing.paymentSource.description }} + {{ + "changePaymentMethod" | i18n + }} + +

+ + +
+

+ {{ "total" | i18n }}: {{ total | currency: "USD" : "$" }} USD + / {{ selectedPlanInterval | i18n }} + +

+
+ +
+ +

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

+

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

+

+ + {{ 0 }} + {{ "additionalStorageGbMessage" | i18n }} + × + {{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} + /{{ "year" | i18n }} + + {{ 0 | currency: "$" }} +

+
+ +

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

+

+ + {{ "additionalUsers" | i18n }}: + {{ formGroup.controls["additionalSeats"].value || 0 }}  + {{ "members" | i18n }} + × + {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ "month" | i18n }} + + + {{ passwordManagerSeatTotal(selectedPlan) | currency: "$" }} + +

+

+ + {{ 0 }} + {{ "additionalStorageGbMessage" | i18n }} + × + {{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} + /{{ "month" | i18n }} + + {{ 0 | currency: "$" }} +

+
+
+ +
+ +

+ + {{ "total" | i18n }} + + + {{ total | currency: "USD" : "$" }} + / {{ selectedPlanInterval | i18n }} + +

+
+
+
+
+ + + + +
+
diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts new file mode 100644 index 0000000000..98c980186c --- /dev/null +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -0,0 +1,658 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; +import { Subject, takeUntil } 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 } 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 { + PaymentMethodType, + PlanType, + ProductTierType, + PlanInterval, +} from "@bitwarden/common/billing/enums"; +import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request"; +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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { PaymentComponent } from "../shared/payment.component"; +import { TaxInfoComponent } from "../shared/tax-info.component"; + +type ChangePlanDialogParams = { + organizationId: string; + subscription: OrganizationSubscriptionResponse; + productTierType: ProductTierType; +}; + +export enum ChangePlanDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +export enum PlanCardState { + Selected = "selected", + NotSelected = "not_selected", + Disabled = "disabled", +} + +export const openChangePlanDialog = ( + dialogService: DialogService, + dialogConfig: DialogConfig, +) => + dialogService.open( + ChangePlanDialogComponent, + dialogConfig, + ); + +type PlanCard = { + name: string; + selected: boolean; +}; + +interface OnSuccessArgs { + organizationId: string; +} + +@Component({ + templateUrl: "./change-plan-dialog.component.html", +}) +export class ChangePlanDialogComponent implements OnInit { + @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; + @ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent; + + @Input() acceptingSponsorship = false; + @Input() organizationId: string; + @Input() showFree = false; + @Input() showCancel = false; + selectedFile: File; + + @Input() + get productTier(): ProductTierType { + return this._productTier; + } + + set productTier(product: ProductTierType) { + this._productTier = product; + this.formGroup?.controls?.productTier?.setValue(product); + } + + 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 = ChangePlanDialogResultType; + + 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], + planInterval: [1], + }); + + planType: string; + selectedPlan: PlanResponse; + selectedInterval: number = 1; + planIntervals = PlanInterval; + passwordManagerPlans: PlanResponse[]; + organization: Organization; + sub: OrganizationSubscriptionResponse; + billing: BillingResponse; + currentPlanName: string; + showPayment: boolean = false; + totalOpened: boolean = false; + currentPlan: PlanResponse; + + private destroy$ = new Subject(); + + constructor( + @Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams, + private dialogRef: DialogRef, + private toastService: ToastService, + private apiService: ApiService, + private i18nService: I18nService, + private cryptoService: CryptoService, + private router: Router, + private syncService: SyncService, + private policyService: PolicyService, + private organizationService: OrganizationService, + private messagingService: MessagingService, + private formBuilder: FormBuilder, + private organizationApiService: OrganizationApiServiceAbstraction, + ) {} + + 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; + this.organization = await this.organizationService.get(this.organizationId); + this.billing = await this.organizationApiService.getBilling(this.organizationId); + } + + if (!this.selfHosted) { + const plans = await this.apiService.getPlans(); + this.passwordManagerPlans = plans.data.filter((plan) => !!plan.PasswordManager); + + 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.policyService + .policyAppliesToActiveUser$(PolicyType.SingleOrg) + .pipe(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.formGroup + .get("planInterval") + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((value: number) => (this.selectedInterval = value)); + + this.discountPercentageFromSub = this.sub?.customerDiscount?.percentOff; + + this.setInitialPlanSelection(); + this.loading = false; + } + + setInitialPlanSelection() { + this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + } + + getPlanByType(productTier: ProductTierType) { + return this.selectableProducts.find((product) => product.productTier === productTier); + } + + planTypeChanged() { + this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + } + + protected getPlanIntervals() { + return [ + { + name: PlanInterval[PlanInterval.Annually], + value: PlanInterval.Annually, + }, + { + name: PlanInterval[PlanInterval.Monthly], + value: PlanInterval.Monthly, + }, + ]; + } + + protected getPlanCardContainerClasses(plan: PlanResponse, index: number) { + let cardState: PlanCardState; + + if (plan == this.selectedPlan) { + cardState = PlanCardState.Selected; + } else if ( + this.selectedInterval === PlanInterval.Monthly && + plan.productTier == ProductTierType.Families + ) { + cardState = PlanCardState.Disabled; + } else { + cardState = PlanCardState.NotSelected; + } + + switch (cardState) { + case PlanCardState.Selected: { + if (this.currentPlan.productTier === ProductTierType.Teams) { + return [ + "tw-group", + "tw-cursor-pointer", + "tw-block", + "tw-rounded", + "tw-w-1/2", + "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", + ]; + } else { + return [ + "tw-group", + "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", + ]; + } + } + 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: { + return [ + "tw-cursor-not-allowed", + "tw-bg-secondary-100", + "tw-font-normal", + "tw-bg-blur", + "tw-text-muted", + "tw-block", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-border-1", + "tw-border-secondary-300", + ]; + } + } + } + + protected selectPlan(plan: PlanResponse) { + if ( + this.selectedInterval === PlanInterval.Monthly && + plan.productTier == ProductTierType.Families + ) { + return; + } + this.selectedPlan = plan; + this.formGroup.patchValue({ productTier: plan.productTier }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + get upgradeRequiresPaymentMethod() { + return ( + this.organization?.productTierType === ProductTierType.Free && + !this.showFree && + !this.billing?.paymentSource + ); + } + + get selectedPlanInterval() { + return this.selectedPlan.isAnnual ? "year" : "month"; + } + + get selectableProducts() { + 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), + ); + + result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); + + return result.reverse(); + } + + 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; + } + + passwordManagerSeatTotal(plan: PlanResponse): number { + if (!plan.PasswordManager.hasAdditionalSeatsOption) { + return 0; + } + + const result = plan.PasswordManager.seatPrice * Math.abs(this.organization.seats || 0); + return result; + } + + get passwordManagerSubtotal() { + 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; + } + + get taxCharges() { + return this.taxComponent != null && this.taxComponent.taxRate != null + ? (this.taxComponent.taxRate / 100) * this.passwordManagerSubtotal + : 0; + } + + get total() { + return this.passwordManagerSubtotal + this.taxCharges || 0; + } + + get teamsStarterPlanIsAvailable() { + return this.selectablePlans.some((plan) => plan.type === PlanType.TeamsStarter); + } + + 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() { + if (this.paymentComponent && this.taxComponent) { + this.paymentComponent!.hideBank = this.taxComponent?.taxFormGroup?.value.country !== "US"; + // Bank Account payments are only available for US customers + if ( + this.paymentComponent.hideBank && + this.paymentComponent.method === PaymentMethodType.BankAccount + ) { + this.paymentComponent.method = PaymentMethodType.Card; + this.paymentComponent.changeMethod(); + } + } + } + + submit = async () => { + if (!this.taxComponent?.taxFormGroup.valid && this.taxComponent?.taxFormGroup.touched) { + this.taxComponent?.taxFormGroup.markAllAsTouched(); + return; + } + + const doSubmit = async (): Promise => { + let orgId: string = null; + orgId = await this.updateOrganization(orgId); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("organizationUpgraded"), + }); + + await this.apiService.refreshIdentityToken(); + 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]); + } + + 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 updateOrganization(orgId: string) { + const request = new OrganizationUpgradeRequest(); + if (this.selectedPlan.productTier !== ProductTierType.Families) { + request.additionalSeats = this.organization.seats; + } + request.premiumAccessAddon = + this.selectedPlan.PasswordManager.hasPremiumAccessOption && + this.formGroup.controls.premiumAccessAddon.value; + request.planType = this.selectedPlan.type; + if (this.showPayment) { + request.billingAddressCountry = this.taxComponent.taxFormGroup?.value.country; + request.billingAddressPostalCode = this.taxComponent.taxFormGroup?.value.postalCode; + } + + if (this.upgradeRequiresPaymentMethod || this.showPayment) { + const tokenResult = await this.paymentComponent.createPaymentToken(); + const paymentRequest = new PaymentRequest(); + paymentRequest.paymentToken = tokenResult[0]; + paymentRequest.paymentMethodType = tokenResult[1]; + paymentRequest.country = this.taxComponent.taxFormGroup?.value.country; + paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode; + await this.organizationApiService.updatePayment(this.organizationId, paymentRequest); + } + + // Backfill pub/priv key if necessary + if (!this.organization.hasPublicAndPrivateKeys) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); + request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + } + + const result = await this.organizationApiService.upgrade(this.organizationId, request); + if (!result.success && result.paymentIntentClientSecret != null) { + await this.paymentComponent.handleStripeCardPayment(result.paymentIntentClientSecret, null); + } + 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 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; + } + + get paymentSourceClasses() { + if (this.billing.paymentSource == null) { + return []; + } + switch (this.billing.paymentSource.type) { + case PaymentMethodType.Card: + return ["bwi-credit-card"]; + case PaymentMethodType.BankAccount: + return ["bwi-bank"]; + case PaymentMethodType.Check: + return ["bwi-money"]; + case PaymentMethodType.PayPal: + return ["bwi-paypal text-primary"]; + default: + return []; + } + } + + 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"); + } + } +} diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index a95efe32e4..5d9dd8cf5b 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -7,6 +7,7 @@ import { BillingSharedModule } from "../shared"; import { AdjustSubscription } from "./adjust-subscription.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { BillingSyncKeyComponent } from "./billing-sync-key.component"; +import { ChangePlanDialogComponent } from "./change-plan-dialog.component"; import { ChangePlanComponent } from "./change-plan.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; @@ -40,6 +41,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, SubscriptionStatusComponent, + ChangePlanDialogComponent, ], }) export class OrganizationBillingModule {} diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 20c38ec94f..c5ed013b1e 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -28,6 +28,7 @@ import { } from "../shared/offboarding-survey.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; +import { ChangePlanDialogResultType, openChangePlanDialog } from "./change-plan-dialog.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; import { ManageBilling } from "./icons/manage-billing.icon"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @@ -66,6 +67,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.EnableTimeThreshold, ); + protected EnableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableUpgradePasswordManagerSub, + ); + constructor( private apiService: ApiService, private platformUtilsService: PlatformUtilsService, @@ -383,7 +388,26 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy }; async changePlan() { - this.showChangePlan = !this.showChangePlan; + const EnableUpgradePasswordManagerSub = await firstValueFrom( + this.EnableUpgradePasswordManagerSub$, + ); + if (EnableUpgradePasswordManagerSub) { + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: this.organizationId, + subscription: this.sub, + productTierType: this.userOrg.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === ChangePlanDialogResultType.Submitted) { + await this.load(); + } + } else { + this.showChangePlan = !this.showChangePlan; + } } closeChangePlan() { diff --git a/apps/web/src/app/billing/shared/tax-info.component.ts b/apps/web/src/app/billing/shared/tax-info.component.ts index 289c4906e9..068bf8a18c 100644 --- a/apps/web/src/app/billing/shared/tax-info.component.ts +++ b/apps/web/src/app/billing/shared/tax-info.component.ts @@ -394,7 +394,7 @@ export class TaxInfoComponent { }); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.parent.params.subscribe(async (params) => { + this.route.parent?.parent?.params.subscribe(async (params) => { this.organizationId = params.organizationId; if (this.organizationId) { try { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 2be00bb889..7af92a1e52 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8738,5 +8738,92 @@ }, "purchasedSeatsRemoved": { "message": "purchased seats removed" + }, + "includesXMembers": { + "message": "for $COUNT$ member", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "costPerMember": { + "message": "$COST$", + "placeholders": { + "cost": { + "content": "$1", + "example": "$3" + } + } + }, + "optionalOnPremHosting": { + "message": "Optional on-premises hosting" + }, + "upgradeFreeOrganization": { + "message": "Upgrade your $NAME$ organization ", + "placeholders": { + "name": { + "content": "$1", + "example": "Teams" + } + } + }, + "includeSsoAuthenticationMessage": { + "message": "SSO Authentication" + }, + "familiesPlanInvLimitReachedManageBilling": { + "message": "Families organizations may have up to $SEATCOUNT$ members. Upgrade to a paid plan to invite more members.", + "placeholders": { + "seatcount": { + "content": "$1", + "example": "6" + } + } + }, + "familiesPlanInvLimitReachedNoManageBilling": { + "message": "Families organizations may have up to $SEATCOUNT$ members. Contact your organization owner to upgrade.", + "placeholders": { + "seatcount": { + "content": "$1", + "example": "6" + } + } + }, + "upgradePlan": { + "message": "Upgrade your plan to invite more members and gain access to additional Bitwarden features" + }, + "upgradeDiscount": { + "message": "Save $AMOUNT$%", + "placeholders": { + "amount": { + "content": "$1", + "example": "2" + } + } + }, + "upgradeEnterpriseMessage": { + "message": "Advanced capabilities for larger businesses" + }, + "upgradeTeamsMessage": { + "message": "Businesses looking for powerful security" + }, + "teamsInviteMessage": { + "message": "Invite unlimited members" + }, + "accessToCreateGroups": { + "message": "Access to create groups" + }, + "syncGroupsAndUsersFromDirectory": { + "message": "Sync groups and users from a directory" + }, + "upgradeFamilyMessage": { + "message": "Share with families and friends" + }, + "accessToPremiumFeatures": { + "message": "Access to Premium features" + }, + "additionalStorageGbMessage": { + "message": "GB additional storage" } } diff --git a/libs/common/src/billing/enums/index.ts b/libs/common/src/billing/enums/index.ts index 3d89c7a546..1a9f3f8219 100644 --- a/libs/common/src/billing/enums/index.ts +++ b/libs/common/src/billing/enums/index.ts @@ -5,3 +5,4 @@ export * from "./transaction-type.enum"; export * from "./bitwarden-product-type.enum"; export * from "./product-tier-type.enum"; export * from "./product-type.enum"; +export * from "./plan-interval.enum"; diff --git a/libs/common/src/billing/enums/plan-interval.enum.ts b/libs/common/src/billing/enums/plan-interval.enum.ts new file mode 100644 index 0000000000..546336748c --- /dev/null +++ b/libs/common/src/billing/enums/plan-interval.enum.ts @@ -0,0 +1,4 @@ +export enum PlanInterval { + Monthly = 0, + Annually = 1, +} diff --git a/libs/common/src/billing/enums/product-tier-type.enum.ts b/libs/common/src/billing/enums/product-tier-type.enum.ts index c40f913ec8..fadf57ccdc 100644 --- a/libs/common/src/billing/enums/product-tier-type.enum.ts +++ b/libs/common/src/billing/enums/product-tier-type.enum.ts @@ -5,3 +5,11 @@ export enum ProductTierType { Enterprise = 3, TeamsStarter = 4, } + +export function isNotSelfUpgradable(productType: ProductTierType): boolean { + return ( + productType !== ProductTierType.Free && + productType !== ProductTierType.TeamsStarter && + productType !== ProductTierType.Families + ); +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ce00640fa9..08c8c35df3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -27,6 +27,7 @@ export enum FeatureFlag { AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page", DeviceTrustLogging = "pm-8285-device-trust-logging", AuthenticatorTwoFactorToken = "authenticator-2fa-token", + EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -64,6 +65,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE, [FeatureFlag.DeviceTrustLogging]: FALSE, [FeatureFlag.AuthenticatorTwoFactorToken]: FALSE, + [FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;