1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

Refactor(billing): Enhance Premium Org Upgrade Payment logic

This commit is contained in:
Stephon Brown
2026-02-03 16:22:04 -05:00
parent 976e13e8eb
commit 08a4e398db
3 changed files with 107 additions and 64 deletions

View File

@@ -84,6 +84,21 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
private readonly INITIAL_TAX_VALUE = 0;
private readonly DEFAULT_SEAT_COUNT = 1;
private readonly DEFAULT_CADENCE = "annually";
private readonly PLAN_MEMBERSHIP_MESSAGES: Record<string, string> = {
families: "familiesMembership",
teams: "teamsMembership",
enterprise: "enterpriseMembership",
};
private readonly UPGRADE_STATUS_MAP: Record<string, PremiumOrgUpgradePaymentStatus> = {
families: PremiumOrgUpgradePaymentStatus.UpgradedToFamilies,
teams: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
enterprise: PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise,
};
private readonly UPGRADE_MESSAGE_KEYS: Record<string, string> = {
families: "upgradeToFamilies",
teams: "upgradeToTeams",
enterprise: "upgradeToEnterprise",
};
protected readonly selectedPlanId = input.required<
PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId
@@ -104,18 +119,10 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
protected readonly selectedPlan = signal<PremiumOrgUpgradePlanDetails | null>(null);
protected readonly loading = signal(true);
protected readonly upgradeToMessage = signal("");
protected readonly planMembershipMessage = computed<string>(() => {
switch (this.selectedPlanId()) {
case "families":
return "familiesMembership";
case "teams":
return "teamsMembership";
case "enterprise":
return "enterpriseMembership";
default:
return "";
}
});
protected readonly planMembershipMessage = computed<string>(
() => this.PLAN_MEMBERSHIP_MESSAGES[this.selectedPlanId()] ?? "",
);
// Use defer to lazily create the observable when subscribed to
protected estimatedInvoice$ = defer(() =>
@@ -133,7 +140,7 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
);
protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, {
initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 },
initialValue: this.getEmptyInvoicePreview(),
});
// Cart Summary data
@@ -156,29 +163,18 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
return {
passwordManager: {
seats: {
translationKey:
this.estimatedInvoice()?.proratedAmountOfMonths > 0
? "planProratedMembershipInMonths"
: this.planMembershipMessage(),
translationParams:
this.estimatedInvoice()?.proratedAmountOfMonths > 0
? [
this.selectedPlan()!.details.name,
`${this.estimatedInvoice()?.proratedAmountOfMonths} month${this.estimatedInvoice()?.proratedAmountOfMonths > 1 ? "s" : ""}`,
]
: [],
cost: this.selectedPlan()?.cost ?? 0,
translationKey: this.getMembershipTranslationKey(),
translationParams: this.getMembershipTranslationParams(),
cost: this.getCartCost(),
quantity: this.DEFAULT_SEAT_COUNT,
hideBreakdown: true,
},
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.estimatedInvoice().tax,
discount: {
type: "amount-off",
credit: {
value: this.estimatedInvoice().credit,
translationKey: "premiumMembershipDiscount",
hideFormattedAmount: true,
},
};
});
@@ -225,28 +221,8 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
if (planDetails) {
this.selectedPlan.set({
tier: this.selectedPlanId(),
details: planDetails,
cost: this.getPlanPrice(planDetails),
});
switch (this.selectedPlanId()) {
case "families":
this.upgradeToMessage.set(this.i18nService.t("upgradeToFamilies", planDetails.name));
break;
case "teams":
this.upgradeToMessage.set(this.i18nService.t("upgradeToTeams", planDetails.name));
break;
case "enterprise":
this.upgradeToMessage.set(
this.i18nService.t("upgradeToEnterprise", planDetails.name),
);
break;
default:
this.upgradeToMessage.set("");
break;
}
this.setSelectedPlan(planDetails);
this.setUpgradeMessage(planDetails);
} else {
this.complete.emit({
status: PremiumOrgUpgradePaymentStatus.Closed,
@@ -326,16 +302,70 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
}
private getUpgradeStatus(planId: string): PremiumOrgUpgradePaymentStatus {
switch (planId) {
case "families":
return PremiumOrgUpgradePaymentStatus.UpgradedToFamilies;
case "teams":
return PremiumOrgUpgradePaymentStatus.UpgradedToTeams;
case "enterprise":
return PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise;
default:
return PremiumOrgUpgradePaymentStatus.Closed;
return this.UPGRADE_STATUS_MAP[planId] ?? PremiumOrgUpgradePaymentStatus.Closed;
}
/**
* Gets the appropriate translation key for the membership display.
* Returns a prorated message if the plan has prorated months, otherwise returns the standard plan message.
*/
private getMembershipTranslationKey(): string {
return this.estimatedInvoice()?.newPlanProratedMonths > 0
? "planProratedMembershipInMonths"
: this.planMembershipMessage();
}
/**
* Gets the translation parameters for the membership display.
* For prorated plans, returns an array with the plan name and formatted month duration.
* For non-prorated plans, returns an empty array.
*/
private getMembershipTranslationParams(): string[] {
if (this.estimatedInvoice()?.newPlanProratedMonths > 0) {
const months = this.estimatedInvoice()!.newPlanProratedMonths;
const monthLabel = this.formatMonthLabel(months);
return [this.selectedPlan()!.details.name, monthLabel];
}
return [];
}
/**
* Formats month count into a readable string (e.g., "1 month", "3 months").
*/
private formatMonthLabel(months: number): string {
return `${months} month${months > 1 ? "s" : ""}`;
}
/**
* Calculates the cart cost, using prorated amount if available, otherwise the plan cost.
*/
private getCartCost(): number {
const proratedAmount = this.estimatedInvoice().newPlanProratedAmount;
return proratedAmount && proratedAmount > 0 ? proratedAmount : this.selectedPlan()!.cost;
}
/**
* Sets the selected plan with tier, details, and cost.
*/
private setSelectedPlan(
planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
): void {
this.selectedPlan.set({
tier: this.selectedPlanId(),
details: planDetails,
cost: this.getPlanPrice(planDetails),
});
}
/**
* Sets the upgrade message based on the selected plan.
*/
private setUpgradeMessage(
planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
): void {
const messageKey = this.UPGRADE_MESSAGE_KEYS[this.selectedPlanId()];
const message = messageKey ? this.i18nService.t(messageKey, planDetails.name) : "";
this.upgradeToMessage.set(message);
}
/**
@@ -364,18 +394,32 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
return 0;
}
/**
* Returns an empty invoice preview with default values.
*/
private getEmptyInvoicePreview(): InvoicePreview {
return {
tax: this.INITIAL_TAX_VALUE,
total: 0,
credit: 0,
newPlanProratedMonths: 0,
newPlanProratedAmount: 0,
};
}
/**
* Refreshes the invoice preview based on the current form state.
*/
private refreshInvoicePreview$(): Observable<InvoicePreview> {
if (this.formGroup.invalid || !this.selectedPlan()) {
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
return of(this.getEmptyInvoicePreview());
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
if (!billingAddress.country || !billingAddress.postalCode) {
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
return of(this.getEmptyInvoicePreview());
}
return from(
this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress),
).pipe(
@@ -385,7 +429,7 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
variant: "error",
message: this.i18nService.t("invoicePreviewErrorMessage"),
});
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
return of(this.getEmptyInvoicePreview());
}),
);
}

View File

@@ -22,7 +22,7 @@
(planSelected)="onPlanSelected($event)"
(closeClicked)="onCloseClicked()"
/>
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
} @else if (step() == PaymentStep && selectedPersonalPlanId() !== null && account() !== null) {
<app-upgrade-payment
[selectedPlanId]="selectedPersonalPlanId()"
[account]="account()"

View File

@@ -51,7 +51,6 @@
<!-- Button space (always reserved) -->
<div class="tw-my-5 tw-h-12">
<button
cdkFocusInitial
bitButton
[buttonType]="cardDetails.button.type"
[block]="true"