[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
This commit is contained in:
Stephon Brown 2026-05-28 12:46:57 -04:00 committed by GitHub
parent 336a243e27
commit 2abb233e60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 11 deletions

View File

@ -6,5 +6,6 @@
[enableSecretsManagerByDefault]="secretsManager"
[initialPlan]="plan"
[initialProductTier]="productTier"
[trialLength]="trialLength"
></app-organization-plans>
</bit-container>

View File

@ -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;
});
}

View File

@ -62,7 +62,7 @@
></billing-cart-summary>
@if (isFamiliesPlan()) {
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}
{{ "paymentChargedWithTrialSpecificLength" | i18n: defaultTrialDays }}
</p>
}
</section>

View File

@ -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<PersonalSubscriptionPricingTierId>();
protected readonly account = input.required<Account>();
protected readonly fromMarketing = input<string | null>(null);

View File

@ -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;
<div class="tw-mb-3">
<bit-radio-button [value]="tier" (change)="changedProduct()">
@ -65,8 +71,8 @@
@if (hasPolicies) {
<li>{{ "includeEnterprisePolicies" | i18n }}</li>
}
@if (trialPeriodDays && createOrganization() && !upgradingFromPremium) {
<li>{{ "xDayFreeTrial" | i18n: trialPeriodDays }}</li>
@if (showTrialOffer) {
<li>{{ "xDayFreeTrial" | i18n: effectiveTrial }}</li>
}
</ul>
} @else {
@ -75,9 +81,9 @@
<li>{{ "includeAllTeamsStarterFeatures" | i18n }}</li>
<li>{{ "chooseMonthlyOrAnnualBilling" | i18n }}</li>
<li>{{ "abilityToAddMoreThanNMembers" | i18n: 10 }}</li>
@if (trialPeriodDays && createOrganization() && !upgradingFromPremium) {
@if (showTrialOffer) {
<li>
{{ "xDayFreeTrial" | i18n: trialPeriodDays }}
{{ "xDayFreeTrial" | i18n: effectiveTrial }}
</li>
}
</ul>
@ -126,8 +132,8 @@
@if (tier != productTypes.Free) {
<li>{{ "priorityCustomerSupport" | i18n }}</li>
}
@if (trialPeriodDays && createOrganization() && !upgradingFromPremium) {
<li>{{ "xDayFreeTrial" | i18n: trialPeriodDays }}</li>
@if (showTrialOffer) {
<li>{{ "xDayFreeTrial" | i18n: effectiveTrial }}</li>
}
</ul>
}

View File

@ -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);

View File

@ -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>(PlanType.Free);
/** Custom trial length from the URL, overrides the plan's default trialPeriodDays for display and API calls. */
readonly trialLength = input<number | undefined>(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 ?? "",

View File

@ -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"