Skip to content

Commit

Permalink
[PM-8161] Payment optional trial MVP (#10872)
Browse files Browse the repository at this point in the history
* Initial comment

* Add changes for the create org with payment method

* Add the secrets manager trail flow

* Add the banners

* Add changes for the Disabled Org

* Add banner to payment method page

* Refactoring changes

* Resolve the bug on tha payment method

* Resolve lint error

* Resolve Pr comments

* resolve the lint issue

* Resolve the lint wrong file issue

* Rename object properly

* Resolve pr comments from sm team

* Resolve the pr comments from sm team

* Fix the failing test

* Resolve some issue with vault

* Resolve the comments from sm team

* Resolve some pr comments from vault team

* Resolve pr comments from auth team

* Exported ValidOrgParams enum

* Removed unnecessary interpolation

* Corrected bit-banner id for trial

* Resolve pr comments from auth team

* Resolve pr comments from auth team

* Removed unnecessary method

* Made OrganizationCreateRequest a subtype of OrganizationNoPaymentMethodCreateRequest

* Resolve review changes from sm

* Resolve review changes from dm

* Resolve the pr comments from billing

* move the free-trial to core

* Move free-trial change to right file

* Revert changes on the free trial  page

* Resolve the comment on protected trial page

* Resolve the comment on protected trial page

* Revert the next async change

* resolve pr comment fro vault team

* resolve the default message comments

* remove unused method

* resolve email sending issue

* Fix the pop issue on payment method

* Fix some console errors

* Fix the pop refresh page

* move the trial services to billing folder

* resolve pr comments

* Resolve the import issues

* Move the observable up

* Resolve blank payment method for trialing org

* Changes to  disable icon is removed onsubmit

* Remove unused references

* add a missing a period at the end of it

* resolve the reload issue

* Resolve the disable icon issue

* Fix the admin access bug

* Resolve the lint issue

* Fix the message incorrect format

* Formatting fixed

* Resolve the access issue of other users role
  • Loading branch information
cyprain-okeke authored Nov 11, 2024
1 parent 888b9e3 commit f593269
Show file tree
Hide file tree
Showing 39 changed files with 971 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit {
protected formBuilder: UntypedFormBuilder,
protected i18nService: I18nService,
protected organizationBillingService: OrganizationBillingService,
private router: Router,
protected router: Router,
) {}

ngOnInit(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,29 @@
bitButton
buttonType="primary"
[disabled]="formGroup.get('name').invalid"
[loading]="createOrganizationLoading"
(click)="createOrganizationOnTrial()"
*ngIf="enableTrialPayment$ | async"
>
{{ "startTrial" | i18n }}
</button>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="formGroup.get('name').invalid"
[loading]="createOrganizationLoading"
cdkStepperNext
*ngIf="!(enableTrialPayment$ | async)"
>
{{ "next" | i18n }}
</button>
</app-vertical-step>
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel">
<app-vertical-step
label="{{ 'billing' | i18n | titlecase }}"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Component, Input, ViewChild } from "@angular/core";
import { Component, Input, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";

import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

import {
OrganizationCreatedEvent,
Expand All @@ -9,18 +17,64 @@ import {
} 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";
import { ValidOrgParams } from "../trial-initiation.component";

const trialFlowOrgs = [
ValidOrgParams.teams,
ValidOrgParams.teamsStarter,
ValidOrgParams.enterprise,
ValidOrgParams.families,
];

@Component({
selector: "app-secrets-manager-trial-paid-stepper",
templateUrl: "secrets-manager-trial-paid-stepper.component.html",
})
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent {
export class SecretsManagerTrialPaidStepperComponent
extends SecretsManagerTrialFreeStepperComponent
implements OnInit
{
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@Input() organizationTypeQueryParameter: string;

plan: PlanType;
createOrganizationLoading = false;
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
organizationId: string;

private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);

constructor(
private route: ActivatedRoute,
private configService: ConfigService,
protected formBuilder: UntypedFormBuilder,
protected i18nService: I18nService,
protected organizationBillingService: OrganizationBillingService,
protected router: Router,
) {
super(formBuilder, i18nService, organizationBillingService, router);
}

async ngOnInit(): Promise<void> {
this.referenceEventRequest = new ReferenceEventRequest();
this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website";

this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
if (trialFlowOrgs.includes(qParams.org)) {
if (qParams.org === ValidOrgParams.teamsStarter) {
this.plan = PlanType.TeamsStarter;
} else if (qParams.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
} else if (qParams.org === ValidOrgParams.enterprise) {
this.plan = PlanType.EnterpriseAnnually;
}
}
});
}

organizationCreated(event: OrganizationCreatedEvent) {
this.organizationId = event.organizationId;
this.billingSubLabel = event.planDescription;
Expand All @@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
this.verticalStepper.previous();
}

async createOrganizationOnTrial(): Promise<void> {
this.createOrganizationLoading = true;
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization: {
name: this.formGroup.get("name").value,
billingEmail: this.formGroup.get("email").value,
initiationPath: "Secrets Manager trial from marketing website",
},
plan: {
type: this.plan,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
passwordManagerSeats: 1,
secretsManagerSeats: 1,
},
});

this.organizationId = response?.id;
this.subLabels.organizationInfo = response?.name;
this.createOrganizationLoading = false;
this.verticalStepper.next();
}

get createAccountLabel() {
const organizationType =
this.productType === ProductTierType.TeamsStarter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,17 @@ <h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').invalid"
cdkStepperNext
[loading]="loading"
(click)="createOrganizationOnTrial()"
>
{{ "next" | i18n }}
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
Expand All @@ -39,6 +41,8 @@ describe("TrialInitiationComponent", () => {
let policyServiceMock: MockProxy<PolicyService>;
let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
let configServiceMock: MockProxy<ConfigService>;

beforeEach(() => {
// only define services directly that we want to mock return values in this component
Expand All @@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => {
policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>();
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
organizationBillingServiceMock = mock<OrganizationBillingService>();
configServiceMock = mock<ConfigService>();

// 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
Expand Down Expand Up @@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => {
provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock,
},
{
provide: OrganizationBillingService,
useValue: organizationBillingServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
}).compileComponents();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
OrganizationInformation,
PlanInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

Expand All @@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite";
import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";

enum ValidOrgParams {
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
Expand Down Expand Up @@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
productTier: ProductTierType;
accountCreateOnly = true;
useTrialStepper = false;
loading = false;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
Expand Down Expand Up @@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
}

private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);

constructor(
private route: ActivatedRoute,
Expand All @@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
private i18nService: I18nService,
private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationBillingService: OrganizationBillingService,
private configService: ConfigService,
) {}

async ngOnInit(): Promise<void> {
Expand Down Expand Up @@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
}
}

async createOrganizationOnTrial() {
this.loading = true;
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.get("name").value,
billingEmail: this.orgInfoFormGroup.get("email").value,
initiationPath: "Password Manager trial from marketing website",
};

const plan: PlanInformation = {
type: this.plan,
passwordManagerSeats: 1,
};

const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization,
plan,
});

this.orgId = response?.id;
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
this.loading = false;
this.verticalStepper.next();
}

createdAccount(email: string) {
this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,16 +345,22 @@ <h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<a></a>
</p>
<app-payment
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && !deprecateStripeSourcesAPI"
*ngIf="
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
!deprecateStripeSourcesAPI
"
[hideCredit]="true"
></app-payment>
<app-payment-v2
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && deprecateStripeSourcesAPI"
*ngIf="
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
deprecateStripeSourcesAPI
"
[showAccountCredit]="false"
>
</app-payment-v2>
<app-tax-info
*ngIf="showPayment || upgradeRequiresPaymentMethod"
*ngIf="showPayment || upgradeRequiresPaymentMethod || isPaymentSourceEmpty()"
(onCountryChanged)="changedCountry()"
></app-tax-info>
<div id="price" class="tw-mt-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
: this.discountPercentageFromSub + this.discountPercentage;
}

isPaymentSourceEmpty() {
return this.deprecateStripeSourcesAPI
? this.paymentSource === null || this.paymentSource === undefined
: this.billing?.paymentSource === null || this.billing?.paymentSource === undefined;
}

isSecretsManagerTrial(): boolean {
return (
this.sub?.subscription?.items?.some((item) =>
Expand Down Expand Up @@ -723,7 +729,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
// Secrets Manager
this.buildSecretsManagerRequest(request);

if (this.upgradeRequiresPaymentMethod || this.showPayment) {
if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
if (this.deprecateStripeSourcesAPI) {
const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";

import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
import { UserVerificationModule } from "../../auth/shared/components/user-verification";
import { LooseComponentsModule } from "../../shared";
import { BillingSharedModule } from "../shared";
Expand Down Expand Up @@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
BillingSharedModule,
OrganizationPlansComponent,
LooseComponentsModule,
BannerModule,
],
declarations: [
AdjustSubscription,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
icon="bwi-billing"
[showClose]="false"
*ngIf="freeTrialData?.shownBanner"
>
{{ freeTrialData.message }}
<a
bitLink
linkType="contrast"
(click)="changePayment()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "routeToPaymentMethodTrigger" | i18n }}
</a>
</bit-banner>
<app-header></app-header>
<bit-container>
<ng-container *ngIf="loading">
Expand Down
Loading

0 comments on commit f593269

Please sign in to comment.