From e3acd27decc79bfbb04bde2efc757c872471adc5 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:50:49 -0600 Subject: [PATCH] [PM-24284] - milestone 3 (#17230) * first draft # Conflicts: # apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts # apps/web/src/app/billing/organizations/organization-plans.component.ts # libs/common/src/billing/services/subscription-pricing.service.ts # libs/common/src/enums/feature-flag.enum.ts * more filtering for pricing cards * prettier * tests * tests v2 --- ...families-for-enterprise-setup.component.ts | 15 +++- .../settings/create-organization.component.ts | 22 ++++- .../services/upgrade-payment.service.spec.ts | 88 ++++++++++++++++--- .../services/upgrade-payment.service.ts | 14 ++- .../change-plan-dialog.component.ts | 20 +++-- .../organization-plans.component.ts | 21 +++-- ...ganization-subscription-cloud.component.ts | 1 + .../complete-trial-initiation.component.ts | 13 ++- .../trial-billing-step.service.ts | 24 ++++- .../src/billing/enums/plan-type.enum.ts | 3 +- .../subscription-pricing.service.spec.ts | 2 +- .../services/subscription-pricing.service.ts | 10 ++- libs/common/src/enums/feature-flag.enum.ts | 2 + 13 files changed, 189 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts index 3c400decd52..568c4922337 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -15,8 +15,9 @@ import { PreValidateSponsorshipResponse } from "@bitwarden/common/admin-console/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlanSponsorshipType, PlanType, 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -43,7 +44,7 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { return; } - value.plan = PlanType.FamiliesAnnually; + value.plan = this._familyPlan; value.productTier = ProductTierType.Families; value.acceptingSponsorship = true; value.planSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise; @@ -63,13 +64,14 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { _selectedFamilyOrganizationId = ""; private _destroy = new Subject(); + private _familyPlan: PlanType; formGroup = this.formBuilder.group({ selectedFamilyOrganizationId: ["", Validators.required], }); constructor( private router: Router, - private platformUtilsService: PlatformUtilsService, + private configService: ConfigService, private i18nService: I18nService, private route: ActivatedRoute, private apiService: ApiService, @@ -120,6 +122,13 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { this.badToken = !this.preValidateSponsorshipResponse.isTokenValid; } + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + this._familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; + this.loading = false; }); diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.ts b/apps/web/src/app/admin-console/settings/create-organization.component.ts index bdf450fb265..45ce89c0e3d 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.ts +++ b/apps/web/src/app/admin-console/settings/create-organization.component.ts @@ -1,11 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; import { PlanType, ProductTierType, ProductType } 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 { OrganizationPlansComponent } from "../../billing"; import { HeaderModule } from "../../layouts/header/header.module"; @@ -17,15 +19,27 @@ import { SharedModule } from "../../shared"; templateUrl: "create-organization.component.html", imports: [SharedModule, OrganizationPlansComponent, HeaderModule], }) -export class CreateOrganizationComponent { +export class CreateOrganizationComponent implements OnInit { protected secretsManager = false; protected plan: PlanType = PlanType.Free; protected productTier: ProductTierType = ProductTierType.Free; - constructor(private route: ActivatedRoute) { + constructor( + private route: ActivatedRoute, + private configService: ConfigService, + ) {} + + async ngOnInit(): Promise { + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + const familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; + this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => { if (qParams.plan === "families" || qParams.productTier == ProductTierType.Families) { - this.plan = PlanType.FamiliesAnnually; + this.plan = familyPlan; this.productTier = ProductTierType.Families; } else if (qParams.plan === "teams" || qParams.productTier == ProductTierType.Teams) { this.plan = PlanType.TeamsAnnually; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index daca452c174..e20d20b0770 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing"; import { mock, mockReset } from "jest-mock-extended"; import { of } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; @@ -12,6 +11,7 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; @@ -36,13 +36,12 @@ describe("UpgradePaymentService", () => { const mockAccountBillingClient = mock(); const mockTaxClient = mock(); const mockLogService = mock(); - const mockApiService = mock(); const mockSyncService = mock(); const mockOrganizationService = mock(); const mockAccountService = mock(); const mockSubscriberBillingClient = mock(); + const mockConfigService = mock(); - mockApiService.refreshIdentityToken.mockResolvedValue({}); mockSyncService.fullSync.mockResolvedValue(true); let sut: UpgradePaymentService; @@ -134,10 +133,10 @@ describe("UpgradePaymentService", () => { { provide: AccountBillingClient, useValue: mockAccountBillingClient }, { provide: TaxClient, useValue: mockTaxClient }, { provide: LogService, useValue: mockLogService }, - { provide: ApiService, useValue: mockApiService }, { provide: SyncService, useValue: mockSyncService }, { provide: OrganizationService, useValue: mockOrganizationService }, { provide: AccountService, useValue: mockAccountService }, + { provide: ConfigService, useValue: mockConfigService }, ], }); @@ -183,11 +182,11 @@ describe("UpgradePaymentService", () => { mockAccountBillingClient, mockTaxClient, mockLogService, - mockApiService, mockSyncService, mockOrganizationService, mockAccountService, mockSubscriberBillingClient, + mockConfigService, ); // Act & Assert @@ -235,11 +234,11 @@ describe("UpgradePaymentService", () => { mockAccountBillingClient, mockTaxClient, mockLogService, - mockApiService, mockSyncService, mockOrganizationService, mockAccountService, mockSubscriberBillingClient, + mockConfigService, ); // Act & Assert @@ -269,11 +268,11 @@ describe("UpgradePaymentService", () => { mockAccountBillingClient, mockTaxClient, mockLogService, - mockApiService, mockSyncService, mockOrganizationService, mockAccountService, mockSubscriberBillingClient, + mockConfigService, ); // Act & Assert @@ -304,11 +303,11 @@ describe("UpgradePaymentService", () => { mockAccountBillingClient, mockTaxClient, mockLogService, - mockApiService, mockSyncService, mockOrganizationService, mockAccountService, mockSubscriberBillingClient, + mockConfigService, ); // Act & Assert @@ -330,11 +329,11 @@ describe("UpgradePaymentService", () => { mockAccountBillingClient, mockTaxClient, mockLogService, - mockApiService, mockSyncService, mockOrganizationService, mockAccountService, mockSubscriberBillingClient, + mockConfigService, ); // Act & Assert service?.accountCredit$.subscribe({ @@ -385,11 +384,11 @@ describe("UpgradePaymentService", () => { mockAccountBillingClient, mockTaxClient, mockLogService, - mockApiService, mockSyncService, mockOrganizationService, mockAccountService, mockSubscriberBillingClient, + mockConfigService, ); // Act & Assert @@ -482,7 +481,6 @@ describe("UpgradePaymentService", () => { mockTokenizedPaymentMethod, mockBillingAddress, ); - expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); @@ -501,7 +499,6 @@ describe("UpgradePaymentService", () => { accountCreditPaymentMethod, mockBillingAddress, ); - expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); @@ -569,7 +566,7 @@ describe("UpgradePaymentService", () => { billingEmail: "test@example.com", }, plan: { - type: PlanType.FamiliesAnnually, + type: PlanType.FamiliesAnnually2025, passwordManagerSeats: 6, }, payment: { @@ -582,10 +579,73 @@ describe("UpgradePaymentService", () => { }), "user-id", ); - expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); + it("should use FamiliesAnnually2025 plan when feature flag is disabled", async () => { + // Arrange + mockConfigService.getFeatureFlag.mockResolvedValue(false); + mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({ + id: "org-id", + name: "Test Organization", + billingEmail: "test@example.com", + } as OrganizationResponse); + + // Act + await sut.upgradeToFamilies( + mockAccount, + mockFamiliesPlanDetails, + mockTokenizedPaymentMethod, + { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }, + ); + + // Assert + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + plan: { + type: PlanType.FamiliesAnnually2025, + passwordManagerSeats: 6, + }, + }), + "user-id", + ); + }); + + it("should use FamiliesAnnually plan when feature flag is enabled", async () => { + // Arrange + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({ + id: "org-id", + name: "Test Organization", + billingEmail: "test@example.com", + } as OrganizationResponse); + + // Act + await sut.upgradeToFamilies( + mockAccount, + mockFamiliesPlanDetails, + mockTokenizedPaymentMethod, + { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }, + ); + + // Assert + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + plan: { + type: PlanType.FamiliesAnnually, + passwordManagerSeats: 6, + }, + }), + "user-id", + ); + }); + it("should throw error if password manager seats are 0", async () => { // Arrange const invalidPlanDetails: PlanDetails = { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index d14a1e40796..9bb963c210d 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -1,7 +1,6 @@ import { Injectable } from "@angular/core"; import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; @@ -17,6 +16,8 @@ import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { LogService } from "@bitwarden/logging"; @@ -59,11 +60,11 @@ export class UpgradePaymentService { private accountBillingClient: AccountBillingClient, private taxClient: TaxClient, private logService: LogService, - private apiService: ApiService, private syncService: SyncService, private organizationService: OrganizationService, private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, + private configService: ConfigService, ) {} userIsOwnerOfFreeOrg$: Observable = this.accountService.activeAccount$.pipe( @@ -169,6 +170,12 @@ export class UpgradePaymentService { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); const passwordManagerSeats = this.getPasswordManagerSeats(planDetails); + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + const familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; const subscriptionInformation: SubscriptionInformation = { organization: { @@ -176,7 +183,7 @@ export class UpgradePaymentService { billingEmail: account.email, // Use account email as billing email }, plan: { - type: PlanType.FamiliesAnnually, + type: familyPlan, passwordManagerSeats: passwordManagerSeats, }, payment: { @@ -224,7 +231,6 @@ export class UpgradePaymentService { } private async refreshAndSync(): Promise { - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); } 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 index 9a6106bebd4..0fd7746fc9d 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -31,7 +31,9 @@ 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -149,6 +151,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { protected estimatedTax: number = 0; private _productTier = ProductTierType.Free; + private _familyPlan: PlanType; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -247,6 +250,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, private organizationWarningsService: OrganizationWarningsService, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -296,10 +300,16 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + this._familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) { const upgradedPlan = this.passwordManagerPlans.find((plan) => this.currentPlan.productTier === ProductTierType.Free - ? plan.type === PlanType.FamiliesAnnually + ? plan.type === this._familyPlan : plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1, ); @@ -544,9 +554,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } if (this.acceptingSponsorship) { - const familyPlan = this.passwordManagerPlans.find( - (plan) => plan.type === PlanType.FamiliesAnnually, - ); + const familyPlan = this.passwordManagerPlans.find((plan) => plan.type === this._familyPlan); this.discount = familyPlan.PasswordManager.basePrice; return [familyPlan]; } @@ -562,6 +570,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { plan.productTier === ProductTierType.TeamsStarter || (this.selectedInterval === PlanInterval.Annually && plan.isAnnual) || (this.selectedInterval === PlanInterval.Monthly && !plan.isAnnual)) && + (plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) && (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && this.planIsEnabled(plan), ); @@ -926,7 +935,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { 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; + return plan.type === this._familyPlan; } if ( @@ -1024,6 +1033,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => { switch (planType) { case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2025: return { tier: "families", cadence: "annually" }; case PlanType.TeamsMonthly: return { tier: "teams", cadence: "monthly" }; diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 11c9b78aa21..561a3e03deb 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -36,8 +36,10 @@ import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/commo 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -126,6 +128,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } private _productTier = ProductTierType.Free; + private _familyPlan: PlanType; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -217,6 +220,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, + private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -256,10 +260,16 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + this._familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) { const upgradedPlan = this.passwordManagerPlans.find((plan) => this.currentPlan.productTier === ProductTierType.Free - ? plan.type === PlanType.FamiliesAnnually + ? plan.type === this._familyPlan : plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1, ); @@ -378,9 +388,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { get selectableProducts() { if (this.acceptingSponsorship) { - const familyPlan = this.passwordManagerPlans.find( - (plan) => plan.type === PlanType.FamiliesAnnually, - ); + const familyPlan = this.passwordManagerPlans.find((plan) => plan.type === this._familyPlan); this.discount = familyPlan.PasswordManager.basePrice; return [familyPlan]; } @@ -397,6 +405,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { plan.productTier === ProductTierType.TeamsStarter) && (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && (!this.hasProvider || plan.productTier !== ProductTierType.TeamsStarter) && + (plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || (this.isProviderQualifiedFor2020Plan() && Allowed2020PlansForLegacyProviders.includes(plan.type))), @@ -413,6 +422,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.passwordManagerPlans?.filter( (plan) => plan.productTier === selectedProductTierType && + (plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || (this.isProviderQualifiedFor2020Plan() && Allowed2020PlansForLegacyProviders.includes(plan.type))), @@ -713,6 +723,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private getPlanFromLegacyEnum(): OrganizationSubscriptionPlan { switch (this.formGroup.value.plan) { case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2025: return { tier: "families", cadence: "annually" }; case PlanType.TeamsMonthly: return { tier: "teams", cadence: "monthly" }; @@ -985,7 +996,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { 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; + return plan.type === this._familyPlan; } if ( 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 fc9f8b1d986..70e16ad3037 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 @@ -300,6 +300,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString()); } else if ( this.sub.planType === PlanType.FamiliesAnnually || + this.sub.planType === PlanType.FamiliesAnnually2025 || this.sub.planType === PlanType.FamiliesAnnually2019 || this.sub.planType === PlanType.TeamsStarter2023 || this.sub.planType === PlanType.TeamsStarter diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 19fa023a5b2..ef34584633b 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -251,7 +251,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { this.loading = true; let trialInitiationPath: InitiationPath = "Password Manager trial from marketing website"; let plan: PlanInformation = { - type: this.getPlanType(), + type: await this.getPlanType(), passwordManagerSeats: 1, }; @@ -293,14 +293,21 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { this.verticalStepper.previous(); } - getPlanType() { + async getPlanType() { + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + const familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; + switch (this.productTier) { case ProductTierType.Teams: return PlanType.TeamsAnnually; case ProductTierType.Enterprise: return PlanType.EnterpriseAnnually; case ProductTierType.Families: - return PlanType.FamiliesAnnually; + return familyPlan; case ProductTierType.Free: return PlanType.Free; default: diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts index 9e4f45ede92..0888ef07afc 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom, from, map, shareReplay } from "rxjs"; +import { combineLatestWith, firstValueFrom, from, map, shareReplay } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; @@ -10,6 +10,8 @@ import { SubscriptionInformation, } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } 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 { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; import { BillingAddressControls, @@ -62,6 +64,7 @@ export class TrialBillingStepService { private apiService: ApiService, private organizationBillingService: OrganizationBillingServiceAbstraction, private taxClient: TaxClient, + private configService: ConfigService, ) {} private plans$ = from(this.apiService.getPlans()).pipe( @@ -70,10 +73,17 @@ export class TrialBillingStepService { getPrices$ = (product: Product, tier: Tier) => this.plans$.pipe( - map((plans) => { + combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)), + map(([plans, milestone3FeatureEnabled]) => { switch (tier) { case "families": { - const annually = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually); + const annually = plans.data.find( + (plan) => + plan.type === + (milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025), + ); return { annually: annually!.PasswordManager.basePrice, }; @@ -149,9 +159,15 @@ export class TrialBillingStepService { ): Promise => { const getPlanType = async (tier: Tier, cadence: Cadence) => { const plans = await firstValueFrom(this.plans$); + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + const familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; switch (tier) { case "families": - return plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!.type; + return plans.data.find((plan) => plan.type === familyPlan)!.type; case "teams": return plans.data.find( (plan) => diff --git a/libs/common/src/billing/enums/plan-type.enum.ts b/libs/common/src/billing/enums/plan-type.enum.ts index 5c356ce42fe..60d62f88700 100644 --- a/libs/common/src/billing/enums/plan-type.enum.ts +++ b/libs/common/src/billing/enums/plan-type.enum.ts @@ -8,7 +8,7 @@ export enum PlanType { EnterpriseMonthly2019 = 4, EnterpriseAnnually2019 = 5, Custom = 6, - FamiliesAnnually = 7, + FamiliesAnnually2025 = 7, TeamsMonthly2020 = 8, TeamsAnnually2020 = 9, EnterpriseMonthly2020 = 10, @@ -23,4 +23,5 @@ export enum PlanType { EnterpriseMonthly = 19, EnterpriseAnnually = 20, TeamsStarter = 21, + FamiliesAnnually = 22, } diff --git a/libs/common/src/billing/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts index 07ad292c568..8f5e9c0a3ab 100644 --- a/libs/common/src/billing/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -25,7 +25,7 @@ describe("DefaultSubscriptionPricingService", () => { let logService: MockProxy; const mockFamiliesPlan = { - type: PlanType.FamiliesAnnually, + type: PlanType.FamiliesAnnually2025, productTier: ProductTierType.Families, name: "Families (Annually)", isAnnual: true, diff --git a/libs/common/src/billing/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts index a4223579c12..f1502eb26e8 100644 --- a/libs/common/src/billing/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -1,5 +1,6 @@ import { combineLatest, + combineLatestWith, from, map, Observable, @@ -141,8 +142,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer ); private families$: Observable = this.plansResponse$.pipe( - map((plans) => { - const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!; + combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)), + map(([plans, milestone3FeatureEnabled]) => { + const familiesPlan = plans.data.find( + (plan) => + plan.type === + (milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025), + )!; return { id: PersonalSubscriptionPricingTierIds.Families, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0fe94f8b6d2..7569445b910 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -32,6 +32,7 @@ export enum FeatureFlag { PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", + PM26462_Milestone_3 = "pm-26462-milestone-3", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -124,6 +125,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, + [FeatureFlag.PM26462_Milestone_3]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE,