1
0
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:
Cy Okeke
2025-09-24 15:49:50 +01:00
parent b12fc338cd
commit 62bc87ce94
10 changed files with 700 additions and 188 deletions

View File

@@ -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"
},

View File

@@ -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 {}

View File

@@ -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 &times;
{{ 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>

View File

@@ -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));
}
}

View File

@@ -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>

View File

@@ -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 });
}
}

View File

@@ -11,6 +11,4 @@
</bit-tab-nav-bar>
</app-header>
<bit-container>
<router-outlet></router-outlet>
</bit-container>
<router-outlet></router-outlet>

View File

@@ -60,6 +60,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
PaymentComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
PricingSummaryComponent,
],
})
export class BillingSharedModule {}

View File

@@ -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">

View File

@@ -29,6 +29,7 @@ export interface PricingSummaryData {
storageGb?: number;
isSecretsManagerTrial?: boolean;
estimatedTax?: number;
customPasswordManagerTitle?: string;
}
@Component({