Mapping company structure, roles and approvals cleanly
B2B Company Accounts let Magento 2 represent real organizations with multiple buyers, team hierarchies and approval chains, instead of reducing customers to a single account. Get company structure, roles, ACL and approval workflows right, and B2B Company Accounts stay stable even with thousands of employees and complex procurement processes.
Table of Contents
- 1. What B2B Company Accounts in Magento 2 are
- 2. Company structure in the data model and Service Contracts
- 3. Mapping team hierarchy and the structure tree programmatically
- 4. Managing permission roles: Company Role and ACL
- 5. Approval workflows: implementing purchase order approval rules
- 6. GraphQL API for B2B Company Accounts
- 7. Extending custom approval rules via plugin
- 8. Multi-user quotes and shared catalogs
- 9. Security and scaling for large company structures
- 10. Summary
- 11. FAQ
1. What B2B Company Accounts in Magento 2 are and what they are for
A classic Magento customer account represents a single person: one email address, one password, one address. B2B Company Accounts break that 1:1 model and instead represent an entire organization, under which any number of buyers with different roles can act. The Company module from the Magento Commerce B2B package provides its own entity model for this, fully extensible through Service Contracts, declarative schema and GraphQL, without touching the core.
The business case is clear: a retail customer buys for themselves, a B2B buyer buys on behalf of a company and with a budget that someone else has to approve. B2B Company Accounts therefore bundle multiple customer accounts under one company, assign them to teams, grant roles with granular permissions and tie orders to approval workflows. Without this model, agencies would have to rebuild every approval flow individually, which in practice leads to inconsistent, hard to maintain one off solutions.
This article covers the full picture: the data model behind B2B Company Accounts, the team hierarchy as a tree structure, the role and ACL system, purchase order approval rules, the GraphQL interface, custom plugin extensions, and how everything interacts with shared catalogs and multi-user quotes. The focus throughout is Magento 2.4.8-p4 with PHP 8.4, Service Contracts and constructor property promotion.
2. Company structure in the data model: company, company_team and Service Contracts
At the core of every B2B Company Account sits the company table with fields such as company_name, legal_name, sales_representative_id, customer_group_id and status. Every buyer who belongs to that company gets additional attributes through company_advanced_customer_entity, such as company_id, job_title, telephone and status, attached to customer_entity as extension attributes. This separation makes it possible to turn a regular customer account into a company account without data loss, and to reverse that conversion.
Access to these tables is consistently handled through Service Contracts rather than direct collection access. CompanyRepositoryInterface, with its get(), save(), getList() and delete() methods, encapsulates persistence, while CompanyManagementInterface provides business logic such as assigning a customer to a company. All tables of the Company modules are declared through db_schema.xml, install scripts are no longer used here. Anyone adding custom attributes to B2B Company Accounts should extend the schema declaratively and read the new fields through their own Service Contracts, instead of preferencing the core repositories.
Besides companies and customers, there is the company_team table, which represents organizational units that are not customer accounts themselves, such as a department or a project team. Teams act as nodes in the structure tree and group buyers independently of their concrete role. The repository below shows how a custom Service Contract for company specific approval rules is implemented with constructor property promotion.
<?php
declare(strict_types=1);
namespace Mironsoft\B2bApproval\Model;
use Magento\Company\Api\CompanyRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\B2bApproval\Api\ApprovalRuleRepositoryInterface;
use Mironsoft\B2bApproval\Api\Data\ApprovalRuleInterface;
use Mironsoft\B2bApproval\Model\ResourceModel\ApprovalRule as ApprovalRuleResource;
use Mironsoft\B2bApproval\Model\ResourceModel\ApprovalRule\CollectionFactory;
/**
* Service contract implementation for reading and persisting company specific approval rules.
*/
class ApprovalRuleRepository implements ApprovalRuleRepositoryInterface
{
/**
* @param ApprovalRuleResource $resource
* @param CollectionFactory $collectionFactory
* @param CompanyRepositoryInterface $companyRepository
*/
public function __construct(
private readonly ApprovalRuleResource $resource,
private readonly CollectionFactory $collectionFactory,
private readonly CompanyRepositoryInterface $companyRepository
) {
}
/**
* Load all approval rules assigned to a B2B company account.
*
* @param int $companyId
* @return ApprovalRuleInterface[]
* @throws NoSuchEntityException
*/
public function getByCompanyId(int $companyId): array
{
$company = $this->companyRepository->get($companyId);
$collection = $this->collectionFactory->create();
$collection->addFieldToFilter('company_id', ['eq' => $company->getId()]);
return $collection->getItems();
}
/**
* Persist a single approval rule for a company account.
*
* @param ApprovalRuleInterface $rule
* @return ApprovalRuleInterface
* @throws \Exception
*/
public function save(ApprovalRuleInterface $rule): ApprovalRuleInterface
{
$this->resource->save($rule);
return $rule;
}
}
3. Mapping team hierarchy and the structure tree programmatically
The actual organizational structure of a B2B Company Account does not live in company itself, but in a separate structure tree that connects customers and teams as nodes. StructureRepositoryInterface returns this tree as a nested node list, where every node carries a type, either customer or team, plus a reference to its parent node. This modeling allows arbitrarily deep hierarchies, from the flat two-level structure of a small business to the multi-level matrix organization of a large group.
Programmatic access to the structure goes through moveNode() and getStructureByCompanyId(), with every move of a node validated server side, so that no cycle can appear in the tree and a customer cannot accidentally end up nested under their own team. For migrations, for example when importing an existing customer hierarchy from an ERP system, a dedicated batch service is worth building, one that builds the structure top down so parent nodes always exist before their children are created.
A common mistake in custom extensions for B2B Company Accounts is manipulating the structure directly through SQL or collection writes. That bypasses the internal validation and can produce inconsistent trees that no longer render correctly in the admin interface. Every change to the team hierarchy should therefore run exclusively through StructureRepositoryInterface, even if that initially looks slower for bulk imports than a direct bulk insert.
4. Managing permission roles: Company Role Resource Model and ACL
Every B2B Company Account needs its own role system, one that operates independently of the global Magento admin ACL but follows the same principle: resources are assigned to roles, roles are assigned to buyers. The Company Role Resource Model persists roles such as buyer, manager or approver with a list of allowed resources, for example creating orders, viewing prices or approving purchase orders. RoleRepositoryInterface provides CRUD operations for these roles and works with the same search criteria patterns as other Magento repositories.
The permission check itself internally reuses the same ACL framework used in the admin area, only with its own resource tree for B2B contexts. Every resource, for instance Magento_PurchaseOrder::approve or Magento_Company::view_addresses, is checked against the resources assigned to the current role. This allows fine grained permissions: a buyer may create orders but not approve them, a manager may do both, and an approver may only review and approve orders without being allowed to purchase anything themselves.
For custom extensions of B2B Company Accounts, it is important to register new ACL resources cleanly through acl.xml in your own module and not interfere with Magento's core resource tree. That keeps roles upgrade safe, and customers can assign new resources in the familiar role management inside their storefront account, without requiring a custom interface for it.
5. Approval workflows: implementing purchase order approval rules
Approval workflows are the real added value of B2B Company Accounts compared to simple customer groups. A purchase order approval rule defines a condition, usually a threshold for the order value, and a list of roles or specific people who must approve an order above that threshold. Once an order exceeds the configured amount, it automatically switches to a "pending approval" status instead of being passed directly into the order pipeline.
Native approval rules can be configured through a company account's storefront management and cover most standard cases: an amount per role, a sequential approval chain across several roles. For more complex scenarios, for example approvals depending on product category, cost center or shipping country, the default configuration is not enough. This is exactly where native rules end and custom plugin extensions have to begin.
| Aspect | Native approval rule | Custom plugin extension | Recommended use |
|---|---|---|---|
| Threshold condition | Fixed amount per role | Arbitrary condition logic, e.g. category or cost center | Custom for complex approval chains |
| Multi-step approval | Sequential role chain only | Parallel or conditional approval paths possible | Custom for matrix organizations |
| Maintenance effort | Maintainable through storefront configuration | Requires deployment and its own tests | Native when standard cases suffice |
| Integration with ACL | Coupled directly through Company Role | Must check ACL resources itself | Native preferred, custom only when needed |
| Performance at scale | Rule evaluation per order, indexed | Extra plugin logic adds overhead | Consistently check custom code against indexes |
Checking whether a buyer is actually allowed to approve an order always combines two conditions: the ACL permission of the assigned role and the threshold configured in the approval rule. The validator below shows, as an example, how both checks come together for a custom B2B Company Accounts feature.
<?php
declare(strict_types=1);
namespace Mironsoft\B2bApproval\Model\Validator;
use Magento\Framework\Authorization;
use Magento\Sales\Api\Data\OrderInterface;
use Mironsoft\B2bApproval\Api\Data\ApprovalRuleInterface;
/**
* Validates whether the current company user role is allowed to approve
* a purchase order once the configured approval rule threshold is exceeded.
*/
class RoleAwareApprovalValidator
{
private const ACL_RESOURCE_APPROVE_PURCHASE_ORDER = 'Magento_PurchaseOrder::approve';
/**
* @param Authorization $authorization
*/
public function __construct(
private readonly Authorization $authorization
) {
}
/**
* Check if the current role may approve the order under the given rule.
*
* @param ApprovalRuleInterface $rule
* @param OrderInterface $order
* @return bool
*/
public function isApprovalRequired(ApprovalRuleInterface $rule, OrderInterface $order): bool
{
$grandTotal = (float) $order->getGrandTotal();
$threshold = (float) $rule->getConditionThreshold();
if ($grandTotal < $threshold) {
return false;
}
return !$this->authorization->isAllowed(self::ACL_RESOURCE_APPROVE_PURCHASE_ORDER);
}
}
6. GraphQL API for B2B Company Accounts
For headless and Hyvä frontends, Magento provides its own GraphQL layer for B2B Company Accounts. The company query returns master data for the company including assigned roles, while companyStructure returns the complete team and customer tree as a pageable structure. Both queries respect the ACL permissions of the requesting customer, a buyer without the right role only sees the parts of the tree in the structure query for which they actually hold read access.
Mutations such as creating, updating or deactivating a team member run through dedicated resolvers that call the same Service Contracts behind the scenes as the storefront forms. The schema fragment below shows how a custom resolver for creating a team member inside an existing B2B Company Account is registered through schema.graphqls, consistent with the native Company schema.
type Query {
company: Company @resolver(class: "Magento\\CompanyGraphQl\\Model\\Resolver\\Company")
companyStructure(rootId: Int, pageSize: Int = 20, currentPage: Int = 1): CompanyStructure
@resolver(class: "Magento\\CompanyGraphQl\\Model\\Resolver\\Structure")
}
type Mutation {
createCompanyTeamMember(input: CompanyTeamMemberInput!): CompanyTeamMemberOutput
@resolver(class: "Mironsoft\\B2bApproval\\Model\\Resolver\\CreateCompanyTeamMember")
}
input CompanyTeamMemberInput {
company_id: Int!
team_id: Int
role_id: Int!
email: String!
firstname: String!
lastname: String!
}
type CompanyTeamMemberOutput {
customer_id: Int!
status: String!
}
For custom GraphQL extensions on B2B Company Accounts, the same rule applies as in the REST and admin context: resolvers must not invent their own permission logic, they must reuse the same Service Contracts and ACL checks already used by the repository and validator. Otherwise, two parallel permission systems emerge that would need to be kept in sync with every change.
7. Extending custom approval rules via plugin
Following the coding standards for this project, extensions to core logic are generally implemented through plugins rather than preferences. For B2B Company Accounts this specifically means: instead of replacing the native approval rule validation, a plugin is registered that extends the existing check with additional conditions, for example a cost center check or a block list for certain product categories.
Registration happens through di.xml in your own module and targets the native validator of the purchase order rule engine. An around plugin can pass through the native decision unchanged and additionally tighten it, but it should never loosen it, unless that is explicitly documented elsewhere and made visible in code review. This restraint protects against a seemingly harmless extension accidentally undermining an existing approval check.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\PurchaseOrderRule\Model\Validator\Rule\ApprovalRule">
<plugin name="MironsoftB2bApprovalExtendApprovalRule"
type="Mironsoft\B2bApproval\Plugin\ExtendApprovalRulePlugin"
sortOrder="10" />
</type>
</config>
Test coverage matters for every plugin based extension of B2B Company Accounts: a faulty plugin that inverts an approval condition incorrectly can cause orders to pass through without the required approval. An integration test that deliberately creates orders above and below the threshold and checks the resulting order status therefore belongs in the definition of done for every approval extension.
8. Multi-user quotes and shared catalogs in combination with company accounts
B2B Company Accounts only reach their full value in combination with two other B2B features: negotiable quotes and shared catalogs. A quote a sales representative creates for a company can be viewed and commented on by multiple buyers of the same company before an approver turns the final quote into an order. This multi-user capability requires the team hierarchy and roles of the company account to be maintained correctly, otherwise buyers see either too many or too few quotes.
Shared catalogs tie a dedicated price list and a dedicated assortment to one or more companies, independent of the classic customer group logic. A B2B Company Account can be assigned to exactly one shared catalog, so all buyers of that company automatically see the same special pricing and the same released assortment. For agencies this means: pricing and assortment logic does not belong in individual customer group rules, it belongs consistently in the shared catalog assignment of the respective company account.
9. Security and scaling aspects for large company structures
The deeper a B2B Company Account's team hierarchy is nested, the more expensive naive tree queries become. A large group with several thousand buyers and a six level team structure should never rematerialize the entire tree from the database on every storefront request. Instead, it is worth building a dedicated cache for the structure query, invalidated through Magento's standard cache tags, plus an index on the foreign key columns of the structure and team tables.
On the security side, the biggest risk in custom extensions of B2B Company Accounts is an incomplete ACL check in your own code: anyone writing a new GraphQL query or a new controller must explicitly check whether the requesting customer actually belongs to the requested company, and not merely whether they own any B2B Company Account at all. Without this company scope check, a buyer from company A could theoretically query data from company B once IDs are guessed or enumerated.
For audit purposes it is also worth adding a dedicated log table that records every approval decision with company, order, approving customer and timestamp. The db_schema.xml fragment below shows such an audit table, including a foreign key to the native company table and an index that keeps evaluations performant even for large company structures.
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_b2b_approval_audit" resource="default" engine="innodb" comment="B2B Approval Audit Log">
<column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
<column xsi:type="int" name="company_id" padding="10" unsigned="true" nullable="false" comment="Company ID"/>
<column xsi:type="int" name="purchase_order_id" padding="10" unsigned="true" nullable="false" comment="Purchase Order ID"/>
<column xsi:type="int" name="approver_customer_id" padding="10" unsigned="true" nullable="false" comment="Approver Customer ID"/>
<column xsi:type="varchar" name="decision" nullable="false" length="32" comment="Approval Decision"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_B2B_APPROVAL_AUDIT_COMPANY_ID_COMPANY_ENTITY_ID"
table="mironsoft_b2b_approval_audit" column="company_id"
referenceTable="company" referenceColumn="entity_id" onDelete="CASCADE"/>
<index referenceId="MIRONSOFT_B2B_APPROVAL_AUDIT_COMPANY_ID" indexType="btree">
<column name="company_id"/>
</index>
</table>
</schema>
10. Summary
B2B Company Accounts in Magento 2 solve a problem that classic customer accounts fundamentally cannot represent: multiple buyers, one shared organization, clear roles and approval chains. The data model made up of company, company_advanced_customer_entity and company_team is consistently accessed through Service Contracts, and the team hierarchy is maintained through StructureRepositoryInterface. Roles and ACL resources govern who is allowed to do what, purchase order approval rules govern from which amount an approval becomes required.
Custom extensions to B2B Company Accounts consistently belong in plugins rather than preferences, new tables exclusively in db_schema.xml, new GraphQL fields in their own schema.graphqls files that reuse the same Service Contracts as storefront and admin. Anyone who sticks to these principles gets B2B Company Accounts that stay performant, secure and maintainable even with thousands of buyers and deep team structures.
B2B Company Accounts in Magento 2, the essentials at a glance
Data model
Access company, company_advanced_customer_entity and company_team through Service Contracts, never write directly through a collection.
Roles & ACL
Company Role Resource Model plus custom ACL resources through acl.xml, never modify the core resource tree.
Approval workflows
Purchase order approval rules for standard cases, plugins for complex conditions, always with an integration test.
Scaling & security
Cache the structure tree, check company scope in every custom query, log approval decisions.
11. FAQ: B2B Company Accounts in Magento 2
1What is a B2B Company Account in Magento 2?
2Which edition offers B2B Company Accounts?
3company_team vs. company_structure?
4Create a new company role programmatically?
5How do approval rules work?
6Custom approval conditions via plugin?
7GraphQL queries for company accounts?
8Shared catalogs and company accounts?
9Scaling with thousands of employees?
10ACL pitfalls with company roles?
Mironsoft
Magento 2 B2B development and company account extensions
B2B Company Accounts that fit your procurement process?
We build company structure, roles and approval workflows for B2B Company Accounts in Magento 2, from the Service Contract architecture through custom plugins to GraphQL integration with your Hyvä frontend.
Company structure
Setting up data model, team hierarchy and roles for B2B Company Accounts cleanly
Approval workflows
Configuring approval rules or extending them with custom conditions via plugin
GraphQL & Hyvä
Connecting company data and approval status performantly to your Hyvä frontend