diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 962191021e..49946806ef 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -1,3 +1,8 @@ + + protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, private configService: ConfigService, private organizationUserService: OrganizationUserService, + private organizationWarningsService: OrganizationWarningsService, ) { super( apiService, @@ -247,6 +250,13 @@ export class MembersComponent extends BaseMembersComponent this.showUserManagementControls$ = organization$.pipe( map((organization) => organization.canManageUsers), ); + organization$ + .pipe( + takeUntilDestroyed(), + tap((org) => (this.organization = org)), + switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)), + ) + .subscribe(); } async getUsers(): Promise { @@ -932,4 +942,14 @@ export class MembersComponent extends BaseMembersComponent .getCheckedUsers() .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } + + async navigateToPaymentMethod() { + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; + await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { + state: { launchPaymentModalAutomatically: true }, + }); + } } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 98431758d2..5f626d4416 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -5,6 +5,7 @@ import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-s import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; @@ -29,6 +30,7 @@ import { MembersComponent } from "./members.component"; ScrollingModule, PasswordStrengthV2Component, ScrollLayoutDirective, + OrganizationFreeTrialWarningComponent, ], declarations: [ BulkConfirmDialogComponent, diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index 9b144fe59a..aa7bf5e5d1 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -36,6 +36,10 @@ import { AdjustPaymentDialogComponent, AdjustPaymentDialogResultType, } from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, +} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; import { FreeTrial } from "../../types/free-trial"; @Component({ @@ -212,15 +216,15 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { }; changePayment = async () => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { data: { - initialPaymentMethod: this.paymentSource?.type, organizationId: this.organizationId, - productTier: this.organization?.productTierType, + subscription: this.organizationSubscriptionResponse, + productTierType: this.organization?.productTierType, }, }); const result = await lastValueFrom(dialogRef.closed); - if (result === AdjustPaymentDialogResultType.Submitted) { + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { this.location.replaceState(this.location.path(), "", {}); if (this.launchPaymentModalAutomatically && !this.organization.enabled) { await this.syncService.fullSync(true); diff --git a/apps/web/src/app/billing/services/plan-card.service.ts b/apps/web/src/app/billing/services/plan-card.service.ts new file mode 100644 index 0000000000..25974a428f --- /dev/null +++ b/apps/web/src/app/billing/services/plan-card.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; + +@Injectable({ providedIn: "root" }) +export class PlanCardService { + constructor(private apiService: ApiService) {} + + async getCadenceCards( + currentPlan: PlanResponse, + subscription: OrganizationSubscriptionResponse, + isSecretsManagerTrial: boolean, + ) { + const plans = await this.apiService.getPlans(); + + const filteredPlans = plans.data.filter((plan) => !!plan.PasswordManager); + + const result = + filteredPlans?.filter( + (plan) => + plan.productTier === currentPlan.productTier && !plan.disabled && !plan.legacyYear, + ) || []; + + const planCards = result.map((plan) => { + let costPerMember = 0; + + if (plan.PasswordManager.basePrice) { + costPerMember = plan.isAnnual + ? plan.PasswordManager.basePrice / 12 + : plan.PasswordManager.basePrice; + } else if (!plan.PasswordManager.basePrice && plan.PasswordManager.hasAdditionalSeatsOption) { + const secretsManagerCost = subscription.useSecretsManager + ? plan.SecretsManager.seatPrice + : 0; + const passwordManagerCost = isSecretsManagerTrial ? 0 : plan.PasswordManager.seatPrice; + costPerMember = (secretsManagerCost + passwordManagerCost) / (plan.isAnnual ? 12 : 1); + } + + const percentOff = subscription.customerDiscount?.percentOff ?? 0; + + const discount = + (percentOff === 0 && plan.isAnnual) || isSecretsManagerTrial ? 20 : percentOff; + + return { + title: plan.isAnnual ? "Annually" : "Monthly", + costPerMember, + discount, + isDisabled: false, + isSelected: plan.isAnnual, + isAnnual: plan.isAnnual, + productTier: plan.productTier, + }; + }); + + return planCards.reverse(); + } +} diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts new file mode 100644 index 0000000000..0b048b379d --- /dev/null +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; +import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information"; +import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; + +import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component"; + +@Injectable({ + providedIn: "root", +}) +export class PricingSummaryService { + private estimatedTax: number = 0; + + constructor(private taxService: TaxServiceAbstraction) {} + + async getPricingSummaryData( + plan: PlanResponse, + sub: OrganizationSubscriptionResponse, + organization: Organization, + selectedInterval: PlanInterval, + taxInformation: TaxInformation, + isSecretsManagerTrial: boolean, + ): Promise { + // Calculation helpers + const passwordManagerSeatTotal = + plan.PasswordManager?.hasAdditionalSeatsOption && !isSecretsManagerTrial + ? plan.PasswordManager.seatPrice * Math.abs(sub?.seats || 0) + : 0; + + const secretsManagerSeatTotal = plan.SecretsManager?.hasAdditionalSeatsOption + ? plan.SecretsManager.seatPrice * Math.abs(sub?.smSeats || 0) + : 0; + + const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub); + + const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption + ? plan.PasswordManager.additionalStoragePricePerGb * + (sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0) + : 0; + + const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0; + + const additionalServiceAccountTotal = + plan.SecretsManager?.hasAdditionalServiceAccountOption && additionalServiceAccount > 0 + ? plan.SecretsManager.additionalPricePerServiceAccount * additionalServiceAccount + : 0; + + let passwordManagerSubtotal = plan.PasswordManager?.basePrice || 0; + if (plan.PasswordManager?.hasAdditionalSeatsOption) { + passwordManagerSubtotal += passwordManagerSeatTotal; + } + if (plan.PasswordManager?.hasPremiumAccessOption) { + passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice; + } + + const secretsManagerSubtotal = plan.SecretsManager + ? (plan.SecretsManager.basePrice || 0) + + secretsManagerSeatTotal + + additionalServiceAccountTotal + : 0; + + const totalAppliedDiscount = 0; + const discountPercentageFromSub = isSecretsManagerTrial + ? 0 + : (sub?.customerDiscount?.percentOff ?? 0); + const discountPercentage = 20; + const acceptingSponsorship = false; + const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; + + this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation); + + const total = organization?.useSecretsManager + ? passwordManagerSubtotal + + additionalStorageTotal + + secretsManagerSubtotal + + this.estimatedTax + : passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax; + + return { + selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month", + passwordManagerSeats: + plan.productTier === ProductTierType.Families ? plan.PasswordManager.baseSeats : sub?.seats, + passwordManagerSeatTotal, + secretsManagerSeatTotal, + additionalStorageTotal, + additionalStoragePriceMonthly, + additionalServiceAccountTotal, + totalAppliedDiscount, + secretsManagerSubtotal, + passwordManagerSubtotal, + total, + organization, + sub, + selectedPlan: plan, + selectedInterval, + discountPercentageFromSub, + discountPercentage, + acceptingSponsorship, + additionalServiceAccount, + storageGb, + isSecretsManagerTrial, + estimatedTax: this.estimatedTax, + }; + } + + async getEstimatedTax( + organization: Organization, + currentPlan: PlanResponse, + sub: OrganizationSubscriptionResponse, + taxInformation: TaxInformation, + ) { + if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) { + return 0; + } + + const request: PreviewOrganizationInvoiceRequest = { + organizationId: organization.id, + passwordManager: { + additionalStorage: 0, + plan: currentPlan?.type, + seats: sub.seats, + }, + taxInformation: { + postalCode: taxInformation.postalCode, + country: taxInformation.country, + taxId: taxInformation.taxId, + }, + }; + + if (organization.useSecretsManager) { + request.secretsManager = { + seats: sub.smSeats ?? 0, + additionalMachineAccounts: + (sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0), + }; + } + const invoiceResponse = await this.taxService.previewOrganizationInvoice(request); + return invoiceResponse.taxAmount; + } + + getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number { + if (!plan || !plan.SecretsManager) { + return 0; + } + const baseServiceAccount = plan.SecretsManager?.baseServiceAccount || 0; + const usedServiceAccounts = sub?.smServiceAccounts || 0; + const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts; + return additionalServiceAccounts <= 0 ? Math.abs(additionalServiceAccounts) : 0; + } +} 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 9a69755b20..7322f04755 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -12,10 +12,13 @@ import { BillingHistoryComponent } from "./billing-history.component"; import { OffboardingSurveyComponent } from "./offboarding-survey.component"; import { PaymentComponent } from "./payment/payment.component"; import { PaymentMethodComponent } from "./payment-method.component"; +import { PlanCardComponent } from "./plan-card/plan-card.component"; +import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component"; import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component"; import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component"; import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component"; import { TaxInfoComponent } from "./tax-info.component"; +import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component"; import { UpdateLicenseDialogComponent } from "./update-license-dialog.component"; import { UpdateLicenseComponent } from "./update-license.component"; import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component"; @@ -41,6 +44,9 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac AdjustStorageDialogComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, + TrialPaymentDialogComponent, + PlanCardComponent, + PricingSummaryComponent, ], exports: [ SharedModule, diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.html b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html new file mode 100644 index 0000000000..08fd3b435f --- /dev/null +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html @@ -0,0 +1,45 @@ +@let isFocused = plan().isSelected; +@let isRecommended = plan().isAnnual; + + +
+ @if (isRecommended) { +
+ {{ "recommended" | i18n }} +
+ } +
+

+ {{ plan().title }} + + + {{ "upgradeDiscount" | i18n: plan().discount }} +

+ + {{ plan().costPerMember | currency: "$" }} + /{{ "monthPerMember" | i18n }} + +
+
+
diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts new file mode 100644 index 0000000000..9e3f03a5e7 --- /dev/null +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts @@ -0,0 +1,68 @@ +import { Component, input, output } from "@angular/core"; + +import { ProductTierType } from "@bitwarden/common/billing/enums"; + +export interface PlanCard { + title: string; + costPerMember: number; + discount?: number; + isDisabled: boolean; + isAnnual: boolean; + isSelected: boolean; + productTier: ProductTierType; +} + +@Component({ + selector: "app-plan-card", + templateUrl: "./plan-card.component.html", + standalone: false, +}) +export class PlanCardComponent { + plan = input.required(); + productTiers = ProductTierType; + + cardClicked = output(); + + getPlanCardContainerClasses(): string[] { + const isSelected = this.plan().isSelected; + const isDisabled = this.plan().isDisabled; + if (isDisabled) { + return [ + "tw-cursor-not-allowed", + "tw-bg-secondary-100", + "tw-font-normal", + "tw-bg-blur", + "tw-text-muted", + "tw-block", + "tw-rounded", + ]; + } + + return isSelected + ? [ + "tw-cursor-pointer", + "tw-block", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-border-primary-600", + "tw-border-2", + "tw-rounded-lg", + "hover:tw-border-primary-700", + "focus:tw-border-3", + "focus:tw-border-primary-700", + "focus:tw-rounded-lg", + ] + : [ + "tw-cursor-pointer", + "tw-block", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-border-secondary-300", + "hover:tw-border-text-main", + "focus:tw-border-2", + "focus:tw-border-primary-700", + ]; + } +} 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 new file mode 100644 index 0000000000..855b83bdb2 --- /dev/null +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html @@ -0,0 +1,259 @@ + +
+

+ {{ "total" | i18n }}: + {{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} USD + / {{ summaryData.selectedPlanInterval | i18n }} + +

+
+ + + +
+ + + + + + + + + + + + + + +

{{ "passwordManager" | i18n }}

+ + + +

+ + + + {{ summaryData.passwordManagerSeats }} {{ "members" | i18n }} × + {{ + (summaryData.selectedPlan.isAnnual + ? summaryData.selectedPlan.PasswordManager.basePrice / 12 + : summaryData.selectedPlan.PasswordManager.basePrice + ) | currency: "$" + }} + /{{ summaryData.selectedPlanInterval | i18n }} + + + {{ "basePrice" | i18n }}: + {{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + + + + + + {{ + summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" + }} + {{ "freeWithSponsorship" | i18n }} + + + {{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }} + + +

+
+ + + +

+ + {{ "additionalUsers" | i18n }}: + {{ summaryData.passwordManagerSeats || 0 }}  + {{ + "members" | i18n + }} + × + {{ summaryData.selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ summaryData.selectedPlanInterval | i18n }} + + + {{ summaryData.passwordManagerSeatTotal | currency: "$" }} + + + {{ "freeForOneYear" | i18n }} + +

+
+ + + +

+ + {{ summaryData.storageGb }} {{ "additionalStorageGbMessage" | i18n }} + × + {{ summaryData.additionalStoragePriceMonthly | currency: "$" }} + /{{ summaryData.selectedPlanInterval | i18n }} + + + + + {{ summaryData.additionalStorageTotal | currency: "$" }} + + + {{ + summaryData.storageGb * + summaryData.selectedPlan.PasswordManager.additionalStoragePricePerGb + | currency: "$" + }} + + + +

+
+
+
+ + + + +

{{ "secretsManager" | i18n }}

+ + + +

+ + + + {{ summaryData.sub?.smSeats }} {{ "members" | i18n }} × + {{ + (summaryData.selectedPlan.isAnnual + ? summaryData.selectedPlan.SecretsManager.basePrice / 12 + : summaryData.selectedPlan.SecretsManager.basePrice + ) | currency: "$" + }} + /{{ summaryData.selectedPlanInterval | i18n }} + + + {{ "basePrice" | i18n }}: + {{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + + + + + {{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }} + +

+
+ + + +

+ + {{ "additionalUsers" | i18n }}: + {{ summaryData.sub?.smSeats || 0 }}  + {{ + "members" | i18n + }} + × + {{ summaryData.selectedPlan.SecretsManager.seatPrice | currency: "$" }} + /{{ summaryData.selectedPlanInterval | i18n }} + + + {{ summaryData.secretsManagerSeatTotal | currency: "$" }} + +

+
+ + + +

+ + {{ summaryData.additionalServiceAccount }} + {{ "serviceAccounts" | i18n | lowercase }} + × + {{ + summaryData.selectedPlan?.SecretsManager?.additionalPricePerServiceAccount + | currency: "$" + }} + /{{ summaryData.selectedPlanInterval | i18n }} + + {{ summaryData.additionalServiceAccountTotal | currency: "$" }} +

+
+
+
+ + + +

+ + {{ + "providerDiscount" | i18n: this.summaryData.discountPercentageFromSub | lowercase + }} + + + {{ summaryData.totalAppliedDiscount | currency: "$" }} + +

+
+
+
+ + +
+ +

+ {{ "estimatedTax" | i18n }} + {{ summaryData.estimatedTax | currency: "USD" : "$" }} +

+
+
+ +
+ +

+ {{ "total" | i18n }} + + {{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} + / {{ summaryData.selectedPlanInterval | 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 new file mode 100644 index 0000000000..d4fdf35b74 --- /dev/null +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts @@ -0,0 +1,48 @@ +import { Component, Input } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlanInterval } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; + +export interface PricingSummaryData { + selectedPlanInterval: string; + passwordManagerSeats: number; + passwordManagerSeatTotal: number; + secretsManagerSeatTotal: number; + additionalStorageTotal: number; + additionalStoragePriceMonthly: number; + additionalServiceAccountTotal: number; + totalAppliedDiscount: number; + secretsManagerSubtotal: number; + passwordManagerSubtotal: number; + total: number; + organization?: Organization; + sub?: OrganizationSubscriptionResponse; + selectedPlan?: PlanResponse; + selectedInterval?: PlanInterval; + discountPercentageFromSub?: number; + discountPercentage?: number; + acceptingSponsorship?: boolean; + additionalServiceAccount?: number; + totalOpened?: boolean; + storageGb?: number; + isSecretsManagerTrial?: boolean; + estimatedTax?: number; +} + +@Component({ + selector: "app-pricing-summary", + templateUrl: "./pricing-summary.component.html", + standalone: false, +}) +export class PricingSummaryComponent { + @Input() summaryData!: PricingSummaryData; + planIntervals = PlanInterval; + + toggleTotalOpened(): void { + if (this.summaryData) { + this.summaryData.totalOpened = !this.summaryData.totalOpened; + } + } +} diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html new file mode 100644 index 0000000000..dbd2899c9e --- /dev/null +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html @@ -0,0 +1,117 @@ + + + {{ "subscribetoEnterprise" | i18n: currentPlanName }} + + +
+

{{ "subscribeEnterpriseSubtitle" | i18n: currentPlanName }}

+ + + +
    +
  • + + {{ "includeEnterprisePolicies" | i18n }} +
  • +
  • + + {{ "passwordLessSso" | i18n }} +
  • +
  • + + {{ "accountRecovery" | i18n }} +
  • +
  • + + {{ "customRoles" | i18n }} +
  • +
  • + + {{ "unlimitedSecretsAndProjects" | i18n }} +
  • +
+ +
    +
  • + + {{ "secureDataSharing" | i18n }} +
  • +
  • + + {{ "eventLogMonitoring" | i18n }} +
  • +
  • + + {{ "directoryIntegration" | i18n }} +
  • +
  • + + {{ "unlimitedSecretsAndProjects" | i18n }} +
  • +
+ +
    +
  • + + {{ "premiumAccounts" | i18n }} +
  • +
  • + + {{ "unlimitedSharing" | i18n }} +
  • +
  • + + {{ "createUnlimitedCollections" | i18n }} +
  • +
+
+ +
+
+

{{ "selectAPlan" | i18n }}

+
+ + +
+ @for (planCard of planCards(); track $index) { + + } +
+
+
+ + +

{{ "paymentMethod" | i18n }}

+ + + + + + +
+
+ + + + + +
diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts new file mode 100644 index 0000000000..ca51ae80e1 --- /dev/null +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts @@ -0,0 +1,365 @@ +import { Component, EventEmitter, Inject, OnInit, Output, signal, ViewChild } from "@angular/core"; +import { firstValueFrom, map } 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 { + getOrganizationById, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { PaymentMethodType, PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { TaxInformation } from "@bitwarden/common/billing/models/domain"; +import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; +import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; +import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; + +import { PlanCardService } from "../../services/plan-card.service"; +import { PaymentComponent } from "../payment/payment.component"; +import { PlanCard } from "../plan-card/plan-card.component"; +import { PricingSummaryData } from "../pricing-summary/pricing-summary.component"; + +import { PricingSummaryService } from "./../../services/pricing-summary.service"; + +type TrialPaymentDialogParams = { + organizationId: string; + subscription: OrganizationSubscriptionResponse; + productTierType: ProductTierType; + initialPaymentMethod?: PaymentMethodType; +}; + +export const TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE = { + CLOSED: "closed", + SUBMITTED: "submitted", +} as const; + +export type TrialPaymentDialogResultType = + (typeof TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE)[keyof typeof TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE]; + +interface OnSuccessArgs { + organizationId: string; +} + +@Component({ + selector: "app-trial-payment-dialog", + templateUrl: "./trial-payment-dialog.component.html", + standalone: false, +}) +export class TrialPaymentDialogComponent implements OnInit { + @ViewChild(PaymentComponent) paymentComponent!: PaymentComponent; + @ViewChild(ManageTaxInformationComponent) taxComponent!: ManageTaxInformationComponent; + + currentPlan!: PlanResponse; + currentPlanName!: string; + productTypes = ProductTierType; + organization!: Organization; + organizationId!: string; + sub!: OrganizationSubscriptionResponse; + selectedInterval: PlanInterval = PlanInterval.Annually; + + planCards = signal([]); + plans!: ListResponse; + + @Output() onSuccess = new EventEmitter(); + protected initialPaymentMethod: PaymentMethodType; + protected taxInformation!: TaxInformation; + protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE; + pricingSummaryData!: PricingSummaryData; + + constructor( + @Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams, + private dialogRef: DialogRef, + private organizationService: OrganizationService, + private i18nService: I18nService, + private organizationApiService: OrganizationApiServiceAbstraction, + private accountService: AccountService, + private planCardService: PlanCardService, + private pricingSummaryService: PricingSummaryService, + private apiService: ApiService, + private toastService: ToastService, + private billingApiService: BillingApiServiceAbstraction, + private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction, + ) { + this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; + } + + async ngOnInit(): Promise { + this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType); + this.sub = + this.dialogParams.subscription ?? + (await this.organizationApiService.getSubscription(this.dialogParams.organizationId)); + this.organizationId = this.dialogParams.organizationId; + this.currentPlan = this.sub?.plan; + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); + if (!userId) { + throw new Error("User ID is required"); + } + const organization = await firstValueFrom( + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.organizationId)), + ); + if (!organization) { + throw new Error("Organization not found"); + } + this.organization = organization; + + const planCards = await this.planCardService.getCadenceCards( + this.currentPlan, + this.sub, + this.isSecretsManagerTrial(), + ); + + this.planCards.set(planCards); + + if (!this.selectedInterval) { + this.selectedInterval = planCards.find((card) => card.isSelected)?.isAnnual + ? PlanInterval.Annually + : PlanInterval.Monthly; + } + + const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); + this.taxInformation = TaxInformation.from(taxInfo); + + this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.taxInformation, + this.isSecretsManagerTrial(), + ); + + this.plans = await this.apiService.getPlans(); + } + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig, + ) => dialogService.open(TrialPaymentDialogComponent, dialogConfig); + + async setSelected(planCard: PlanCard) { + this.selectedInterval = planCard.isAnnual ? PlanInterval.Annually : PlanInterval.Monthly; + + this.planCards.update((planCards) => { + return planCards.map((planCard) => { + if (planCard.isSelected) { + return { + ...planCard, + isSelected: false, + }; + } else { + return { + ...planCard, + isSelected: true, + }; + } + }); + }); + + await this.selectPlan(); + + this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.taxInformation, + this.isSecretsManagerTrial(), + ); + } + + protected async selectPlan() { + if ( + this.selectedInterval === PlanInterval.Monthly && + this.currentPlan.productTier == ProductTierType.Families + ) { + return; + } + + const filteredPlans = this.plans.data.filter( + (plan) => + plan.productTier === this.currentPlan.productTier && + plan.isAnnual === (this.selectedInterval === PlanInterval.Annually), + ); + if (filteredPlans.length > 0) { + this.currentPlan = filteredPlans[0]; + } + try { + await this.refreshSalesTax(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const translatedMessage = this.i18nService.t(errorMessage); + this.toastService.showToast({ + title: "", + variant: "error", + message: !translatedMessage || translatedMessage === "" ? errorMessage : translatedMessage, + }); + } + } + + protected get showTaxIdField(): boolean { + switch (this.currentPlan.productTier) { + case ProductTierType.Free: + case ProductTierType.Families: + return false; + default: + return true; + } + } + + private async refreshSalesTax(): Promise { + if ( + this.taxInformation === undefined || + !this.taxInformation.country || + !this.taxInformation.postalCode + ) { + return; + } + + const request: PreviewOrganizationInvoiceRequest = { + organizationId: this.organizationId, + passwordManager: { + additionalStorage: 0, + plan: this.currentPlan?.type, + seats: this.sub.seats, + }, + taxInformation: { + postalCode: this.taxInformation.postalCode, + country: this.taxInformation.country, + taxId: this.taxInformation.taxId, + }, + }; + + if (this.organization.useSecretsManager) { + request.secretsManager = { + seats: this.sub.smSeats ?? 0, + additionalMachineAccounts: + (this.sub.smServiceAccounts ?? 0) - + (this.sub.plan.SecretsManager?.baseServiceAccount ?? 0), + }; + } + + this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.taxInformation, + this.isSecretsManagerTrial(), + ); + } + + async taxInformationChanged(event: TaxInformation) { + this.taxInformation = event; + this.toggleBankAccount(); + await this.refreshSalesTax(); + } + + toggleBankAccount = () => { + this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; + + if ( + !this.paymentComponent.showBankAccount && + this.paymentComponent.selected === PaymentMethodType.BankAccount + ) { + this.paymentComponent.select(PaymentMethodType.Card); + } + }; + + isSecretsManagerTrial(): boolean { + return ( + this.sub?.subscription?.items?.some((item) => + this.sub?.customerDiscount?.appliesTo?.includes(item.productId), + ) ?? false + ); + } + + async onSubscribe(): Promise { + if (!this.taxComponent.validate()) { + this.taxComponent.markAllAsTouched(); + } + try { + await this.updateOrganizationPaymentMethod( + this.organizationId, + this.paymentComponent, + this.taxInformation, + ); + + if (this.currentPlan.type !== this.sub.planType) { + const changePlanRequest = new ChangePlanFrequencyRequest(); + changePlanRequest.newPlanType = this.currentPlan.type; + await this.organizationBillingApiServiceAbstraction.changeSubscriptionFrequency( + this.organizationId, + changePlanRequest, + ); + } + + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("updatedPaymentMethod"), + }); + + this.onSuccess.emit({ organizationId: this.organizationId }); + this.dialogRef.close(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED); + } catch (error) { + const msg = + typeof error === "object" && error !== null && "message" in error + ? (error as { message: string }).message + : String(error); + this.toastService.showToast({ + variant: "error", + title: undefined, + message: this.i18nService.t(msg) || msg, + }); + } + } + + private async updateOrganizationPaymentMethod( + organizationId: string, + paymentComponent: PaymentComponent, + taxInformation: TaxInformation, + ): Promise { + const paymentSource = await paymentComponent.tokenize(); + + const request = new UpdatePaymentMethodRequest(); + request.paymentSource = paymentSource; + request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation); + + await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request); + } + + resolvePlanName(productTier: ProductTierType): string { + switch (productTier) { + case ProductTierType.Enterprise: + return this.i18nService.t("planNameEnterprise"); + case ProductTierType.Free: + return this.i18nService.t("planNameFree"); + case ProductTierType.Families: + return this.i18nService.t("planNameFamilies"); + case ProductTierType.Teams: + return this.i18nService.t("planNameTeams"); + case ProductTierType.TeamsStarter: + return this.i18nService.t("planNameTeamsStarter"); + default: + return this.i18nService.t("planNameFree"); + } + } +} diff --git a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts index 074358537b..a7ce53c999 100644 --- a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts @@ -1,8 +1,10 @@ import { AsyncPipe } from "@angular/common"; -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { Observable } from "rxjs"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Observable, Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -37,16 +39,28 @@ import { OrganizationFreeTrialWarning } from "../types"; `, imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], }) -export class OrganizationFreeTrialWarningComponent implements OnInit { +export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy { @Input({ required: true }) organization!: Organization; @Output() clicked = new EventEmitter(); warning$!: Observable; + private destroy$ = new Subject(); constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); + this.organizationWarningsService + .refreshWarningsForOrganization$(this.organization.id as OrganizationId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.refresh(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } refresh = () => { diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts index fa53992afe..78c17a5d38 100644 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts @@ -1,6 +1,7 @@ +import { Location } from "@angular/common"; import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { filter, from, lastValueFrom, map, Observable, switchMap, takeWhile } from "rxjs"; +import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -10,10 +11,15 @@ import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/r import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, +} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; const format = (date: Date) => @@ -26,6 +32,7 @@ const format = (date: Date) => @Injectable({ providedIn: "root" }) export class OrganizationWarningsService { private cache$ = new Map>(); + private refreshWarnings$ = new Subject(); constructor( private configService: ConfigService, @@ -34,6 +41,8 @@ export class OrganizationWarningsService { private organizationApiService: OrganizationApiServiceAbstraction, private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, private router: Router, + private location: Location, + protected syncService: SyncService, ) {} getFreeTrialWarning$ = ( @@ -174,10 +183,33 @@ export class OrganizationWarningsService { }); break; } + case "add_payment_method_optional_trial": { + const organizationSubscriptionResponse = + await this.organizationApiService.getSubscription(organization.id); + + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + subscription: organizationSubscriptionResponse, + productTierType: organization?.productTierType, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { + this.refreshWarnings$.next(organization.id as OrganizationId); + } + } } }), ); + refreshWarningsForOrganization$(organizationId: OrganizationId): Observable { + return this.refreshWarnings$.pipe( + filter((id) => id === organizationId), + map((): void => void 0), + ); + } + private getResponse$ = ( organization: Organization, bypassCache: boolean = false, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d5ded3c75e..9c9ecc7972 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -568,6 +568,9 @@ "cancel": { "message": "Cancel" }, + "later": { + "message": "Later" + }, "canceled": { "message": "Canceled" }, @@ -4630,6 +4633,9 @@ "receiveMarketingEmailsV2": { "message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox." }, + "subscribe": { + "message": "Subscribe" + }, "unsubscribe": { "message": "Unsubscribe" }, @@ -10900,5 +10906,26 @@ "example": "12-3456789" } } + }, + "subscribetoEnterprise": { + "message": "Subscribe to $PLAN$", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "subscribeEnterpriseSubtitle": { + "message": "Your 7-day $PLAN$ trial starts today. Add a payment method now to continue using these features after your trial ends: ", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "unlimitedSecretsAndProjects": { + "message": "Unlimited secrets and projects" } -} +} \ No newline at end of file diff --git a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts index 4975da0d7d..29301e626b 100644 --- a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts @@ -1,3 +1,4 @@ +import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { @@ -28,4 +29,9 @@ export abstract class OrganizationBillingApiServiceAbstraction { organizationKey: string; }, ) => Promise; + + abstract changeSubscriptionFrequency: ( + organizationId: string, + request: ChangePlanFrequencyRequest, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/change-plan-frequency.request.ts b/libs/common/src/billing/models/request/change-plan-frequency.request.ts new file mode 100644 index 0000000000..70b7718166 --- /dev/null +++ b/libs/common/src/billing/models/request/change-plan-frequency.request.ts @@ -0,0 +1,9 @@ +import { PlanType } from "../../enums"; + +export class ChangePlanFrequencyRequest { + newPlanType: PlanType; + + constructor(newPlanType?: PlanType) { + this.newPlanType = newPlanType!; + } +} diff --git a/libs/common/src/billing/services/organization/organization-billing-api.service.ts b/libs/common/src/billing/services/organization/organization-billing-api.service.ts index 1189316a48..e9456f6102 100644 --- a/libs/common/src/billing/services/organization/organization-billing-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-billing-api.service.ts @@ -1,3 +1,4 @@ +import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { ApiService } from "../../../abstractions/api.service"; @@ -83,4 +84,17 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ return response as string; } + + async changeSubscriptionFrequency( + organizationId: string, + request: ChangePlanFrequencyRequest, + ): Promise { + return await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/billing/change-frequency", + request, + true, + false, + ); + } }