From 0650cafb2855514fe492c8a6e1fa3ef156a62b25 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 16 Jul 2018 17:17:07 -0400 Subject: [PATCH] org billing settings setup --- jslib | 2 +- src/app/app-routing.module.ts | 6 + src/app/app.module.ts | 2 + .../settings/delete-organization.component.ts | 2 +- .../organization-billing.component.html | 152 +++++++++++++ .../organization-billing.component.ts | 200 ++++++++++++++++++ .../settings/adjust-payment.component.html | 2 +- src/app/settings/adjust-payment.component.ts | 6 +- src/app/settings/adjust-storage.component.ts | 6 +- .../create-organization.component.html | 2 +- src/app/settings/delete-account.component.ts | 2 +- src/app/settings/payment.component.html | 35 ++- src/app/settings/payment.component.ts | 38 +++- src/app/settings/premium.component.html | 2 +- src/app/settings/user-billing.component.html | 18 +- src/app/settings/user-billing.component.ts | 7 +- src/locales/en/messages.json | 45 ++++ 17 files changed, 501 insertions(+), 26 deletions(-) create mode 100644 src/app/organizations/settings/organization-billing.component.html create mode 100644 src/app/organizations/settings/organization-billing.component.ts diff --git a/jslib b/jslib index b2c700ad28..6b4ae1b8d5 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit b2c700ad285d9336426284f766b434be8c509f0a +Subproject commit 6b4ae1b8d591e973fe4036d3c10b6f0be81debc9 diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 7679bbce34..7872bb4023 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -26,6 +26,7 @@ import { ManageComponent as OrgManageComponent } from './organizations/manage/ma import { PeopleComponent as OrgPeopleComponent } from './organizations/manage/people.component'; import { AccountComponent as OrgAccountComponent } from './organizations/settings/account.component'; +import { OrganizationBillingComponent } from './organizations/settings/organization-billing.component'; import { SettingsComponent as OrgSettingsComponent } from './organizations/settings/settings.component'; import { ExportComponent as OrgExportComponent } from './organizations/tools/export.component'; @@ -187,6 +188,11 @@ const routes: Routes = [ children: [ { path: '', pathMatch: 'full', redirectTo: 'account' }, { path: 'account', component: OrgAccountComponent, data: { titleId: 'myOrganization' } }, + { + path: 'billing', + component: OrganizationBillingComponent, + data: { titleId: 'billingAndLicensing' }, + }, ], }, ], diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f440021c9f..a1ebf397f5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -53,6 +53,7 @@ import { UserGroupsComponent as OrgUserGroupsComponent } from './organizations/m import { AccountComponent as OrgAccountComponent } from './organizations/settings/account.component'; import { DeleteOrganizationComponent } from './organizations/settings/delete-organization.component'; +import { OrganizationBillingComponent } from './organizations/settings/organization-billing.component'; import { SettingsComponent as OrgSettingComponent } from './organizations/settings/settings.component'; import { ExportComponent as OrgExportComponent } from './organizations/tools/export.component'; @@ -188,6 +189,7 @@ import { SearchPipe } from 'jslib/angular/pipes/search.pipe'; OptionsComponent, OrgAccountComponent, OrgAddEditComponent, + OrganizationBillingComponent, OrgAttachmentsComponent, OrgCiphersComponent, OrgCollectionAddEditComponent, diff --git a/src/app/organizations/settings/delete-organization.component.ts b/src/app/organizations/settings/delete-organization.component.ts index 148999cd4d..b8a584c112 100644 --- a/src/app/organizations/settings/delete-organization.component.ts +++ b/src/app/organizations/settings/delete-organization.component.ts @@ -34,7 +34,7 @@ export class DeleteOrganizationComponent { const request = new PasswordVerificationRequest(); request.masterPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, null); try { - this.formPromise = this.apiService.postDeleteOrganization(this.organizationId, request); + this.formPromise = this.apiService.deleteOrganization(this.organizationId, request); await this.formPromise; this.analytics.eventTrack.next({ action: 'Deleted Organization' }); this.toasterService.popAsync('success', this.i18nService.t('organizationDeleted'), diff --git a/src/app/organizations/settings/organization-billing.component.html b/src/app/organizations/settings/organization-billing.component.html new file mode 100644 index 0000000000..cc6d7324c4 --- /dev/null +++ b/src/app/organizations/settings/organization-billing.component.html @@ -0,0 +1,152 @@ + + + + {{'subscriptionCanceled' | i18n}} + +

{{'subscriptionPendingCanceled' | i18n}}

+ +
+
+
{{'billingPlan' | i18n}}
+
{{billing.plan}}
+
{{'expiration' | i18n}}
+
{{billing.expiration | date:'mediumDate'}}
+
{{'neverExpires' | i18n}}
+
+
+
+
+
{{'billingPlan' | i18n}}
+
{{billing.plan}}
+ +
{{'status' | i18n}}
+
+ {{subscription.status || '-'}} + {{'pendingCancellation' | i18n}} +
+
{{'nextCharge' | i18n}}
+
{{nextInvoice ? ((nextInvoice.date | date: 'mediumDate') + ', ' + (nextInvoice.amount | currency:'$')) + : '-'}} +
+
+
+
+
+ {{'details' | i18n}} + + + + + + + +
+ {{i.name}} {{i.quantity > 1 ? '×' + i.quantity : ''}} @ {{i.amount | currency:'$'}} + + {{(i.quantity * i.amount) | currency:'$'}} /{{i.interval | i18n}} +
+
+
+ +
+ + + {{'manageSubscription' | i18n}} + +
+
+
+

{{'updateLicense' | i18n}}

+ +
+
+
+ +
+ + + +
+

{{'storage' | i18n}}

+

{{'subscriptionStorage' | i18n : billing.maxStorageGb || 0 : billing.storageName || '0 MB'}}

+
+
{{(storagePercentage / 100) | percent}}
+
+ +
+
+ + +
+ +
+
+

{{'paymentMethod' | i18n}}

+

{{'noPaymentMethod' | i18n}}

+ + +

{{'verifyBankAccountDesc' | i18n}} {{'verifyBankAccountFailureWarning' | i18n}}

+ +
+

+ + {{paymentSource.description}} +

+
+ + + +

{{'charges' | i18n}}

+

{{'noCharges' | i18n}}

+ + + + + + + + + + +
+ + + + {{c.createdDate | date:'mediumDate'}}{{c.paymentSource ? c.paymentSource.description : '-'}}{{c.status}}{{c.amount | currency:'$'}}
+ * {{'chargesStatement' | i18n : 'BITWARDEN'}} +
+
diff --git a/src/app/organizations/settings/organization-billing.component.ts b/src/app/organizations/settings/organization-billing.component.ts new file mode 100644 index 0000000000..cfcf200644 --- /dev/null +++ b/src/app/organizations/settings/organization-billing.component.ts @@ -0,0 +1,200 @@ +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router +} from '@angular/router'; + +import { ToasterService } from 'angular2-toaster'; +import { Angulartics2 } from 'angulartics2'; + + +import { BillingChargeResponse } from 'jslib/models/response/billingResponse'; +import { OrganizationBillingResponse } from 'jslib/models/response/organizationBillingResponse'; + +import { ApiService } from 'jslib/abstractions/api.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; +import { TokenService } from 'jslib/abstractions/token.service'; + +import { PaymentMethodType } from 'jslib/enums/paymentMethodType'; +import { PlanType } from 'jslib/enums/planType'; + +@Component({ + selector: 'app-org-billing', + templateUrl: 'organization-billing.component.html', +}) +export class OrganizationBillingComponent implements OnInit { + loading = false; + firstLoaded = false; + organizationId: string; + adjustStorageAdd = true; + showAdjustStorage = false; + showAdjustPayment = false; + showUpdateLicense = false; + billing: OrganizationBillingResponse; + paymentMethodType = PaymentMethodType; + selfHosted = false; + + cancelPromise: Promise; + reinstatePromise: Promise; + + constructor(private tokenService: TokenService, private apiService: ApiService, + private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, + private analytics: Angulartics2, private toasterService: ToasterService, + private router: Router, private route: ActivatedRoute) { + this.selfHosted = platformUtilsService.isSelfHost(); + } + + async ngOnInit() { + this.route.parent.parent.params.subscribe(async (params) => { + this.organizationId = params.organizationId; + await this.load(); + this.firstLoaded = true; + }); + } + + async load() { + if (this.loading) { + return; + } + this.loading = true; + this.billing = await this.apiService.getOrganizationBilling(this.organizationId); + this.loading = false; + } + + async reinstate() { + if (this.loading) { + return; + } + + const confirmed = await this.platformUtilsService.showDialog(this.i18nService.t('reinstateConfirmation'), + this.i18nService.t('reinstateSubscription'), this.i18nService.t('yes'), this.i18nService.t('cancel')); + if (!confirmed) { + return; + } + + try { + this.reinstatePromise = this.apiService.postReinstatePremium(); + await this.reinstatePromise; + this.analytics.eventTrack.next({ action: 'Reinstated Premium' }); + this.toasterService.popAsync('success', null, this.i18nService.t('reinstated')); + this.load(); + } catch { } + } + + async cancel() { + if (this.loading) { + return; + } + + const confirmed = await this.platformUtilsService.showDialog(this.i18nService.t('cancelConfirmation'), + this.i18nService.t('cancelSubscription'), this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); + if (!confirmed) { + return; + } + + try { + this.cancelPromise = this.apiService.postCancelPremium(); + await this.cancelPromise; + this.analytics.eventTrack.next({ action: 'Canceled Premium' }); + this.toasterService.popAsync('success', null, this.i18nService.t('canceledSubscription')); + this.load(); + } catch { } + } + + downloadLicense() { + if (this.loading) { + return; + } + } + + updateLicense() { + if (this.loading) { + return; + } + this.showUpdateLicense = true; + } + + closeUpdateLicense(load: boolean) { + this.showUpdateLicense = false; + if (load) { + this.load(); + } + } + + adjustStorage(add: boolean) { + this.adjustStorageAdd = add; + this.showAdjustStorage = true; + } + + closeStorage(load: boolean) { + this.showAdjustStorage = false; + if (load) { + this.load(); + } + } + + changePayment() { + this.showAdjustPayment = true; + } + + closePayment(load: boolean) { + this.showAdjustPayment = false; + if (load) { + this.load(); + } + } + + async viewInvoice(charge: BillingChargeResponse) { + const token = await this.tokenService.getToken(); + const url = this.apiService.apiBaseUrl + '/organizations/' + this.organizationId + + '/billing-invoice/' + charge.invoiceId + '?access_token=' + token; + this.platformUtilsService.launchUri(url); + } + + get subscriptionMarkedForCancel() { + return this.subscription != null && !this.subscription.cancelled && this.subscription.cancelAtEndDate; + } + + get subscription() { + return this.billing != null ? this.billing.subscription : null; + } + + get nextInvoice() { + return this.billing != null ? this.billing.upcomingInvoice : null; + } + + get paymentSource() { + return this.billing != null ? this.billing.paymentSource : null; + } + + get charges() { + return this.billing != null ? this.billing.charges : null; + } + + get storagePercentage() { + return this.billing != null && this.billing.maxStorageGb ? + +(100 * (this.billing.storageGb / this.billing.maxStorageGb)).toFixed(2) : 0; + } + + get storageProgressWidth() { + return this.storagePercentage < 5 ? 5 : 0; + } + + get billingInterval() { + const monthly = this.billing.planType === PlanType.EnterpriseMonthly || + this.billing.planType === PlanType.TeamsMonthly; + return monthly ? 'month' : 'year'; + } + + get storageGbPrice() { + return this.billingInterval === 'month' ? 0.5 : 4; + } + + get seatPrice() { + return 4; + } +} diff --git a/src/app/settings/adjust-payment.component.html b/src/app/settings/adjust-payment.component.html index 03c45e7836..056820b246 100644 --- a/src/app/settings/adjust-payment.component.html +++ b/src/app/settings/adjust-payment.component.html @@ -10,7 +10,7 @@

{{(currentType != null ? 'changePaymentMethod' : 'addPaymentMethod') | i18n}}

- +
{{'paymentChargedWithTrial' | i18n : (interval | i18n) }}

{{'paymentInformation' | i18n}}

- +
-
+
+ + +
+
@@ -309,3 +314,31 @@ {{'paypalClickSubmit' | i18n}}
+ + + {{'verifyBankAccountInitialDesc' | i18n}} {{'verifyBankAccountFailureWarning' | i18n}} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/src/app/settings/payment.component.ts b/src/app/settings/payment.component.ts index 7ca2dc64fb..282c454b4c 100644 --- a/src/app/settings/payment.component.ts +++ b/src/app/settings/payment.component.ts @@ -20,8 +20,10 @@ const Keys = { }) export class PaymentComponent implements OnInit { @Input() showOptions = true; + @Input() method = 'card'; + @Input() hideBank = false; + @Input() hidePaypal = false; - method = 'card'; card: any = { number: null, exp_month: null, @@ -29,6 +31,14 @@ export class PaymentComponent implements OnInit { address_country: '', address_zip: null, }; + bank: any = { + routing_number: null, + account_number: null, + account_holder_name: null, + account_holder_type: '', + currency: 'USD', + country: 'US', + }; cardExpMonthOptions: any[]; cardExpYearOptions: any[]; @@ -68,24 +78,32 @@ export class PaymentComponent implements OnInit { { name: '-- ' + i18nService.t('select') + ' --', value: null }, ]; const year = (new Date()).getFullYear(); - for (let i = year; i < (year + 10); i++) { + for (let i = year; i < (year + 15); i++) { this.cardExpYearOptions.push({ name: i.toString(), value: i.toString().slice(-2) }); } } ngOnInit() { + if (!this.showOptions) { + this.hidePaypal = this.method !== 'paypal'; + this.hideBank = this.method !== 'bank'; + } window.document.head.appendChild(this.stripeScript); - window.document.head.appendChild(this.btScript); + if (!this.hidePaypal) { + window.document.head.appendChild(this.btScript); + } } ngOnDestroy() { window.document.head.removeChild(this.stripeScript); - window.document.head.removeChild(this.btScript); Array.from(window.document.querySelectorAll('iframe')).forEach((el) => { if (el.src != null && el.src.indexOf('stripe') > -1) { window.document.body.removeChild(el); } }); + if (!this.hidePaypal) { + window.document.head.removeChild(this.btScript); + } } changeMethod() { @@ -127,7 +145,7 @@ export class PaymentComponent implements OnInit { }).catch((err: any) => { reject(err.message); }); - } else { + } else if (this.method === 'card') { (window as any).Stripe.card.createToken(this.card, (status: number, response: any) => { if (status === 200 && response.id != null) { resolve(response.id); @@ -137,6 +155,16 @@ export class PaymentComponent implements OnInit { reject(); } }); + } else if (this.method === 'bank') { + (window as any).Stripe.bankAccount.createToken(this.bank, (status: number, response: any) => { + if (status === 200 && response.id != null) { + resolve(response.id); + } else if (response.error != null) { + reject(response.error.message); + } else { + reject(); + } + }); } }); } diff --git a/src/app/settings/premium.component.html b/src/app/settings/premium.component.html index d436234929..973593c889 100644 --- a/src/app/settings/premium.component.html +++ b/src/app/settings/premium.component.html @@ -65,7 +65,7 @@
{{'paymentChargedAnnually' | i18n}}

{{'paymentInformation' | i18n}}

- +