1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-29 15:53:45 +00:00

fix(billing): update tests and logic

update

update

fix
This commit is contained in:
Stephon Brown
2026-01-27 12:37:29 -05:00
parent 0b2b2732ba
commit 5a22f6b1b1
5 changed files with 291 additions and 19 deletions

View File

@@ -32,9 +32,6 @@
<section>
<billing-cart-summary #cartSummaryComponent [cart]="cart()"></billing-cart-summary>
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}
</p>
</section>
</ng-container>

View File

@@ -146,6 +146,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockPreviewInvoiceClient = mock<PreviewInvoiceClient>();
const mockLogService = mock<LogService>();
const mockI18nService = { t: jest.fn((key: string, ...params: any[]) => key) };
const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account;
const mockTeamsPlan: BusinessSubscriptionPricingTier = {
@@ -181,6 +182,12 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
jest.clearAllMocks();
mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined);
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined);
mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({
tax: 5.0,
total: 53.0,
credit: 10.0,
proratedAmountOfMonths: 1,
});
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of([mockTeamsPlan]),
@@ -199,7 +206,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
},
{ provide: ToastService, useValue: mockToastService },
{ provide: LogService, useValue: mockLogService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: I18nService, useValue: mockI18nService },
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
{
@@ -251,7 +258,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
it("should initialize with the correct plan details", () => {
expect(component["selectedPlan"]()).not.toBeNull();
expect(component["selectedPlan"]()?.details.id).toBe("teams");
expect(component["upgradeToMessage"]()).toContain("startFreeTrial");
expect(component["upgradeToMessage"]()).toContain("upgradeToTeams");
});
it("should handle invalid plan id that doesn't exist in pricing tiers", async () => {
@@ -406,4 +413,213 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
PremiumOrgUpgradePaymentStatus.Closed,
);
});
describe("Invoice Preview", () => {
it("should return zero values when billing address is incomplete", fakeAsync(() => {
component["formGroup"].patchValue({
organizationName: "Test Org",
billingAddress: {
country: "US",
postalCode: "", // Missing postal code
},
});
tick(1000);
fixture.detectChanges();
const estimatedInvoice = component["estimatedInvoice"]();
expect(estimatedInvoice.tax).toBe(0);
expect(estimatedInvoice.total).toBe(0);
}));
});
describe("Form Validation", () => {
it("should validate organization name is required", () => {
component["formGroup"].patchValue({ organizationName: "" });
expect(component["formGroup"].get("organizationName")?.invalid).toBe(true);
});
it("should validate organization name when provided", () => {
component["formGroup"].patchValue({ organizationName: "My Organization" });
expect(component["formGroup"].get("organizationName")?.valid).toBe(true);
});
it("should return false when payment component validation fails", () => {
component["formGroup"].patchValue({
organizationName: "Test Org",
billingAddress: {
country: "US",
postalCode: "12345",
},
});
const mockPaymentComponent = {
validate: jest.fn().mockReturnValue(false),
} as any;
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
expect(component["isFormValid"]()).toBe(false);
});
});
describe("Cart Calculation", () => {
it("should calculate cart with correct values for selected plan", () => {
const cart = component["cart"]();
expect(cart.passwordManager.seats.cost).toBe(48); // Teams annual price per user
expect(cart.passwordManager.seats.quantity).toBe(1);
expect(cart.cadence).toBe("annually");
});
it("should return default cart when no plan is selected", () => {
component["selectedPlan"].set(null);
const cart = component["cart"]();
expect(cart.passwordManager.seats.cost).toBe(0);
expect(cart.passwordManager.seats.quantity).toBe(0);
expect(cart.estimatedTax).toBe(0);
});
});
describe("ngAfterViewInit", () => {
it("should collapse cart summary after view init", () => {
const mockCartSummary = {
isExpanded: signal(true),
} as any;
jest.spyOn(component, "cartSummaryComponent").mockReturnValue(mockCartSummary);
component.ngAfterViewInit();
expect(mockCartSummary.isExpanded()).toBe(false);
});
});
describe("Plan Price Calculation", () => {
it("should calculate price for personal plan with annualPrice", () => {
const price = component["getPlanPrice"](mockFamiliesPlan);
expect(price).toBe(40);
});
it("should calculate price for business plan with annualPricePerUser", () => {
const price = component["getPlanPrice"](mockTeamsPlan);
expect(price).toBe(48);
});
it("should return 0 when passwordManager is missing", () => {
const invalidPlan = { ...mockTeamsPlan, passwordManager: undefined } as any;
const price = component["getPlanPrice"](invalidPlan);
expect(price).toBe(0);
});
});
describe("processUpgrade", () => {
it("should throw error when billing address is incomplete", async () => {
component["formGroup"].patchValue({
organizationName: "Test Org",
billingAddress: {
country: "",
postalCode: "",
},
});
const mockPaymentComponent = {
tokenize: jest.fn().mockResolvedValue({ type: "card", token: "mock-token" }),
} as any;
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
await expect(component["processUpgrade"]()).rejects.toThrow("Billing address is incomplete");
});
it("should throw error when organization name is missing", async () => {
component["formGroup"].patchValue({
organizationName: "",
billingAddress: {
country: "US",
postalCode: "12345",
},
});
const mockPaymentComponent = {
tokenize: jest.fn().mockResolvedValue({ type: "card", token: "mock-token" }),
} as any;
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required");
});
it("should throw error when payment method tokenization fails", async () => {
component["formGroup"].patchValue({
organizationName: "Test Org",
billingAddress: {
country: "US",
postalCode: "12345",
},
});
const mockPaymentComponent = {
tokenize: jest.fn().mockResolvedValue(null),
} as any;
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
await expect(component["processUpgrade"]()).rejects.toThrow("Payment method is required");
});
});
describe("Plan Membership Messages", () => {
it("should return correct membership message for families plan", async () => {
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
const newComponent = newFixture.componentInstance;
newFixture.componentRef.setInput(
"selectedPlanId",
"families" as PersonalSubscriptionPricingTierId,
);
newFixture.componentRef.setInput("account", mockAccount);
newFixture.detectChanges();
await newFixture.whenStable();
expect(newComponent["planMembershipMessage"]()).toBe("familiesMembership");
});
it("should return correct membership message for teams plan", () => {
expect(component["planMembershipMessage"]()).toBe("teamsMembership");
});
it("should return correct membership message for enterprise plan", async () => {
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
const newComponent = newFixture.componentInstance;
newFixture.componentRef.setInput(
"selectedPlanId",
"enterprise" as BusinessSubscriptionPricingTierId,
);
newFixture.componentRef.setInput("account", mockAccount);
newFixture.detectChanges();
await newFixture.whenStable();
expect(newComponent["planMembershipMessage"]()).toBe("enterpriseMembership");
});
});
describe("Error Handling", () => {
it("should log error and continue when submit fails", async () => {
jest.spyOn(component as any, "isFormValid").mockReturnValue(true);
jest.spyOn(component as any, "processUpgrade").mockRejectedValue(new Error("Network error"));
await component["submit"]();
expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", expect.any(Error));
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "upgradeErrorMessage",
});
});
});
describe("goBack Output", () => {
it("should emit goBack event when back action is triggered", () => {
const goBackSpy = jest.spyOn(component["goBack"], "emit");
component["goBack"].emit();
expect(goBackSpy).toHaveBeenCalled();
});
});
});

View File

@@ -103,18 +103,36 @@ 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 "";
}
});
// Use defer to lazily create the observable when subscribed to
protected estimatedInvoice$ = defer(() =>
this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
combineLatest([
this.formGroup.controls.billingAddress.valueChanges,
this.formGroup.controls.organizationName.valueChanges,
]).pipe(
startWith(
this.formGroup.controls.billingAddress.value,
this.formGroup.controls.organizationName.value,
),
debounceTime(1000),
switchMap(() => this.refreshInvoicePreview$()),
),
);
protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, {
initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 },
initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 },
});
// Cart Summary data
@@ -122,7 +140,12 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
if (!this.selectedPlan()) {
return {
passwordManager: {
seats: { translationKey: "", cost: 0, quantity: 0 },
seats: {
translationKey: this.planMembershipMessage(),
cost: 0,
quantity: 0,
hideBreakdown: true,
},
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.INITIAL_TAX_VALUE,
@@ -132,14 +155,30 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
return {
passwordManager: {
seats: {
translationKey: this.selectedPlan()?.details.name ?? "",
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,
quantity: this.DEFAULT_SEAT_COUNT,
hideBreakdown: true,
},
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.estimatedInvoice().tax,
discount: { type: "amount-off", value: this.estimatedInvoice().credit },
discount: {
type: "amount-off",
value: this.estimatedInvoice().credit,
translationKey: "premiumMembershipDiscount",
quantity: 1,
},
};
});
@@ -183,7 +222,22 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
cost: this.getPlanPrice(planDetails),
});
this.upgradeToMessage.set(this.i18nService.t("startFreeTrial", planDetails.name));
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;
}
} else {
this.complete.emit({
status: PremiumOrgUpgradePaymentStatus.Closed,
@@ -215,7 +269,7 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
const result = await this.processUpgrade();
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUpdated", this.selectedPlan()?.details.name),
message: this.i18nService.t("plansUpdated", this.selectedPlan()?.details.name),
});
this.complete.emit(result);
} catch (error: unknown) {
@@ -306,12 +360,12 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
*/
private refreshInvoicePreview$(): Observable<InvoicePreview> {
if (this.formGroup.invalid || !this.selectedPlan()) {
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
if (!billingAddress.country || !billingAddress.postalCode) {
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
}
return from(
this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress),
@@ -322,7 +376,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 });
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
}),
);
}

View File

@@ -57,7 +57,9 @@ describe("PremiumOrgUpgradeService", () => {
fullSync: jest.fn().mockResolvedValue(undefined),
} as any;
keyService = {
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
makeOrgKey: jest
.fn()
.mockResolvedValue([{ encryptedString: "encrypted-string" }, "decrypted-key"]),
} as any;
TestBed.configureTestingModule({
@@ -85,7 +87,7 @@ describe("PremiumOrgUpgradeService", () => {
expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith(
"Test Organization",
"encrypted-key",
"encrypted-string",
2, // ProductTierType.Teams
"annually",
mockBillingAddress,

View File

@@ -20,6 +20,7 @@ export type PremiumOrgUpgradePlanDetails = {
tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId;
details: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier;
cost: number;
proratedAmount?: number;
};
export type PaymentFormValues = {
@@ -34,6 +35,7 @@ export interface InvoicePreview {
tax: number;
total: number;
credit: number;
proratedAmountOfMonths: number;
}
@Injectable()
@@ -58,6 +60,7 @@ export class PremiumOrgUpgradeService {
tax: invoicePreviewResponse.tax,
total: invoicePreviewResponse.total,
credit: invoicePreviewResponse.credit,
proratedAmountOfMonths: invoicePreviewResponse.proratedAmountOfMonths,
};
}
@@ -80,7 +83,7 @@ export class PremiumOrgUpgradeService {
await this.accountBillingClient.upgradePremiumToOrganization(
organizationName,
encryptedKey,
encryptedKey.encryptedString,
tier,
SubscriptionCadenceIds.Annually,
billingAddress,