from tier prices to an invisible B2B catalog
Customer Groups are the central mechanism in Magento 2 for steering pricing and visibility by customer segment: from wholesale pricing to hidden B2B catalogs to per-group tier prices. Combining Catalog Price Rules, Tier Pricing, visibility plugins and Shared Catalog correctly lets teams model complex B2B and B2C pricing logic without fragile checkout hacks, provided they stay on top of reindex cycles, scoping mistakes and the special role of the NOT LOGGED IN group.
Table of Contents
- 1. Understanding Customer Groups as a control layer
- 2. Creating customer groups and assigning tax classes
- 3. Scoping Catalog Price Rules to customer groups
- 4. Tier Pricing and Special Price per customer group
- 5. Visibility control via plugin on the product collection
- 6. Assigning customers to groups programmatically
- 7. B2B scenarios: wholesale and hidden catalogs
- 8. Shared Catalog vs. plain customer groups
- 9. Performance, reindex and common pitfalls
- 10. Summary
- 11. FAQ
1. Understanding Customer Groups as a control layer
Customer Groups in Magento 2 are not a pure segmentation label for marketing reports, they are a fully fledged control layer that reaches deep into price calculation, tax class logic and visibility. Every customer and every guest belongs to exactly one customer group, referenced through the group_id attribute on the customer and quote object. This id feeds into almost every price relevant calculation: tier price resolution, Catalog Price Rule application, the price index and the tax class mapping via the customer tax class.
The decisive architectural advantage of Customer Groups over individual per-customer special conditions is scalability. Instead of maintaining a separate price list for every single B2B customer, a store defines a limited number of groups such as Wholesale, Contract Partner or Premium Reseller and assigns customers to them. Price rules, tier prices and visibility rules are then maintained once per group and apply to all members at the same time. This indirection is the core of any scalable customer group pricing strategy in medium and large Magento installations.
This article deliberately focuses on the operational side: how to create customer groups, how price rules and tier prices reference them, how visibility is controlled through a plugin approach on top of the pricing logic, and which reindex mechanics run in the background. Architecture and design pattern fundamentals of Magento 2 are assumed as prior knowledge and are not repeated here.
2. Creating customer groups and assigning tax classes
New Customer Groups are created in the admin under Stores > Customers > Customer Groups. Every group consists of a name and an assigned tax class, managed through Magento\Tax\Model\ClassModel. This mapping is the first pricing lever: a B2B group can be mapped to a tax class without VAT display, while the default retail group uses the regular tax class with gross prices. The tax class works together with the Tax Rule, which combines customer tax class and product tax class into a concrete tax rate.
Four Customer Groups exist as system groups in every fresh Magento installation: NOT LOGGED IN (id 0), General (id 1), Wholesale (id 2) and Retailer (id 3). The group with id 0 has a special role that gets covered in more depth in the pitfalls section: it applies to every visitor who is not logged in, which includes storefront GraphQL price queries without a customer token. New groups automatically receive the next free id, and it is important never to hardcode these ids in deployment scripts. Resolve them through CustomerGroupRepositoryInterface by group name instead, since ids can differ between environments (dev, staging, live) if groups were created in a different order.
For the programmatic creation of new Customer Groups, for example in a setup script of a custom module, use Magento\Customer\Api\GroupRepositoryInterface with a GroupInterface data object. The group name, the tax class id and optionally the tax_class_id attribute for B2B relevant tax logic are set on it. Important when maintaining a custom module (say Mironsoft_CustomerGroupTools): if a customer group is created via a data patch, that patch should be idempotent and check whether a group with the same name already exists before creating it, in order to prevent duplicates on repeated execution.
3. Scoping Catalog Price Rules to customer groups
Catalog Price Rules (Marketing > Catalog Price Rule) are the most important mechanism for applying automated discounts or surcharges at the product level for specific Customer Groups, without editing every product individually. Every rule consists of conditions that match product attributes, categories or the customer group, and actions that set a percentage discount, a fixed amount or a fixed price. The Customer Groups field in the rule form is a multi-select and determines which groups the rule is even considered for, independent of the remaining conditions.
The decisive technical point is that Catalog Price Rules are not applied to every price lookup at runtime, they are precomputed into the price index. The catalog_rule_price indexer calculates the effective rule price for every combination of product, website, customer group and date, and writes it into the catalogrule_product_price table. Only after that does the catalog_product_price indexer take over that value into the final price index catalog_product_index_price. If a rule is created only for the Wholesale group but the catalog_rule_price cron job does not run in time, wholesale customers keep seeing the old price until the next reindex cycle completes.
A common scoping mistake with customer group pricing happens when a rule is accidentally created for "All Customer Groups" instead of the specifically intended group. Because Catalog Price Rules are applied in priority order and the "Discard subsequent rules" option exists, a broadly scoped rule can override a later, more narrowly scoped rule for a specific group, or prevent it from applying at all. Priority should therefore always be set so that more specific, group bound rules are evaluated before generic rules, and "Discard subsequent rules" should only be used deliberately and documented.
# After any change to Catalog Price Rules or their
# customer group assignment: rebuild the rule price index and the final price index
bin/magento indexer:reindex catalogrule_rule
bin/magento indexer:reindex catalogrule_product
bin/magento indexer:reindex catalog_product_price
# Check the status of all price relevant indexers
bin/magento indexer:status
# Manually trigger the cron job that refreshes catalogrule_price in the background
bin/magento cron:run --group index
4. Tier Pricing and Special Price per customer group
Tier Pricing enables quantity based tiered prices that can additionally be defined differently per Customer Group. Through the service contract Magento\Catalog\Api\Data\ProductInterface combined with Magento\Catalog\Api\Data\ProductTierPriceInterface, every tier price entry stores a quantity (qty), a price or percentage discount (percentage_value), a customer group id and optionally a website id. The special customer group id 0 in this context means "All Groups" and needs to be distinguished semantically from the NOT LOGGED IN customer group (also id 0 in the customer_group context), which causes confusion in practice and must be kept in mind while debugging.
Programmatically, tier prices can be set through ProductRepositoryInterface::save() after populating the tier_prices property of the product, or in a more granular way through the Magento\Catalog\Api\ProductTierPriceManagementInterface available since Magento 2.2, which can read, add and remove individual tier price entries without saving the entire product. This is significantly more performant than a full ProductRepositoryInterface::save() call per product for bulk operations across many products, because the latter runs through the entire product save cycle including all plugins and observers.
<?php
declare(strict_types=1);
namespace Mironsoft\CustomerGroupTools\Service;
use Magento\Catalog\Api\Data\ProductTierPriceInterface;
use Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory;
use Magento\Catalog\Api\ProductTierPriceManagementInterface;
use Magento\Customer\Api\GroupRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
/**
* Assigns wholesale tier pricing to a product for a specific customer group.
*/
final class WholesaleTierPriceAssigner
{
/**
* @param ProductTierPriceManagementInterface $tierPriceManagement Service contract for granular tier price CRUD
* @param ProductTierPriceInterfaceFactory $tierPriceFactory Factory for tier price data objects
* @param GroupRepositoryInterface $groupRepository Resolves customer group id by name
*/
public function __construct(
private readonly ProductTierPriceManagementInterface $tierPriceManagement,
private readonly ProductTierPriceInterfaceFactory $tierPriceFactory,
private readonly GroupRepositoryInterface $groupRepository,
) {
}
/**
* Adds a quantity-based tier price for the "Wholesale" customer group without
* touching any other tier price entry on the product.
*
* @param string $sku Target product SKU
* @param int $qty Minimum quantity for the tier
* @param float $price Absolute tier price
* @return void
* @throws NoSuchEntityException if the wholesale group does not exist
*/
public function assignWholesaleTier(string $sku, int $qty, float $price): void
{
$groupId = $this->resolveGroupId('Wholesale');
/** @var ProductTierPriceInterface $tierPrice */
$tierPrice = $this->tierPriceFactory->create();
$tierPrice->setCustomerGroupId($groupId);
$tierPrice->setQty($qty);
$tierPrice->setValue($price);
$existing = $this->tierPriceManagement->getList($sku);
$existing[] = $tierPrice;
$this->tierPriceManagement->add($sku, $existing);
}
/**
* Resolves a customer group id by its label, avoiding hardcoded group ids
* that may differ between environments.
*
* @param string $groupName Human readable customer group name
* @return int Resolved group id
* @throws NoSuchEntityException if no matching group is found
*/
private function resolveGroupId(string $groupName): int
{
$searchResult = $this->groupRepository->getList(
$this->groupRepository->getList(
(new \Magento\Framework\Api\SearchCriteriaBuilder())->create()
)->getItems()
);
foreach ($searchResult as $group) {
if ($group->getCode() === $groupName) {
return (int) $group->getId();
}
}
throw new NoSuchEntityException(__('Customer group "%1" not found', $groupName));
}
}
Special Price (the special_price field with an optional time window through special_from_date and special_to_date) is, unlike Tier Pricing, not directly tied to customer groups, but can be overridden per group in combination with a Catalog Price Rule. In practice it is advisable to use Special Price for time limited, group independent promotional prices and to use Tier Pricing or Catalog Price Rules for anything bound to a Customer Group.
5. Visibility control via plugin on the product collection
Native Catalog Product Visibility in Magento 2 (Not Visible Individually, Catalog, Search, Catalog/Search) has no customer group dimension out of the box. For B2B scenarios where certain products should only be visible to a group like Wholesale, while remaining completely hidden for General and NOT LOGGED IN, standard visibility is not sufficient. This is where targeted visibility control through a plugin on the product collection adds an access layer on top of the price and stock layer.
The cleanest approach is a plugin on Magento\Catalog\Model\ResourceModel\Product\Collection that applies a filter on a custom product attribute such as restricted_customer_groups after the collection loads (afterLoad) or before the final query is built (beforeLoad). Alternatively, and more consistent for search results, the filter can be applied at the search and category layer level by placing a plugin on Magento\CatalogSearch\Model\Layer\Filter\... or more directly on the Magento\Framework\Search\Request\Builder, excluding products with an unfitting customer group assignment at the Elasticsearch/OpenSearch query level rather than filtering them out of the result set in PHP afterwards.
<?php
declare(strict_types=1);
namespace Mironsoft\CustomerGroupTools\Plugin;
use Magento\Catalog\Model\ResourceModel\Product\Collection;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\App\ResourceConnection;
/**
* Restricts the visible product collection based on the current customer's
* group, so B2B-only products stay hidden from other customer groups.
*/
final class RestrictProductCollectionByCustomerGroup
{
/**
* @param CustomerSession $customerSession Provides the current customer group id
* @param ResourceConnection $resourceConnection Direct DB access for the EAV attribute join
*/
public function __construct(
private readonly CustomerSession $customerSession,
private readonly ResourceConnection $resourceConnection,
) {
}
/**
* Joins the restricted_customer_groups attribute and filters out products
* that explicitly exclude the current customer group.
*
* @param Collection $subject Product collection being loaded
* @return void
*/
public function beforeLoad(Collection $subject): void
{
if ($subject->isLoaded()) {
return;
}
$groupId = (int) $this->customerSession->getCustomerGroupId();
$attributeCode = 'restricted_customer_groups';
if (!$subject->getAttribute($attributeCode)) {
return;
}
$subject->addAttributeToSelect($attributeCode);
$subject->addFieldToFilter(
[$attributeCode, $attributeCode],
[
['null' => true],
['nfin' => [$groupId]],
]
);
}
}
An important detail of this plugin based approach to visibility control: it runs in addition to regular visibility, it does not replace it. Products still need to be correctly set to Catalog/Search, the plugin merely removes products whose customer group restriction does not match the current session on top of that. For direct access via the product detail page URL, an additional plugin on Magento\Catalog\Model\Product or an observer on catalog_controller_product_init_after should trigger a 404 redirect, otherwise the URL stays directly reachable despite hidden category and search results.
6. Assigning customers to groups programmatically
Programmatically assigning customers to Customer Groups runs through Magento\Customer\Api\CustomerRepositoryInterface. The CustomerInterface data object carries the group_id attribute directly, so a simple setGroupId() followed by save() is enough to reassign a customer. For bulk moves, for example when hundreds of existing customers need to be moved from General into a new B2B group after a contract change, calling the repository save cycle once per customer is inefficient, since every call triggers events, observers and indexer invalidations.
For such bulk reassignments a dedicated CLI command is recommended, registered through Magento\Framework\Console\Cli respectively Magento's Symfony console integration, which internally either iterates over the repository in controlled batches or, for very large volumes, performs a direct bulk update on the customer_entity table, followed by a targeted reindex of the affected customer ids instead of a full reindex.
<?php
declare(strict_types=1);
namespace Mironsoft\CustomerGroupTools\Console\Command;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* CLI command for bulk-reassigning customers from one customer group to another,
* e.g. after a wholesale contract migration.
*/
final class ReassignCustomerGroupCommand extends Command
{
/**
* @param CustomerRepositoryInterface $customerRepository Service contract for customer read/write
* @param SearchCriteriaBuilder $searchCriteriaBuilder Builds the filter for the source group
*/
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
?string $name = null,
) {
parent::__construct($name);
}
/**
* Configures command name, description and required arguments.
*
* @return void
*/
protected function configure(): void
{
$this->setName('mironsoft:customer-group:reassign')
->setDescription('Bulk-reassigns customers from one customer group to another')
->addArgument('sourceGroupId', InputArgument::REQUIRED, 'Source customer group id')
->addArgument('targetGroupId', InputArgument::REQUIRED, 'Target customer group id');
}
/**
* Executes the reassignment, in batches of 200 customers, and reports progress.
*
* @param InputInterface $input Console input
* @param OutputInterface $output Console output
* @return int Exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$sourceGroupId = (int) $input->getArgument('sourceGroupId');
$targetGroupId = (int) $input->getArgument('targetGroupId');
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('group_id', $sourceGroupId, 'eq')
->setPageSize(200)
->create();
$result = $this->customerRepository->getList($searchCriteria);
$total = $result->getTotalCount();
$output->writeln(sprintf('<info>Found %d customers in group %d</info>', $total, $sourceGroupId));
foreach ($result->getItems() as $customer) {
$customer->setGroupId($targetGroupId);
$this->customerRepository->save($customer);
}
$output->writeln('<info>Reassignment complete. Run indexer:reindex customer_grid and catalog_product_price.</info>');
return Command::SUCCESS;
}
}
The hint at the end of the output is not an afterthought: after a bulk move between Customer Groups, the price index must be rebuilt, since group specific tier prices and Catalog Price Rules only take effect correctly for the newly assigned customers after the reindex. This step is surprisingly often forgotten in practice, because the repository save itself does not throw an error when the index is stale, the frontend simply keeps showing the old prices without any warning.
7. B2B scenarios: wholesale and hidden catalogs
The classic B2B scenario with Customer Groups is a Wholesale group with negotiated net prices, modeled through a combination of group specific Tier Pricing for quantity tiers and a Catalog Price Rule for flat discounts across entire categories. The advantage of this combination over pure Tier Pricing is that new products added to a discounted category automatically receive the wholesale discount through the rule's category condition, without requiring a manual tier price entry for every new product.
A second common scenario is the fully hidden B2B catalog: all prices and sometimes the products themselves are invisible for the NOT LOGGED IN group, and only after logging in with an approved B2B customer group do prices and the full product catalog appear. Technically this combines the visibility plugin from section 5 with a layout adjustment that replaces the price block (Magento\Catalog\Block\Product\Price respectively its Hyvä view model counterpart) for NOT LOGGED IN with a "price on request" or "sign in now" notice. It is important not to hide this logic only in the frontend template but also at the API level: the GraphQL price query must respect the customer group as well, otherwise the hidden price can still be read out through a direct API call.
A third pattern is staged activation: a newly registered B2B customer group (say "B2B Pending") sees no prices at all at first, until a sales representative moves the customer to a "B2B Approved" group with full price access after a credit check. This activation can be implemented through an admin UI element in the customer edit grid, which internally uses exactly the CustomerRepositoryInterface::save() call shown in section 6, extended with an email notification via an observer on the customer_save_after event.
8. Shared Catalog vs. plain customer groups
Shared Catalog is a Commerce exclusive feature (not available in Magento Open Source) that builds on top of Customer Groups but additionally offers a granular catalog view with individual price lists per company account, without needing a separate customer group for every price variant. A Shared Catalog still internally references a customer group, but extends it with its own price list (company_credit and shared_catalog tables) that is more finely editable than a classic Catalog Price Rule.
For Magento Open Source, where Shared Catalog is not available, combining several specific Customer Groups with an ACL and plugin approach is the pragmatic substitute: a separate customer group is created for every required price tier, and the visibility and pricing logic is modeled as described in sections 3 through 5 through Catalog Price Rules, Tier Pricing and a visibility plugin. The downside of this approach is the higher maintenance effort for very many individual company conditions, since every new price tier means a new customer group with its own rule maintenance, while Shared Catalog offers this flexibility natively with a fraction of the administrative overhead.
| Mechanism | Use case | Scalability | Maintenance effort | Edition |
|---|---|---|---|---|
| Tier Pricing | Quantity tiers per customer group | Good with few groups | Per product, manual or via service contract | Open Source & Commerce |
| Catalog Price Rule | Category or attribute wide discounts | Very good, affects many products at once | Low, maintained centrally | Open Source & Commerce |
| Shared Catalog | Individual company price lists | Very good with many company accounts | Low thanks to native UI | Commerce only |
| Visibility Plugin | Hiding products per customer group | Good, requires custom attribute | Medium, one time development effort | Open Source & Commerce |
| Special Price | Time limited promotional prices | Not group specific natively | Low, per product | Open Source & Commerce |
The table shows that the choice of mechanism strongly depends on the concrete Customer Groups scenario: a few clearly defined B2B groups with stable conditions go a long way with Catalog Price Rules and Tier Pricing, while many individual company conditions without a Commerce license can only be modeled with plain customer groups at a noticeably higher manual maintenance cost.
9. Performance, reindex and common pitfalls
Every additional Customer Group potentially multiplies the size of the price index, since catalog_product_index_price keeps a separate row per combination of product, website and customer group. With a catalog of 100,000 products and 10 active customer groups across 2 websites, this can quickly become several million index rows. The full index run (bin/magento indexer:reindex catalog_product_price) scales accordingly with the number of groups, and with a very large number of groups carrying individual tier prices, reindex time can increase significantly. In practice it pays off to consistently disable or delete unused customer groups instead of leaving them around "for future use", since every group feeds permanently into every price index run.
The most common pitfall with customer group pricing is forgetting the reindex after a tier price change. If a tier price is set through ProductRepositoryInterface::save() or ProductTierPriceManagementInterface::add() while the indexer mode is set to "Update by Schedule" instead of "Update on Save", the change does not become visible in the price index immediately, only after the next cron run of the indexer_reindex_all_invalid job. For bulk imports through CLI commands, it is therefore advisable to either explicitly trigger the reindex at the end of the script, or, for very large data sets, to partially reindex only the affected product ids rather than forcing a complete full index.
A second classic mistake is incorrectly scoping a Catalog Price Rule, where a discount accidentally stays active for the NOT LOGGED IN group as well, even though it was only meant for Wholesale. Since NOT LOGGED IN is the default group for guests and for unauthenticated GraphQL requests, an incorrectly scoped B2B discount in this case leaks straight onto the publicly visible storefront and can lead to significant financial miscalculations. A regular audit of all active Catalog Price Rules focusing on their assigned customer groups should be a fixed part of the pricing maintenance process, especially after rule changes made by marketing teams without a technical background.
10. Summary
Customer groups are the central control layer for pricing and visibility in Magento 2: through tax classes, Catalog Price Rules, tier pricing and a visibility plugin on the product collection, B2B pricing, hidden catalogs and staged activation can all be modeled without a Commerce license. Programmatic group assignment via CustomerRepositoryInterface enables both individual and bulk reassignments, for example after a sales approval following a credit check.
Anyone without a Commerce license who has to do without Shared Catalog instead combines several specific customer groups with Catalog Price Rules and tier pricing, accepting higher maintenance effort for very many individual company conditions in return. Two disciplines matter most in production: consistent reindexing after every tier price change, and a regular audit of all Catalog Price Rules against accidental NOT-LOGGED-IN scoping.
Customer groups and price rules, the essentials at a glance
Pricing logic
Catalog Price Rules for category-wide discounts, tier pricing for quantity breaks per customer group.
Visibility
A plugin on the product collection controls visibility, the GraphQL price query must share the same logic.
Shared Catalog vs. Open Source
Shared Catalog is Commerce only, otherwise several customer groups act as a pragmatic substitute.
Operations
Never skip a reindex after tier price changes, audit Catalog Price Rules regularly for NOT-LOGGED-IN scoping.
11. FAQ: Customer Groups and Price Rules in Magento 2
1What do customer groups control exactly?
2Catalog Price Rule or tier pricing?
3How do I hide prices from guests?
4How do I assign customers programmatically?
5What is staged B2B activation?
6Shared Catalog or plain customer groups?
7Why does the price index grow with each group?
8Why is a tier price change not visible immediately?
9How do I prevent a discount leaking to guests?
10Should I delete unused groups?
Mironsoft
Magento 2 B2B pricing logic, customer groups and visibility control
Customer groups that actually reflect your pricing structure?
We analyze existing customer group setups, fix incorrect scoping on Catalog Price Rules and implement tier pricing, visibility plugins and reindex strategies for scalable B2B pricing logic.
B2B pricing logic
Tier Pricing and Catalog Price Rules cleanly scoped per customer group and documented
Segmentation
Visibility plugins and hidden catalogs for B2B only areas and activation workflows
Reindex strategy
Optimizing price index performance for many customer groups and building bulk reassignment tools