mirror of
https://github.com/bitwarden/browser
synced 2026-02-04 02:33:33 +00:00
Changes for premium subscription page
This commit is contained in:
@@ -52,6 +52,18 @@
|
||||
"submit": {
|
||||
"message": "Submit"
|
||||
},
|
||||
"upgrade": {
|
||||
"message": "Upgrade"
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"upgradeToFamilies": {
|
||||
"message": "Upgrade to Families"
|
||||
},
|
||||
"familiesUpgradeSuccess": {
|
||||
"message": "You've upgrade to Families!"
|
||||
},
|
||||
"emailAddress": {
|
||||
"message": "Email address"
|
||||
},
|
||||
@@ -3136,6 +3148,9 @@
|
||||
"organizationName": {
|
||||
"message": "Organization name"
|
||||
},
|
||||
"organizationNameDescription": {
|
||||
"message": "Your organization name will appear in invitations you send to members."
|
||||
},
|
||||
"keyConnectorDomain": {
|
||||
"message": "Key Connector domain"
|
||||
},
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { BillingSharedModule } from "../shared";
|
||||
|
||||
import { BillingHistoryViewComponent } from "./billing-history-view.component";
|
||||
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module";
|
||||
import { PremiumComponent } from "./premium/premium.component";
|
||||
import { UpgradeDialogComponent } from "./premium/upgrade-dialog.component";
|
||||
import { SubscriptionComponent } from "./subscription.component";
|
||||
import { UserSubscriptionComponent } from "./user-subscription.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [IndividualBillingRoutingModule, BillingSharedModule, HeaderModule],
|
||||
imports: [
|
||||
IndividualBillingRoutingModule,
|
||||
BillingSharedModule,
|
||||
HeaderModule,
|
||||
PricingCardComponent,
|
||||
],
|
||||
declarations: [
|
||||
SubscriptionComponent,
|
||||
BillingHistoryViewComponent,
|
||||
UserSubscriptionComponent,
|
||||
PremiumComponent,
|
||||
UpgradeDialogComponent,
|
||||
],
|
||||
})
|
||||
export class IndividualBillingModule {}
|
||||
|
||||
@@ -1,119 +1,142 @@
|
||||
<bit-section>
|
||||
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
|
||||
<bit-callout
|
||||
type="info"
|
||||
*ngIf="hasPremiumFromAnyOrganization$ | async"
|
||||
title="{{ 'youHavePremiumAccess' | i18n }}"
|
||||
icon="bwi bwi-star-f"
|
||||
>
|
||||
{{ "alreadyPremiumFromOrg" | i18n }}
|
||||
</bit-callout>
|
||||
<bit-callout type="success">
|
||||
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpEmergency" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpReports" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpTotp" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpSupport" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpFuture" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
|
||||
{{
|
||||
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
|
||||
}}
|
||||
<div class="tw-max-w-4xl tw-mx-auto">
|
||||
<bit-section *ngIf="shouldShowNewDesign$ | async">
|
||||
<div class="tw-max-w-4xl tw-ml-auto tw-mr-16 tw-text-center">
|
||||
<div
|
||||
class="tw-inline-block tw-bg-background-alt tw-border-[0.5px] tw-border-secondary-700 tw-rounded-full tw-px-2 tw-py-1 tw-mt-8 tw-mb-6"
|
||||
>
|
||||
<span bitTypography="helper" class="tw-text-secondary-700">
|
||||
You have the Bitwarden Free plan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 *ngIf="!isSelfHost" class="tw-mt-6 tw-text-4xl">Upgrade for complete security</h2>
|
||||
<p class="tw-text-muted tw-mb-6">
|
||||
Unlock more security features with Premium, or start sharing items with Families
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Two-Card Layout -->
|
||||
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-6 tw-mt-6 tw-justify-center">
|
||||
<!-- Premium Card -->
|
||||
<div>
|
||||
@if (premiumCardData$ | async; as premiumData) {
|
||||
<billing-pricing-card
|
||||
[tagline]="'Complete online security'"
|
||||
[price]="{ amount: premiumData.price, cadence: 'monthly' }"
|
||||
[button]="{ type: 'primary', text: 'Upgrade to Premium' }"
|
||||
[features]="premiumData.features"
|
||||
(buttonClick)="openUpgradeDialog('Premium')"
|
||||
>
|
||||
<h3 slot="title" bitTypography="h3" class="tw-m-0">Premium</h3>
|
||||
</billing-pricing-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Families Card -->
|
||||
<div>
|
||||
@if (familiesCardData$ | async; as familiesData) {
|
||||
<billing-pricing-card
|
||||
[tagline]="'Premium security for your family'"
|
||||
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
|
||||
[button]="{ type: 'secondary', text: 'Upgrade to Families' }"
|
||||
[features]="familiesData.features"
|
||||
(buttonClick)="openUpgradeDialog('Families')"
|
||||
>
|
||||
<h3 slot="title" bitTypography="h3" class="tw-m-0">Families</h3>
|
||||
</billing-pricing-card>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Plans Link -->
|
||||
<div class="tw-text-center tw-mt-6">
|
||||
<p class="tw-text-muted tw-mb-2">Prices exclude tax and are billed annually</p>
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
routerLink="/create-organization"
|
||||
[queryParams]="{ plan: 'families' }"
|
||||
href="https://bitwarden.com/pricing/business/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ "bitwardenFamiliesPlan" | i18n }}
|
||||
View business plans <i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
bitButton
|
||||
href="{{ premiumURL }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
buttonType="secondary"
|
||||
*ngIf="isSelfHost"
|
||||
</div>
|
||||
</bit-section>
|
||||
|
||||
<!-- Legacy Design (shown when user already has premium access) -->
|
||||
<bit-section *ngIf="!(shouldShowNewDesign$ | async)">
|
||||
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
|
||||
<bit-callout
|
||||
type="info"
|
||||
*ngIf="hasPremiumFromAnyOrganization$ | async"
|
||||
title="{{ 'youHavePremiumAccess' | i18n }}"
|
||||
icon="bwi bwi-star-f"
|
||||
>
|
||||
{{ "purchasePremium" | i18n }}
|
||||
</a>
|
||||
</bit-callout>
|
||||
</bit-section>
|
||||
<bit-section *ngIf="isSelfHost">
|
||||
<individual-self-hosting-license-uploader
|
||||
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
|
||||
/>
|
||||
</bit-section>
|
||||
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
formControlName="additionalStorage"
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
|
||||
/>
|
||||
<bit-hint>{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
{{ "alreadyPremiumFromOrg" | i18n }}
|
||||
</bit-callout>
|
||||
<bit-callout type="success">
|
||||
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpEmergency" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpReports" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpTotp" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpSupport" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpFuture" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
|
||||
{{
|
||||
"premiumPriceWithFamilyPlan"
|
||||
| i18n
|
||||
: (((getPremiumPrice() | async) || 0) * 12 | currency: "$")
|
||||
: familyPlanMaxUserCount
|
||||
}}
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
routerLink="/create-organization"
|
||||
[queryParams]="{ plan: 'families' }"
|
||||
>
|
||||
{{ "bitwardenFamiliesPlan" | i18n }}
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
bitButton
|
||||
href="{{ premiumURL }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
buttonType="secondary"
|
||||
*ngIf="isSelfHost"
|
||||
>
|
||||
{{ "purchasePremium" | i18n }}
|
||||
</a>
|
||||
</bit-callout>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
|
||||
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
|
||||
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB ×
|
||||
{{ storageGBPrice | currency: "$" }} =
|
||||
{{ additionalStorageCost | currency: "$" }}
|
||||
<hr class="tw-my-3" />
|
||||
<bit-section *ngIf="isSelfHost">
|
||||
<individual-self-hosting-license-uploader
|
||||
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
|
||||
/>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
|
||||
<app-payment [showBankAccount]="false"></app-payment>
|
||||
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()"></app-tax-info>
|
||||
<div class="tw-mb-4">
|
||||
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
|
||||
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
|
||||
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
|
||||
<p bitTypography="body1">
|
||||
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
|
||||
</p>
|
||||
<button type="submit" buttonType="primary" bitButton bitFormButton>
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, ViewChild } from "@angular/core";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { combineLatest, concatMap, from, Observable, of, switchMap } from "rxjs";
|
||||
import { debounceTime } from "rxjs/operators";
|
||||
import { combineLatest, concatMap, firstValueFrom, from, Observable, of, switchMap } from "rxjs";
|
||||
import { map, shareReplay } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PaymentComponent } from "../../shared/payment/payment.component";
|
||||
import { TaxInfoComponent } from "../../shared/tax-info.component";
|
||||
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
|
||||
import { PersonalSubscriptionPricingTier } from "../../types/subscription-pricing-tier";
|
||||
|
||||
import { UpgradeDialogComponent, UpgradeDialogResult } from "./upgrade-dialog.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./premium.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class PremiumComponent {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
|
||||
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||
protected hasPremiumPersonally$: Observable<boolean>;
|
||||
protected shouldShowNewDesign$: Observable<boolean>;
|
||||
protected personalPricingTiers$: Observable<PersonalSubscriptionPricingTier[]>;
|
||||
protected premiumCardData$: Observable<{
|
||||
tier: PersonalSubscriptionPricingTier | undefined;
|
||||
price: number;
|
||||
features: string[];
|
||||
}>;
|
||||
protected familiesCardData$: Observable<{
|
||||
tier: PersonalSubscriptionPricingTier | undefined;
|
||||
price: number;
|
||||
features: string[];
|
||||
}>;
|
||||
|
||||
protected addOnFormGroup = new FormGroup({
|
||||
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
|
||||
@@ -43,11 +54,10 @@ export class PremiumComponent {
|
||||
|
||||
protected cloudWebVaultURL: string;
|
||||
protected isSelfHost = false;
|
||||
protected providerId: string;
|
||||
|
||||
protected estimatedTax: number = 0;
|
||||
protected readonly familyPlanMaxUserCount = 6;
|
||||
protected readonly premiumPrice = 10;
|
||||
protected readonly storageGBPrice = 4;
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
@@ -63,6 +73,8 @@ export class PremiumComponent {
|
||||
private tokenService: TokenService,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private dialogService: DialogService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
) {
|
||||
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
||||
|
||||
@@ -72,6 +84,53 @@ export class PremiumComponent {
|
||||
),
|
||||
);
|
||||
|
||||
this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
|
||||
),
|
||||
);
|
||||
|
||||
// Show new design when user doesn't have premium from any source
|
||||
this.shouldShowNewDesign$ = combineLatest([
|
||||
this.hasPremiumFromAnyOrganization$,
|
||||
this.hasPremiumPersonally$,
|
||||
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
|
||||
|
||||
// Load personal subscription pricing tiers
|
||||
this.personalPricingTiers$ =
|
||||
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
|
||||
|
||||
// Initialize combined observables for pricing cards
|
||||
this.premiumCardData$ = this.personalPricingTiers$.pipe(
|
||||
map((tiers) => {
|
||||
const tier = tiers.find((t) => t.id === "premium");
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "standalone"
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.familiesCardData$ = this.personalPricingTiers$.pipe(
|
||||
map((tiers) => {
|
||||
const tier = tiers.find((t) => t.id === "families");
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "packaged"
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
combineLatest([
|
||||
this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
@@ -93,10 +152,10 @@ export class PremiumComponent {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.addOnFormGroup.controls.additionalStorage.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntilDestroyed())
|
||||
.subscribe(() => {
|
||||
this.refreshSalesTax();
|
||||
this.activatedRoute.parent.parent.parent.params
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((params) => {
|
||||
this.providerId = params.providerId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,75 +209,95 @@ export class PremiumComponent {
|
||||
await this.postFinalizeUpgrade();
|
||||
};
|
||||
|
||||
submitPayment = async (): Promise<void> => {
|
||||
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
|
||||
if (this.taxInfoComponent.taxFormGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("paymentMethodType", type.toString());
|
||||
formData.append("paymentToken", token);
|
||||
formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString());
|
||||
formData.append("country", this.taxInfoComponent.country);
|
||||
formData.append("postalCode", this.taxInfoComponent.postalCode);
|
||||
|
||||
await this.apiService.postPremium(formData);
|
||||
await this.finalizeUpgrade();
|
||||
await this.postFinalizeUpgrade();
|
||||
};
|
||||
|
||||
protected get additionalStorageCost(): number {
|
||||
return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage;
|
||||
}
|
||||
|
||||
protected get premiumURL(): string {
|
||||
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
|
||||
}
|
||||
|
||||
protected get subtotal(): number {
|
||||
return this.premiumPrice + this.additionalStorageCost;
|
||||
}
|
||||
|
||||
protected get total(): number {
|
||||
return this.subtotal + this.estimatedTax;
|
||||
}
|
||||
|
||||
protected async onLicenseFileSelectedChanged(): Promise<void> {
|
||||
await this.postFinalizeUpgrade();
|
||||
}
|
||||
|
||||
private refreshSalesTax(): void {
|
||||
if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) {
|
||||
protected async openUpgradeDialog(type: "Premium" | "Families"): Promise<void> {
|
||||
try {
|
||||
const dialogData = await this.getPricingForUpgrade(type);
|
||||
const dialogRef = this.dialogService.open<UpgradeDialogResult>(UpgradeDialogComponent, {
|
||||
data: dialogData,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
await this.handleUpgradeResult(result, type);
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getPricingForUpgrade(type: "Premium" | "Families") {
|
||||
const pricingTiers = await firstValueFrom(this.personalPricingTiers$);
|
||||
const tier =
|
||||
type === "Premium"
|
||||
? pricingTiers.find((t) => t.id === "premium")
|
||||
: pricingTiers.find((t) => t.id === "families");
|
||||
|
||||
const price =
|
||||
tier?.passwordManager.type === "standalone"
|
||||
? tier.passwordManager.annualPrice
|
||||
: tier?.passwordManager.type === "packaged"
|
||||
? tier.passwordManager.annualPrice
|
||||
: 0;
|
||||
|
||||
return {
|
||||
type,
|
||||
price,
|
||||
providerId: this.providerId,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleUpgradeResult(
|
||||
result: UpgradeDialogResult | null,
|
||||
type: "Premium" | "Families",
|
||||
): Promise<void> {
|
||||
if (!result?.success) {
|
||||
return;
|
||||
}
|
||||
const request: PreviewIndividualInvoiceRequest = {
|
||||
passwordManager: {
|
||||
additionalStorage: this.addOnFormGroup.value.additionalStorage,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode: this.taxInfoComponent.postalCode,
|
||||
country: this.taxInfoComponent.country,
|
||||
},
|
||||
};
|
||||
|
||||
this.taxService
|
||||
.previewIndividualInvoice(request)
|
||||
.then((invoice) => {
|
||||
this.estimatedTax = invoice.taxAmount;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastService.showToast({
|
||||
title: "",
|
||||
variant: "error",
|
||||
message: this.i18nService.t(error.message),
|
||||
});
|
||||
if (type === "Premium") {
|
||||
await this.navigateToSubscriptionPage();
|
||||
} else if (type === "Families" && result.orgId) {
|
||||
await this.router.navigate(["/organizations/" + result.orgId]);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("familiesUpgradeSuccess"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected onTaxInformationChanged(): void {
|
||||
this.refreshSalesTax();
|
||||
// Helper methods for backward compatibility (if needed elsewhere)
|
||||
protected getPremiumTier(): Observable<PersonalSubscriptionPricingTier | undefined> {
|
||||
return this.premiumCardData$.pipe(map((data) => data.tier));
|
||||
}
|
||||
|
||||
protected getFamiliesTier(): Observable<PersonalSubscriptionPricingTier | undefined> {
|
||||
return this.familiesCardData$.pipe(map((data) => data.tier));
|
||||
}
|
||||
|
||||
protected getPremiumPrice(): Observable<number> {
|
||||
return this.premiumCardData$.pipe(map((data) => data.price));
|
||||
}
|
||||
|
||||
protected getFamiliesPrice(): Observable<number> {
|
||||
return this.familiesCardData$.pipe(map((data) => data.price));
|
||||
}
|
||||
|
||||
protected getPremiumFeatures(): Observable<string[]> {
|
||||
return this.premiumCardData$.pipe(map((data) => data.features));
|
||||
}
|
||||
|
||||
protected getFamiliesFeatures(): Observable<string[]> {
|
||||
return this.familiesCardData$.pipe(map((data) => data.features));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<form [formGroup]="upgradeForm" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="large">
|
||||
<span bitDialogTitle class="tw-font-semibold"> Upgrade to {{ data.type }} </span>
|
||||
|
||||
<div bitDialogContent>
|
||||
@if (data.type === "Families") {
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "organizationName" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="organisationName" required />
|
||||
</bit-form-field>
|
||||
<p bitTypography="helper" class="tw-text-muted -tw-mt-3">
|
||||
{{ "organizationNameDescription" | i18n }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Payment Information Section -->
|
||||
<bit-section>
|
||||
<h3 bitTypography="h3">{{ "paymentMethod" | i18n }}</h3>
|
||||
<app-payment [showAccountCredit]="false"></app-payment>
|
||||
</bit-section>
|
||||
|
||||
<!-- Billing Address Section -->
|
||||
<bit-section>
|
||||
<h3 bitTypography="h3">{{ "billingAddress" | i18n }}</h3>
|
||||
<app-manage-tax-information
|
||||
[startWith]="taxInformation"
|
||||
(taxInformationChanged)="taxInformationChanged($event)"
|
||||
></app-manage-tax-information>
|
||||
</bit-section>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<bit-section>
|
||||
@if (pricingSummaryData(); as summaryData) {
|
||||
<app-pricing-summary [summaryData]="summaryData"></app-pricing-summary>
|
||||
}
|
||||
<p bitTypography="helper" class="tw-italic tw-text-muted tw-mt-2">
|
||||
{{ "paymentChargedWithTrial" | i18n }}
|
||||
</p>
|
||||
</bit-section>
|
||||
</div>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" type="submit" bitFormButton>
|
||||
{{ "upgrade" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" (click)="close()">
|
||||
{{ "back" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,333 @@
|
||||
import { Component, Inject, signal, viewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/organization-create.request";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-organization-create.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { PlanType, PlanInterval } from "@bitwarden/common/billing/enums";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
|
||||
import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request";
|
||||
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
|
||||
import { PaymentComponent } from "../../shared/payment/payment.component";
|
||||
import { PricingSummaryData } from "../../shared/pricing-summary/pricing-summary.component";
|
||||
import { PersonalSubscriptionPricingTierIds } from "../../types/subscription-pricing-tier";
|
||||
|
||||
export interface UpgradeDialogResult {
|
||||
success: boolean;
|
||||
orgId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "./upgrade-dialog.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class UpgradeDialogComponent {
|
||||
paymentComponent = viewChild(PaymentComponent);
|
||||
taxInfoComponent = viewChild(ManageTaxInformationComponent);
|
||||
|
||||
protected totalOpened = signal(false);
|
||||
protected pricingSummaryData = signal<PricingSummaryData | null>(null);
|
||||
|
||||
protected estimatedTax: number = 0;
|
||||
protected taxInformation: TaxInformation;
|
||||
|
||||
upgradeForm = this.formBuilder.group({
|
||||
organisationName: ["", [Validators.required]],
|
||||
});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA)
|
||||
public data: { type: "Premium" | "Families"; price: number; providerId: string },
|
||||
private dialogRef: DialogRef<UpgradeDialogResult>,
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private syncService: SyncService,
|
||||
private toastService: ToastService,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private formBuilder: FormBuilder,
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
) {
|
||||
// Initialize pricing summary for both Premium and Families plans
|
||||
void this.initializePricingSummary();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.data.type === "Premium") {
|
||||
await this.upgradeToPremium();
|
||||
} else {
|
||||
await this.upgradeToFamilies();
|
||||
}
|
||||
};
|
||||
|
||||
private upgradeToPremium = async (): Promise<void> => {
|
||||
if (this.taxInfoComponent() !== undefined && !this.taxInfoComponent().validate()) {
|
||||
this.taxInfoComponent().markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { type, token } = await this.paymentComponent().tokenize();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("paymentMethodType", type.toString());
|
||||
formData.append("paymentToken", token);
|
||||
formData.append("country", this.taxInfoComponent().getTaxInformation().country);
|
||||
formData.append("postalCode", this.taxInfoComponent().getTaxInformation().postalCode);
|
||||
|
||||
await this.apiService.postPremium(formData);
|
||||
await this.finalizeUpgrade();
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("premiumUpdated"),
|
||||
});
|
||||
|
||||
this.dialogRef.close({ success: true });
|
||||
} catch (error) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async upgradeToFamilies(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const orgKey = await this.keyService.makeOrgKey<OrgKey>(activeUserId);
|
||||
const key = orgKey[0].encryptedString;
|
||||
const collection = await this.encryptService.encryptString(
|
||||
this.i18nService.t("defaultCollection"),
|
||||
orgKey[1],
|
||||
);
|
||||
const collectionCt = collection.encryptedString;
|
||||
const orgKeys = await this.keyService.makeKeyPair(orgKey[1]);
|
||||
|
||||
const request = new OrganizationCreateRequest();
|
||||
request.key = key;
|
||||
request.collectionName = collectionCt;
|
||||
request.name = this.upgradeForm.controls.organisationName.value;
|
||||
request.billingEmail = activeAccount.email;
|
||||
request.initiationPath = "New organization creation in-product";
|
||||
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
|
||||
|
||||
const { type, token } = await this.paymentComponent().tokenize();
|
||||
|
||||
request.paymentToken = token;
|
||||
request.paymentMethodType = type;
|
||||
request.additionalSeats = 0;
|
||||
request.additionalStorageGb = 0;
|
||||
request.premiumAccessAddon = false;
|
||||
request.planType = PlanType.FamiliesAnnually;
|
||||
request.billingAddressPostalCode = this.taxInformation?.postalCode;
|
||||
request.billingAddressCountry = this.taxInformation?.country;
|
||||
request.taxIdNumber = this.taxInformation?.taxId;
|
||||
request.billingAddressLine1 = this.taxInformation?.line1;
|
||||
request.billingAddressLine2 = this.taxInformation?.line2;
|
||||
request.billingAddressCity = this.taxInformation?.city;
|
||||
request.billingAddressState = this.taxInformation?.state;
|
||||
request.additionalSeats = 0;
|
||||
request.additionalServiceAccounts = 0;
|
||||
|
||||
let organisationId: string;
|
||||
|
||||
if (this.data.providerId) {
|
||||
const providerRequest = new ProviderOrganizationCreateRequest("", request);
|
||||
const providerKey = await this.keyService.getProviderKey(this.data.providerId);
|
||||
providerRequest.organizationCreateRequest.key = (
|
||||
await this.encryptService.wrapSymmetricKey(orgKey[1], providerKey)
|
||||
).encryptedString;
|
||||
const orgId = (
|
||||
await this.apiService.postProviderCreateOrganization(this.data.providerId, providerRequest)
|
||||
).organizationId;
|
||||
|
||||
organisationId = orgId;
|
||||
} else {
|
||||
organisationId = (await this.organizationApiService.create(request)).id;
|
||||
}
|
||||
if (organisationId) {
|
||||
this.dialogRef.close({ success: true, orgId: organisationId });
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeUpgrade(): Promise<void> {
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
|
||||
protected get total(): number {
|
||||
return this.data.price + this.estimatedTax;
|
||||
}
|
||||
|
||||
protected get dialogTitle(): string {
|
||||
return this.data.type === "Premium" ? "upgradeToPremium" : "upgradeToFamilies";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize pricing summary for both Premium and Families plans using real data
|
||||
*/
|
||||
private async initializePricingSummary(): Promise<void> {
|
||||
const personalTiers = await firstValueFrom(
|
||||
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(),
|
||||
);
|
||||
const tierId =
|
||||
this.data.type === "Premium"
|
||||
? PersonalSubscriptionPricingTierIds.Premium
|
||||
: PersonalSubscriptionPricingTierIds.Families;
|
||||
const tier = personalTiers.find((t) => t.id === tierId);
|
||||
|
||||
if (!tier) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPremium = this.data.type === "Premium";
|
||||
const isFamilies = this.data.type === "Families";
|
||||
|
||||
const pricingSummaryData: PricingSummaryData = {
|
||||
selectedPlanInterval: "year",
|
||||
selectedInterval: PlanInterval.Annually,
|
||||
|
||||
passwordManagerSeats:
|
||||
isFamilies && tier.passwordManager.type === "packaged" ? tier.passwordManager.users : 1,
|
||||
passwordManagerSeatTotal: tier.passwordManager.annualPrice,
|
||||
passwordManagerSubtotal: tier.passwordManager.annualPrice,
|
||||
|
||||
secretsManagerSeatTotal: 0,
|
||||
additionalStorageTotal: 0,
|
||||
additionalStoragePriceMonthly: tier.passwordManager.annualPricePerAdditionalStorageGB || 0,
|
||||
additionalServiceAccountTotal: 0,
|
||||
totalAppliedDiscount: 0,
|
||||
secretsManagerSubtotal: 0,
|
||||
|
||||
total: this.data.price,
|
||||
estimatedTax: this.estimatedTax,
|
||||
totalOpened: this.totalOpened(),
|
||||
|
||||
selectedPlan: {
|
||||
isAnnual: true,
|
||||
PasswordManager: isPremium
|
||||
? {
|
||||
basePrice: tier.passwordManager.annualPrice,
|
||||
baseSeats: 1,
|
||||
seatPrice: 0,
|
||||
hasAdditionalSeatsOption: false,
|
||||
additionalStoragePricePerGb:
|
||||
tier.passwordManager.annualPricePerAdditionalStorageGB || 0,
|
||||
hasAdditionalStorageOption: true,
|
||||
}
|
||||
: {
|
||||
basePrice: 0,
|
||||
baseSeats: 0,
|
||||
seatPrice:
|
||||
tier.passwordManager.type === "packaged"
|
||||
? tier.passwordManager.annualPrice / tier.passwordManager.users
|
||||
: tier.passwordManager.annualPrice,
|
||||
hasAdditionalSeatsOption: true,
|
||||
additionalStoragePricePerGb:
|
||||
tier.passwordManager.annualPricePerAdditionalStorageGB || 0,
|
||||
hasAdditionalStorageOption: true,
|
||||
},
|
||||
} as any,
|
||||
|
||||
organization: { useSecretsManager: false } as any,
|
||||
|
||||
customPasswordManagerTitle: isPremium ? "Premium" : undefined,
|
||||
};
|
||||
|
||||
this.pricingSummaryData.set(pricingSummaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tax information in pricing summary
|
||||
*/
|
||||
private updatePricingSummaryTax(): void {
|
||||
const currentData = this.pricingSummaryData();
|
||||
if (currentData) {
|
||||
const updatedData: PricingSummaryData = {
|
||||
...currentData,
|
||||
estimatedTax: this.estimatedTax,
|
||||
total: this.data.price + this.estimatedTax,
|
||||
totalOpened: this.totalOpened(),
|
||||
};
|
||||
this.pricingSummaryData.set(updatedData);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshSalesTax(): void {
|
||||
const { country, postalCode } = this.taxInfoComponent().getTaxInformation();
|
||||
if (!country || !postalCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let request: PreviewIndividualInvoiceRequest | PreviewOrganizationInvoiceRequest;
|
||||
|
||||
if (this.data.type === "Premium") {
|
||||
request = {
|
||||
passwordManager: {
|
||||
additionalStorage: 0,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode,
|
||||
country,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
request = {
|
||||
passwordManager: {
|
||||
additionalStorage: 0,
|
||||
plan: PlanType.FamiliesAnnually,
|
||||
seats: 0,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode,
|
||||
country,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.taxService
|
||||
.previewIndividualInvoice(request)
|
||||
.then((invoice) => {
|
||||
this.estimatedTax = invoice.taxAmount;
|
||||
this.updatePricingSummaryTax();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastService.showToast({
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
variant: "error",
|
||||
message:
|
||||
this.i18nService.t("taxCalculationError") || this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected taxInformationChanged(event: TaxInformation): void {
|
||||
this.taxInformation = event;
|
||||
this.refreshSalesTax();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close({ success: false });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,4 @@
|
||||
</bit-tab-nav-bar>
|
||||
</app-header>
|
||||
|
||||
<bit-container>
|
||||
<router-outlet></router-outlet>
|
||||
</bit-container>
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -60,6 +60,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
|
||||
PaymentComponent,
|
||||
IndividualSelfHostingLicenseUploaderComponent,
|
||||
OrganizationSelfHostingLicenseUploaderComponent,
|
||||
PricingSummaryComponent,
|
||||
],
|
||||
})
|
||||
export class BillingSharedModule {}
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
<ng-container
|
||||
*ngIf="!summaryData.isSecretsManagerTrial || summaryData.organization.useSecretsManager"
|
||||
>
|
||||
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
|
||||
<p class="tw-font-semibold tw-mt-3 tw-mb-1">
|
||||
{{ summaryData.customPasswordManagerTitle || ("passwordManager" | i18n) }}
|
||||
</p>
|
||||
|
||||
<!-- Base Price -->
|
||||
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.basePrice">
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface PricingSummaryData {
|
||||
storageGb?: number;
|
||||
isSecretsManagerTrial?: boolean;
|
||||
estimatedTax?: number;
|
||||
customPasswordManagerTitle?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
Reference in New Issue
Block a user