1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +00:00

[AC-1863] Send initiationPath on organization or user signup (#7747)

* Sent initiation path for organization and user signups

* Rename organizationQueryParameter > organizationTypeQueryParameter

* Jared's feedback

* Split PM & SM initiation path
This commit is contained in:
Alex Morask
2024-02-26 14:20:11 -05:00
committed by GitHub
parent 4cf911a45c
commit f53af7c466
20 changed files with 366 additions and 350 deletions

View File

@@ -70,7 +70,6 @@ export class RegisterFormComponent extends BaseRegisterComponent {
async ngOnInit() {
await super.ngOnInit();
this.referenceData = this.referenceDataValue;
if (this.queryParamEmail) {
this.formGroup.get("email")?.setValue(this.queryParamEmail);
}

View File

@@ -1,183 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ProductType } from "@bitwarden/common/enums";
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";
import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../../billing/shared";
export interface OrganizationInfo {
name: string;
email: string;
}
export interface OrganizationCreatedEvent {
organizationId: string;
planDescription: string;
}
enum SubscriptionCadence {
Monthly,
Annual,
}
export enum SubscriptionType {
Teams,
Enterprise,
}
@Component({
selector: "app-secrets-manager-trial-billing-step",
templateUrl: "secrets-manager-trial-billing-step.component.html",
imports: [BillingSharedModule],
standalone: true,
})
export class SecretsManagerTrialBillingStepComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
@Input() organizationInfo: OrganizationInfo;
@Input() subscriptionType: SubscriptionType;
@Output() steppedBack = new EventEmitter();
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
loading = true;
annualCadence = SubscriptionCadence.Annual;
monthlyCadence = SubscriptionCadence.Monthly;
formGroup = this.formBuilder.group({
cadence: [SubscriptionCadence.Annual, Validators.required],
});
formPromise: Promise<string>;
applicablePlans: PlanResponse[];
annualPlan: PlanResponse;
monthlyPlan: PlanResponse;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private formBuilder: FormBuilder,
private messagingService: MessagingService,
private organizationBillingService: OrganizationBillingService,
private platformUtilsService: PlatformUtilsService,
) {}
async ngOnInit(): Promise<void> {
const plans = await this.apiService.getPlans();
this.applicablePlans = plans.data.filter(this.isApplicable);
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
this.loading = false;
}
async submit(): Promise<void> {
this.formPromise = this.createOrganization();
const organizationId = await this.formPromise;
const planDescription = this.getPlanDescription();
this.platformUtilsService.showToast(
"success",
this.i18nService.t("organizationCreated"),
this.i18nService.t("organizationReadyToGo"),
);
this.organizationCreated.emit({
organizationId,
planDescription,
});
this.messagingService.send("organizationCreated", organizationId);
}
protected changedCountry() {
this.paymentComponent.hideBank = this.taxInfoComponent.taxInfo.country !== "US";
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
protected stepBack() {
this.steppedBack.emit();
}
private async createOrganization(): Promise<string> {
const plan = this.findPlanFor(this.formGroup.value.cadence);
const paymentMethod = await this.paymentComponent.createPaymentToken();
const response = await this.organizationBillingService.purchaseSubscription({
organization: {
name: this.organizationInfo.name,
billingEmail: this.organizationInfo.email,
},
plan: {
type: plan.type,
passwordManagerSeats: 1,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
secretsManagerSeats: 1,
},
payment: {
paymentMethod,
billing: {
postalCode: this.taxInfoComponent.taxInfo.postalCode,
country: this.taxInfoComponent.taxInfo.country,
taxId: this.taxInfoComponent.taxInfo.taxId,
addressLine1: this.taxInfoComponent.taxInfo.line1,
addressLine2: this.taxInfoComponent.taxInfo.line2,
city: this.taxInfoComponent.taxInfo.city,
state: this.taxInfoComponent.taxInfo.state,
},
},
});
return response.id;
}
private findPlanFor(cadence: SubscriptionCadence) {
switch (this.subscriptionType) {
case SubscriptionType.Teams:
return cadence === SubscriptionCadence.Annual
? this.applicablePlans.find((plan) => plan.type === PlanType.TeamsAnnually)
: this.applicablePlans.find((plan) => plan.type === PlanType.TeamsMonthly);
case SubscriptionType.Enterprise:
return cadence === SubscriptionCadence.Annual
? this.applicablePlans.find((plan) => plan.type === PlanType.EnterpriseAnnually)
: this.applicablePlans.find((plan) => plan.type === PlanType.EnterpriseMonthly);
}
}
private getPlanDescription(): string {
const plan = this.findPlanFor(this.formGroup.value.cadence);
const price =
plan.SecretsManager.basePrice === 0
? plan.SecretsManager.seatPrice
: plan.SecretsManager.basePrice;
switch (this.formGroup.value.cadence) {
case SubscriptionCadence.Annual:
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
case SubscriptionCadence.Monthly:
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
}
}
private isApplicable(plan: PlanResponse): boolean {
const hasSecretsManager = !!plan.SecretsManager;
const isTeamsOrEnterprise =
plan.product === ProductType.Teams || plan.product === ProductType.Enterprise;
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
return hasSecretsManager && isTeamsOrEnterprise && notDisabledOrLegacy;
}
}

View File

@@ -5,7 +5,11 @@
[subLabel]="subLabels.createAccount"
[addSubLabelSpacing]="true"
>
<app-register-form [isInTrialFlow]="true" (createdAccount)="accountCreated($event)">
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step

View File

@@ -1,9 +1,10 @@
import { Component, ViewChild } from "@angular/core";
import { Component, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
@@ -12,7 +13,7 @@ import { VerticalStepperComponent } from "../../trial-initiation/vertical-steppe
selector: "app-secrets-manager-trial-free-stepper",
templateUrl: "secrets-manager-trial-free-stepper.component.html",
})
export class SecretsManagerTrialFreeStepperComponent {
export class SecretsManagerTrialFreeStepperComponent implements OnInit {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
formGroup = this.formBuilder.group({
@@ -39,6 +40,8 @@ export class SecretsManagerTrialFreeStepperComponent {
organizationId: string;
referenceEventRequest: ReferenceEventRequest;
constructor(
protected formBuilder: UntypedFormBuilder,
protected i18nService: I18nService,
@@ -46,6 +49,11 @@ export class SecretsManagerTrialFreeStepperComponent {
private router: Router,
) {}
ngOnInit(): void {
this.referenceEventRequest = new ReferenceEventRequest();
this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website";
}
accountCreated(email: string): void {
this.formGroup.get("email")?.setValue(email);
this.subLabels.createAccount = email;

View File

@@ -5,7 +5,11 @@
[subLabel]="createAccountLabel"
[addSubLabelSpacing]="true"
>
<app-register-form [isInTrialFlow]="true" (createdAccount)="accountCreated($event)">
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
@@ -24,21 +28,22 @@
</button>
</app-vertical-step>
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel">
<app-secrets-manager-trial-billing-step
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: formGroup.get('name').value,
email: formGroup.get('email').value
email: formGroup.get('email').value,
type: productType
}"
[subscriptionType]="paidSubscriptionType"
[subscriptionProduct]="SubscriptionProduct.SecretsManager"
(steppedBack)="steppedBack()"
(organizationCreated)="organizationCreated($event)"
></app-secrets-manager-trial-billing-step>
></app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="{{ 'confirmationDetails' | i18n | titlecase }}">
<app-trial-confirmation-details
[email]="formGroup.get('email').value"
[orgLabel]="subscriptionType"
[orgLabel]="organizationTypeQueryParameter"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<button type="button" bitButton buttonType="primary" (click)="navigateTo('vault')">

View File

@@ -1,12 +1,14 @@
import { Component, Input, ViewChild } from "@angular/core";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
import { ProductType } from "@bitwarden/common/enums";
import {
OrganizationCreatedEvent,
SubscriptionType,
} from "./secrets-manager-trial-billing-step.component";
SubscriptionProduct,
TrialOrganizationType,
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
@Component({
selector: "app-secrets-manager-trial-paid-stepper",
@@ -14,7 +16,7 @@ import {
})
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@Input() subscriptionType: string;
@Input() organizationTypeQueryParameter: string;
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
organizationId: string;
@@ -31,16 +33,24 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
get createAccountLabel() {
const organizationType =
this.paidSubscriptionType == SubscriptionType.Enterprise ? "Enterprise" : "Teams";
this.productType === ProductType.TeamsStarter
? "Teams Starter"
: ProductType[this.productType];
return `Before creating your ${organizationType} organization, you first need to log in or create a personal account.`;
}
get paidSubscriptionType() {
switch (this.subscriptionType) {
get productType(): TrialOrganizationType {
switch (this.organizationTypeQueryParameter) {
case "enterprise":
return SubscriptionType.Enterprise;
return ProductType.Enterprise;
case "families":
return ProductType.Families;
case "teams":
return SubscriptionType.Teams;
return ProductType.Teams;
case "teamsStarter":
return ProductType.TeamsStarter;
}
}
protected readonly SubscriptionProduct = SubscriptionProduct;
}

View File

@@ -21,7 +21,10 @@
class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100"
>
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
{{ "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" | i18n: subscriptionType }}
{{
"startYour7DayFreeTrialOfBitwardenSecretsManagerFor"
| i18n: organizationTypeQueryParameter
}}
</h2>
<environment-selector
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"
@@ -32,7 +35,7 @@
></app-secrets-manager-trial-free-stepper>
<app-secrets-manager-trial-paid-stepper
*ngIf="!freeOrganization"
[subscriptionType]="subscriptionType"
[organizationTypeQueryParameter]="organizationTypeQueryParameter"
></app-secrets-manager-trial-paid-stepper>
</div>
</div>

View File

@@ -7,7 +7,7 @@ import { Subject, takeUntil } from "rxjs";
templateUrl: "secrets-manager-trial.component.html",
})
export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
subscriptionType: string;
organizationTypeQueryParameter: string;
private destroy$ = new Subject<void>();
@@ -15,7 +15,7 @@ export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => {
this.subscriptionType = queryParameters.org;
this.organizationTypeQueryParameter = queryParameters.org;
});
}
@@ -25,6 +25,6 @@ export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
}
get freeOrganization() {
return this.subscriptionType === "free";
return this.organizationTypeQueryParameter === "free";
}
}

View File

@@ -97,14 +97,18 @@
</button>
</app-vertical-step>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
<app-billing
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[plan]="plan"
[product]="product"
[orgInfoForm]="orgInfoFormGroup"
(previousStep)="previousStep()"
(onTrialBillingSuccess)="billingSuccess($event)"
></app-billing>
[organizationInfo]="{
name: orgInfoFormGroup.get('name').value,
email: orgInfoFormGroup.get('email').value,
type: trialOrganizationType
}"
[subscriptionProduct]="SubscriptionProduct.PasswordManager"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>
</app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details

View File

@@ -17,8 +17,13 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { RouterService } from "./../../core/router.service";
import { SubscriptionType } from "./secrets-manager/secrets-manager-trial-billing-step.component";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
enum ValidOrgParams {
@@ -79,7 +84,6 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
ValidOrgParams.individual,
];
layouts = ValidLayoutParams;
orgTypes = ValidOrgParams;
referenceData: ReferenceEventRequest;
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@@ -171,6 +175,10 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
// Are they coming from an email for sponsoring a families organization
// After logging in redirect them to setup the families sponsorship
this.setupFamilySponsorship(qParams.sponsorshipToken);
this.referenceData.initiationPath = this.accountCreateOnly
? "Registration form"
: "Password Manager trial from marketing website";
});
const invite = await this.stateService.getOrganizationInvitation();
@@ -241,6 +249,12 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
this.verticalStepper.next();
}
createdOrganization(event: OrganizationCreatedEvent) {
this.orgId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
navigateToOrgVault() {
// 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
@@ -274,6 +288,15 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
return this.i18nService.t(translationKey, this.org);
}
get trialOrganizationType(): TrialOrganizationType {
switch (this.product) {
case ProductType.Free:
return null;
default:
return this.product;
}
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
@@ -283,5 +306,5 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
}
}
protected readonly SubscriptionType = SubscriptionType;
protected readonly SubscriptionProduct = SubscriptionProduct;
}

View File

@@ -10,7 +10,7 @@ import { SecretsManagerTrialFreeStepperComponent } from "../../auth/trial-initia
import { SecretsManagerTrialPaidStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component";
import { SecretsManagerTrialComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial.component";
import { PaymentComponent, TaxInfoComponent } from "../../billing";
import { BillingComponent } from "../../billing/accounts/trial-initiation/billing.component";
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
import { SharedModule } from "../../shared";
@@ -35,7 +35,6 @@ import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component";
import { Teams3ContentComponent } from "./content/teams3-content.component";
import { SecretsManagerTrialBillingStepComponent } from "./secrets-manager/secrets-manager-trial-billing-step.component";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@@ -50,14 +49,13 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
EnvironmentSelectorModule,
PaymentComponent,
TaxInfoComponent,
SecretsManagerTrialBillingStepComponent,
TrialBillingStepComponent,
],
declarations: [
TrialInitiationComponent,
EnterpriseContentComponent,
TeamsContentComponent,
ConfirmationDetailsComponent,
BillingComponent,
DefaultContentComponent,
EnterpriseContentComponent,
Enterprise1ContentComponent,

View File

@@ -1,78 +0,0 @@
<form #form [formGroup]="formGroup" [appApiAction]="formPromise" (ngSubmit)="submit()">
<div class="tw-container tw-mb-3">
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<div class="tw-mb-1 tw-items-center" *ngFor="let selectablePlan of selectablePlans">
<label class="tw- tw-block tw-text-main" for="interval{{ selectablePlan.type }}">
<input
checked
class="tw-h-4 tw-w-4 tw-align-middle"
id="interval{{ selectablePlan.type }}"
name="plan"
type="radio"
[value]="selectablePlan.type"
formControlName="plan"
/>
<ng-container *ngIf="selectablePlan.isAnnual">
<ng-container *ngIf="selectablePlan.PasswordManager">
{{ "annual" | i18n }} -
{{
(selectablePlan.PasswordManager.basePrice === 0
? selectablePlan.PasswordManager.seatPrice
: selectablePlan.PasswordManager.basePrice
) | currency: "$"
}}
/{{ "yr" | i18n }}
</ng-container>
<ng-container *ngIf="!selectablePlan.PasswordManager && selectablePlan.SecretsManager">
{{ "annual" | i18n }} -
{{
(selectablePlan.SecretsManager.basePrice === 0
? selectablePlan.SecretsManager.seatPrice
: selectablePlan.SecretsManager.basePrice
) | currency: "$"
}}
/{{ "yr" | i18n }}
</ng-container>
</ng-container>
<ng-container *ngIf="!selectablePlan.isAnnual">
<ng-container *ngIf="selectablePlan.PasswordManager">
{{ "monthly" | i18n }} -
{{
(selectablePlan.PasswordManager.basePrice === 0
? selectablePlan.PasswordManager.seatPrice
: selectablePlan.PasswordManager.basePrice
) | currency: "$"
}}
/{{ "monthAbbr" | i18n }}
</ng-container>
<ng-container *ngIf="!selectablePlan.PasswordManager && selectablePlan.SecretsManager">
{{ "monthly" | i18n }} -
{{
(selectablePlan.SecretsManager.basePrice === 0
? selectablePlan.SecretsManager.seatPrice
: selectablePlan.SecretsManager.basePrice
) | currency: "$"
}}
/{{ "monthAbbr" | i18n }}
</ng-container>
</ng-container>
</label>
</div>
</div>
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-payment [hideCredit]="true" [trialFlow]="true"></app-payment>
<app-tax-info [trialFlow]="true" (onCountryChanged)="changedCountry()"></app-tax-info>
</div>
<div class="tw-flex tw-space-x-2">
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
{{ "startTrial" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
</div>
</div>
</form>

View File

@@ -1,34 +0,0 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/enums";
import { OrganizationPlansComponent } from "../../organizations";
@Component({
selector: "app-billing",
templateUrl: "./billing.component.html",
})
export class BillingComponent extends OrganizationPlansComponent {
@Input() orgInfoForm: FormGroup;
@Output() previousStep = new EventEmitter();
async ngOnInit() {
const additionalSeats =
this.product == ProductType.Families || this.plan === PlanType.TeamsStarter ? 0 : 1;
this.formGroup.patchValue({
name: this.orgInfoForm.value.name,
billingEmail: this.orgInfoForm.value.email,
additionalSeats: additionalSeats,
plan: this.plan,
product: this.product,
});
this.isInTrialFlow = true;
await super.ngOnInit();
}
stepBack() {
this.previousStep.emit();
}
}

View File

@@ -16,7 +16,7 @@
<div class="tw-container tw-mb-3">
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<div class="tw-mb-1 tw-items-center">
<div class="tw-mb-1 tw-items-center" *ngIf="annualPlan !== null">
<label class="tw- tw-block tw-text-main" for="annual">
<input
class="tw-h-4 tw-w-4 tw-align-middle"
@@ -27,16 +27,11 @@
formControlName="cadence"
/>
{{ "annual" | i18n }} -
{{
(annualPlan.SecretsManager.basePrice === 0
? annualPlan.SecretsManager.seatPrice
: annualPlan.SecretsManager.basePrice
) | currency: "$"
}}
{{ getPriceFor(annualCadence) | currency: "$" }}
/{{ "yr" | i18n }}
</label>
</div>
<div class="tw-mb-1 tw-items-center">
<div class="tw-mb-1 tw-items-center" *ngIf="monthlyPlan !== null">
<label class="tw- tw-block tw-text-main" for="monthly">
<input
class="tw-h-4 tw-w-4 tw-align-middle"
@@ -47,12 +42,7 @@
formControlName="cadence"
/>
{{ "monthly" | i18n }} -
{{
(monthlyPlan.SecretsManager.basePrice === 0
? monthlyPlan.SecretsManager.seatPrice
: monthlyPlan.SecretsManager.basePrice
) | currency: "$"
}}
{{ getPriceFor(monthlyCadence) | currency: "$" }}
/{{ "monthAbbr" | i18n }}
</label>
</div>

View File

@@ -0,0 +1,242 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
BillingInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
OrganizationInformation,
PaymentInformation,
PlanInformation,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ProductType } from "@bitwarden/common/enums";
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";
import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../shared";
export type TrialOrganizationType = Exclude<ProductType, ProductType.Free>;
export interface OrganizationInfo {
name: string;
email: string;
type: TrialOrganizationType;
}
export interface OrganizationCreatedEvent {
organizationId: string;
planDescription: string;
}
enum SubscriptionCadence {
Annual,
Monthly,
}
export enum SubscriptionProduct {
PasswordManager,
SecretsManager,
}
@Component({
selector: "app-trial-billing-step",
templateUrl: "trial-billing-step.component.html",
imports: [BillingSharedModule],
standalone: true,
})
export class TrialBillingStepComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
@Input() organizationInfo: OrganizationInfo;
@Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager;
@Output() steppedBack = new EventEmitter();
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
loading = true;
annualCadence = SubscriptionCadence.Annual;
monthlyCadence = SubscriptionCadence.Monthly;
formGroup = this.formBuilder.group({
cadence: [SubscriptionCadence.Annual, Validators.required],
});
formPromise: Promise<string>;
applicablePlans: PlanResponse[];
annualPlan?: PlanResponse;
monthlyPlan?: PlanResponse;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private formBuilder: FormBuilder,
private messagingService: MessagingService,
private organizationBillingService: OrganizationBillingService,
private platformUtilsService: PlatformUtilsService,
) {}
async ngOnInit(): Promise<void> {
const plans = await this.apiService.getPlans();
this.applicablePlans = plans.data.filter(this.isApplicable);
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
this.loading = false;
}
async submit(): Promise<void> {
this.formPromise = this.createOrganization();
const organizationId = await this.formPromise;
const planDescription = this.getPlanDescription();
this.platformUtilsService.showToast(
"success",
this.i18nService.t("organizationCreated"),
this.i18nService.t("organizationReadyToGo"),
);
this.organizationCreated.emit({
organizationId,
planDescription,
});
this.messagingService.send("organizationCreated", organizationId);
}
protected changedCountry() {
this.paymentComponent.hideBank = this.taxInfoComponent.taxInfo.country !== "US";
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
protected getPriceFor(cadence: SubscriptionCadence): number {
const plan = this.findPlanFor(cadence);
return this.subscriptionProduct === SubscriptionProduct.PasswordManager
? plan.PasswordManager.basePrice === 0
? plan.PasswordManager.seatPrice
: plan.PasswordManager.basePrice
: plan.SecretsManager.basePrice === 0
? plan.SecretsManager.seatPrice
: plan.SecretsManager.basePrice;
}
protected stepBack() {
this.steppedBack.emit();
}
private async createOrganization(): Promise<string> {
const planResponse = this.findPlanFor(this.formGroup.value.cadence);
const paymentMethod = await this.paymentComponent.createPaymentToken();
const organization: OrganizationInformation = {
name: this.organizationInfo.name,
billingEmail: this.organizationInfo.email,
initiationPath:
this.subscriptionProduct === SubscriptionProduct.PasswordManager
? "Password Manager trial from marketing website"
: "Secrets Manager trial from marketing website",
};
const plan: PlanInformation = {
type: planResponse.type,
passwordManagerSeats: 1,
};
if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) {
plan.subscribeToSecretsManager = true;
plan.isFromSecretsManagerTrial = true;
plan.secretsManagerSeats = 1;
}
const payment: PaymentInformation = {
paymentMethod,
billing: this.getBillingInformationFromTaxInfoComponent(),
};
const response = await this.organizationBillingService.purchaseSubscription({
organization,
plan,
payment,
});
return response.id;
}
private productTypeToPlanTypeMap: {
[productType in TrialOrganizationType]: {
[cadence in SubscriptionCadence]?: PlanType;
};
} = {
[ProductType.Enterprise]: {
[SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually,
[SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly,
},
[ProductType.Families]: {
[SubscriptionCadence.Annual]: PlanType.FamiliesAnnually,
// No monthly option for Families plan
},
[ProductType.Teams]: {
[SubscriptionCadence.Annual]: PlanType.TeamsAnnually,
[SubscriptionCadence.Monthly]: PlanType.TeamsMonthly,
},
[ProductType.TeamsStarter]: {
// No annual option for Teams Starter plan
[SubscriptionCadence.Monthly]: PlanType.TeamsStarter,
},
};
private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null {
const productType = this.organizationInfo.type;
const planType = this.productTypeToPlanTypeMap[productType]?.[cadence];
return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null;
}
private getBillingInformationFromTaxInfoComponent(): BillingInformation {
return {
postalCode: this.taxInfoComponent.taxInfo.postalCode,
country: this.taxInfoComponent.taxInfo.country,
taxId: this.taxInfoComponent.taxInfo.taxId,
addressLine1: this.taxInfoComponent.taxInfo.line1,
addressLine2: this.taxInfoComponent.taxInfo.line2,
city: this.taxInfoComponent.taxInfo.city,
state: this.taxInfoComponent.taxInfo.state,
};
}
private getPlanDescription(): string {
const plan = this.findPlanFor(this.formGroup.value.cadence);
const price =
this.subscriptionProduct === SubscriptionProduct.PasswordManager
? plan.PasswordManager.basePrice === 0
? plan.PasswordManager.seatPrice
: plan.PasswordManager.basePrice
: plan.SecretsManager.basePrice === 0
? plan.SecretsManager.seatPrice
: plan.SecretsManager.basePrice;
switch (this.formGroup.value.cadence) {
case SubscriptionCadence.Annual:
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
case SubscriptionCadence.Monthly:
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
}
}
private isApplicable(plan: PlanResponse): boolean {
const hasCorrectProductType =
plan.product === ProductType.Enterprise ||
plan.product === ProductType.Families ||
plan.product === ProductType.Teams ||
plan.product === ProductType.TeamsStarter;
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
return hasCorrectProductType && notDisabledOrLegacy;
}
}

View File

@@ -642,6 +642,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
request.collectionName = collectionCt;
request.name = this.formGroup.controls.name.value;
request.billingEmail = this.formGroup.controls.billingEmail.value;
request.initiationPath = "New organization creation in-product";
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
if (this.selectedPlan.type === PlanType.Free) {

View File

@@ -1,4 +1,5 @@
import { PaymentMethodType, PlanType } from "../../../billing/enums";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { OrganizationKeysRequest } from "./organization-keys.request";
@@ -23,9 +24,9 @@ export class OrganizationCreateRequest {
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -1,10 +1,12 @@
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
import { PaymentMethodType, PlanType } from "../enums";
export type OrganizationInformation = {
name: string;
billingEmail: string;
businessName?: string;
initiationPath?: InitiationPath;
};
export type PlanInformation = {

View File

@@ -76,6 +76,18 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
};
}
private prohibitsAdditionalSeats(planType: PlanType) {
switch (planType) {
case PlanType.Free:
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2019:
case PlanType.TeamsStarter:
return true;
default:
return false;
}
}
private setOrganizationInformation(
request: OrganizationCreateRequest,
information: OrganizationInformation,
@@ -83,6 +95,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
request.name = information.name;
request.businessName = information.businessName;
request.billingEmail = information.billingEmail;
request.initiationPath = information.initiationPath;
}
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
@@ -121,7 +134,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
): void {
request.planType = information.type;
if (request.planType === PlanType.Free) {
if (this.prohibitsAdditionalSeats(request.planType)) {
request.useSecretsManager = information.subscribeToSecretsManager;
request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial;
return;

View File

@@ -1,6 +1,14 @@
export type InitiationPath =
| "Registration form"
| "Password Manager trial from marketing website"
| "Secrets Manager trial from marketing website"
| "New organization creation in-product"
| "Upgrade in-product";
export class ReferenceEventRequest {
id: string;
session: string;
layout: string;
flow: string;
initiationPath: InitiationPath;
}