1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 19:53:59 +00:00

refactoring the implementation

This commit is contained in:
Cy Okeke
2025-06-18 11:04:04 +01:00
parent 302fd55e82
commit 0118ada270
6 changed files with 1208 additions and 1150 deletions

View File

@@ -34,8 +34,8 @@ import {
AdjustPaymentDialogResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
import {
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
TrialPaymentMethodDialogComponent,
TrialPaymentMethodDialogResultType,
} from "../../shared/trial-subscription-dialog/trial-payment-method-dialog.component";
import { FreeTrial } from "../../types/free-trial";
@@ -202,7 +202,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === TrialPaymentMethodDialogResultType.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,113 @@
import { Injectable } from "@angular/core";
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
@Injectable()
export class PlanSelectionService {
getPlanCardContainerClasses(
plan: PlanResponse,
index: number,
isCardDisabled: (index: number) => boolean,
): string[] {
const isSelected = plan.isAnnual;
const isDisabled = isCardDisabled(index);
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",
"hover:tw-border-primary-700",
"focus:tw-border-2",
"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",
];
}
selectPlan(
plan: PlanResponse,
selectedInterval: number,
currentPlan: PlanResponse,
onPlanSelected: (selectedPlan: PlanResponse) => void,
): void {
if (
selectedInterval === PlanInterval.Monthly &&
plan.productTier === ProductTierType.Families
) {
return;
}
onPlanSelected(plan);
}
getSelectablePlans(
passwordManagerPlans: PlanResponse[],
selectedPlan: PlanResponse,
planIsEnabled: (plan: PlanResponse) => boolean,
): PlanResponse[] {
const result =
passwordManagerPlans?.filter(
(plan) => plan.productTier === selectedPlan.productTier && planIsEnabled(plan),
) || [];
result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder).reverse();
return result;
}
handleKeydown(
event: KeyboardEvent,
index: number,
isCardDisabled: (index: number) => boolean,
): void {
const cardElements = Array.from(document.querySelectorAll(".product-card")) as HTMLElement[];
let newIndex = index;
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
do {
newIndex = (newIndex + direction + cardElements.length) % cardElements.length;
} while (isCardDisabled(newIndex) && newIndex !== index);
event.preventDefault();
setTimeout(() => {
const card = cardElements[newIndex];
if (
!(
card.classList.contains("tw-bg-secondary-100") &&
card.classList.contains("tw-text-muted")
)
) {
card?.focus();
}
}, 0);
}
}
}

View File

@@ -0,0 +1,153 @@
import { Injectable } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } 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";
@Injectable()
export class PricingCalculationService {
calculatePasswordManagerSubtotal(
selectedPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
discount: number,
): number {
if (!selectedPlan || !selectedPlan.PasswordManager) {
return 0;
}
let subTotal = selectedPlan.PasswordManager.basePrice;
if (selectedPlan.PasswordManager.hasAdditionalSeatsOption) {
subTotal += this.calculatePasswordManagerSeatTotal(selectedPlan, subscription, false);
}
if (selectedPlan.PasswordManager.hasPremiumAccessOption) {
subTotal += selectedPlan.PasswordManager.premiumAccessOptionPrice;
}
return subTotal - discount;
}
calculateSecretsManagerSubtotal(
selectedPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
secretsManagerTotal: number,
): number {
const plan = selectedPlan;
if (!plan || !plan.SecretsManager) {
return secretsManagerTotal || 0;
}
if (secretsManagerTotal) {
return secretsManagerTotal;
}
return (
plan.SecretsManager.basePrice +
this.calculateSecretsManagerSeatTotal(plan, subscription?.smSeats) +
this.calculateAdditionalServiceAccountTotal(plan, 0)
); // This will be calculated separately
}
getPasswordManagerSeats(
selectedPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
): number {
if (!selectedPlan) {
return 0;
}
if (selectedPlan.productTier === ProductTierType.Families) {
return selectedPlan.PasswordManager.baseSeats;
}
return subscription?.seats;
}
calculateTotal(
organization: Organization,
selectedPlan: PlanResponse,
passwordManagerSubtotal: number,
estimatedTax: number,
subscription: OrganizationSubscriptionResponse,
): number {
if (!organization || !selectedPlan) {
return 0;
}
if (organization.useSecretsManager) {
return (
this.calculateAdditionalStorageTotal(selectedPlan, subscription) +
this.calculateSecretsManagerSubtotal(selectedPlan, subscription, 0) +
estimatedTax
);
}
return (
passwordManagerSubtotal +
this.calculateAdditionalStorageTotal(selectedPlan, subscription) +
estimatedTax
);
}
calculateAdditionalServiceAccount(
currentPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
): number {
if (!currentPlan || !currentPlan.SecretsManager) {
return 0;
}
const baseServiceAccount = currentPlan.SecretsManager?.baseServiceAccount || 0;
const usedServiceAccounts = subscription?.smServiceAccounts || 0;
const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts;
return additionalServiceAccounts <= 0 ? Math.abs(additionalServiceAccounts) : 0;
}
calculatePasswordManagerSeatTotal(
plan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
isSecretsManagerTrial: boolean,
): number {
if (!plan.PasswordManager.hasAdditionalSeatsOption || isSecretsManagerTrial) {
return 0;
}
return plan.PasswordManager.seatPrice * Math.abs(subscription?.seats || 0);
}
calculateSecretsManagerSeatTotal(plan: PlanResponse, seats: number): number {
if (!plan.SecretsManager.hasAdditionalSeatsOption) {
return 0;
}
return plan.SecretsManager.seatPrice * Math.abs(seats || 0);
}
calculateAdditionalStorageTotal(
plan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
): number {
if (!plan.PasswordManager.hasAdditionalStorageOption) {
return 0;
}
return (
plan.PasswordManager.additionalStoragePricePerGb *
Math.abs(subscription?.maxStorageGb ? subscription.maxStorageGb - 1 : 0)
);
}
calculateAdditionalServiceAccountTotal(
plan: PlanResponse,
additionalServiceAccount: number,
): number {
if (!plan.SecretsManager.hasAdditionalServiceAccountOption || additionalServiceAccount === 0) {
return 0;
}
return plan.SecretsManager.additionalPricePerServiceAccount * additionalServiceAccount;
}
calculateTotalAppliedDiscount(total: number, discountPercentageFromSub: number): number {
return total * (discountPercentageFromSub / 100);
}
}

View File

@@ -0,0 +1,149 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { PaymentComponent } from "../payment/payment.component";
@Injectable()
export class TrialPaymentMethodService {
isSecretsManagerTrial(subscription: OrganizationSubscriptionResponse): boolean {
return (
subscription?.subscription?.items?.some((item) =>
subscription?.customerDiscount?.appliesTo?.includes(item.productId),
) ?? false
);
}
shouldShowTaxIdField(
organizationId: string,
productTier: ProductTierType,
providerId?: string,
): boolean {
if (organizationId) {
switch (productTier) {
case ProductTierType.Free:
case ProductTierType.Families:
return false;
default:
return true;
}
} else {
return !!providerId;
}
}
async submitPayment(
organizationId: string,
paymentComponent: PaymentComponent,
taxInformation: TaxInformation,
billingApiService: BillingApiServiceAbstraction,
apiService: ApiService,
): Promise<void> {
if (organizationId) {
await this.updateOrganizationPaymentMethod(
organizationId,
paymentComponent,
taxInformation,
billingApiService,
);
} else {
await this.updatePremiumUserPaymentMethod(paymentComponent, taxInformation, apiService);
}
}
async refreshSalesTax(
organizationId: string,
selectedPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
organization: Organization,
taxInformation: TaxInformation,
taxService: TaxServiceAbstraction,
i18nService: I18nService,
toastService: ToastService,
): Promise<number> {
const request: PreviewOrganizationInvoiceRequest = {
organizationId: organizationId,
passwordManager: {
additionalStorage: 0,
plan: selectedPlan?.type,
seats: subscription.seats,
},
taxInformation: {
postalCode: taxInformation.postalCode,
country: taxInformation.country,
taxId: taxInformation.taxId,
},
};
if (organization.useSecretsManager) {
request.secretsManager = {
seats: subscription.smSeats,
additionalMachineAccounts:
subscription.smServiceAccounts - subscription.plan.SecretsManager.baseServiceAccount,
};
}
try {
const invoice = await taxService.previewOrganizationInvoice(request);
return invoice.taxAmount;
} catch (error) {
const translatedMessage = i18nService.t(error.message);
toastService.showToast({
title: "",
variant: "error",
message: !translatedMessage || translatedMessage === "" ? error.message : translatedMessage,
});
throw error;
}
}
private async updateOrganizationPaymentMethod(
organizationId: string,
paymentComponent: PaymentComponent,
taxInformation: TaxInformation,
billingApiService: BillingApiServiceAbstraction,
): Promise<void> {
const paymentSource = await paymentComponent.tokenize();
const request = new UpdatePaymentMethodRequest();
request.paymentSource = paymentSource;
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await billingApiService.updateOrganizationPaymentMethod(organizationId, request);
}
private async updatePremiumUserPaymentMethod(
paymentComponent: PaymentComponent,
taxInformation: TaxInformation,
apiService: ApiService,
): Promise<void> {
const { type, token } = await paymentComponent.tokenize();
const request = new PaymentRequest();
request.paymentMethodType = type;
request.paymentToken = token;
request.country = taxInformation.country;
request.postalCode = taxInformation.postalCode;
request.taxId = taxInformation.taxId;
request.state = taxInformation.state;
request.line1 = taxInformation.line1;
request.line2 = taxInformation.line2;
request.city = taxInformation.city;
request.state = taxInformation.state;
await apiService.postAccountPayment(request);
}
}