1
0
mirror of https://github.com/bitwarden/web synced 2025-12-10 13:23:15 +00:00

Price and Plan Updates (#598)

* added the multi select checkbox to org ciphers

* wired up select all/none

* allowed for bulk delete of ciphers from the org vault

* refactored bulk actions into a dedicated component

* tweaked formatting settings and reformatted files

* moved some shared code to jslib

* some more formatting fixes

* undid jslib connection changes

* removed a function that was moved to jslib

* reset jslib again?

* set up delete many w/admin cipher methods

* removed extra href tags

* added organization id to bulk delete request model when coming from an org vault

* fixed up some compiler warnings for formatting

* updated organization create component to pull list of plans from static store

* wired up the organization create page to new data struct

* continued work on plan updates

* accounted for the subscription screen in plan updates

* adjusted for code review changes from server PR for plan updates

* cleaned up linter errors

* changed a few variable names

* moved price information, added sales tax and subtotal labels

* code review fixups for bulk delete from org vault

* added back a removed parameter from the vault component

* seperated some imports with newlines

* updated jslib

* resolved some build errors

* updated names to reflect server name changes for plan updates

* adjusted logic for using annual total for annual prices in server model

* rearranged an import for the linter

* broke up an async call

* updated organization create component to pull list of plans from static store

* wired up the organization create page to new data struct

* continued work on plan updates

* accounted for the subscription screen in plan updates

* adjusted for code review changes from server PR for plan updates

* cleaned up linter errors

* changed a few variable names

* moved price information, added sales tax and subtotal labels

* updated names to reflect server name changes for plan updates

* adjusted logic for using annual total for annual prices in server model

* rearranged an import for the linter

* broke up an async call

* resolved merge fun

* updated jslib

* made plans a public variable

* removed sales tax hooks

* added a getter for selected plan interval

* went a little too crazy with the interval getter

* formatting

* added a semicolon

* updated jslib

Co-authored-by: Addison Beck <addisonbeck@MacBook-Pro.local>
This commit is contained in:
Addison Beck
2020-08-12 17:16:38 -04:00
committed by GitHub
parent c46af91240
commit 5f04950358
4 changed files with 280 additions and 291 deletions

View File

@@ -24,7 +24,7 @@
</app-callout> </app-callout>
<dl *ngIf="selfHosted"> <dl *ngIf="selfHosted">
<dt>{{'billingPlan' | i18n}}</dt> <dt>{{'billingPlan' | i18n}}</dt>
<dd>{{sub.plan}}</dd> <dd>{{sub.plan.name}}</dd>
<dt>{{'expiration' | i18n}}</dt> <dt>{{'expiration' | i18n}}</dt>
<dd *ngIf="sub.expiration"> <dd *ngIf="sub.expiration">
{{sub.expiration | date:'mediumDate'}} {{sub.expiration | date:'mediumDate'}}
@@ -39,7 +39,7 @@
<div class="col-4"> <div class="col-4">
<dl> <dl>
<dt>{{'billingPlan' | i18n}}</dt> <dt>{{'billingPlan' | i18n}}</dt>
<dd>{{sub.plan}}</dd> <dd>{{sub.plan.name}}</dd>
<ng-container *ngIf="subscription"> <ng-container *ngIf="subscription">
<dt>{{'status' | i18n}}</dt> <dt>{{'status' | i18n}}</dt>
<dd> <dd>

View File

@@ -13,7 +13,6 @@ import { ApiService } from 'jslib/abstractions/api.service';
import { I18nService } from 'jslib/abstractions/i18n.service'; import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service'; import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { TokenService } from 'jslib/abstractions/token.service';
import { PlanType } from 'jslib/enums/planType'; import { PlanType } from 'jslib/enums/planType';
@@ -38,10 +37,10 @@ export class OrganizationSubscriptionComponent implements OnInit {
cancelPromise: Promise<any>; cancelPromise: Promise<any>;
reinstatePromise: Promise<any>; reinstatePromise: Promise<any>;
constructor(private tokenService: TokenService, private apiService: ApiService, constructor(private apiService: ApiService, private platformUtilsService: PlatformUtilsService,
private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private i18nService: I18nService, private analytics: Angulartics2,
private analytics: Angulartics2, private toasterService: ToasterService, private toasterService: ToasterService, private messagingService: MessagingService,
private messagingService: MessagingService, private route: ActivatedRoute) { private route: ActivatedRoute) {
this.selfHosted = platformUtilsService.isSelfHost(); this.selfHosted = platformUtilsService.isSelfHost();
} }
@@ -192,34 +191,20 @@ export class OrganizationSubscriptionComponent implements OnInit {
} }
get billingInterval() { get billingInterval() {
const monthly = this.sub.planType === PlanType.EnterpriseMonthly || const monthly = !this.sub.plan.isAnnual;
this.sub.planType === PlanType.TeamsMonthly;
return monthly ? 'month' : 'year'; return monthly ? 'month' : 'year';
} }
get storageGbPrice() { get storageGbPrice() {
return this.billingInterval === 'month' ? 0.5 : 4; return this.sub.plan.additionalStoragePricePerGb;
} }
get seatPrice() { get seatPrice() {
switch (this.sub.planType) { return this.sub.plan.seatPrice;
case PlanType.EnterpriseMonthly:
return 4;
case PlanType.EnterpriseAnnually:
return 36;
case PlanType.TeamsMonthly:
return 2.5;
case PlanType.TeamsAnnually:
return 24;
default:
return 0;
}
} }
get canAdjustSeats() { get canAdjustSeats() {
return this.sub.planType === PlanType.EnterpriseMonthly || return this.sub.plan.hasAdditionalSeatsOption;
this.sub.planType === PlanType.EnterpriseAnnually ||
this.sub.planType === PlanType.TeamsMonthly || this.sub.planType === PlanType.TeamsAnnually;
} }
get canDownloadLicense() { get canDownloadLicense() {

View File

@@ -1,3 +1,7 @@
<ng-container *ngIf="loading">
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</ng-container>
<ng-container *ngIf="createOrganization && selfHosted"> <ng-container *ngIf="createOrganization && selfHosted">
<p>{{'uploadLicenseFileOrg' | i18n}}</p> <p>{{'uploadLicenseFileOrg' | i18n}}</p>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
@@ -13,7 +17,8 @@
</button> </button>
</form> </form>
</ng-container> </ng-container>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate *ngIf="!selfHosted"> <form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate
*ngIf="!loading && !selfHosted && this.plans">
<h2 class="mt-5">{{'generalInformation' | i18n}}</h2> <h2 class="mt-5">{{'generalInformation' | i18n}}</h2>
<div class="row" *ngIf="createOrganization"> <div class="row" *ngIf="createOrganization">
<div class="form-group col-6"> <div class="form-group col-6">
@@ -38,69 +43,53 @@
</div> </div>
</div> </div>
<h2 class="mt-5">{{'chooseYourPlan' | i18n}}</h2> <h2 class="mt-5">{{'chooseYourPlan' | i18n}}</h2>
<div class="form-check form-check-block" *ngIf="!ownedBusiness && showFree"> <div *ngFor="let selectableProduct of selectableProducts" class="form-check form-check-block">
<input class="form-check-input" type="radio" name="PlanType" id="planFree" value="free" [(ngModel)]="plan" <input class="form-check-input" type="radio" name="product" id="product{{selectableProduct.product}}"
(change)="changedPlan()"> [value]="selectableProduct.product" [(ngModel)]="product" (change)="changedProduct()">
<label class="form-check-label" for="planFree"> <label class="form-check-label" for="product">
{{'planNameFree' | i18n}} {{ selectableProduct.nameLocalizationKey | i18n}}
<small class="mb-1">{{'planDescFree' | i18n : '1'}}</small> <small class="mb-1">{{ selectableProduct.descriptionLocalizationKey | i18n : '1'}}</small>
<small>• {{'limitedUsers' | i18n : '2'}}</small> <small *ngIf="selectableProduct.product == productTypes.Free">
<small> {{'limitedCollections' | i18n : '2'}}</small> {{'limitedUsers' | i18n : selectableProduct.maxUsers }}</small>
<span>{{'freeForever' | i18n}}</span> <small *ngIf="selectableProduct.product != productTypes.Free && selectableProduct.maxUsers">
{{'addShareLimitedUsers' | i18n : selectableProduct.maxUsers}}</small>
<small *ngIf="!selectableProduct.maxUsers">
{{'addShareUnlimitedUsers' | i18n}}</small>
<small *ngIf="selectableProduct.maxCollections">
{{'limitedCollections' | i18n : selectableProduct.maxCollections }}</small>
<small *ngIf="selectableProduct.maxAdditionalSeats">
{{'addShareLimitedUsers' | i18n : selectableProduct.maxAdditionalSeats }}</small>
<small *ngIf="!selectableProduct.maxCollections">• {{'createUnlimitedCollections' | i18n}}</small>
<small *ngIf="selectableProduct.baseStorageGb">
{{'gbEncryptedFileStorage' | i18n : selectableProduct.baseStorageGb + 'GB'}}</small>
<small *ngIf="selectableProduct.hasGroups">• {{'controlAccessWithGroups' | i18n}}</small>
<small *ngIf="selectableProduct.hasApi">• {{'trackAuditLogs' | i18n}}</small>
<small *ngIf="selectableProduct.hasDirectory">• {{'syncUsersFromDirectory' | i18n}}</small>
<small *ngIf="selectableProduct.hasSelfHost">• {{'onPremHostingOptional' | i18n}}</small>
<small *ngIf="selectableProduct.usersGetPremium">• {{'usersGetPremium' | i18n}}</small>
<small *ngIf="selectableProduct.hasSso">• sso blurb here</small>
<small *ngIf="selectableProduct.product != productTypes.Free">• {{'priorityCustomerSupport' | i18n}}</small>
<small *ngIf="selectableProduct.trialPeriodDays">
{{'xDayFreeTrial' | i18n : selectableProduct.trialPeriodDays }}
</small>
<span *ngIf="selectableProduct.product != productTypes.Free">
<ng-container *ngIf="selectableProduct.basePrice">
{{selectableProduct.basePrice / 12 | currency:'$'}} /{{'month' | i18n}},
{{'includesXUsers' | i18n : selectableProduct.baseSeats}}
<ng-container *ngIf="selectableProduct.hasAdditionalSeatsOption">
{{('additionalUsers' | i18n).toLowerCase()}}
{{selectableProduct.seatPrice / 12| currency:'$'}} /{{'month' | i18n}}
</ng-container>
</ng-container>
</span>
<span *ngIf="!selectableProduct.basePrice && selectableProduct.hasAdditionalSeatsOption">
{{'costPerUser' | i18n : (selectableProduct.seatPrice / 12 | currency:'$')}} /{{'month' | i18n}}
</span>
<span *ngIf="selectableProduct.product == productTypes.Free">{{'freeForever' | i18n}}</span>
</label> </label>
</div> </div>
<div class="form-check form-check-block" *ngIf="!ownedBusiness"> <div *ngIf="product !== productTypes.Free">
<input class="form-check-input" type="radio" name="PlanType" id="planFamilies" value="families" <ng-container *ngIf="selectedPlan.hasAdditionalSeatsOption && !selectedPlan.baseSeats">
[(ngModel)]="plan" (change)="changedPlan()">
<label class="form-check-label" for="planFamilies">
{{'planNameFamilies' | i18n}}
<small class="mb-1">{{'planDescFamilies' | i18n}}</small>
<small>• {{'addShareLimitedUsers' | i18n : '5'}}</small>
<small>• {{'createUnlimitedCollections' | i18n}}</small>
<small>• {{'gbEncryptedFileStorage' | i18n : '1 GB'}}</small>
<small>• {{'onPremHostingOptional' | i18n}}</small>
<small>• {{'priorityCustomerSupport' | i18n}}</small>
<small>• {{'xDayFreeTrial' | i18n : '7'}}</small>
<span>{{1 | currency:'$'}} /{{'month' | i18n}}, {{'includesXUsers' | i18n : 5}}</span>
</label>
</div>
<div class="form-check form-check-block">
<input class="form-check-input" type="radio" name="PlanType" id="planTeams" value="teams" [(ngModel)]="plan"
(change)="changedPlan()">
<label class="form-check-label" for="planTeams">
{{'planNameTeams' | i18n}}
<small class="mb-1">{{'planDescTeams' | i18n}}</small>
<small>• {{'addShareUnlimitedUsers' | i18n}}</small>
<small>• {{'createUnlimitedCollections' | i18n}}</small>
<small>• {{'gbEncryptedFileStorage' | i18n : '1 GB'}}</small>
<small>• {{'priorityCustomerSupport' | i18n}}</small>
<small>• {{'xDayFreeTrial' | i18n : '7'}}</small>
<span>{{5 | currency:'$'}} /{{'month' | i18n}}, {{'includesXUsers' | i18n : 5}},
{{('additionalUsers' | i18n).toLowerCase()}}
{{2 | currency:'$'}} /{{'month' | i18n}}</span>
</label>
</div>
<div class="form-check form-check-block">
<input class="form-check-input" type="radio" name="PlanType" id="planEnterprise" value="enterprise"
[(ngModel)]="plan" (change)="changedPlan()">
<label class="form-check-label" for="planEnterprise">
{{'planNameEnterprise' | i18n}}
<small class="mb-1">{{'planDescEnterprise' | i18n}}</small>
<small>• {{'addShareUnlimitedUsers' | i18n}}</small>
<small>• {{'createUnlimitedCollections' | i18n}}</small>
<small>• {{'gbEncryptedFileStorage' | i18n : '1 GB'}}</small>
<small>• {{'controlAccessWithGroups' | i18n}}</small>
<small>• {{'trackAuditLogs' | i18n}}</small>
<small>• {{'syncUsersFromDirectory' | i18n}}</small>
<small>• {{'onPremHostingOptional' | i18n}}</small>
<small>• {{'usersGetPremium' | i18n}}</small>
<small>• {{'priorityCustomerSupport' | i18n}}</small>
<small>• {{'xDayFreeTrial' | i18n : '7'}}</small>
<span>{{'costPerUser' | i18n : (3 | currency:'$')}} /{{'month' | i18n}}</span>
</label>
</div>
<ng-container *ngIf="!plans[plan].noPayment">
<ng-container *ngIf="!plans[plan].noAdditionalSeats && !plans[plan].baseSeats">
<h2 class="mt-5">{{'users' | i18n}}</h2> <h2 class="mt-5">{{'users' | i18n}}</h2>
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
@@ -113,13 +102,13 @@
</div> </div>
</ng-container> </ng-container>
<h2 class="mt-5">{{'addons' | i18n}}</h2> <h2 class="mt-5">{{'addons' | i18n}}</h2>
<div class="row" *ngIf="!plans[plan].noAdditionalSeats && plans[plan].baseSeats"> <div class="row" *ngIf="selectedPlan.hasAdditionalSeatsOption && selectedPlan.baseSeats">
<div class="form-group col-6"> <div class="form-group col-6">
<label for="additionalSeats">{{'additionalUserSeats' | i18n}}</label> <label for="additionalSeats">{{'additionalUserSeats' | i18n}}</label>
<input id="additionalSeats" class="form-control" type="number" name="AdditionalSeats" <input id="additionalSeats" class="form-control" type="number" name="AdditionalSeats"
[(ngModel)]="additionalSeats" min="0" max="100000" placeholder="{{'userSeatsDesc' | i18n}}"> [(ngModel)]="additionalSeats" min="0" max="100000" placeholder="{{'userSeatsDesc' | i18n}}">
<small <small
class="text-muted form-text">{{'userSeatsAdditionalDesc' | i18n : plans[plan].baseSeats : (plans[plan].seatPrice | currency:'$')}}</small> class="text-muted form-text">{{'userSeatsAdditionalDesc' | i18n : selectedPlan.baseSeats : (seatPriceMonthly(selectedPlan) | currency:'$')}}</small>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@@ -129,11 +118,11 @@
[(ngModel)]="additionalStorage" min="0" max="99" step="1" [(ngModel)]="additionalStorage" min="0" max="99" step="1"
placeholder="{{'additionalStorageGbDesc' | i18n}}"> placeholder="{{'additionalStorageGbDesc' | i18n}}">
<small <small
class="text-muted form-text">{{'additionalStorageIntervalDesc' | i18n : '1 GB' : (storageGb.price | currency:'$') : ('month' | i18n)}}</small> class="text-muted form-text">{{'additionalStorageIntervalDesc' | i18n : '1 GB' : (additionalStoragePriceMonthly(selectedPlan) | currency:'$') : ('month' | i18n)}}</small>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="form-group col-6" *ngIf="plans[plan].canBuyPremiumAccessAddon"> <div class="form-group col-6" *ngIf="selectedPlan.hasPremiumAccessOption">
<div class="form-check"> <div class="form-check">
<input id="premiumAccess" class="form-check-input" type="checkbox" name="PremiumAccessAddon" <input id="premiumAccess" class="form-check-input" type="checkbox" name="PremiumAccessAddon"
[(ngModel)]="premiumAccessAddon"> [(ngModel)]="premiumAccessAddon">
@@ -144,62 +133,77 @@
</div> </div>
</div> </div>
<h2 class="spaced-header">{{'summary' | i18n}}</h2> <h2 class="spaced-header">{{'summary' | i18n}}</h2>
<div class="form-check form-check-block"> <div class="form-check form-check-block" *ngFor="let selectablePlan of selectablePlans">
<input class="form-check-input" type="radio" name="BillingInterval" id="intervalAnnually" value="year" <input class="form-check-input" type="radio" name="BillingInterval" id="interval{{selectablePlan.type}}"
[(ngModel)]="interval"> [value]="selectablePlan.type" [(ngModel)]="plan">
<label class="form-check-label" for="intervalAnnually"> <label class="form-check-label" for="interval{{selectablePlan.type}}">
{{'annually' | i18n}} <ng-container *ngIf="selectablePlan.isAnnual">
<small *ngIf="plans[plan].annualBasePrice"> {{'annually' | i18n}}
{{'basePrice' | i18n}}: {{plans[plan].basePrice | currency:'$'}} &times;12 {{'monthAbbr' | i18n}} = <small *ngIf="selectablePlan.basePrice">
{{baseTotal(true) | currency:'$'}} {{'basePrice' | i18n}}: {{ selectablePlan.basePrice / 12 | currency:'$'}} &times;12
/{{'year' | i18n}} {{'monthAbbr' | i18n}}
</small> =
<small *ngIf="!plans[plan].noAdditionalSeats"> {{selectablePlan.basePrice | currency:'$'}}
<span *ngIf="plans[plan].baseSeats">{{'additionalUsers' | i18n}}:</span> /{{'year' | i18n}}
<span *ngIf="!plans[plan].baseSeats">{{'users' | i18n}}:</span> </small>
{{additionalSeats || 0}} &times; {{plans[plan].seatPrice | currency:'$'}} &times;12 <small *ngIf="selectablePlan.hasAdditionalSeatsOption">
{{'monthAbbr' | i18n}} = {{seatTotal(true) <span *ngIf="selectablePlan.baseSeats">{{'additionalUsers' | i18n}}:</span>
<span *ngIf="!selectablePlan.baseSeats">{{'users' | i18n}}:</span>
{{additionalSeats || 0}} &times; {{selectablePlan.seatPrice / 12 | currency:'$'}} &times;12
{{'monthAbbr' | i18n}} = {{seatTotal(selectablePlan)
| currency:'$'}} /{{'year' | i18n}} | currency:'$'}} /{{'year' | i18n}}
</small> </small>
<small> <small *ngIf="selectablePlan.hasAdditionalStorageOption">
{{'additionalStorageGb' | i18n}}: {{additionalStorage || 0}} &times; {{'additionalStorageGb' | i18n}}: {{additionalStorage || 0}} &times;
{{storageGb.price | currency:'$'}} &times;12 {{'monthAbbr' {{selectablePlan.additionalStoragePricePerGb / 12 | currency:'$'}} &times;12 {{'monthAbbr'
| i18n}} = {{additionalStorageTotal(true) | currency:'$'}} /{{'year' | i18n}} | i18n}} = {{additionalStorageTotal(selectablePlan) | currency:'$'}}
</small> /{{'year' | i18n}}
<small *ngIf="plans[plan].canBuyPremiumAccessAddon && premiumAccessAddon"> </small>
{{'premiumAccess' | i18n}}: <small *ngIf="selectablePlan.hasPremiumAccessOption && premiumAccessAddon">
{{3.33 | currency:'$'}} &times;12 {{'monthAbbr' | i18n}} = {{40 | currency:'$'}} /{{'year' | i18n}} {{'premiumAccess' | i18n}}:
</small> {{selectablePlan.premiumAccessOptionCost / 12 | currency:'$'}} &times;12 {{'monthAbbr' | i18n}}
</label> =
</div> {{40 | currency:'$'}}
<div class="form-check form-check-block" *ngIf="plans[plan].monthlySeatPrice"> /{{'year' | i18n}}
<input class="form-check-input" type="radio" name="BillingInterval" id="intervalMonthly" value="month" </small>
[(ngModel)]="interval"> </ng-container>
<label class="form-check-label" for="intervalMonthly"> <ng-container *ngIf="!selectablePlan.isAnnual">
{{'monthly' | i18n}} {{'monthly' | i18n}}
<small *ngIf="plans[plan].monthlyBasePrice"> <small *ngIf="selectablePlan.basePrice">
{{'basePrice' | i18n}}: {{baseTotal(false) | currency:'$'}} /{{'month' | i18n}} {{'basePrice' | i18n}}: {{selectablePlan.basePrice | currency:'$'}} {{'monthAbbr' | i18n}}
</small> =
<small *ngIf="!plans[plan].noAdditionalSeats"> {{selectablePlan.basePrice | currency:'$'}}
<span *ngIf="plans[plan].baseSeats">{{'additionalUsers' | i18n}}:</span> /{{'month' | i18n}}
<span *ngIf="!plans[plan].baseSeats">{{'users' | i18n}}:</span> </small>
{{additionalSeats || 0}} &times; {{plans[plan].monthlySeatPrice | currency:'$'}} = <small *ngIf="selectablePlan.hasAdditionalSeatsOption">
{{seatTotal(false) | currency:'$'}} /{{'month' <span *ngIf="selectablePlan.baseSeats">{{'additionalUsers' | i18n}}:</span>
| i18n}} <span *ngIf="!selectablePlan.baseSeats">{{'users' | i18n}}:</span>
</small> {{additionalSeats || 0}} &times; {{selectablePlan.seatPrice | currency:'$'}}
<small> {{'monthAbbr' | i18n}} = {{seatTotal(selectablePlan)
{{'additionalStorageGb' | i18n}}: {{additionalStorage || 0}} &times;
{{storageGb.monthlyPrice | currency:'$'}} = {{additionalStorageTotal(false)
| currency:'$'}} /{{'month' | i18n}} | currency:'$'}} /{{'month' | i18n}}
</small> </small>
<small *ngIf="selectablePlan.hasAdditionalStorageOption">
{{'additionalStorageGb' | i18n}}: {{additionalStorage || 0}} &times;
{{selectablePlan.additionalStoragePricePerGb | currency:'$'}} {{'monthAbbr'
| i18n}} = {{additionalStorageTotal(selectablePlan) | currency:'$'}}
/{{'month' | i18n}}
</small>
<small *ngIf="selectablePlan.hasPremiumAccessOption && premiumAccessAddon">
{{'premiumAccess' | i18n}}:
{{selectablePlan.premiumAccessOptionCost | currency:'$'}} {{'monthAbbr' | i18n}} =
{{40 | currency:'$'}}
/{{'month' | i18n}}
</small>
</ng-container>
</label> </label>
</div> </div>
<hr class="my-3"> <hr class="my-3">
<div class="text-lg"> <div class="text-lg">
<strong>{{'total' | i18n}}:</strong> {{total | currency:'USD $'}} /{{interval | i18n}} <strong>{{'total' | i18n}}:</strong> {{subtotal | currency:'USD $'}} /{{selectedPlanInterval | i18n}}
</div> </div>
<ng-container *ngIf="createOrganization"> <ng-container *ngIf="createOrganization">
<small class="text-muted font-italic">{{'paymentChargedWithTrial' | i18n : (interval | i18n) }}</small> <small
class="text-muted font-italic">{{'paymentChargedWithTrial' | i18n : (selectedPlanInterval | i18n) }}</small>
<h2 class="spaced-header mb-4">{{'paymentInformation' | i18n}}</h2> <h2 class="spaced-header mb-4">{{'paymentInformation' | i18n}}</h2>
<app-payment [hideCredit]="true"></app-payment> <app-payment [hideCredit]="true"></app-payment>
<app-tax-info (onCountryChanged)="changedCountry()"></app-tax-info> <app-tax-info (onCountryChanged)="changedCountry()"></app-tax-info>
@@ -209,8 +213,8 @@
</ng-container> </ng-container>
<small class="text-muted font-italic mt-2 d-block" *ngIf="!createOrganization"> <small class="text-muted font-italic mt-2 d-block" *ngIf="!createOrganization">
{{'paymentCharged' | i18n : (interval | i18n) }}</small> {{'paymentCharged' | i18n : (interval | i18n) }}</small>
</ng-container> </div>
<div [ngClass]="{'mt-4': !createOrganization || plans[plan].noPayment}"> <div class="mt-4">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading"> <button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i> <i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'submit' | i18n}}</span> <span>{{'submit' | i18n}}</span>

View File

@@ -2,6 +2,7 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output, Output,
ViewChild, ViewChild,
} from '@angular/core'; } from '@angular/core';
@@ -22,76 +23,39 @@ import { PaymentComponent } from './payment.component';
import { TaxInfoComponent } from './tax-info.component'; import { TaxInfoComponent } from './tax-info.component';
import { PlanType } from 'jslib/enums/planType'; import { PlanType } from 'jslib/enums/planType';
import { ProductType } from 'jslib/enums/productType';
import { OrganizationCreateRequest } from 'jslib/models/request/organizationCreateRequest'; import { OrganizationCreateRequest } from 'jslib/models/request/organizationCreateRequest';
import { OrganizationUpgradeRequest } from 'jslib/models/request/organizationUpgradeRequest'; import { OrganizationUpgradeRequest } from 'jslib/models/request/organizationUpgradeRequest';
import { PlanResponse } from 'jslib/models/response/planResponse';
@Component({ @Component({
selector: 'app-organization-plans', selector: 'app-organization-plans',
templateUrl: 'organization-plans.component.html', templateUrl: 'organization-plans.component.html',
}) })
export class OrganizationPlansComponent { export class OrganizationPlansComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent; @ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent;
@Input() organizationId: string; @Input() organizationId: string;
@Input() showFree = true; @Input() showFree = true;
@Input() showCancel = false; @Input() showCancel = false;
@Input() plan = 'free'; @Input() product: ProductType = ProductType.Free;
@Input() plan: PlanType = PlanType.Free;
@Output() onSuccess = new EventEmitter(); @Output() onSuccess = new EventEmitter();
@Output() onCanceled = new EventEmitter(); @Output() onCanceled = new EventEmitter();
selfHosted = false; loading: boolean = true;
ownedBusiness = false; selfHosted: boolean = false;
premiumAccessAddon = false; ownedBusiness: boolean = false;
storageGbPriceMonthly = 0.33; premiumAccessAddon: boolean = false;
additionalStorage = 0; additionalStorage: number = 0;
additionalSeats = 0; additionalSeats: number = 0;
interval = 'year';
name: string; name: string;
billingEmail: string; billingEmail: string;
businessName: string; businessName: string;
storageGb: any = { plans: PlanResponse[];
price: 0.33,
monthlyPrice: 0.50,
yearlyPrice: 4,
};
plans: any = {
free: {
basePrice: 0,
noAdditionalSeats: true,
noPayment: true,
},
families: {
basePrice: 1,
annualBasePrice: 12,
baseSeats: 5,
noAdditionalSeats: true,
annualPlanType: PlanType.FamiliesAnnually,
canBuyPremiumAccessAddon: true,
},
teams: {
basePrice: 5,
annualBasePrice: 60,
monthlyBasePrice: 8,
baseSeats: 5,
seatPrice: 2,
annualSeatPrice: 24,
monthlySeatPrice: 2.5,
monthPlanType: PlanType.TeamsMonthly,
annualPlanType: PlanType.TeamsAnnually,
},
enterprise: {
seatPrice: 3,
annualSeatPrice: 36,
monthlySeatPrice: 4,
monthPlanType: PlanType.EnterpriseMonthly,
annualPlanType: PlanType.EnterpriseAnnually,
},
};
formPromise: Promise<any>;
constructor(private apiService: ApiService, private i18nService: I18nService, constructor(private apiService: ApiService, private i18nService: I18nService,
private analytics: Angulartics2, private toasterService: ToasterService, private analytics: Angulartics2, private toasterService: ToasterService,
@@ -100,6 +64,134 @@ export class OrganizationPlansComponent {
this.selfHosted = platformUtilsService.isSelfHost(); this.selfHosted = platformUtilsService.isSelfHost();
} }
async ngOnInit() {
const plans = await this.apiService.getPlans();
this.plans = plans.data;
this.loading = false;
}
get createOrganization() {
return this.organizationId == null;
}
get productTypes() {
return ProductType;
}
get selectedPlan() {
return this.plans.find((plan) => plan.type === this.plan);
}
get selectedPlanInterval() {
return this.selectedPlan.isAnnual
? 'year'
: 'month';
}
get selectableProducts() {
let validPlans = this.plans;
if (this.ownedBusiness) {
validPlans = validPlans.filter((plan) => plan.canBeUsedByBusiness);
}
if (!this.showFree) {
validPlans = validPlans.filter((plan) => plan.product !== ProductType.Free);
}
validPlans = validPlans
.filter((plan) => !plan.legacyYear
&& !plan.disabled
&& (plan.isAnnual || plan.product === this.productTypes.Free));
return validPlans;
}
get selectablePlans() {
return this.plans.filter((plan) => !plan.legacyYear && !plan.disabled && plan.product === this.product);
}
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
if (!selectedPlan.isAnnual) {
return selectedPlan.additionalStoragePricePerGb;
}
return selectedPlan.additionalStoragePricePerGb / 12;
}
seatPriceMonthly(selectedPlan: PlanResponse) {
if (!selectedPlan.isAnnual) {
return selectedPlan.seatPrice;
}
return selectedPlan.seatPrice / 12;
}
additionalStorageTotal(plan: PlanResponse): number {
if (!plan.hasAdditionalStorageOption) {
return 0;
}
return plan.additionalStoragePricePerGb * Math.abs(this.additionalStorage || 0);
}
seatTotal(plan: PlanResponse): number {
if (!plan.hasAdditionalSeatsOption) {
return 0;
}
return plan.seatPrice * Math.abs(this.additionalSeats || 0);
}
get subtotal() {
let subTotal = this.selectedPlan.basePrice;
if (this.selectedPlan.hasAdditionalSeatsOption && this.additionalSeats) {
subTotal += this.seatTotal(this.selectedPlan);
}
if (this.selectedPlan.hasAdditionalStorageOption && this.additionalStorage) {
subTotal += this.additionalStorageTotal(this.selectedPlan);
}
if (this.selectedPlan.hasPremiumAccessOption && this.premiumAccessAddon) {
subTotal += this.selectedPlan.premiumAccessOptionPrice;
}
return subTotal;
}
changedProduct() {
this.plan = this.selectablePlans[0].type;
if (!this.selectedPlan.hasPremiumAccessOption) {
this.premiumAccessAddon = false;
}
if (!this.selectedPlan.hasAdditionalStorageOption) {
this.additionalStorage = 0;
}
if (!this.selectedPlan.hasAdditionalSeatsOption) {
this.additionalSeats = 0;
} else if (!this.additionalSeats && !this.selectedPlan.baseSeats &&
this.selectedPlan.hasAdditionalSeatsOption) {
this.additionalSeats = 1;
}
}
changedOwnedBusiness() {
if (!this.ownedBusiness || this.selectedPlan.canBeUsedByBusiness) {
return;
}
this.plan = PlanType.TeamsMonthly;
}
changedCountry() {
this.paymentComponent.hideBank = this.taxComponent.taxInfo.country !== 'US';
// Bank Account payments are only available for US customers
if (this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
cancel() {
this.onCanceled.emit();
}
async submit() { async submit() {
let files: FileList = null; let files: FileList = null;
if (this.createOrganization && this.selfHosted) { if (this.createOrganization && this.selfHosted) {
@@ -117,7 +209,7 @@ export class OrganizationPlansComponent {
let orgId: string = null; let orgId: string = null;
if (this.createOrganization) { if (this.createOrganization) {
let tokenResult: [string, PaymentMethodType] = null; let tokenResult: [string, PaymentMethodType] = null;
if (!this.selfHosted && this.plan !== 'free') { if (!this.selfHosted && this.plan !== PlanType.Free) {
tokenResult = await this.paymentComponent.createPaymentToken(); tokenResult = await this.paymentComponent.createPaymentToken();
} }
const shareKey = await this.cryptoService.makeShareKey(); const shareKey = await this.cryptoService.makeShareKey();
@@ -140,7 +232,7 @@ export class OrganizationPlansComponent {
request.name = this.name; request.name = this.name;
request.billingEmail = this.billingEmail; request.billingEmail = this.billingEmail;
if (this.plan === 'free') { if (this.selectedPlan.type === PlanType.Free) {
request.planType = PlanType.Free; request.planType = PlanType.Free;
} else { } else {
request.paymentToken = tokenResult[0]; request.paymentToken = tokenResult[0];
@@ -148,13 +240,9 @@ export class OrganizationPlansComponent {
request.businessName = this.ownedBusiness ? this.businessName : null; request.businessName = this.ownedBusiness ? this.businessName : null;
request.additionalSeats = this.additionalSeats; request.additionalSeats = this.additionalSeats;
request.additionalStorageGb = this.additionalStorage; request.additionalStorageGb = this.additionalStorage;
request.premiumAccessAddon = this.plans[this.plan].canBuyPremiumAccessAddon && request.premiumAccessAddon = this.selectedPlan.hasPremiumAccessOption &&
this.premiumAccessAddon; this.premiumAccessAddon;
if (this.interval === 'month') { request.planType = this.selectedPlan.type;
request.planType = this.plans[this.plan].monthPlanType;
} else {
request.planType = this.plans[this.plan].annualPlanType;
}
request.billingAddressPostalCode = this.taxComponent.taxInfo.postalCode; request.billingAddressPostalCode = this.taxComponent.taxInfo.postalCode;
request.billingAddressCountry = this.taxComponent.taxInfo.country; request.billingAddressCountry = this.taxComponent.taxInfo.country;
if (this.taxComponent.taxInfo.includeTaxId) { if (this.taxComponent.taxInfo.includeTaxId) {
@@ -173,13 +261,9 @@ export class OrganizationPlansComponent {
request.businessName = this.ownedBusiness ? this.businessName : null; request.businessName = this.ownedBusiness ? this.businessName : null;
request.additionalSeats = this.additionalSeats; request.additionalSeats = this.additionalSeats;
request.additionalStorageGb = this.additionalStorage; request.additionalStorageGb = this.additionalStorage;
request.premiumAccessAddon = this.plans[this.plan].canBuyPremiumAccessAddon && request.premiumAccessAddon = this.selectedPlan.hasPremiumAccessOption &&
this.premiumAccessAddon; this.premiumAccessAddon;
if (this.interval === 'month') { request.planType = this.selectedPlan.type;
request.planType = this.plans[this.plan].monthPlanType;
} else {
request.planType = this.plans[this.plan].annualPlanType;
}
const result = await this.apiService.postOrganizationUpgrade(this.organizationId, request); const result = await this.apiService.postOrganizationUpgrade(this.organizationId, request);
if (!result.success && result.paymentIntentClientSecret != null) { if (!result.success && result.paymentIntentClientSecret != null) {
await this.paymentComponent.handleStripeCardPayment(result.paymentIntentClientSecret, null); await this.paymentComponent.handleStripeCardPayment(result.paymentIntentClientSecret, null);
@@ -202,94 +286,10 @@ export class OrganizationPlansComponent {
} }
}; };
this.formPromise = doSubmit(); const formPromise = doSubmit();
await this.formPromise; await formPromise;
this.onSuccess.emit(); this.onSuccess.emit();
} catch { } } catch { }
} }
cancel() {
this.onCanceled.emit();
}
changedPlan() {
if (!this.plans[this.plan].canBuyPremiumAccessAddon) {
this.premiumAccessAddon = false;
}
if (this.plans[this.plan].monthPlanType == null) {
this.interval = 'year';
}
if (this.plans[this.plan].noAdditionalSeats) {
this.additionalSeats = 0;
} else if (!this.additionalSeats && !this.plans[this.plan].baseSeats &&
!this.plans[this.plan].noAdditionalSeats) {
this.additionalSeats = 1;
}
}
changedOwnedBusiness() {
if (!this.ownedBusiness || this.plan === 'teams' || this.plan === 'enterprise') {
return;
}
this.plan = 'teams';
}
additionalStorageTotal(annual: boolean): number {
if (annual) {
return Math.abs(this.additionalStorage || 0) * this.storageGb.yearlyPrice;
} else {
return Math.abs(this.additionalStorage || 0) * this.storageGb.monthlyPrice;
}
}
seatTotal(annual: boolean): number {
if (this.plans[this.plan].noAdditionalSeats) {
return 0;
}
if (annual) {
return this.plans[this.plan].annualSeatPrice * Math.abs(this.additionalSeats || 0);
} else {
return this.plans[this.plan].monthlySeatPrice * Math.abs(this.additionalSeats || 0);
}
}
baseTotal(annual: boolean): number {
if (annual) {
return Math.abs(this.plans[this.plan].annualBasePrice || 0);
} else {
return Math.abs(this.plans[this.plan].monthlyBasePrice || 0);
}
}
premiumAccessTotal(annual: boolean): number {
if (this.plans[this.plan].canBuyPremiumAccessAddon && this.premiumAccessAddon) {
if (annual) {
return 40;
}
}
return 0;
}
changedCountry() {
this.paymentComponent.hideBank = this.taxComponent.taxInfo.country !== 'US';
// Bank Account payments are only available for US customers
if (this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
get total(): number {
const annual = this.interval === 'year';
return this.baseTotal(annual) + this.seatTotal(annual) + this.additionalStorageTotal(annual) +
this.premiumAccessTotal(annual);
}
get createOrganization() {
return this.organizationId == null;
}
} }