1
0
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:
cyprain-okeke
2025-07-22 15:58:17 +01:00
committed by GitHub
parent 5290e0a63b
commit 96f31aac3a
19 changed files with 1264 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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 }} &times;
{{
(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 }}&nbsp;
<span *ngIf="!summaryData.selectedPlan.PasswordManager.baseSeats">{{
"members" | i18n
}}</span>
&times;
{{ 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 }}
&times;
{{ 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 }} &times;
{{
(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 }}&nbsp;
<span *ngIf="!summaryData.selectedPlan.SecretsManager.baseSeats">{{
"members" | i18n
}}</span>
&times;
{{ 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 }}
&times;
{{
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>

View File

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

View File

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

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { PlanType } from "../../enums";
export class ChangePlanFrequencyRequest {
newPlanType: PlanType;
constructor(newPlanType?: PlanType) {
this.newPlanType = newPlanType!;
}
}

View File

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