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 @@
+
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");
+ }
+ }
+}