From 0ec12df828000cf47165b2244dd38a92d0b1dc42 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 23 Jan 2026 14:21:58 -0600 Subject: [PATCH] feat(billing): add premium org component --- ...premium-org-upgrade-payment.component.html | 62 +++ ...mium-org-upgrade-payment.component.spec.ts | 409 ++++++++++++++++++ .../premium-org-upgrade-payment.component.ts | 329 ++++++++++++++ .../premium-org-upgrade.service.spec.ts | 198 +++++++++ .../services/premium-org-upgrade.service.ts | 106 +++++ 5 files changed, 1104 insertions(+) create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts 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 new file mode 100644 index 00000000000..36e9a4d127c --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html @@ -0,0 +1,62 @@ +
+ + {{ upgradeToMessage() }} + +
+
+ + {{ "organizationName" | i18n }} + + +

+ {{ "organizationNameDescription" | i18n }} +

+
+
+
{{ "paymentMethod" | i18n }}
+ +
{{ "billingAddress" | i18n }}
+ + +
+
+ +
+ +

+ {{ "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 new file mode 100644 index 00000000000..625282db88c --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts @@ -0,0 +1,409 @@ +import { + Component, + input, + ChangeDetectionStrategy, + CUSTOM_ELEMENTS_SCHEMA, + signal, +} from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { CartSummaryComponent } from "@bitwarden/pricing"; + +import { AccountBillingClient } from "../../../clients/account-billing.client"; +import { PreviewInvoiceClient } from "../../../clients/preview-invoice.client"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "../../../payment/components"; + +import { + PremiumOrgUpgradePaymentComponent, + PremiumOrgUpgradePaymentStatus, +} from "./premium-org-upgrade-payment.component"; +import { PremiumOrgUpgradeService } from "./services/premium-org-upgrade.service"; + +// Mock Components +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "billing-cart-summary", + template: `

Mock Cart Summary

`, + providers: [{ provide: CartSummaryComponent, useClass: MockCartSummaryComponent }], +}) +class MockCartSummaryComponent { + readonly cart = input.required(); + readonly header = input(); + readonly isExpanded = signal(false); +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-enter-payment-method", + template: `

Mock Enter Payment Method

`, + providers: [ + { + provide: EnterPaymentMethodComponent, + useClass: MockEnterPaymentMethodComponent, + }, + ], +}) +class MockEnterPaymentMethodComponent { + readonly group = input.required(); + readonly showBankAccount = input(true); + readonly showPayPal = input(true); + readonly showAccountCredit = input(false); + readonly hasEnoughAccountCredit = input(true); + readonly includeBillingAddress = input(false); + + tokenize = jest.fn().mockResolvedValue({ type: "card", token: "mock-token" }); + validate = jest.fn().mockReturnValue(true); + + static getFormGroup = () => + new FormGroup({ + type: new FormControl("card", { nonNullable: true }), + bankAccount: new FormGroup({ + routingNumber: new FormControl("", { nonNullable: true }), + accountNumber: new FormControl("", { nonNullable: true }), + accountHolderName: new FormControl("", { nonNullable: true }), + accountHolderType: new FormControl("", { nonNullable: true }), + }), + billingAddress: new FormGroup({ + country: new FormControl("", { nonNullable: true }), + postalCode: new FormControl("", { nonNullable: true }), + }), + }); +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-enter-billing-address", + template: `

Mock Enter Billing Address

`, + providers: [ + { + provide: EnterBillingAddressComponent, + useClass: MockEnterBillingAddressComponent, + }, + ], +}) +class MockEnterBillingAddressComponent { + readonly scenario = input.required(); + readonly group = input.required(); + + static getFormGroup = () => + new FormGroup({ + country: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + postalCode: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + line1: new FormControl(null), + line2: new FormControl(null), + city: new FormControl(null), + state: new FormControl(null), + taxId: new FormControl(null), + }); +} + +describe("PremiumOrgUpgradePaymentComponent", () => { + beforeAll(() => { + // Mock IntersectionObserver - required because DialogComponent uses it to detect scrollable content. + // This browser API doesn't exist in the Jest/Node.js test environment. + // This is necessary because we are unable to mock DialogComponent which is not directly importable + global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve() {} + } as any; + }); + + let component: PremiumOrgUpgradePaymentComponent; + let fixture: ComponentFixture; + const mockPremiumOrgUpgradeService = mock(); + const mockSubscriptionPricingService = mock(); + const mockToastService = mock(); + const mockAccountBillingClient = mock(); + const mockPreviewInvoiceClient = mock(); + const mockLogService = mock(); + + const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account; + const mockTeamsPlan: BusinessSubscriptionPricingTier = { + id: "teams", + name: "Teams", + description: "Teams plan", + availableCadences: ["annually"], + passwordManager: { + annualPricePerUser: 48, + type: "scalable", + features: [], + }, + secretsManager: { + annualPricePerUser: 24, + type: "scalable", + features: [], + }, + }; + const mockFamiliesPlan: PersonalSubscriptionPricingTier = { + id: "families", + name: "Families", + description: "Families plan", + availableCadences: ["annually"], + passwordManager: { + annualPrice: 40, + users: 6, + type: "packaged", + features: [], + }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined); + mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined); + + mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue( + of([mockTeamsPlan]), + ); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of([mockFamiliesPlan]), + ); + + await TestBed.configureTestingModule({ + imports: [PremiumOrgUpgradePaymentComponent], + providers: [ + { provide: PremiumOrgUpgradeService, useValue: mockPremiumOrgUpgradeService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: ToastService, useValue: mockToastService }, + { provide: LogService, useValue: mockLogService }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: AccountBillingClient, useValue: mockAccountBillingClient }, + { provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient }, + { + provide: KeyService, + useValue: { + makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]), + }, + }, + { + provide: SyncService, + useValue: { fullSync: jest.fn().mockResolvedValue(undefined) }, + }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(PremiumOrgUpgradePaymentComponent, { + add: { + imports: [ + MockEnterBillingAddressComponent, + MockEnterPaymentMethodComponent, + MockCartSummaryComponent, + ], + }, + remove: { + imports: [ + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + CartSummaryComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId); + fixture.componentRef.setInput("account", mockAccount); + fixture.detectChanges(); + + // Wait for ngOnInit to complete + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + 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"); + }); + + it("should handle invalid plan id that doesn't exist in pricing tiers", async () => { + // Create a fresh component with an invalid plan ID from the start + const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + const newComponent = newFixture.componentInstance; + + newFixture.componentRef.setInput( + "selectedPlanId", + "non-existent-plan" as BusinessSubscriptionPricingTierId, + ); + newFixture.componentRef.setInput("account", mockAccount); + newFixture.detectChanges(); + + await newFixture.whenStable(); + + expect(newComponent["selectedPlan"]()).toBeNull(); + }); + + it("should handle invoice preview errors gracefully", fakeAsync(() => { + mockPremiumOrgUpgradeService.previewProratedInvoice.mockRejectedValue( + new Error("Network error"), + ); + + // Component should still render and be usable even when invoice preview fails + fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId); + fixture.componentRef.setInput("account", mockAccount); + fixture.detectChanges(); + tick(); + + expect(component).toBeTruthy(); + expect(component["selectedPlan"]()).not.toBeNull(); + expect(mockToastService.showToast).not.toHaveBeenCalled(); + })); + + describe("submit", () => { + it("should successfully upgrade to organization", async () => { + const completeSpy = jest.spyOn(component["complete"], "emit"); + + // Mock isFormValid and processUpgrade to bypass form validation + jest.spyOn(component as any, "isFormValid").mockReturnValue(true); + jest.spyOn(component as any, "processUpgrade").mockResolvedValue({ + status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + organizationId: null, + }); + + component["formGroup"].setValue({ + organizationName: "My New Org", + paymentForm: { + type: "card", + bankAccount: { + routingNumber: "", + accountNumber: "", + accountHolderName: "", + accountHolderType: "", + }, + billingAddress: { + country: "", + postalCode: "", + }, + }, + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "organizationUpdated", + }); + expect(completeSpy).toHaveBeenCalledWith({ + status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + organizationId: null, + }); + }); + + it("should show an error toast if upgrade fails", async () => { + // Mock isFormValid to return true + jest.spyOn(component as any, "isFormValid").mockReturnValue(true); + // Mock processUpgrade to throw an error + jest + .spyOn(component as any, "processUpgrade") + .mockRejectedValue(new Error("Submission Error")); + + component["formGroup"].setValue({ + organizationName: "My New Org", + paymentForm: { + type: "card", + bankAccount: { + routingNumber: "", + accountNumber: "", + accountHolderName: "", + accountHolderType: "", + }, + billingAddress: { + country: "", + postalCode: "", + }, + }, + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "upgradeErrorMessage", + }); + }); + + it("should not submit if the form is invalid", async () => { + const markAllAsTouchedSpy = jest.spyOn(component["formGroup"], "markAllAsTouched"); + component["formGroup"].get("organizationName")?.setValue(""); + fixture.detectChanges(); + + await component["submit"](); + + expect(markAllAsTouchedSpy).toHaveBeenCalled(); + expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled(); + }); + }); + + it("should map plan id to correct upgrade status", () => { + expect(component["getUpgradeStatus"]("families" as PersonalSubscriptionPricingTierId)).toBe( + PremiumOrgUpgradePaymentStatus.UpgradedToFamilies, + ); + expect(component["getUpgradeStatus"]("teams" as BusinessSubscriptionPricingTierId)).toBe( + PremiumOrgUpgradePaymentStatus.UpgradedToTeams, + ); + expect(component["getUpgradeStatus"]("enterprise" as BusinessSubscriptionPricingTierId)).toBe( + PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise, + ); + expect(component["getUpgradeStatus"]("some-other-plan" as any)).toBe( + PremiumOrgUpgradePaymentStatus.Closed, + ); + }); +}); 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 new file mode 100644 index 00000000000..ab514fca22a --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts @@ -0,0 +1,329 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + input, + OnInit, + output, + signal, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { + catchError, + of, + combineLatest, + startWith, + debounceTime, + switchMap, + Observable, + from, + defer, +} from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { Cart, CartSummaryComponent } from "@bitwarden/pricing"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "../../../payment/components"; + +import { + PremiumOrgUpgradeService, + PremiumOrgUpgradePlanDetails, + InvoicePreview, +} from "./services/premium-org-upgrade.service"; + +export const PremiumOrgUpgradePaymentStatus = { + Closed: "closed", + UpgradedToTeams: "upgradedToTeams", + UpgradedToEnterprise: "upgradedToEnterprise", + UpgradedToFamilies: "upgradedToFamilies", +} as const; + +export type PremiumOrgUpgradePaymentStatus = UnionOfValues; + +export type PremiumOrgUpgradePaymentResult = { + status: PremiumOrgUpgradePaymentStatus; + organizationId?: string | null; +}; + +@Component({ + selector: "app-premium-org-upgrade-payment", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DialogModule, + SharedModule, + CartSummaryComponent, + ButtonModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + ], + providers: [PremiumOrgUpgradeService], + templateUrl: "./premium-org-upgrade-payment.component.html", +}) +export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit { + private readonly INITIAL_TAX_VALUE = 0; + private readonly DEFAULT_SEAT_COUNT = 1; + private readonly DEFAULT_CADENCE = "annually"; + + protected readonly selectedPlanId = input.required< + PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId + >(); + protected readonly account = input.required(); + protected goBack = output(); + protected complete = output(); + + readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent); + readonly cartSummaryComponent = viewChild.required(CartSummaryComponent); + + protected formGroup = new FormGroup({ + organizationName: new FormControl("", [Validators.required]), + paymentForm: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + protected readonly selectedPlan = signal(null); + protected readonly loading = signal(true); + protected readonly upgradeToMessage = signal(""); + + // 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), + debounceTime(1000), + switchMap(() => this.refreshInvoicePreview$()), + ), + ); + + protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, { + initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 }, + }); + + // Cart Summary data + protected readonly cart = computed(() => { + if (!this.selectedPlan()) { + return { + passwordManager: { + seats: { translationKey: "", cost: 0, quantity: 0 }, + }, + cadence: this.DEFAULT_CADENCE, + estimatedTax: this.INITIAL_TAX_VALUE, + }; + } + + return { + passwordManager: { + seats: { + translationKey: this.selectedPlan()?.details.name ?? "", + cost: this.selectedPlan()?.cost ?? 0, + quantity: this.DEFAULT_SEAT_COUNT, + }, + }, + cadence: this.DEFAULT_CADENCE, + estimatedTax: this.estimatedInvoice().tax, + discount: { type: "amount-off", value: this.estimatedInvoice().credit }, + }; + }); + + constructor( + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, + private logService: LogService, + private destroyRef: DestroyRef, + private premiumOrgUpgradeService: PremiumOrgUpgradeService, + ) {} + + async ngOnInit(): Promise { + combineLatest([ + this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(), + this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(), + ]) + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([personalPlans, businessPlans]) => { + const plans: (PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier)[] = [ + ...personalPlans, + ...businessPlans, + ]; + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + + if (planDetails) { + this.selectedPlan.set({ + tier: this.selectedPlanId(), + details: planDetails, + cost: this.getPlanPrice(planDetails), + }); + + this.upgradeToMessage.set(this.i18nService.t("startFreeTrial", planDetails.name)); + } else { + this.complete.emit({ + status: PremiumOrgUpgradePaymentStatus.Closed, + organizationId: null, + }); + return; + } + }); + + this.loading.set(false); + } + + ngAfterViewInit(): void { + const cartSummaryComponent = this.cartSummaryComponent(); + cartSummaryComponent.isExpanded.set(false); + } + + protected submit = async (): Promise => { + if (!this.isFormValid()) { + this.formGroup.markAllAsTouched(); + return; + } + + if (!this.selectedPlan()) { + throw new Error("No plan selected"); + } + + try { + const result = await this.processUpgrade(); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("organizationUpdated", this.selectedPlan()?.details.name), + }); + this.complete.emit(result); + } catch (error: unknown) { + this.logService.error("Upgrade failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("upgradeErrorMessage"), + }); + } + }; + + protected isFormValid(): boolean { + return this.formGroup.valid && this.paymentComponent().validate(); + } + + private async processUpgrade(): Promise { + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const organizationName = this.formGroup.value?.organizationName; + + if (!billingAddress.country || !billingAddress.postalCode) { + throw new Error("Billing address is incomplete"); + } + + if (!organizationName) { + throw new Error("Organization name is required"); + } + + const paymentMethod = await this.paymentComponent().tokenize(); + + if (!paymentMethod) { + throw new Error("Payment method is required"); + } + + await this.premiumOrgUpgradeService.upgradeToOrganization( + this.account(), + organizationName, + this.selectedPlan()!, + billingAddress, + ); + + return { + status: this.getUpgradeStatus(this.selectedPlanId()), + organizationId: null, + }; + } + + 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; + } + } + + /** + * Calculates the price for the currently selected plan. + * + * This method retrieves the `passwordManager` details from the selected plan. It then determines + * the appropriate price based on the properties available on the `passwordManager` object. + * It prioritizes `annualPrice` for individual-style plans and falls back to `annualPricePerUser` + * for user-based plans. + * + * @returns The annual price of the plan as a number. Returns `0` if the plan or its price cannot be determined. + */ + private getPlanPrice( + plan: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier, + ): number { + const passwordManager = plan.passwordManager; + if (!passwordManager) { + return 0; + } + + if ("annualPrice" in passwordManager) { + return passwordManager.annualPrice ?? 0; + } else if ("annualPricePerUser" in passwordManager) { + return passwordManager.annualPricePerUser ?? 0; + } + return 0; + } + + /** + * Refreshes the invoice preview based on the current form state. + */ + private refreshInvoicePreview$(): Observable { + if (this.formGroup.invalid || !this.selectedPlan()) { + return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 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 from( + this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress), + ).pipe( + catchError((error: unknown) => { + this.logService.error("Invoice preview failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("invoicePreviewErrorMessage"), + }); + return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 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 new file mode 100644 index 00000000000..7de8778ac33 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts @@ -0,0 +1,198 @@ +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BusinessSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { KeyService } from "@bitwarden/key-management"; + +import { AccountBillingClient } from "../../../../clients/account-billing.client"; +import { PreviewInvoiceClient } from "../../../../clients/preview-invoice.client"; +import { BillingAddress } from "../../../../payment/types"; + +import { + PremiumOrgUpgradePlanDetails, + PremiumOrgUpgradeService, +} from "./premium-org-upgrade.service"; + +describe("PremiumOrgUpgradeService", () => { + let service: PremiumOrgUpgradeService; + let accountBillingClient: jest.Mocked; + let previewInvoiceClient: jest.Mocked; + let syncService: jest.Mocked; + let keyService: jest.Mocked; + + const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account; + const mockPlanDetails: PremiumOrgUpgradePlanDetails = { + tier: BusinessSubscriptionPricingTierIds.Teams, + details: { + id: BusinessSubscriptionPricingTierIds.Teams, + name: "Teams", + passwordManager: { + annualPrice: 48, + users: 1, + }, + }, + } as any; + const mockBillingAddress: BillingAddress = { + country: "US", + postalCode: "12345", + line1: null, + line2: null, + city: null, + state: null, + taxId: null, + }; + + beforeEach(() => { + accountBillingClient = { + upgradePremiumToOrganization: jest.fn().mockResolvedValue(undefined), + } as any; + previewInvoiceClient = { + previewProrationForPremiumUpgrade: jest + .fn() + .mockResolvedValue({ tax: 5, total: 55, credit: 0 }), + } as any; + syncService = { + fullSync: jest.fn().mockResolvedValue(undefined), + } as any; + keyService = { + makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]), + } as any; + + TestBed.configureTestingModule({ + providers: [ + PremiumOrgUpgradeService, + { provide: AccountBillingClient, useValue: accountBillingClient }, + { provide: PreviewInvoiceClient, useValue: previewInvoiceClient }, + { provide: SyncService, useValue: syncService }, + { provide: AccountService, useValue: { activeAccount$: of(mockAccount) } }, + { provide: KeyService, useValue: keyService }, + ], + }); + + service = TestBed.inject(PremiumOrgUpgradeService); + }); + + describe("upgradeToOrganization", () => { + it("should successfully upgrade premium account to organization", async () => { + await service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ); + + expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith( + "Test Organization", + "encrypted-key", + 2, // ProductTierType.Teams + "annually", + mockBillingAddress, + ); + expect(keyService.makeOrgKey).toHaveBeenCalledWith("user-id"); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should throw an error if organization name is missing", async () => { + await expect( + service.upgradeToOrganization(mockAccount, "", mockPlanDetails, mockBillingAddress), + ).rejects.toThrow("Organization name is required for organization upgrade"); + }); + + it("should throw an error if billing address is incomplete", async () => { + const incompleteBillingAddress: BillingAddress = { + country: "", + postalCode: "", + line1: null, + line2: null, + city: null, + state: null, + taxId: null, + }; + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + incompleteBillingAddress, + ), + ).rejects.toThrow("Billing address information is incomplete"); + }); + + it("should throw an error for invalid plan tier", async () => { + const invalidPlanDetails = { + tier: "invalid-tier" as any, + details: mockPlanDetails.details, + cost: 0, + }; + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + invalidPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Invalid plan tier for organization upgrade"); + }); + + it("should propagate error if key generation fails", async () => { + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Key generation failed"); + }); + + it("should propagate error if upgrade API call fails", async () => { + accountBillingClient.upgradePremiumToOrganization.mockRejectedValue( + new Error("API call failed"), + ); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("API call failed"); + }); + + it("should propagate error if sync fails", async () => { + syncService.fullSync.mockRejectedValue(new Error("Sync failed")); + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails, + mockBillingAddress, + ), + ).rejects.toThrow("Sync failed"); + }); + }); + + describe("previewProratedInvoice", () => { + it("should call previewProrationForPremiumUpgrade and return invoice preview", async () => { + const result = await service.previewProratedInvoice(mockPlanDetails, mockBillingAddress); + + expect(result).toEqual({ tax: 5, total: 55, credit: 0 }); + expect(previewInvoiceClient.previewProrationForPremiumUpgrade).toHaveBeenCalledWith( + 2, // ProductTierType.Teams + mockBillingAddress, + ); + }); + + it("should throw an error if invoice preview fails", async () => { + previewInvoiceClient.previewProrationForPremiumUpgrade.mockRejectedValue( + new Error("Invoice API error"), + ); + await expect( + service.previewProratedInvoice(mockPlanDetails, mockBillingAddress), + ).rejects.toThrow("Invoice API error"); + }); + }); +}); 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 new file mode 100644 index 00000000000..d9da925b81e --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from "@angular/core"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BusinessSubscriptionPricingTier, + BusinessSubscriptionPricingTierId, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { KeyService } from "@bitwarden/key-management"; + +import { AccountBillingClient, PreviewInvoiceClient } from "../../../../clients"; +import { BillingAddress } from "../../../../payment/types"; + +export type PremiumOrgUpgradePlanDetails = { + tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId; + details: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier; + cost: number; +}; + +export type PaymentFormValues = { + organizationName?: string | null; + billingAddress: { + country: string; + postalCode: string; + }; +}; + +export interface InvoicePreview { + tax: number; + total: number; + credit: number; +} + +@Injectable() +export class PremiumOrgUpgradeService { + constructor( + private accountBillingClient: AccountBillingClient, + private previewInvoiceClient: PreviewInvoiceClient, + private syncService: SyncService, + private keyService: KeyService, + ) {} + + async previewProratedInvoice( + planDetails: PremiumOrgUpgradePlanDetails, + billingAddress: BillingAddress, + ): Promise { + const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier); + + const invoicePreviewResponse = + await this.previewInvoiceClient.previewProrationForPremiumUpgrade(tier, billingAddress); + + return { + tax: invoicePreviewResponse.tax, + total: invoicePreviewResponse.total, + credit: invoicePreviewResponse.credit, + }; + } + + async upgradeToOrganization( + account: Account, + organizationName: string, + planDetails: PremiumOrgUpgradePlanDetails, + billingAddress: BillingAddress, + ): Promise { + if (!organizationName) { + throw new Error("Organization name is required for organization upgrade"); + } + + if (!billingAddress?.country || !billingAddress?.postalCode) { + throw new Error("Billing address information is incomplete"); + } + + const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier); + const [encryptedKey] = await this.keyService.makeOrgKey(account.id); + + await this.accountBillingClient.upgradePremiumToOrganization( + organizationName, + encryptedKey, + tier, + SubscriptionCadenceIds.Annually, + billingAddress, + ); + + await this.syncService.fullSync(true); + } + + private ProductTierTypeFromSubscriptionTierId( + tierId: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId, + ): ProductTierType { + switch (tierId) { + case "families": + return ProductTierType.Families; + case "teams": + return ProductTierType.Teams; + case "enterprise": + return ProductTierType.Enterprise; + default: + throw new Error("Invalid plan tier for organization upgrade"); + } + } +}