mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[PM 18701]Optional payment modal after signup (#15384)
* Implement the planservice * Add the pricing component and service * Add the change plan type service * resolve the unit test issues * Move the changeSubscriptionFrequency endpoint * Rename planservice to plancardservice * Remove unused and correct typos * Resolve the double asignment * resolve the unit test failing * Remove default payment setting to card * remove unnecessary check * Property initialPaymentMethod has no initializer * move the logic to service * Move estimate tax to pricing service * Refactor thr pricing summary component * Resolve the lint unit test error * Add changes for auto modal * Remove custom role for sm * Resolve the blank member page issue * Changes on the pricing display
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
<app-organization-free-trial-warning
|
||||
[organization]="organization"
|
||||
(clicked)="navigateToPaymentMethod()"
|
||||
>
|
||||
</app-organization-free-trial-warning>
|
||||
<app-header>
|
||||
<bit-search
|
||||
class="tw-grow"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
ChangePlanDialogResultType,
|
||||
openChangePlanDialog,
|
||||
} from "../../../billing/organizations/change-plan-dialog.component";
|
||||
import { OrganizationWarningsService } from "../../../billing/warnings/services";
|
||||
import { BaseMembersComponent } from "../../common/base-members.component";
|
||||
import { PeopleTableDataSource } from "../../common/people-table-data-source";
|
||||
import { GroupApiService } from "../core";
|
||||
@@ -148,6 +150,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
@@ -247,6 +250,13 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
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<OrganizationUserView[]> {
|
||||
@@ -932,4 +942,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
.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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
59
apps/web/src/app/billing/services/plan-card.service.ts
Normal file
59
apps/web/src/app/billing/services/plan-card.service.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
155
apps/web/src/app/billing/services/pricing-summary.service.ts
Normal file
155
apps/web/src/app/billing/services/pricing-summary.service.ts
Normal file
@@ -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<PricingSummaryData> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@let isFocused = plan().isSelected;
|
||||
@let isRecommended = plan().isAnnual;
|
||||
|
||||
<bit-card
|
||||
class="tw-h-full"
|
||||
[ngClass]="getPlanCardContainerClasses()"
|
||||
(click)="cardClicked.emit()"
|
||||
[attr.tabindex]="!isFocused || plan().isDisabled ? '-1' : '0'"
|
||||
[attr.data-selected]="plan()?.isSelected"
|
||||
>
|
||||
<div class="tw-relative">
|
||||
@if (isRecommended) {
|
||||
<div
|
||||
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-700 !tw-text-contrast': plan().isSelected,
|
||||
'tw-bg-secondary-100': !plan().isSelected,
|
||||
}"
|
||||
>
|
||||
{{ "recommended" | i18n }}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="tw-px-2 tw-pb-[4px]"
|
||||
[ngClass]="{
|
||||
'tw-py-1': !plan().isSelected,
|
||||
'tw-py-0': plan().isSelected,
|
||||
}"
|
||||
>
|
||||
<h3
|
||||
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
|
||||
>
|
||||
<span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span>
|
||||
<!-- Discount Badge -->
|
||||
<span class="tw-mr-1 tw-ml-2" *ngIf="isRecommended" bitBadge variant="success">
|
||||
{{ "upgradeDiscount" | i18n: plan().discount }}</span
|
||||
>
|
||||
</h3>
|
||||
<span>
|
||||
<b class="tw-text-lg tw-font-semibold">{{ plan().costPerMember | currency: "$" }} </b>
|
||||
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</bit-card>
|
||||
@@ -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<PlanCard>();
|
||||
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",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<ng-container>
|
||||
<div class="tw-mt-4">
|
||||
<p class="tw-text-lg tw-mb-1">
|
||||
<span class="tw-font-semibold"
|
||||
>{{ "total" | i18n }}:
|
||||
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} USD</span
|
||||
>
|
||||
<span class="tw-text-xs tw-font-light"> / {{ summaryData.selectedPlanInterval | i18n }}</span>
|
||||
<button
|
||||
(click)="toggleTotalOpened()"
|
||||
type="button"
|
||||
[bitIconButton]="summaryData.totalOpened ? 'bwi-angle-down' : 'bwi-angle-up'"
|
||||
size="small"
|
||||
aria-hidden="true"
|
||||
></button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="summaryData.totalOpened">
|
||||
<!-- Main content container -->
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-4">
|
||||
<bit-hint class="tw-w-full">
|
||||
<ng-container *ngIf="summaryData.isSecretsManagerTrial; else showPasswordManagerFirst">
|
||||
<ng-container *ngTemplateOutlet="secretsManagerSection"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="passwordManagerSection"></ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #showPasswordManagerFirst>
|
||||
<ng-container *ngTemplateOutlet="passwordManagerSection"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="secretsManagerSection"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Password Manager section -->
|
||||
<ng-template #passwordManagerSection>
|
||||
<ng-container
|
||||
*ngIf="!summaryData.isSecretsManagerTrial || summaryData.organization.useSecretsManager"
|
||||
>
|
||||
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
|
||||
|
||||
<!-- Base Price -->
|
||||
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.basePrice">
|
||||
<p class="tw-mb-1 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span>
|
||||
<ng-container [ngSwitch]="summaryData.selectedInterval">
|
||||
<ng-container *ngSwitchCase="planIntervals.Annually">
|
||||
{{ summaryData.passwordManagerSeats }} {{ "members" | i18n }} ×
|
||||
{{
|
||||
(summaryData.selectedPlan.isAnnual
|
||||
? summaryData.selectedPlan.PasswordManager.basePrice / 12
|
||||
: summaryData.selectedPlan.PasswordManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
/{{ summaryData.selectedPlanInterval | i18n }}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchDefault>
|
||||
{{ "basePrice" | i18n }}:
|
||||
{{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }}
|
||||
{{ "monthAbbr" | i18n }}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</span>
|
||||
<span>
|
||||
<ng-container
|
||||
*ngIf="summaryData.acceptingSponsorship; else notAcceptingSponsorship"
|
||||
>
|
||||
<span class="tw-line-through">{{
|
||||
summaryData.selectedPlan.PasswordManager.basePrice | currency: "$"
|
||||
}}</span>
|
||||
{{ "freeWithSponsorship" | i18n }}
|
||||
</ng-container>
|
||||
<ng-template #notAcceptingSponsorship>
|
||||
{{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }}
|
||||
</ng-template>
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- Additional Seats -->
|
||||
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.hasAdditionalSeatsOption">
|
||||
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span>
|
||||
<span *ngIf="summaryData.selectedPlan.PasswordManager.baseSeats"
|
||||
>{{ "additionalUsers" | i18n }}:</span
|
||||
>
|
||||
{{ summaryData.passwordManagerSeats || 0 }}
|
||||
<span *ngIf="!summaryData.selectedPlan.PasswordManager.baseSeats">{{
|
||||
"members" | i18n
|
||||
}}</span>
|
||||
×
|
||||
{{ summaryData.selectedPlan.PasswordManager.seatPrice | currency: "$" }}
|
||||
/{{ summaryData.selectedPlanInterval | i18n }}
|
||||
</span>
|
||||
<span *ngIf="!summaryData.isSecretsManagerTrial">
|
||||
{{ summaryData.passwordManagerSeatTotal | currency: "$" }}
|
||||
</span>
|
||||
<span *ngIf="summaryData.isSecretsManagerTrial">
|
||||
{{ "freeForOneYear" | i18n }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- Additional Storage -->
|
||||
<ng-container
|
||||
*ngIf="
|
||||
summaryData.selectedPlan.PasswordManager.hasAdditionalStorageOption &&
|
||||
summaryData.storageGb > 0
|
||||
"
|
||||
>
|
||||
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span>
|
||||
{{ summaryData.storageGb }} {{ "additionalStorageGbMessage" | i18n }}
|
||||
×
|
||||
{{ summaryData.additionalStoragePriceMonthly | currency: "$" }}
|
||||
/{{ summaryData.selectedPlanInterval | i18n }}
|
||||
</span>
|
||||
<span>
|
||||
<ng-container [ngSwitch]="summaryData.selectedInterval">
|
||||
<ng-container *ngSwitchCase="planIntervals.Annually">
|
||||
{{ summaryData.additionalStorageTotal | currency: "$" }}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchDefault>
|
||||
{{
|
||||
summaryData.storageGb *
|
||||
summaryData.selectedPlan.PasswordManager.additionalStoragePricePerGb
|
||||
| currency: "$"
|
||||
}}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Secrets Manager section -->
|
||||
<ng-template #secretsManagerSection>
|
||||
<ng-container *ngIf="summaryData.organization.useSecretsManager">
|
||||
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p>
|
||||
|
||||
<!-- Base Price -->
|
||||
<ng-container *ngIf="summaryData.selectedPlan?.SecretsManager?.basePrice">
|
||||
<p class="tw-mb-1 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span>
|
||||
<ng-container [ngSwitch]="summaryData.selectedInterval">
|
||||
<ng-container *ngSwitchCase="planIntervals.Annually">
|
||||
{{ summaryData.sub?.smSeats }} {{ "members" | i18n }} ×
|
||||
{{
|
||||
(summaryData.selectedPlan.isAnnual
|
||||
? summaryData.selectedPlan.SecretsManager.basePrice / 12
|
||||
: summaryData.selectedPlan.SecretsManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
/{{ summaryData.selectedPlanInterval | i18n }}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchDefault>
|
||||
{{ "basePrice" | i18n }}:
|
||||
{{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }}
|
||||
{{ "monthAbbr" | i18n }}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</span>
|
||||
<span *ngIf="summaryData.selectedInterval === planIntervals.Monthly">
|
||||
{{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- Additional Seats -->
|
||||
<ng-container
|
||||
*ngIf="summaryData.selectedPlan?.SecretsManager?.hasAdditionalSeatsOption"
|
||||
>
|
||||
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span>
|
||||
<span *ngIf="summaryData.selectedPlan.SecretsManager.baseSeats"
|
||||
>{{ "additionalUsers" | i18n }}:</span
|
||||
>
|
||||
{{ summaryData.sub?.smSeats || 0 }}
|
||||
<span *ngIf="!summaryData.selectedPlan.SecretsManager.baseSeats">{{
|
||||
"members" | i18n
|
||||
}}</span>
|
||||
×
|
||||
{{ summaryData.selectedPlan.SecretsManager.seatPrice | currency: "$" }}
|
||||
/{{ summaryData.selectedPlanInterval | i18n }}
|
||||
</span>
|
||||
<span>
|
||||
{{ summaryData.secretsManagerSeatTotal | currency: "$" }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- Additional Service Accounts -->
|
||||
<ng-container
|
||||
*ngIf="
|
||||
summaryData.selectedPlan?.SecretsManager?.hasAdditionalServiceAccountOption &&
|
||||
summaryData.additionalServiceAccount > 0
|
||||
"
|
||||
>
|
||||
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span>
|
||||
{{ summaryData.additionalServiceAccount }}
|
||||
{{ "serviceAccounts" | i18n | lowercase }}
|
||||
×
|
||||
{{
|
||||
summaryData.selectedPlan?.SecretsManager?.additionalPricePerServiceAccount
|
||||
| currency: "$"
|
||||
}}
|
||||
/{{ summaryData.selectedPlanInterval | i18n }}
|
||||
</span>
|
||||
<span>{{ summaryData.additionalServiceAccountTotal | currency: "$" }}</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Discount Section -->
|
||||
<ng-container *ngIf="summaryData.discountPercentageFromSub > 0">
|
||||
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
|
||||
<span class="tw-text-xs">
|
||||
{{
|
||||
"providerDiscount" | i18n: this.summaryData.discountPercentageFromSub | lowercase
|
||||
}}
|
||||
</span>
|
||||
<span class="tw-line-through tw-text-xs">
|
||||
{{ summaryData.totalAppliedDiscount | currency: "$" }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
</bit-hint>
|
||||
</div>
|
||||
|
||||
<!-- Tax and Total Section -->
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
|
||||
<bit-hint class="tw-w-full">
|
||||
<p
|
||||
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
|
||||
>
|
||||
<span class="tw-font-semibold">{{ "estimatedTax" | i18n }}</span>
|
||||
<span>{{ summaryData.estimatedTax | currency: "USD" : "$" }}</span>
|
||||
</p>
|
||||
</bit-hint>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
|
||||
<bit-hint class="tw-w-full">
|
||||
<p
|
||||
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
|
||||
>
|
||||
<span class="tw-font-semibold">{{ "total" | i18n }}</span>
|
||||
<span>
|
||||
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }}
|
||||
<span class="tw-text-xs tw-font-semibold"
|
||||
>/ {{ summaryData.selectedPlanInterval | i18n }}</span
|
||||
>
|
||||
</span>
|
||||
</p>
|
||||
</bit-hint>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<bit-dialog dialogSize="default">
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
{{ "subscribetoEnterprise" | i18n: currentPlanName }}
|
||||
</span>
|
||||
|
||||
<div bitDialogContent>
|
||||
<p>{{ "subscribeEnterpriseSubtitle" | i18n: currentPlanName }}</p>
|
||||
|
||||
<!-- Plan Features List -->
|
||||
<ng-container [ngSwitch]="currentPlan?.productTier">
|
||||
<ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Enterprise">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "includeEnterprisePolicies" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "passwordLessSso" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "accountRecovery" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="!organization?.canAccessSecretsManager">
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "customRoles" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="organization?.canAccessSecretsManager">
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "unlimitedSecretsAndProjects" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Teams">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "secureDataSharing" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "eventLogMonitoring" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "directoryIntegration" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="organization?.canAccessSecretsManager">
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "unlimitedSecretsAndProjects" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Families">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumAccounts" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "unlimitedSharing" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
|
||||
{{ "createUnlimitedCollections" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="!(currentPlan?.productTier === productTypes.Families)">
|
||||
<div class="tw-mb-3 tw-flex tw-justify-between">
|
||||
<h4 class="tw-text-lg tw-text-main">{{ "selectAPlan" | i18n }}</h4>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="planCards().length > 0">
|
||||
<div
|
||||
class="tw-grid tw-grid-flow-col tw-gap-4 tw-mb-2"
|
||||
[class]="'tw-grid-cols-' + planCards().length"
|
||||
>
|
||||
@for (planCard of planCards(); track $index) {
|
||||
<app-plan-card [plan]="planCard" (cardClicked)="setSelected(planCard)"></app-plan-card>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<!-- Payment Information -->
|
||||
<ng-container>
|
||||
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
|
||||
<ng-container bitDialogContent>
|
||||
<app-payment
|
||||
[showAccountCredit]="false"
|
||||
[showBankAccount]="!!organizationId"
|
||||
[initialPaymentMethod]="initialPaymentMethod"
|
||||
></app-payment>
|
||||
<app-manage-tax-information
|
||||
*ngIf="taxInformation"
|
||||
[showTaxIdField]="showTaxIdField"
|
||||
[startWith]="taxInformation"
|
||||
(taxInformationChanged)="taxInformationChanged($event)"
|
||||
/>
|
||||
</ng-container>
|
||||
<!-- Pricing Breakdown -->
|
||||
<app-pricing-summary
|
||||
*ngIf="pricingSummaryData"
|
||||
[summaryData]="pricingSummaryData"
|
||||
></app-pricing-summary>
|
||||
</ng-container>
|
||||
</div>
|
||||
<!-- Dialog Footer -->
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" type="button" [bitAction]="onSubscribe.bind(this)">
|
||||
{{ "subscribe" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.CLOSED">
|
||||
{{ "later" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -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<PlanCard[]>([]);
|
||||
plans!: ListResponse<PlanResponse>;
|
||||
|
||||
@Output() onSuccess = new EventEmitter<OnSuccessArgs>();
|
||||
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<TrialPaymentDialogResultType>,
|
||||
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<void> {
|
||||
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<TrialPaymentDialogParams>,
|
||||
) => dialogService.open<TrialPaymentDialogResultType>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void>();
|
||||
|
||||
warning$!: Observable<OrganizationFreeTrialWarning>;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
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 = () => {
|
||||
|
||||
@@ -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<OrganizationId, Observable<OrganizationWarningsResponse>>();
|
||||
private refreshWarnings$ = new Subject<OrganizationId>();
|
||||
|
||||
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<void> {
|
||||
return this.refreshWarnings$.pipe(
|
||||
filter((id) => id === organizationId),
|
||||
map((): void => void 0),
|
||||
);
|
||||
}
|
||||
|
||||
private getResponse$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
|
||||
abstract changeSubscriptionFrequency: (
|
||||
organizationId: string,
|
||||
request: ChangePlanFrequencyRequest,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlanType } from "../../enums";
|
||||
|
||||
export class ChangePlanFrequencyRequest {
|
||||
newPlanType: PlanType;
|
||||
|
||||
constructor(newPlanType?: PlanType) {
|
||||
this.newPlanType = newPlanType!;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
return await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + organizationId + "/billing/change-frequency",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user