[SG-69] Billing payment step (#3133)

* billing folder added

* initial commit

* [SG-74] Trial Initiation Component with Vertical Stepper (#2913)

* Vertical stepper PoC

* Convert stepper css to tailwind

* trial component start

* trial component params

* tailwind-ify header

* Support teams, enterprise, and families layout param and more layout ui work

* Some more theming fixes

* Rename TrialModule to TrialInitiationModule

* Stepper fixes, plus more functionality demo

* Cleanup

* layout params and placeholders

* Only allow trial route to be hit if not logged in

* fix typo

* Use background-alt2 color for header

* Move vertical stepper out of trial-initiation

* Create components for the different plan types

* Remove width on steps

* Remove content projection for label

* Tailwind style fixes

* Extract step content into a component

* Remove layout param for now

* Remove step tags

* remove pointer classes from step button

* Remove most tailwind important designations

* Update apps/web/src/app/modules/vertical-stepper/vertical-step.component.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Tailwind and layout fixes

* Remove container

* lint & prettier fixes

* Remove extra CdkStep declaration

* Styles fixes

* Style logo directly

* Remove 0 margin on image

* Fix tiling and responsiveness

* Minor padding fixes for org pages

* Update apps/web/src/app/modules/trial-initiation/trial-initiation.component.html

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* prettier fix

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* [SG-65] Reusable Registration Form (#2946)

* created reusable registration form

* fixed conflicts

* replicated reactive form changes in other clients

* removed comments

* client template cleanup

* client template cleanup

* removed comments in template file

* changed to component suffix

* switched show password to use component

* comments resolution

* comments resolution

* added toast disable functionality

* removed unused locale

* mode custom input validator generic

* fixed button

* fixed linter

* removed horizontal rule

* switched to button component

* Added billng step

* Added keys to locale

* billing trial initiation step

* billing trial initiation step

* Dont load billing content until the step is selected

* billing trial initiation step

* billing trial initiation step

* billing trial initiation step

* made the get plans endpoint anonymous

* merged with master and extra changes

* major changes on billing step

* billing step sub label

* Made changes to billing step sub label

* removed unused variable

* removed unused logic

* cleanup

* fixed suggestions

* removed unused reference

* added billing sub label

Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
Co-authored-by: addison <addisonbeck1@gmail.com>
This commit is contained in:
Gbubemi Smith 2022-07-20 02:00:25 +01:00 committed by GitHub
parent 8aca6459cf
commit f07e071f09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 259 additions and 31 deletions

View File

@ -0,0 +1,48 @@
<form #form [formGroup]="formGroup" [appApiAction]="formPromise" (ngSubmit)="submit()">
<div class="tw-container tw-mb-3">
<div class="tw-mb-6">
<h2 class="tw-text-base tw-font-semibold tw-mb-3">{{ "billingPlanLabel" | i18n }}</h2>
<div class="tw-items-center tw-mb-1" *ngFor="let selectablePlan of selectablePlans">
<label class="tw-block tw- tw-text-main" for="interval{{ selectablePlan.type }}">
<input
checked
class="tw-w-4 tw-h-4 tw-align-middle"
id="interval{{ selectablePlan.type }}"
name="plan"
type="radio"
[value]="selectablePlan.type"
formControlName="plan"
/>
<ng-container *ngIf="selectablePlan.isAnnual">
{{ "annual" | i18n }} -
{{
(selectablePlan.basePrice === 0 ? selectablePlan.seatPrice : selectablePlan.basePrice)
| currency: "$"
}}
/{{ "yr" | i18n }}
</ng-container>
<ng-container *ngIf="!selectablePlan.isAnnual">
{{ "monthly" | i18n }} -
{{
(selectablePlan.basePrice === 0 ? selectablePlan.seatPrice : selectablePlan.basePrice)
| currency: "$"
}}
/{{ "monthAbbr" | i18n }}
</ng-container>
</label>
</div>
</div>
<div class="tw-mb-4 tw-overflow-auto">
<h2 class="tw-text-base tw-mb-3 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">
<bit-submit-button [loading]="form.loading">{{ "startTrial" | i18n }}</bit-submit-button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
</div>
</div>
</form>

View File

@ -0,0 +1,68 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { FormBuilder, FormGroup } from "@angular/forms";
import { Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy.service";
import { SyncService } from "@bitwarden/common/abstractions/sync.service";
import { OrganizationPlansComponent } from "src/app/settings/organization-plans.component";
@Component({
selector: "app-billing",
templateUrl: "./billing.component.html",
})
export class BillingComponent extends OrganizationPlansComponent {
@Input() orgInfoForm: FormGroup;
@Output() previousStep = new EventEmitter();
constructor(
apiService: ApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
cryptoService: CryptoService,
router: Router,
syncService: SyncService,
policyService: PolicyService,
organizationService: OrganizationService,
logService: LogService,
messagingService: MessagingService,
formBuilder: FormBuilder
) {
super(
apiService,
i18nService,
platformUtilsService,
cryptoService,
router,
syncService,
policyService,
organizationService,
logService,
messagingService,
formBuilder
);
}
async ngOnInit() {
this.formGroup.patchValue({
name: this.orgInfoForm.get("name")?.value,
billingEmail: this.orgInfoForm.get("email")?.value,
additionalSeats: 1,
plan: this.plan,
product: this.product,
});
this.isInTrialFlow = true;
await super.ngOnInit();
}
stepBack() {
this.previousStep.emit();
}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../shared.module";
import { BillingComponent } from "./billing.component";
@NgModule({
imports: [SharedModule],
declarations: [BillingComponent],
exports: [BillingComponent],
})
export class BillingModule {}

View File

@ -114,7 +114,6 @@ import { EmergencyAccessComponent } from "../settings/emergency-access.component
import { EmergencyAddEditComponent } from "../settings/emergency-add-edit.component";
import { OrganizationPlansComponent } from "../settings/organization-plans.component";
import { PaymentMethodComponent } from "../settings/payment-method.component";
import { PaymentComponent } from "../settings/payment.component";
import { PreferencesComponent } from "../settings/preferences.component";
import { PremiumComponent } from "../settings/premium.component";
import { ProfileComponent } from "../settings/profile.component";
@ -125,7 +124,6 @@ import { SettingsComponent } from "../settings/settings.component";
import { SponsoredFamiliesComponent } from "../settings/sponsored-families.component";
import { SponsoringOrgRowComponent } from "../settings/sponsoring-org-row.component";
import { SubscriptionComponent } from "../settings/subscription.component";
import { TaxInfoComponent } from "../settings/tax-info.component";
import { TwoFactorAuthenticatorComponent } from "../settings/two-factor-authenticator.component";
import { TwoFactorDuoComponent } from "../settings/two-factor-duo.component";
import { TwoFactorEmailComponent } from "../settings/two-factor-email.component";
@ -271,7 +269,6 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
PasswordGeneratorHistoryComponent,
PasswordGeneratorPolicyComponent,
PasswordRepromptComponent,
PaymentComponent,
PaymentMethodComponent,
PersonalOwnershipPolicyComponent,
PreferencesComponent,
@ -304,7 +301,6 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
SponsoringOrgRowComponent,
SsoComponent,
SubscriptionComponent,
TaxInfoComponent,
ToolsComponent,
TwoFactorAuthenticationPolicyComponent,
TwoFactorAuthenticatorComponent,
@ -425,7 +421,6 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
PasswordGeneratorHistoryComponent,
PasswordGeneratorPolicyComponent,
PasswordRepromptComponent,
PaymentComponent,
PaymentMethodComponent,
PersonalOwnershipPolicyComponent,
PreferencesComponent,
@ -458,7 +453,6 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
SponsoringOrgRowComponent,
SsoComponent,
SubscriptionComponent,
TaxInfoComponent,
ToolsComponent,
TwoFactorAuthenticationPolicyComponent,
TwoFactorAuthenticatorComponent,

View File

@ -67,6 +67,8 @@ import {
} from "@bitwarden/components";
import { PasswordStrengthComponent } from "../components/password-strength.component";
import { PaymentComponent } from "../settings/payment.component";
import { TaxInfoComponent } from "../settings/tax-info.component";
registerLocaleData(localeAf, "af");
registerLocaleData(localeAz, "az");
@ -120,7 +122,7 @@ registerLocaleData(localeZhCn, "zh-CN");
registerLocaleData(localeZhTw, "zh-TW");
@NgModule({
declarations: [PasswordStrengthComponent],
declarations: [PasswordStrengthComponent, PaymentComponent, TaxInfoComponent],
imports: [
CommonModule,
DragDropModule,
@ -155,8 +157,10 @@ registerLocaleData(localeZhTw, "zh-TW");
ButtonModule,
MenuModule,
FormFieldModule,
PasswordStrengthComponent,
SubmitButtonModule,
PasswordStrengthComponent,
PaymentComponent,
TaxInfoComponent,
],
providers: [DatePipe],
bootstrap: [],

View File

@ -57,11 +57,15 @@
Next
</button>
</app-vertical-step>
<app-vertical-step label="Billing">
<!-- Replace with Billing step -->
<p>This is content of "Step 3"</p>
<button bitButton buttonType="secondary" cdkStepperPrevious>Back</button>
<button bitButton buttonType="primary" cdkStepperNext>Complete step</button>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
<app-billing
*ngIf="stepper.selectedIndex === 2"
[plan]="plan"
[product]="product"
[orgInfoForm]="orgInfoFormGroup"
(previousStep)="previousStep()"
(onTrialBillingSuccess)="billingSuccess($event)"
></app-billing>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" subLabel="Fancy sub label">
<!-- Replace with Confirmation details step -->

View File

@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { PlanType } from "@bitwarden/common/enums/planType";
import { ProductType } from "@bitwarden/common/enums/productType";
import { PolicyData } from "@bitwarden/common/models/data/policyData";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/models/domain/masterPasswordPolicyOptions";
import { Policy } from "@bitwarden/common/models/domain/policy";
@ -24,6 +26,10 @@ export class TrialInitiationComponent implements OnInit {
email = "";
org = "teams";
orgInfoSubLabel = "";
orgId = "";
billingSubLabel = "";
plan: PlanType;
product: ProductType;
accountCreateOnly = true;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
@ -31,11 +37,7 @@ export class TrialInitiationComponent implements OnInit {
orgInfoFormGroup = this.formBuilder.group({
name: ["", [Validators.required]],
additionalStorage: [0, [Validators.min(0), Validators.max(99)]],
additionalSeats: [0, [Validators.min(0), Validators.max(100000)]],
businessName: [""],
plan: [],
product: [],
email: [""],
});
constructor(
@ -54,9 +56,23 @@ export class TrialInitiationComponent implements OnInit {
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
}
if (qParams.org) {
this.org = qParams.org;
this.accountCreateOnly = false;
if (!qParams.org) {
return;
}
this.org = qParams.org;
this.accountCreateOnly = false;
if (qParams.org === "families") {
this.plan = PlanType.FamiliesAnnually;
this.product = ProductType.Families;
} else if (qParams.org === "teams") {
this.plan = PlanType.TeamsAnnually;
this.product = ProductType.Teams;
} else if (qParams.org === "enterprise") {
this.plan = PlanType.EnterpriseAnnually;
this.product = ProductType.Enterprise;
}
});
@ -93,10 +109,26 @@ export class TrialInitiationComponent implements OnInit {
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
}
//set billing sub label
if (event.selectedIndex === 2) {
this.billingSubLabel = this.i18nService.t("billingTrialSubLabel");
}
}
createdAccount(email: string) {
this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email);
this.verticalStepper.next();
}
billingSuccess(event: any) {
this.orgId = event?.orgId;
this.billingSubLabel = event?.subLabelText;
this.verticalStepper.next();
}
previousStep() {
this.verticalStepper.previous();
}
}

View File

@ -9,6 +9,7 @@ import { RegisterFormModule } from "../register-form/register-form.module";
import { SharedModule } from "../shared.module";
import { VerticalStepperModule } from "../vertical-stepper/vertical-stepper.module";
import { BillingModule } from "./../billing/billing.module";
import { EnterpriseContentComponent } from "./enterprise-content.component";
import { FamiliesContentComponent } from "./families-content.component";
import { TeamsContentComponent } from "./teams-content.component";
@ -22,6 +23,7 @@ import { TrialInitiationComponent } from "./trial-initiation.component";
FormFieldModule,
RegisterFormModule,
OrganizationCreateModule,
BillingModule,
],
declarations: [
TrialInitiationComponent,

View File

@ -43,12 +43,14 @@ export class OrganizationPlansComponent implements OnInit {
@Input() providerId: string;
@Output() onSuccess = new EventEmitter();
@Output() onCanceled = new EventEmitter();
@Output() onTrialBillingSuccess = new EventEmitter();
loading = true;
selfHosted = false;
productTypes = ProductType;
formPromise: Promise<any>;
singleOrgPolicyBlock = false;
isInTrialFlow = false;
discount = 0;
formGroup = this.formBuilder.group({
@ -149,7 +151,7 @@ export class OrganizationPlansComponent implements OnInit {
}
get selectablePlans() {
return this.plans.filter(
return this.plans?.filter(
(plan) =>
!plan.legacyYear && !plan.disabled && plan.product === this.formGroup.controls.product.value
);
@ -321,10 +323,18 @@ export class OrganizationPlansComponent implements OnInit {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship) {
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
this.router.navigate(["/organizations/" + orgId]);
}
if (this.isInTrialFlow) {
this.onTrialBillingSuccess.emit({
orgId: orgId,
subLabelText: this.billingSubLabelText(),
});
}
return orgId;
};
@ -448,4 +458,18 @@ export class OrganizationPlansComponent implements OnInit {
return orgId;
}
private billingSubLabelText(): string {
const selectedPlan = this.selectedPlan;
const price = selectedPlan.basePrice === 0 ? selectedPlan.seatPrice : selectedPlan.basePrice;
let text = "";
if (selectedPlan.isAnnual) {
text += `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
} else {
text += `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
}
return text;
}
}

View File

@ -58,11 +58,11 @@
</div>
<ng-container *ngIf="showMethods && method === paymentMethodType.Card">
<div class="row">
<div class="form-group col-4">
<div [ngClass]="trialFlow ? 'col-4' : 'col-4'" class="form-group">
<label for="stripe-card-number-element">{{ "number" | i18n }}</label>
<div id="stripe-card-number-element" class="form-control stripe-form-control"></div>
</div>
<div class="form-group col-8 d-flex align-items-end">
<div *ngIf="!trialFlow" class="form-group col-8 d-flex align-items-end">
<img
src="../../images/cards.png"
alt="Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay"
@ -70,11 +70,11 @@
height="32"
/>
</div>
<div class="form-group col-4">
<div [ngClass]="trialFlow ? 'col-3' : 'col-4'" class="form-group">
<label for="stripe-card-expiry-element">{{ "expiration" | i18n }}</label>
<div id="stripe-card-expiry-element" class="form-control stripe-form-control"></div>
</div>
<div class="form-group col-4">
<div [ngClass]="trialFlow ? 'col-5' : 'col-4'" class="form-group">
<div class="d-flex">
<label for="stripe-card-cvc-element">
{{ "securityCode" | i18n }}

View File

@ -25,6 +25,7 @@ export class PaymentComponent implements OnInit, OnDestroy {
@Input() hideBank = false;
@Input() hidePaypal = false;
@Input() hideCredit = false;
@Input() trialFlow = false;
private destroy$: Subject<void> = new Subject<void>();

View File

@ -265,7 +265,7 @@
</select>
</div>
</div>
<div class="col-3">
<div [ngClass]="trialFlow ? 'col-4' : 'col-3'">
<div class="form-group">
<label for="addressPostalCode">{{ "zipPostalCode" | i18n }}</label>
<input

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Output } from "@angular/core";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -12,6 +12,7 @@ import { TaxRateResponse } from "@bitwarden/common/models/response/taxRateRespon
templateUrl: "tax-info.component.html",
})
export class TaxInfoComponent {
@Input() trialFlow = false;
@Output() onCountryChanged = new EventEmitter();
loading = true;

View File

@ -644,6 +644,9 @@
"newAccountCreated": {
"message": "Your new account has been created! You may now log in."
},
"trialAccountCreated": {
"message": "Account created successfully."
},
"masterPassSent": {
"message": "We've sent you an email with your master password hint."
},
@ -1669,6 +1672,12 @@
"billing": {
"message": "Billing"
},
"billingPlanLabel": {
"message": "Billing Plan"
},
"paymentType": {
"message": "Payment Type"
},
"accountCredit": {
"message": "Account Credit",
"description": "Financial term. In the case of Bitwarden, a positive balance means that you owe money, while a negative balance means that you have a credit (Bitwarden owes you money)."
@ -1785,6 +1794,9 @@
"year": {
"message": "year"
},
"yr": {
"message": "yr"
},
"month": {
"message": "month"
},
@ -1813,6 +1825,9 @@
"billingInformation": {
"message": "Billing Information"
},
"billingTrialSubLabel": {
"message": "Your payment method will not be charged during the 7 day free trial."
},
"creditCard": {
"message": "Credit Card"
},
@ -2175,6 +2190,9 @@
"annually": {
"message": "Annually"
},
"annual": {
"message": "Annual"
},
"basePrice": {
"message": "Base Price"
},

View File

@ -17,6 +17,7 @@ import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwo
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { DEFAULT_KDF_ITERATIONS, DEFAULT_KDF_TYPE } from "@bitwarden/common/enums/kdfType";
import { PasswordLogInCredentials } from "@bitwarden/common/models/domain/logInCredentials";
import { KeysRequest } from "@bitwarden/common/models/request/keysRequest";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/referenceEventRequest";
import { RegisterRequest } from "@bitwarden/common/models/request/registerRequest";
@ -200,10 +201,29 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
throw e;
}
}
this.platformUtilsService.showToast("success", null, this.i18nService.t("newAccountCreated"));
if (this.isInTrialFlow) {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("trialAccountCreated")
);
//login user here
const credentials = new PasswordLogInCredentials(
email,
masterPassword,
this.captchaToken,
null
);
await this.authService.logIn(credentials);
this.createdAccount.emit(this.formGroup.get("email")?.value);
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("newAccountCreated")
);
this.router.navigate([this.successRoute], { queryParams: { email: email } });
}
} catch (e) {

View File

@ -1395,7 +1395,7 @@ export class ApiService implements ApiServiceAbstraction {
// Plan APIs
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.send("GET", "/plans/", null, true, true);
const r = await this.send("GET", "/plans/", null, false, true);
return new ListResponse(r, PlanResponse);
}