diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html index 36e9a4d127c..1df2ca13178 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html @@ -32,9 +32,6 @@
-

- {{ "paymentChargedWithTrial" | i18n }} -

diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts index 625282db88c..fdfc3e7f4f7 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts @@ -146,6 +146,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => { const mockAccountBillingClient = mock(); const mockPreviewInvoiceClient = mock(); const mockLogService = mock(); + 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(); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts index ab514fca22a..c7ca7a88383 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts @@ -103,18 +103,36 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit protected readonly selectedPlan = signal(null); protected readonly loading = signal(true); protected readonly upgradeToMessage = signal(""); + protected readonly planMembershipMessage = computed(() => { + 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 { 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 }); }), ); } diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts index 7de8778ac33..8b0b2c712bb 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts @@ -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, diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts index d9da925b81e..3d75ae58479 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts @@ -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,