From 2abb233e60cb3118d30f7323fcce41bcc01fc120 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 28 May 2026 12:46:57 -0400 Subject: [PATCH] [PM-36878] Update Existing User Trail Flow (#20610) * feat(billing): introduce constant for default trial length * feat(billing): pass trial initiation ID to backend * feat(billing): display dynamic trial length in UI * fix(billing): format * refactor(billing): remove unused trialInitiationId * fix(billing): update trial initiation button loading condition * refactor(billing): remove deprecated fixed-length trial message keys * refactor(i18n): update payment charged with trial message key * feat(admin-console): add custom trial length to organization creation * feat(billing): implement custom trial length in organization plans * feat(billing): display default trial length in individual upgrade summary * test(billing): add tests for custom trial length in organization plans * test(billing): fix test getter call * feat(billing): introduce showTrialOffer variable to exclude free tier * refactor(billing): utilize showTrialOffer variable in template conditions * fix(billing): run prettier * refactor(billing): remove hardcoded default trial length for new organizations * refactor: use undefined for optional trialLength properties * refactor: update trialLength check for undefined in API request * feat: include custom trial length in free trial determination * fix(billing): use plan's trialPeriodDays for payment description if available * fix(billing): correctly determine free trial display based on length * fix(billing): run formatter --- .../create-organization.component.html | 1 + .../settings/create-organization.component.ts | 3 + .../upgrade-payment.component.html | 2 +- .../upgrade-payment.component.ts | 2 + .../organization-plans.component.html | 18 ++- .../organization-plans.component.spec.ts | 105 ++++++++++++++++++ .../organization-plans.component.ts | 18 ++- apps/web/src/locales/en/messages.json | 10 +- 8 files changed, 148 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.html b/apps/web/src/app/admin-console/settings/create-organization.component.html index fca003c2a70..0c1aa924166 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.html +++ b/apps/web/src/app/admin-console/settings/create-organization.component.html @@ -6,5 +6,6 @@ [enableSecretsManagerByDefault]="secretsManager" [initialPlan]="plan" [initialProductTier]="productTier" + [trialLength]="trialLength" > 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 2bee4769cb0..59489beef83 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 @@ -21,6 +21,7 @@ export class CreateOrganizationComponent implements OnInit, OnDestroy { protected secretsManager = false; protected plan: PlanType = PlanType.Free; protected productTier: ProductTierType = ProductTierType.Free; + protected trialLength?: number; constructor(private route: ActivatedRoute) {} @@ -49,6 +50,8 @@ export class CreateOrganizationComponent implements OnInit, OnDestroy { } this.secretsManager = qParams.product == ProductType.SecretsManager; + + this.trialLength = qParams.trialLength ? parseInt(qParams.trialLength) : undefined; }); } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index b36f7c54e50..418f2f72df4 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -62,7 +62,7 @@ > @if (isFamiliesPlan()) {

- {{ "paymentChargedWithTrial" | i18n }} + {{ "paymentChargedWithTrialSpecificLength" | i18n: defaultTrialDays }}

} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 5b4003f7fca..c2f37385b63 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -40,6 +40,7 @@ import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; import { Cart, CartSummaryComponent, Discount } from "@bitwarden/pricing"; +import { DEFAULT_TRIAL_LENGTH_DAYS } from "@bitwarden/web-vault/app/billing/constants"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { @@ -105,6 +106,7 @@ export type UpgradePaymentParams = { }) export class UpgradePaymentComponent implements OnInit, AfterViewInit { private readonly INITIAL_TAX_VALUE = 0; + protected readonly defaultTrialDays = DEFAULT_TRIAL_LENGTH_DAYS; protected readonly selectedPlanId = input.required(); protected readonly account = input.required(); protected readonly fromMarketing = input(null); diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 9452715cbdf..8a987228d19 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -47,6 +47,12 @@ @let hasDirectory = selectableProduct.hasDirectory; @let usersGetPremium = selectableProduct.usersGetPremium; @let trialPeriodDays = selectableProduct.trialPeriodDays; + @let effectiveTrial = trialLength() ?? trialPeriodDays; + @let showTrialOffer = + tier != productTypes.Free && + effectiveTrial && + createOrganization() && + !upgradingFromPremium;
@@ -65,8 +71,8 @@ @if (hasPolicies) {
  • {{ "includeEnterprisePolicies" | i18n }}
  • } - @if (trialPeriodDays && createOrganization() && !upgradingFromPremium) { -
  • {{ "xDayFreeTrial" | i18n: trialPeriodDays }}
  • + @if (showTrialOffer) { +
  • {{ "xDayFreeTrial" | i18n: effectiveTrial }}
  • } } @else { @@ -75,9 +81,9 @@
  • {{ "includeAllTeamsStarterFeatures" | i18n }}
  • {{ "chooseMonthlyOrAnnualBilling" | i18n }}
  • {{ "abilityToAddMoreThanNMembers" | i18n: 10 }}
  • - @if (trialPeriodDays && createOrganization() && !upgradingFromPremium) { + @if (showTrialOffer) {
  • - {{ "xDayFreeTrial" | i18n: trialPeriodDays }} + {{ "xDayFreeTrial" | i18n: effectiveTrial }}
  • } @@ -126,8 +132,8 @@ @if (tier != productTypes.Free) {
  • {{ "priorityCustomerSupport" | i18n }}
  • } - @if (trialPeriodDays && createOrganization() && !upgradingFromPremium) { -
  • {{ "xDayFreeTrial" | i18n: trialPeriodDays }}
  • + @if (showTrialOffer) { +
  • {{ "xDayFreeTrial" | i18n: effectiveTrial }}
  • } } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts b/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts index 0e884d71e9a..bbdab746f66 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts @@ -34,6 +34,7 @@ import { PreviewInvoiceClient, SubscriberBillingClient, } from "@bitwarden/web-vault/app/billing/clients"; +import { DEFAULT_TRIAL_LENGTH_DAYS } from "@bitwarden/web-vault/app/billing/constants"; import { OrganizationInformationComponent } from "../../admin-console/organizations/create/organization-information.component"; import { PremiumOrgUpgradeService } from "../individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service"; @@ -1678,6 +1679,49 @@ describe("OrganizationPlansComponent", () => { }); }); + it("should include trialLength in org creation request when trialLength input is set", async () => { + fixture.componentRef.setInput("trialLength", 14); + + component["formGroup"].patchValue({ + name: "Trial Org", + billingEmail: "trial@example.com", + productTier: ProductTierType.Enterprise, + plan: PlanType.EnterpriseAnnually, + additionalSeats: 10, + }); + + component["billingFormGroup"].controls.billingAddress.patchValue({ + country: "US", + postalCode: "12345", + line1: "123 Street", + city: "City", + state: "CA", + }); + + mockKeyService.makeOrgKey.mockResolvedValue([ + { encryptedString: "mock-key" }, + {} as any, + ] as any); + + mockEncryptService.encryptString.mockResolvedValue({ + encryptedString: "mock-collection", + } as any); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); + + mockOrganizationApiService.create.mockResolvedValue({ id: "trial-org-id" } as any); + + setupMockPaymentMethodComponent(component, "mock-token", "mock-type"); + + await component.submit(); + + const request = mockOrganizationApiService.create.mock.calls[0][0]; + expect(request.trialLength).toBe(14); + }); + it("should not navigate away when in trial flow", async () => { component["isInTrialFlow"] = true; @@ -1994,6 +2038,67 @@ describe("OrganizationPlansComponent", () => { expect(paymentDesc.length).toBeGreaterThan(0); }); + it("should use paymentChargedWithTrialSpecificLength with plan's trialPeriodDays when on a trial plan and no custom trialLength", () => { + component["formGroup"].controls.productTier.setValue(ProductTierType.Enterprise); + component.changedProduct(); + + const paymentDesc = component.paymentDesc; + + expect(paymentDesc).not.toBeNull(); + expect(mockI18nService.t).toHaveBeenCalledWith( + "paymentChargedWithTrialSpecificLength", + DEFAULT_TRIAL_LENGTH_DAYS, + ); + }); + + it("should use paymentChargedWithTrialSpecificLength with custom trialLength when provided", () => { + fixture.componentRef.setInput("trialLength", 14); + component["formGroup"].controls.productTier.setValue(ProductTierType.Enterprise); + component.changedProduct(); + + const paymentDesc = component.paymentDesc; + + expect(paymentDesc).not.toBeNull(); + expect(mockI18nService.t).toHaveBeenCalledWith("paymentChargedWithTrialSpecificLength", 14); + }); + + it("should not show trial when trialLength input is 0", () => { + fixture.componentRef.setInput("trialLength", 0); + component["formGroup"].controls.productTier.setValue(ProductTierType.Enterprise); + component.changedProduct(); + + expect(component.freeTrial()).toBe(false); + }); + + it("should not show trial when trialPeriodDays is 0", () => { + component["passwordManagerPlans"] = [ + { + type: PlanType.EnterpriseAnnually, + productTier: ProductTierType.Enterprise, + name: "Enterprise", + isAnnual: true, + canBeUsedByBusiness: true, + trialPeriodDays: 0, + upgradeSortOrder: 4, + displaySortOrder: 4, + PasswordManager: { + basePrice: 0, + seatPrice: 72, + hasAdditionalSeatsOption: true, + hasAdditionalStorageOption: true, + hasPremiumAccessOption: true, + baseStorageGb: 1, + additionalStoragePricePerGb: 4, + premiumAccessOptionPrice: 40, + }, + SecretsManager: null, + } as PlanResponse, + ]; + component["formGroup"].controls.plan.setValue(PlanType.EnterpriseAnnually); + + expect(component.freeTrial()).toBe(false); + }); + it("should display tax ID field for business plans", () => { component["formGroup"].controls.productTier.setValue(ProductTierType.Free); expect(component["showTaxIdField"]()).toBe(false); 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 04ea070dec8..fc9fcead484 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -58,6 +58,7 @@ import { PreviewInvoiceClient, SubscriberBillingClient, } from "@bitwarden/web-vault/app/billing/clients"; +import { DEFAULT_TRIAL_LENGTH_DAYS } from "@bitwarden/web-vault/app/billing/constants"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, @@ -126,6 +127,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { */ readonly initialPlan = input(PlanType.Free); + /** Custom trial length from the URL, overrides the plan's default trialPeriodDays for display and API calls. */ + readonly trialLength = input(undefined); + // Derived signals readonly hasPremiumPersonally = toSignal( this.accountService.activeAccount$.pipe( @@ -174,7 +178,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.selectedPlan()?.isAnnual ? "year" : "month", ); - readonly freeTrial = computed(() => this.selectedPlan()?.trialPeriodDays != null); + readonly freeTrial = computed( + () => (this.trialLength() ?? this.selectedPlan()?.trialPeriodDays ?? 0) > 0, + ); readonly planOffersSecretsManager = computed(() => this.selectedSecretsManagerPlan() != null); @@ -659,7 +665,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.acceptingSponsorship()) { return this.i18nService.t("paymentSponsored"); } else if (this.freeTrial() && this.createOrganization() && !this.canUpgradeFromPremium()) { - return this.i18nService.t("paymentChargedWithTrial"); + return this.i18nService.t( + "paymentChargedWithTrialSpecificLength", + this.trialLength() ?? this.selectedPlan()?.trialPeriodDays ?? DEFAULT_TRIAL_LENGTH_DAYS, + ); } else { return this.i18nService.t("paymentCharged", this.i18nService.t(this.selectedPlanInterval())); } @@ -1073,6 +1082,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { request.coupons = this.eligibleCouponIds(); } + const trialLength = this.trialLength(); + if (trialLength !== undefined) { + request.trialLength = trialLength; + } + if (this.hasProvider()) { const providerRequest = new ProviderOrganizationCreateRequest( this.formGroup.controls.clientOwnerEmail.value ?? "", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 72262b62b19..35b1be1a166 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3607,8 +3607,14 @@ "paymentChargedWithUnpaidSubscription": { "message": "Your payment method will be charged for any unpaid subscriptions." }, - "paymentChargedWithTrial": { - "message": "Your plan comes with a free 7 day trial. Your payment method will not be charged until the trial has ended. You may cancel at any time." + "paymentChargedWithTrialSpecificLength": { + "message": "Your plan comes with a free $TRIAL_LENGTH$ day trial. Your payment method will not be charged until the trial has ended. You may cancel at any time.", + "placeholders": { + "trial_length": { + "content": "$1", + "example": "7" + } + } }, "paymentInformation": { "message": "Payment information"