Home Guides Architecture B2B Features Development
Explanation

B2B Features Development

Master Adobe Commerce B2B architecture including company accounts, shared catalogs, requisition lists, quick order, quote negotiation, and B2B-specific permissions.

Advanced 60 minutes Magento 2.4.7+

B2B Features Development

Overview

Adobe Commerce B2B extends the platform with enterprise features for business-to-business commerce. Understanding the B2B architecture is critical for implementing custom workflows, integrations, and business logic for corporate purchasing environments.

What you'll learn: - Company account architecture and hierarchy management - Shared catalog implementation and product visibility control - Requisition list functionality and custom list types - Quick order implementation and bulk ordering workflows - Quote negotiation system and approval workflows - B2B-specific permission models (roles, rules, ACL) - Credit limit management and purchase order workflows - Integration patterns for ERP and procurement systems

Prerequisites: - Adobe Commerce 2.4.7+ with B2B module enabled - Advanced understanding of Magento customer management - Experience with catalog, pricing, and cart systems - Knowledge of ACL, permissions, and authorization - Familiarity with quote and order workflows

Note: B2B features are Adobe Commerce only and not available in Open Source.


B2B Architecture Overview

Core B2B Modules

Magento_Company           - Company account management
Magento_CompanyCredit     - Credit limit and payment on account
Magento_SharedCatalog     - Catalog visibility and custom pricing
Magento_RequisitionList   - Saved shopping lists
Magento_NegotiableQuote   - Quote negotiation workflow
Magento_QuickOrder        - Bulk SKU entry
Magento_PurchaseOrder     - Approval workflows
Magento_B2b               - Integration layer

B2B Customer Hierarchy

Company (ABC Corporation)
  ├── Company Admin (john@abc.com)
  ├── Team Lead (sarah@abc.com)
  │   ├── Buyer 1 (mike@abc.com)
  │   └── Buyer 2 (lisa@abc.com)
  └── Finance Manager (david@abc.com)

Key Concepts

  • Company Account: Organization entity with admin, structure, and users
  • Shared Catalog: Custom product catalog and pricing per company
  • Requisition List: Reusable shopping lists for frequent purchases
  • Negotiable Quote: Request-for-quote workflow with admin negotiation
  • Purchase Order: Approval rules based on amount, requester, approvers
  • Company Credit: Credit limit and payment-on-account capability

Company Account Architecture

Company Entity Structure

Database Schema:

company
  ├── company_id (PK)
  ├── company_name
  ├── legal_name
  ├── company_email
  ├── status (0=pending, 1=approved, 2=rejected, 3=blocked)
  ├── sales_representative_id
  ├── customer_group_id
  ├── super_user_id (company admin customer_id)
  └── ...

company_advanced_customer_entity
  ├── entity_id (PK)
  ├── customer_id (FK to customer_entity)
  ├── company_id (FK to company)
  ├── job_title
  ├── status (0=inactive, 1=active)
  └── telephone

company_structure
  ├── structure_id (PK)
  ├── parent_id (self-referencing)
  ├── entity_id (customer_id)
  ├── entity_type (0=customer, 1=team)
  ├── path (materialized path for hierarchy)
  └── level

Creating a Company Programmatically

File: Vendor/B2BExtension/Model/CompanyCreator.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model;

use Magento\Company\Api\CompanyRepositoryInterface;
use Magento\Company\Api\Data\CompanyInterface;
use Magento\Company\Api\Data\CompanyInterfaceFactory;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Customer\Api\Data\CustomerInterface;
use Magento\Framework\Exception\LocalizedException;
use Psr\Log\LoggerInterface;

class CompanyCreator
{
    public function __construct(
        private readonly CompanyInterfaceFactory $companyFactory,
        private readonly CompanyRepositoryInterface $companyRepository,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * Create company with admin user
     *
     * @param array $companyData
     * @param CustomerInterface $adminCustomer
     * @return CompanyInterface
     * @throws LocalizedException
     */
    public function createCompany(array $companyData, CustomerInterface $adminCustomer): CompanyInterface
    {
        try {
            // Validate required fields
            $this->validateCompanyData($companyData);

            /** @var CompanyInterface $company */
            $company = $this->companyFactory->create();

            $company->setCompanyName($companyData['company_name'])
                ->setLegalName($companyData['legal_name'] ?? $companyData['company_name'])
                ->setCompanyEmail($companyData['company_email'])
                ->setStatus(CompanyInterface::STATUS_PENDING)
                ->setSuperUserId((int)$adminCustomer->getId())
                ->setCustomerGroupId((int)($companyData['customer_group_id'] ?? 1))
                ->setComment($companyData['comment'] ?? '');

            // Set address data
            if (isset($companyData['street'])) {
                $company->setStreet($companyData['street']);
            }
            if (isset($companyData['city'])) {
                $company->setCity($companyData['city']);
            }
            if (isset($companyData['country_id'])) {
                $company->setCountryId($companyData['country_id']);
            }
            if (isset($companyData['region_id'])) {
                $company->setRegionId($companyData['region_id']);
            }
            if (isset($companyData['postcode'])) {
                $company->setPostcode($companyData['postcode']);
            }
            if (isset($companyData['telephone'])) {
                $company->setTelephone($companyData['telephone']);
            }

            // Save company
            $savedCompany = $this->companyRepository->save($company);

            $this->logger->info('Company created successfully', [
                'company_id' => $savedCompany->getId(),
                'company_name' => $savedCompany->getCompanyName()
            ]);

            return $savedCompany;

        } catch (\Exception $e) {
            $this->logger->error('Company creation failed', [
                'exception' => $e->getMessage(),
                'company_data' => $companyData
            ]);
            throw new LocalizedException(__('Failed to create company: %1', $e->getMessage()));
        }
    }

    /**
     * Approve company
     */
    public function approveCompany(int $companyId): void
    {
        $company = $this->companyRepository->get($companyId);
        $company->setStatus(CompanyInterface::STATUS_APPROVED);
        $this->companyRepository->save($company);
    }

    /**
     * Add user to company
     */
    public function addCompanyUser(
        int $companyId,
        CustomerInterface $customer,
        ?int $parentId = null,
        string $jobTitle = ''
    ): void {
        $company = $this->companyRepository->get($companyId);

        // Set company association in customer extension attributes
        $extensionAttributes = $customer->getExtensionAttributes();
        $extensionAttributes->setCompanyAttributes([
            'company_id' => $companyId,
            'status' => 1, // Active
            'job_title' => $jobTitle,
        ]);

        $customer->setExtensionAttributes($extensionAttributes);
        $this->customerRepository->save($customer);
    }

    /**
     * Validate company data
     */
    private function validateCompanyData(array $data): void
    {
        $requiredFields = ['company_name', 'company_email'];

        foreach ($requiredFields as $field) {
            if (empty($data[$field])) {
                throw new LocalizedException(__('Required field missing: %1', $field));
            }
        }

        if (!filter_var($data['company_email'], FILTER_VALIDATE_EMAIL)) {
            throw new LocalizedException(__('Invalid company email format'));
        }
    }
}

Company Hierarchy Management

File: Vendor/B2BExtension/Model/CompanyStructure.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model;

use Magento\Company\Api\Data\StructureInterface;
use Magento\Company\Model\Company\Structure as CompanyStructure;
use Magento\Framework\Exception\LocalizedException;

class CompanyStructureManager
{
    public function __construct(
        private readonly CompanyStructure $companyStructure
    ) {
    }

    /**
     * Create team under parent
     *
     * @param int $companyId
     * @param int $parentStructureId
     * @param string $teamName
     * @return StructureInterface
     */
    public function createTeam(int $companyId, int $parentStructureId, string $teamName): StructureInterface
    {
        $team = $this->companyStructure->addNode(
            0, // Team entity (not customer)
            $parentStructureId,
            $teamName
        );

        return $team;
    }

    /**
     * Move user to different team/parent
     */
    public function moveUser(int $customerId, int $newParentId): void
    {
        $userNode = $this->companyStructure->getStructureByCustomerId($customerId);

        if (!$userNode) {
            throw new LocalizedException(__('User structure not found'));
        }

        $this->companyStructure->moveNode($userNode->getId(), $newParentId);
    }

    /**
     * Get all users under a parent (recursive)
     *
     * @param int $parentStructureId
     * @return array
     */
    public function getAllChildUsers(int $parentStructureId): array
    {
        $children = $this->companyStructure->getAllowedChildrenIds($parentStructureId);

        $users = [];
        foreach ($children as $childId) {
            $node = $this->companyStructure->getStructureById($childId);

            if ($node->getEntityType() == StructureInterface::TYPE_CUSTOMER) {
                $users[] = $node->getEntityId(); // customer_id
            }

            // Recursively get children
            $users = array_merge($users, $this->getAllChildUsers($childId));
        }

        return array_unique($users);
    }

    /**
     * Get user's direct manager
     */
    public function getUserManager(int $customerId): ?int
    {
        $userNode = $this->companyStructure->getStructureByCustomerId($customerId);

        if (!$userNode || !$userNode->getParentId()) {
            return null;
        }

        $parentNode = $this->companyStructure->getStructureById($userNode->getParentId());

        // Find first customer-type parent
        while ($parentNode && $parentNode->getEntityType() != StructureInterface::TYPE_CUSTOMER) {
            if (!$parentNode->getParentId()) {
                return null;
            }
            $parentNode = $this->companyStructure->getStructureById($parentNode->getParentId());
        }

        return $parentNode ? $parentNode->getEntityId() : null;
    }
}

Shared Catalog Implementation

Shared Catalog Architecture

Shared catalogs control: 1. Product Visibility: Which products a company can see 2. Custom Pricing: Tier prices specific to company/catalog 3. Category Access: Category visibility restrictions

Creating a Shared Catalog

File: Vendor/B2BExtension/Model/SharedCatalogCreator.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model;

use Magento\SharedCatalog\Api\Data\SharedCatalogInterface;
use Magento\SharedCatalog\Api\Data\SharedCatalogInterfaceFactory;
use Magento\SharedCatalog\Api\SharedCatalogRepositoryInterface;
use Magento\SharedCatalog\Model\SharedCatalogAssignment;
use Magento\Framework\Exception\LocalizedException;

class SharedCatalogCreator
{
    public function __construct(
        private readonly SharedCatalogInterfaceFactory $sharedCatalogFactory,
        private readonly SharedCatalogRepositoryInterface $sharedCatalogRepository,
        private readonly SharedCatalogAssignment $sharedCatalogAssignment,
        private readonly \Psr\Log\LoggerInterface $logger
    ) {
    }

    /**
     * Create shared catalog for company
     *
     * @param string $name
     * @param int $customerGroupId
     * @param int $createdBy
     * @return SharedCatalogInterface
     */
    public function create(string $name, int $customerGroupId, int $createdBy): SharedCatalogInterface
    {
        try {
            /** @var SharedCatalogInterface $sharedCatalog */
            $sharedCatalog = $this->sharedCatalogFactory->create();

            $sharedCatalog->setName($name)
                ->setDescription('Shared catalog for ' . $name)
                ->setType(SharedCatalogInterface::TYPE_CUSTOM)
                ->setCreatedBy($createdBy)
                ->setCustomerGroupId($customerGroupId)
                ->setTaxClassId(3); // Default product tax class

            $savedCatalog = $this->sharedCatalogRepository->save($sharedCatalog);

            $this->logger->info('Shared catalog created', [
                'catalog_id' => $savedCatalog->getId(),
                'name' => $savedCatalog->getName()
            ]);

            return $savedCatalog;

        } catch (\Exception $e) {
            $this->logger->error('Shared catalog creation failed', [
                'exception' => $e->getMessage()
            ]);
            throw new LocalizedException(__('Failed to create shared catalog'));
        }
    }

    /**
     * Assign products to shared catalog
     *
     * @param int $sharedCatalogId
     * @param array $productSkus
     */
    public function assignProducts(int $sharedCatalogId, array $productSkus): void
    {
        $this->sharedCatalogAssignment->assignProductsForCatalog(
            $sharedCatalogId,
            $productSkus
        );
    }

    /**
     * Assign categories to shared catalog
     *
     * @param int $sharedCatalogId
     * @param array $categoryIds
     */
    public function assignCategories(int $sharedCatalogId, array $categoryIds): void
    {
        $this->sharedCatalogAssignment->assignCategoriesForCatalog(
            $sharedCatalogId,
            $categoryIds
        );
    }

    /**
     * Unassign products from shared catalog
     */
    public function unassignProducts(int $sharedCatalogId, array $productSkus): void
    {
        $this->sharedCatalogAssignment->unassignProductsForCatalog(
            $sharedCatalogId,
            $productSkus
        );
    }
}

Custom Pricing for Shared Catalog

File: Vendor/B2BExtension/Model/SharedCatalogPricing.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model;

use Magento\SharedCatalog\Api\ProductItemManagementInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;

class SharedCatalogPricing
{
    public function __construct(
        private readonly ProductItemManagementInterface $productItemManagement,
        private readonly ProductRepositoryInterface $productRepository
    ) {
    }

    /**
     * Set custom price for product in shared catalog
     *
     * @param int $sharedCatalogId
     * @param string $sku
     * @param float $customPrice
     */
    public function setCustomPrice(int $sharedCatalogId, string $sku, float $customPrice): void
    {
        $this->productItemManagement->addItems(
            $sharedCatalogId,
            [
                [
                    'sku' => $sku,
                    'custom_price' => $customPrice
                ]
            ]
        );
    }

    /**
     * Set tier prices for shared catalog
     *
     * @param int $sharedCatalogId
     * @param string $sku
     * @param array $tierPrices [['qty' => 10, 'price' => 8.50], ...]
     */
    public function setTierPricing(int $sharedCatalogId, string $sku, array $tierPrices): void
    {
        $product = $this->productRepository->get($sku);

        $existingTierPrices = $product->getTierPrices() ?? [];

        // Add shared catalog tier prices
        foreach ($tierPrices as $tierPrice) {
            $existingTierPrices[] = [
                'website_id' => 0,
                'customer_group_id' => $this->getCustomerGroupId($sharedCatalogId),
                'qty' => $tierPrice['qty'],
                'value' => $tierPrice['price'],
                'percentage_value' => null,
            ];
        }

        $product->setTierPrices($existingTierPrices);
        $this->productRepository->save($product);
    }

    /**
     * Apply percentage discount to all products in catalog
     */
    public function applyGlobalDiscount(int $sharedCatalogId, float $discountPercent): void
    {
        // Implementation would iterate through assigned products
        // and apply percentage discount
    }

    private function getCustomerGroupId(int $sharedCatalogId): int
    {
        // Retrieve customer group ID from shared catalog
        return 0; // Simplified
    }
}

Requisition Lists

Requisition List Architecture

Requisition lists are persistent shopping lists that can be: - Created by company users - Shared within company hierarchy - Used for recurring purchases - Quickly added to cart

Custom Requisition List Type

File: Vendor/B2BExtension/Model/RequisitionList/CustomList.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model\RequisitionList;

use Magento\RequisitionList\Api\Data\RequisitionListInterface;
use Magento\RequisitionList\Api\RequisitionListRepositoryInterface;
use Magento\RequisitionList\Model\RequisitionListItem\SaveHandler;
use Magento\Framework\Exception\LocalizedException;

class CustomListManager
{
    public function __construct(
        private readonly RequisitionListRepositoryInterface $requisitionListRepository,
        private readonly SaveHandler $itemSaveHandler,
        private readonly \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
    ) {
    }

    /**
     * Create recurring order list
     *
     * @param int $customerId
     * @param string $name
     * @param string $description
     * @return RequisitionListInterface
     */
    public function createRecurringList(
        int $customerId,
        string $name,
        string $description = ''
    ): RequisitionListInterface {
        /** @var RequisitionListInterface $list */
        $list = $this->requisitionListRepository->create();

        $list->setCustomerId($customerId)
            ->setName($name)
            ->setDescription($description);

        return $this->requisitionListRepository->save($list);
    }

    /**
     * Add product to requisition list
     *
     * @param int $listId
     * @param string $sku
     * @param float $qty
     * @param array $options
     */
    public function addProduct(
        int $listId,
        string $sku,
        float $qty = 1.0,
        array $options = []
    ): void {
        $product = $this->productRepository->get($sku);

        $item = [
            'requisition_list_id' => $listId,
            'sku' => $sku,
            'qty' => $qty,
            'options' => $options,
        ];

        $this->itemSaveHandler->saveItem($item, $product);
    }

    /**
     * Add entire list to cart
     *
     * @param int $listId
     * @param \Magento\Quote\Api\CartRepositoryInterface $cartRepository
     * @param int $customerId
     */
    public function addListToCart(
        int $listId,
        \Magento\Quote\Api\CartRepositoryInterface $cartRepository,
        int $customerId
    ): void {
        $list = $this->requisitionListRepository->get($listId);

        if ($list->getCustomerId() != $customerId) {
            throw new LocalizedException(__('Unauthorized access to requisition list'));
        }

        $quote = $cartRepository->getActiveForCustomer($customerId);

        foreach ($list->getItems() as $item) {
            try {
                $product = $this->productRepository->get($item->getSku());

                $buyRequest = new \Magento\Framework\DataObject([
                    'qty' => $item->getQty(),
                    'options' => $item->getOptions(),
                ]);

                $quote->addProduct($product, $buyRequest);

            } catch (\Exception $e) {
                // Log error but continue with other items
                continue;
            }
        }

        $cartRepository->save($quote);
    }

    /**
     * Clone requisition list
     */
    public function cloneList(int $listId, int $targetCustomerId, string $newName): RequisitionListInterface
    {
        $sourceList = $this->requisitionListRepository->get($listId);

        $newList = $this->createRecurringList(
            $targetCustomerId,
            $newName,
            $sourceList->getDescription()
        );

        foreach ($sourceList->getItems() as $item) {
            $this->addProduct(
                (int)$newList->getId(),
                $item->getSku(),
                (float)$item->getQty(),
                $item->getOptions() ?? []
            );
        }

        return $newList;
    }
}

Quick Order Implementation

Quick Order Architecture

Quick Order allows bulk SKU entry with quantity for rapid ordering.

File: Vendor/B2BExtension/Model/QuickOrder/Processor.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model\QuickOrder;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;

class Processor
{
    private array $errors = [];
    private array $successful = [];

    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly CartRepositoryInterface $cartRepository
    ) {
    }

    /**
     * Process bulk SKU list
     *
     * @param array $items [['sku' => 'ABC', 'qty' => 5], ...]
     * @param int $customerId
     * @return array ['successful' => [...], 'errors' => [...]]
     */
    public function processBulkOrder(array $items, int $customerId): array
    {
        $this->errors = [];
        $this->successful = [];

        $quote = $this->cartRepository->getActiveForCustomer($customerId);

        foreach ($items as $item) {
            try {
                $this->processItem($item, $quote);
                $this->successful[] = $item['sku'];

            } catch (\Exception $e) {
                $this->errors[] = [
                    'sku' => $item['sku'],
                    'error' => $e->getMessage()
                ];
            }
        }

        if (!empty($this->successful)) {
            $this->cartRepository->save($quote);
        }

        return [
            'successful' => $this->successful,
            'errors' => $this->errors
        ];
    }

    /**
     * Process single item
     */
    private function processItem(array $item, $quote): void
    {
        if (empty($item['sku'])) {
            throw new \InvalidArgumentException('SKU is required');
        }

        $qty = $item['qty'] ?? 1;

        if ($qty <= 0) {
            throw new \InvalidArgumentException('Quantity must be greater than 0');
        }

        try {
            $product = $this->productRepository->get($item['sku']);

            if (!$product->isSalable()) {
                throw new \RuntimeException('Product is not available for purchase');
            }

            $buyRequest = new \Magento\Framework\DataObject(['qty' => $qty]);
            $quote->addProduct($product, $buyRequest);

        } catch (NoSuchEntityException $e) {
            throw new \RuntimeException('Product not found: ' . $item['sku']);
        }
    }

    /**
     * Parse CSV input for quick order
     *
     * @param string $csvContent
     * @return array
     */
    public function parseCsvInput(string $csvContent): array
    {
        $lines = explode("\n", trim($csvContent));
        $items = [];

        foreach ($lines as $line) {
            $parts = str_getcsv($line);

            if (count($parts) < 1 || empty(trim($parts[0]))) {
                continue;
            }

            $items[] = [
                'sku' => trim($parts[0]),
                'qty' => isset($parts[1]) ? (float)trim($parts[1]) : 1.0,
            ];
        }

        return $items;
    }

    /**
     * Validate SKUs before processing
     */
    public function validateSkus(array $skus): array
    {
        $validation = [
            'valid' => [],
            'invalid' => [],
        ];

        foreach ($skus as $sku) {
            try {
                $product = $this->productRepository->get($sku);
                $validation['valid'][] = [
                    'sku' => $sku,
                    'name' => $product->getName(),
                    'price' => $product->getFinalPrice(),
                ];
            } catch (NoSuchEntityException $e) {
                $validation['invalid'][] = $sku;
            }
        }

        return $validation;
    }
}

Quote Negotiation Workflow

Negotiable Quote Architecture

Flow: 1. Customer creates quote request 2. Admin/Sales rep reviews and modifies 3. Customer accepts/declines/negotiates 4. Approved quote converts to order

Quote Management

File: Vendor/B2BExtension/Model/Quote/NegotiableQuoteManager.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model\Quote;

use Magento\NegotiableQuote\Api\NegotiableQuoteRepositoryInterface;
use Magento\NegotiableQuote\Api\Data\NegotiableQuoteInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Magento\Framework\Exception\LocalizedException;

class NegotiableQuoteManager
{
    public function __construct(
        private readonly NegotiableQuoteRepositoryInterface $negotiableQuoteRepository,
        private readonly CartRepositoryInterface $cartRepository,
        private readonly \Psr\Log\LoggerInterface $logger
    ) {
    }

    /**
     * Create negotiable quote from cart
     *
     * @param int $quoteId
     * @param string $quoteName
     * @param string $comment
     * @return NegotiableQuoteInterface
     */
    public function createNegotiableQuote(
        int $quoteId,
        string $quoteName,
        string $comment = ''
    ): NegotiableQuoteInterface {
        $quote = $this->cartRepository->get($quoteId);

        if (!$quote->getItemsCount()) {
            throw new LocalizedException(__('Cannot create quote from empty cart'));
        }

        // Create negotiable quote
        $negotiableQuote = $quote->getExtensionAttributes()->getNegotiableQuote();

        if (!$negotiableQuote) {
            throw new LocalizedException(__('Quote is not negotiable'));
        }

        $negotiableQuote->setQuoteName($quoteName)
            ->setStatus(NegotiableQuoteInterface::STATUS_CREATED)
            ->setIsRegularQuote(true)
            ->setNegotiatedPriceValue(null);

        if ($comment) {
            $negotiableQuote->setCreatorId($quote->getCustomerId())
                ->setCreatorType(NegotiableQuoteInterface::CREATOR_TYPE_BUYER);
        }

        $this->negotiableQuoteRepository->save($negotiableQuote);

        $this->logger->info('Negotiable quote created', [
            'quote_id' => $quoteId,
            'quote_name' => $quoteName
        ]);

        return $negotiableQuote;
    }

    /**
     * Apply admin discount to negotiable quote
     *
     * @param int $quoteId
     * @param float $discountPercent
     * @param string $comment
     */
    public function applyAdminDiscount(
        int $quoteId,
        float $discountPercent,
        string $comment = ''
    ): void {
        $quote = $this->cartRepository->get($quoteId);
        $negotiableQuote = $quote->getExtensionAttributes()->getNegotiableQuote();

        if (!$negotiableQuote) {
            throw new LocalizedException(__('Quote is not negotiable'));
        }

        // Calculate new total
        $originalPrice = (float)$quote->getBaseGrandTotal();
        $discountAmount = $originalPrice * ($discountPercent / 100);
        $newPrice = $originalPrice - $discountAmount;

        $negotiableQuote->setNegotiatedPriceType(
            NegotiableQuoteInterface::NEGOTIATED_PRICE_TYPE_PERCENTAGE_DISCOUNT
        )
            ->setNegotiatedPriceValue($discountPercent)
            ->setStatus(NegotiableQuoteInterface::STATUS_SUBMITTED_BY_ADMIN);

        $this->negotiableQuoteRepository->save($negotiableQuote);

        $this->logger->info('Admin discount applied to quote', [
            'quote_id' => $quoteId,
            'discount_percent' => $discountPercent,
            'original_price' => $originalPrice,
            'new_price' => $newPrice
        ]);
    }

    /**
     * Customer accepts quote
     */
    public function acceptQuote(int $quoteId, int $customerId): void
    {
        $quote = $this->cartRepository->get($quoteId);

        if ($quote->getCustomerId() != $customerId) {
            throw new LocalizedException(__('Unauthorized access to quote'));
        }

        $negotiableQuote = $quote->getExtensionAttributes()->getNegotiableQuote();

        $negotiableQuote->setStatus(NegotiableQuoteInterface::STATUS_ORDERED);

        $this->negotiableQuoteRepository->save($negotiableQuote);
    }

    /**
     * Customer declines quote
     */
    public function declineQuote(int $quoteId, int $customerId, string $reason = ''): void
    {
        $quote = $this->cartRepository->get($quoteId);

        if ($quote->getCustomerId() != $customerId) {
            throw new LocalizedException(__('Unauthorized access to quote'));
        }

        $negotiableQuote = $quote->getExtensionAttributes()->getNegotiableQuote();

        $negotiableQuote->setStatus(NegotiableQuoteInterface::STATUS_DECLINED);

        $this->negotiableQuoteRepository->save($negotiableQuote);
    }

    /**
     * Get quote history/comments
     */
    public function getQuoteHistory(int $quoteId): array
    {
        // Retrieve comment history from negotiable_quote_history table
        // Simplified implementation
        return [];
    }
}

Purchase Order Approval Workflow

Purchase Order Rules

File: Vendor/B2BExtension/Model/PurchaseOrder/ApprovalRule.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model\PurchaseOrder;

use Magento\PurchaseOrder\Api\Data\PurchaseOrderInterface;
use Magento\Framework\Exception\LocalizedException;

class ApprovalRuleEngine
{
    /**
     * Check if purchase order requires approval
     *
     * @param PurchaseOrderInterface $purchaseOrder
     * @return bool
     */
    public function requiresApproval(PurchaseOrderInterface $purchaseOrder): bool
    {
        $rules = $this->getApprovalRules($purchaseOrder->getCompanyId());

        foreach ($rules as $rule) {
            if ($this->evaluateRule($rule, $purchaseOrder)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get approval rules for company
     */
    private function getApprovalRules(int $companyId): array
    {
        // Example rules
        return [
            [
                'type' => 'amount_threshold',
                'condition' => 'greater_than',
                'value' => 1000.00,
                'approvers' => [2, 5], // Customer IDs
            ],
            [
                'type' => 'requester_role',
                'condition' => 'equals',
                'value' => 'junior_buyer',
                'approvers' => [3], // Manager
            ],
        ];
    }

    /**
     * Evaluate single rule
     */
    private function evaluateRule(array $rule, PurchaseOrderInterface $purchaseOrder): bool
    {
        switch ($rule['type']) {
            case 'amount_threshold':
                return $this->evaluateAmountThreshold($rule, $purchaseOrder);

            case 'requester_role':
                return $this->evaluateRequesterRole($rule, $purchaseOrder);

            default:
                return false;
        }
    }

    /**
     * Check if amount exceeds threshold
     */
    private function evaluateAmountThreshold(array $rule, PurchaseOrderInterface $purchaseOrder): bool
    {
        $amount = (float)$purchaseOrder->getGrandTotal();
        $threshold = (float)$rule['value'];

        return match ($rule['condition']) {
            'greater_than' => $amount > $threshold,
            'less_than' => $amount < $threshold,
            'equals' => $amount == $threshold,
            default => false,
        };
    }

    /**
     * Check requester role
     */
    private function evaluateRequesterRole(array $rule, PurchaseOrderInterface $purchaseOrder): bool
    {
        // Get creator's role from company structure
        $creatorRole = $this->getCreatorRole($purchaseOrder->getCreatorId());

        return $creatorRole === $rule['value'];
    }

    /**
     * Get required approvers for purchase order
     */
    public function getRequiredApprovers(PurchaseOrderInterface $purchaseOrder): array
    {
        $approvers = [];
        $rules = $this->getApprovalRules($purchaseOrder->getCompanyId());

        foreach ($rules as $rule) {
            if ($this->evaluateRule($rule, $purchaseOrder)) {
                $approvers = array_merge($approvers, $rule['approvers']);
            }
        }

        return array_unique($approvers);
    }

    /**
     * Approve purchase order
     */
    public function approve(PurchaseOrderInterface $purchaseOrder, int $approverId): void
    {
        $requiredApprovers = $this->getRequiredApprovers($purchaseOrder);

        if (!in_array($approverId, $requiredApprovers)) {
            throw new LocalizedException(__('User not authorized to approve this purchase order'));
        }

        // Mark as approved by this approver
        // Check if all required approvers have approved
        // If yes, change status to approved
    }

    private function getCreatorRole(int $customerId): string
    {
        // Retrieve from company_advanced_customer_entity
        return 'junior_buyer'; // Simplified
    }
}

Company Credit Management

Credit Limit and Balance

File: Vendor/B2BExtension/Model/CompanyCredit/CreditManager.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model\CompanyCredit;

use Magento\CompanyCredit\Api\CreditLimitRepositoryInterface;
use Magento\CompanyCredit\Api\Data\CreditLimitInterface;
use Magento\Framework\Exception\LocalizedException;

class CreditManager
{
    public function __construct(
        private readonly CreditLimitRepositoryInterface $creditLimitRepository
    ) {
    }

    /**
     * Set credit limit for company
     *
     * @param int $companyId
     * @param float $creditLimit
     * @param string $currencyCode
     */
    public function setCreditLimit(int $companyId, float $creditLimit, string $currencyCode = 'USD'): void
    {
        $credit = $this->getCreditByCompanyId($companyId);

        $credit->setCreditLimit($creditLimit)
            ->setCurrencyCode($currencyCode);

        $this->creditLimitRepository->save($credit);
    }

    /**
     * Get available credit
     */
    public function getAvailableCredit(int $companyId): float
    {
        $credit = $this->getCreditByCompanyId($companyId);

        $creditLimit = (float)$credit->getCreditLimit();
        $balance = (float)$credit->getBalance();

        return $creditLimit - $balance;
    }

    /**
     * Check if company can purchase with credit
     */
    public function canPurchase(int $companyId, float $amount): bool
    {
        $availableCredit = $this->getAvailableCredit($companyId);

        return $availableCredit >= $amount;
    }

    /**
     * Allocate credit for purchase
     */
    public function allocateCredit(int $companyId, float $amount): void
    {
        if (!$this->canPurchase($companyId, $amount)) {
            throw new LocalizedException(__('Insufficient credit limit'));
        }

        $credit = $this->getCreditByCompanyId($companyId);

        $newBalance = (float)$credit->getBalance() + $amount;
        $credit->setBalance($newBalance);

        $this->creditLimitRepository->save($credit);
    }

    /**
     * Refund credit
     */
    public function refundCredit(int $companyId, float $amount): void
    {
        $credit = $this->getCreditByCompanyId($companyId);

        $newBalance = max(0, (float)$credit->getBalance() - $amount);
        $credit->setBalance($newBalance);

        $this->creditLimitRepository->save($credit);
    }

    /**
     * Get credit history
     */
    public function getCreditHistory(int $companyId): array
    {
        // Retrieve from company_credit_history table
        // Simplified
        return [];
    }

    private function getCreditByCompanyId(int $companyId): CreditLimitInterface
    {
        return $this->creditLimitRepository->get($companyId);
    }
}

B2B Permissions and ACL

Custom Company Roles

File: Vendor/B2BExtension/Model/Authorization/RoleManager.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Model\Authorization;

use Magento\Company\Api\AclInterface;

class RoleManager
{
    public function __construct(
        private readonly AclInterface $acl
    ) {
    }

    /**
     * Check if user has permission
     *
     * @param int $customerId
     * @param string $resource
     * @return bool
     */
    public function hasPermission(int $customerId, string $resource): bool
    {
        return $this->acl->isAllowed($customerId, $resource);
    }

    /**
     * Define custom B2B resources
     */
    public function getB2BResources(): array
    {
        return [
            'Magento_Company::view',
            'Magento_Company::view_account',
            'Magento_Company::edit_account',
            'Magento_Company::view_address',
            'Magento_Company::edit_address',
            'Magento_Company::contacts',
            'Magento_Company::user_management',
            'Magento_Company::roles_view',
            'Magento_Company::roles_edit',
            'Magento_Sales::all',
            'Magento_Sales::place_order',
            'Magento_Sales::view_orders',
            'Magento_Sales::view_orders_sub',
            'Magento_NegotiableQuote::all',
            'Magento_NegotiableQuote::manage',
            'Magento_NegotiableQuote::checkout',
            'Magento_PurchaseOrder::all',
            'Magento_PurchaseOrder::view_purchase_orders',
            'Magento_PurchaseOrder::view_purchase_orders_for_subordinates',
            'Magento_PurchaseOrder::approve_purchase_orders',
        ];
    }
}

Testing B2B Features

Integration Test

File: Vendor/B2BExtension/Test/Integration/Model/CompanyCreatorTest.php

<?php
declare(strict_types=1);

namespace Vendor\B2BExtension\Test\Integration\Model;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;

class CompanyCreatorTest extends TestCase
{
    private $objectManager;
    private $companyCreator;

    protected function setUp(): void
    {
        $this->objectManager = Bootstrap::getObjectManager();
        $this->companyCreator = $this->objectManager->get(
            \Vendor\B2BExtension\Model\CompanyCreator::class
        );
    }

    /**
     * @magentoDataFixture Magento/Customer/_files/customer.php
     */
    public function testCreateCompanySuccess(): void
    {
        $customerRepository = $this->objectManager->get(
            \Magento\Customer\Api\CustomerRepositoryInterface::class
        );

        $customer = $customerRepository->get('customer@example.com');

        $companyData = [
            'company_name' => 'Test Company',
            'legal_name' => 'Test Company LLC',
            'company_email' => 'company@test.com',
            'street' => '123 Main St',
            'city' => 'Los Angeles',
            'country_id' => 'US',
            'region_id' => 12,
            'postcode' => '90001',
            'telephone' => '555-1234',
        ];

        $company = $this->companyCreator->createCompany($companyData, $customer);

        $this->assertNotNull($company->getId());
        $this->assertEquals('Test Company', $company->getCompanyName());
        $this->assertEquals($customer->getId(), $company->getSuperUserId());
    }
}

Summary

Key Takeaways: - Company accounts provide hierarchical organization structure with admin and user roles - Shared catalogs enable custom product visibility and pricing per company/customer group - Requisition lists support recurring purchases and bulk ordering workflows - Quick order facilitates rapid SKU-based ordering for experienced buyers - Negotiable quotes support request-for-quote workflows with admin negotiation - Purchase orders enforce approval rules based on amount, requester, or custom logic - Company credit provides payment-on-account capability with credit limits - B2B permissions system extends Magento ACL for company-specific resources


Assumptions

  • Magento Version: Adobe Commerce 2.4.7+ with B2B modules enabled
  • PHP Version: 8.2+
  • License: Valid Adobe Commerce license required for B2B features
  • Company Structure: Hierarchical organization with defined roles
  • Integration: May require ERP/CRM integration for credit, pricing sync

Why This Approach

  • Service Contracts: All B2B features use repository interfaces for API stability
  • Hierarchy Model: Materialized path in company_structure enables efficient tree queries
  • Shared Catalog: Catalog visibility and pricing decoupled from core catalog
  • Quote Workflow: Stateful negotiation with history tracking
  • Approval Rules: Configurable rule engine supports custom business logic
  • Credit Management: Separate credit ledger with transaction history

Security Impact

  • Company Isolation: Strict validation of company ID on all operations
  • Authorization Checks: ACL verification for company resources
  • Quote Access: Users can only access quotes for their company
  • Approval Permissions: Purchase order approvers validated against rules
  • Credit Limits: Server-side validation prevents over-spending
  • PII Protection: Company and user data subject to GDPR compliance

Performance Impact

  • Company Structure Queries: Materialized path enables efficient subtree queries
  • Shared Catalog Filtering: Additional joins on product collections; use indexing
  • Quote Negotiation: Quote locking prevents concurrent modifications
  • Credit Checks: Real-time credit validation on checkout
  • FPC: B2B pages often uncacheable due to user-specific content

Backward Compatibility

  • B2B APIs: Repository interfaces stable across Adobe Commerce versions
  • Company Structure: Hierarchy model unchanged since B2B 1.0
  • Quote Schema: Negotiable quote schema additions BC-compliant
  • Extension Attributes: B2B uses extension attributes for core entity augmentation

Tests to Add

Unit Tests:

testCompanyCreation()
testSharedCatalogAssignment()
testQuoteNegotiation()
testApprovalRuleEvaluation()
testCreditAllocation()

Integration Tests:

testCompanyHierarchyTraversal()
testSharedCatalogPricing()
testRequisitionListToCart()
testPurchaseOrderWorkflow()

Functional Tests (MFTF):

<test name="AdminCreateCompanyTest">
<test name="StorefrontNegotiableQuoteWorkflowTest">
<test name="StorefrontPurchaseOrderApprovalTest">

Docs to Update

  • README.md: B2B module installation, configuration, feature overview
  • docs/COMPANY.md: Company structure diagram, hierarchy management
  • docs/QUOTES.md: Quote negotiation workflow, state diagram
  • docs/APPROVALS.md: Purchase order approval rules, configuration examples
  • Admin User Guide: Screenshots of company admin, shared catalog setup, quote management UI