mirror of
https://github.com/bitwarden/clients.git
synced 2026-06-04 21:04:29 +08:00
[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:
parent
336a243e27
commit
2abb233e60
@ -6,5 +6,6 @@
|
||||
[enableSecretsManagerByDefault]="secretsManager"
|
||||
[initialPlan]="plan"
|
||||
[initialProductTier]="productTier"
|
||||
[trialLength]="trialLength"
|
||||
></app-organization-plans>
|
||||
</bit-container>
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 ?? "",
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user