From 62bc87ce948d454ab6bd91bbefa68e5d979eeb0d Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Wed, 24 Sep 2025 15:49:50 +0100 Subject: [PATCH] Changes for premium subscription page --- apps/browser/src/_locales/en/messages.json | 15 + .../individual/individual-billing.module.ts | 11 +- .../individual/premium/premium.component.html | 245 +++++++------ .../individual/premium/premium.component.ts | 223 ++++++++---- .../premium/upgrade-dialog.component.html | 51 +++ .../premium/upgrade-dialog.component.ts | 333 ++++++++++++++++++ .../individual/subscription.component.html | 4 +- .../billing/shared/billing-shared.module.ts | 1 + .../pricing-summary.component.html | 4 +- .../pricing-summary.component.ts | 1 + 10 files changed, 700 insertions(+), 188 deletions(-) create mode 100644 apps/web/src/app/billing/individual/premium/upgrade-dialog.component.html create mode 100644 apps/web/src/app/billing/individual/premium/upgrade-dialog.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 72c3892af62..393b9e6c8b2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index ad75da00c99..9dd1f872f24 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -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 {} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index 3f0f97541df..5c5c28a6efe 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -1,119 +1,142 @@ - -

{{ "goPremium" | i18n }}

- - {{ "alreadyPremiumFromOrg" | i18n }} - - -

{{ "premiumUpgradeUnlockFeatures" | i18n }}

-
    -
  • - - {{ "premiumSignUpStorage" | i18n }} -
  • -
  • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
  • -
  • - - {{ "premiumSignUpEmergency" | i18n }} -
  • -
  • - - {{ "premiumSignUpReports" | i18n }} -
  • -
  • - - {{ "premiumSignUpTotp" | i18n }} -
  • -
  • - - {{ "premiumSignUpSupport" | i18n }} -
  • -
  • - - {{ "premiumSignUpFuture" | i18n }} -
  • -
-

- {{ - "premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount - }} +

+ +
+
+ + You have the Bitwarden Free plan + +
+ +

Upgrade for complete security

+

+ Unlock more security features with Premium, or start sharing items with Families +

+
+ + +
+ +
+ @if (premiumCardData$ | async; as premiumData) { + +

Premium

+
+ } +
+ + +
+ @if (familiesCardData$ | async; as familiesData) { + +

Families

+
+ } +
+
+ + +
+

Prices exclude tax and are billed annually

- {{ "bitwardenFamiliesPlan" | i18n }} + View business plans -

- + + + + +

{{ "goPremium" | i18n }}

+ - {{ "purchasePremium" | i18n }} -
- - - - - -
- -

{{ "addons" | i18n }}

-
- - {{ "additionalStorageGb" | i18n }} - - {{ - "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) - }} - -
+ {{ "alreadyPremiumFromOrg" | i18n }} + + +

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+
    +
  • + + {{ "premiumSignUpStorage" | i18n }} +
  • +
  • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
  • +
  • + + {{ "premiumSignUpEmergency" | i18n }} +
  • +
  • + + {{ "premiumSignUpReports" | i18n }} +
  • +
  • + + {{ "premiumSignUpTotp" | i18n }} +
  • +
  • + + {{ "premiumSignUpSupport" | i18n }} +
  • +
  • + + {{ "premiumSignUpFuture" | i18n }} +
  • +
+

+ {{ + "premiumPriceWithFamilyPlan" + | i18n + : (((getPremiumPrice() | async) || 0) * 12 | currency: "$") + : familyPlanMaxUserCount + }} + + {{ "bitwardenFamiliesPlan" | i18n }} + +

+ + {{ "purchasePremium" | i18n }} + +
- -

{{ "summary" | i18n }}

- {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB × - {{ storageGBPrice | currency: "$" }} = - {{ additionalStorageCost | currency: "$" }} -
+ + - -

{{ "paymentInformation" | i18n }}

- - -
-
- {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} - {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} -
-
-
-

- {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} -

- -
- +
diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index 974c22455ff..d116e0f2036 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -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; + protected hasPremiumPersonally$: Observable; + protected shouldShowNewDesign$: Observable; + protected personalPricingTiers$: Observable; + 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(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 => { - 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 { await this.postFinalizeUpgrade(); } - private refreshSalesTax(): void { - if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) { + protected async openUpgradeDialog(type: "Premium" | "Families"): Promise { + try { + const dialogData = await this.getPricingForUpgrade(type); + const dialogRef = this.dialogService.open(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 { + 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 { + return this.premiumCardData$.pipe(map((data) => data.tier)); + } + + protected getFamiliesTier(): Observable { + return this.familiesCardData$.pipe(map((data) => data.tier)); + } + + protected getPremiumPrice(): Observable { + return this.premiumCardData$.pipe(map((data) => data.price)); + } + + protected getFamiliesPrice(): Observable { + return this.familiesCardData$.pipe(map((data) => data.price)); + } + + protected getPremiumFeatures(): Observable { + return this.premiumCardData$.pipe(map((data) => data.features)); + } + + protected getFamiliesFeatures(): Observable { + return this.familiesCardData$.pipe(map((data) => data.features)); } } diff --git a/apps/web/src/app/billing/individual/premium/upgrade-dialog.component.html b/apps/web/src/app/billing/individual/premium/upgrade-dialog.component.html new file mode 100644 index 00000000000..8ebf1526787 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/upgrade-dialog.component.html @@ -0,0 +1,51 @@ +
+ + Upgrade to {{ data.type }} + +
+ @if (data.type === "Families") { + + {{ "organizationName" | i18n }} + + +

+ {{ "organizationNameDescription" | i18n }} +

+ } + + + +

{{ "paymentMethod" | i18n }}

+ +
+ + + +

{{ "billingAddress" | i18n }}

+ +
+ + + + @if (pricingSummaryData(); as summaryData) { + + } +

+ {{ "paymentChargedWithTrial" | i18n }} +

+
+
+ + + + + +
+
diff --git a/apps/web/src/app/billing/individual/premium/upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/premium/upgrade-dialog.component.ts new file mode 100644 index 00000000000..32feb80f588 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/upgrade-dialog.component.ts @@ -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(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, + 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 => { + 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 { + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + const orgKey = await this.keyService.makeOrgKey(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 { + 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 { + 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 }); + } +} diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index fa2eb0412a9..bcbd1aaf94e 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -11,6 +11,4 @@ - - - + diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 7322f047551..5e2d9f656b9 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -60,6 +60,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac PaymentComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, + PricingSummaryComponent, ], }) export class BillingSharedModule {} diff --git a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html index 428d6b7f04e..22be71afd31 100644 --- a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html @@ -37,7 +37,9 @@ -

{{ "passwordManager" | i18n }}

+

+ {{ summaryData.customPasswordManagerTitle || ("passwordManager" | i18n) }} +

diff --git a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts index d4fdf35b743..ce8646c13e9 100644 --- a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts @@ -29,6 +29,7 @@ export interface PricingSummaryData { storageGb?: number; isSecretsManagerTrial?: boolean; estimatedTax?: number; + customPasswordManagerTitle?: string; } @Component({