orangecat / module-prices-company
Per-company, per-SKU, quantity-aware custom pricing rules for Magento 2 B2B
Package info
github.com/olivertar/m2_pricescompany
Type:magento2-module
pkg:composer/orangecat/module-prices-company
Requires
- php: >=8.1
- magento/framework: *
- orangecat/core: *
- orangecat/module-company: *
- orangecat/module-prices: *
README
Per-company, per-SKU, quantity-aware custom pricing rules for Magento 2 B2B.
Module: Orangecat_PricesCompany
Version: 1.0.0
License: OSL-3.0
Author: Oliverio Gombert olivertar@gmail.com
Table of Contents
- Overview
- Theme Compatibility
- Requirements
- Installation
- What Gets Installed
- Configuration
- Store Admin Guide
- Developer Guide
- REST API
- Frontend Routes Reference
- DevOps & Integrator Notes
Overview
Orangecat_PricesCompany attaches company-specific pricing rules directly to a company entity. Each rule targets a single SKU at a minimum purchase quantity and applies one of three discount strategies: a fixed final price, a flat amount off, or a percentage off. Rules are quantity-tiered: the highest-matching threshold applies, enabling volume pricing per company.
The module registers a PricesCompanyCalculator into the Orangecat_Prices orchestrator pool. The orchestrator calls every registered calculator in sequence; this module's calculator returns a price (or null if no rule matches) and the orchestrator applies it to the product.
Responsibilities:
- Store per-company, per-SKU, per-quantity pricing rules in a dedicated flat table
- Register a price calculator into the
Orangecat_Pricesorchestrator pool - Expose an Admin UI grid to browse companies and manage their pricing rules
- Support adding products via an interactive AJAX modal with per-row discount configuration
- Expose a REST API for programmatic bulk upsert, search, calculation, and deletion
- Support bulk CSV import/export via Magento's native ImportExport framework
Position in the Orangecat B2B Dependency Chain
Orangecat_Core (via composer: orangecat/core)
└── Orangecat_Company
└── Orangecat_Prices (price orchestrator and calculator pool)
└── Orangecat_PricesCompany ← this module
Price Resolution Strategy
When the Prices orchestrator resolves the final price for a product, it passes a $basePrice to each registered calculator. This module's behavior depends on the Price Resolution Strategy setting:
| Mode | Description |
|---|---|
| Overwrite (default) | The company rule is applied against the catalog base price, ignoring any upstream discount (e.g., from PricesList). Company rule wins absolutely. |
| Stack | The $basePrice received is already adjusted by earlier calculators (e.g., a PricesList discount). The company rule stacks on top, compounding discounts. |
Discount Types
| Value | Label | Calculation |
|---|---|---|
fixed_price |
Fixed Price Override | Final price = amount (ignores base price) |
fixed_amount |
Fixed Amount Discount | Final price = base_price - amount |
percentage |
Percentage Discount | Final price = base_price × (1 - amount/100) |
For fixed_amount and percentage, the base_price used is determined by the resolution mode (see above). The calculated price is floored at 0.0 and never goes negative.
Tier Pricing
Multiple rules for the same company + SKU at different qty thresholds create volume tiers. The calculator picks the rule with the highest qty that is ≤ the requested quantity. The getTiers() method returns all tier breakpoints (qty > 1) for display on the product page.
Theme Compatibility
| Theme | Status | Notes |
|---|---|---|
| Luma | Supported | No frontend templates. Pricing applies transparently via the Prices orchestrator. |
| Hyvä | Supported | No frontend templates. Pricing applies transparently via the Prices orchestrator. |
| Breeze Evolution | Supported | No frontend templates. Pricing applies transparently via the Prices orchestrator. |
This module has no frontend views. All pricing logic runs server-side inside the orchestrator pool and surfaces through the standard catalog price layer, making it theme-agnostic.
Requirements
| Dependency | Version / Notes |
|---|---|
| Magento 2 | 2.4.x |
| PHP | ≥ 8.1 |
orangecat/core |
composer dependency |
Orangecat_Company |
must be enabled (provides mycompany table and CompanyRepositoryInterface) |
Orangecat_Prices |
must be enabled (provides the CalculatorPool injection point) |
Magento_ImportExport |
for CSV import support |
Magento_Catalog |
for product lookup during import and calculation |
Installation
This module ships as a git submodule of the main B2B SDK repository.
# From the repo root (if not already initialized) git submodule update --init app/code/Orangecat/PricesCompany # Inside the PHP container bin/magento module:enable Orangecat_PricesCompany bin/magento setup:upgrade bin/magento setup:di:compile bin/magento setup:static-content:deploy -f bin/magento cache:flush
What Gets Installed
Database Tables
pricescompany_item — Flat table storing per-company pricing rules.
| Column | Type | Notes |
|---|---|---|
item_id |
INT UNSIGNED AUTO_INCREMENT | Primary key |
company_id |
INT UNSIGNED NOT NULL | FK → mycompany.entity_id ON DELETE CASCADE |
sku |
VARCHAR(64) NOT NULL | Product SKU |
qty |
DECIMAL(12,4) NOT NULL DEFAULT 1.0000 | Minimum quantity threshold for this rule |
discount_type |
VARCHAR(32) NOT NULL DEFAULT fixed_price |
One of: fixed_price, fixed_amount, percentage |
amount |
DECIMAL(12,4) NOT NULL DEFAULT 0.0000 | Price, flat discount amount, or percentage value |
created_at |
TIMESTAMP | Set on insert |
updated_at |
TIMESTAMP | Updated automatically on every write |
Constraints and indexes:
- Unique:
(company_id, sku, qty)— only one rule per company/SKU/quantity combination - Index:
company_id(btree),sku(btree) - Foreign key:
company_id → mycompany.entity_idwithON DELETE CASCADE
EAV Attributes
None.
Data Patches
None. No default records are created on install.
Configuration
Settings live under Stores > Configuration > Prices > Company Custom Prices.
Company Custom Prices (prices/pricescompany)
| Label | Config path | Default | Description |
|---|---|---|---|
| Enable Custom Company Prices | prices/pricescompany/enabled |
Yes | Master switch. When disabled, the calculator always returns null and has no effect on pricing. |
| Price Resolution Strategy | prices/pricescompany/resolution_mode |
overwrite |
Controls what base_price is passed to the calculator. See Price Resolution Strategy. Only visible when enabled = Yes. |
prices/pricescompany/enabled
prices/pricescompany/resolution_mode
The resolution_mode field accepts two values: overwrite and stack.
Store Admin Guide
Navigation
Catalog > Manage Company Prices
This menu item opens the Company Prices index, a grid listing all companies from the mycompany table.
Company Prices Index
The grid shows every company with columns for company name and ID. From here you can:
- Click a company row (or the Edit action) to open that company's pricing rules.
- Use the grid's search and filter tools to locate a specific company.
Company Edit Screen — Pricing Rules Grid
Opening a company shows its item grid: all custom pricing rules for that company.
Available columns in the item grid:
| Column | Description |
|---|---|
| SKU | Product SKU the rule applies to |
| Qty | Minimum quantity threshold |
| Discount Type | Fixed Price Override, Fixed Amount Discount, or Percentage Discount |
| Amount | Price, deduction amount, or percentage depending on type |
Adding rules — "Add Products" button:
- Click Add Products. A product selector modal opens (standard Magento catalog grid).
- Select one or more products and click the Add button.
- A pricing dialog appears with one row per selected product, pre-filled with the catalog price.
- For each row, choose the Discount Type and enter the Amount. Optionally adjust the Qty threshold (defaults to 1).
- Click Confirm & Add. The rules are saved immediately via AJAX and the item grid reloads.
Inline editing:
Double-click any cell in the Qty, Amount, or Discount Type columns to edit it inline. Changes save automatically on blur.
Deleting rules:
- Click the Delete action on a single row.
- Select multiple rows and use Actions > Delete for mass deletion.
CSV Import
Navigate to System > Import, select entity type Company Custom Prices, and upload a CSV file.
Required CSV columns: company_id, sku, qty, discount_type, amount.
A sample file is available at Files/Sample/pricescompany_item.csv:
company_id,sku,qty,discount_type,amount 1,24-MB01,1,percentage,15 1,24-MB01,10,percentage,25 2,24-MB01,1,fixed_price,25.99 2,24-MB04,1,fixed_amount,5
Supported behaviors: Add/Update (append), Replace, Delete.
Validation checks:
company_idmust exist in themycompanytable.skumust exist in the product catalog.discount_typemust be one offixed_price,fixed_amount,percentage.company_id,sku,qty, andamountare all required.
Developer Guide
Module Structure
PricesCompany/
├── Api/
│ ├── Data/
│ │ ├── PricesCompanyItemInterface.php # Data object contract
│ │ └── PricesCompanyItemSearchResultsInterface.php
│ ├── PricesCompanyItemManagementInterface.php # Bulk ops + price calculation
│ └── PricesCompanyItemRepositoryInterface.php # CRUD repository contract
├── Block/Adminhtml/Company/Edit/
│ ├── BackButton.php
│ └── GenericButton.php
├── Controller/Adminhtml/
│ ├── Company/
│ │ ├── Index.php # Company listing grid
│ │ ├── Edit.php # Company edit (item grid) page
│ │ └── Save.php # Placeholder; actual saves happen via AJAX
│ └── Item/
│ ├── Add.php # AJAX: save products from the pricing dialog
│ ├── Delete.php # Delete single item
│ ├── GetProducts.php # AJAX: fetch product details for dialog pre-fill
│ ├── InlineEdit.php # AJAX: inline grid edits
│ └── MassDelete.php # Mass delete action
├── Files/Sample/
│ └── pricescompany_item.csv # Sample import file
├── Model/
│ ├── Calculator/
│ │ └── PricesCompanyCalculator.php # PriceCalculatorInterface impl — injected into CalculatorPool
│ ├── Config/Source/
│ │ ├── DiscountType.php # Option source for discount_type column
│ │ └── ResolutionMode.php # Option source for resolution_mode config
│ ├── Import/
│ │ ├── PricesCompanyItem.php # ImportExport entity class
│ │ └── PricesCompanyItem/Validator.php
│ ├── Item/DataProvider.php
│ ├── Company/DataProvider.php
│ ├── Config.php # Typed access to system config values
│ ├── PricesCompanyItem.php # Model (implements PricesCompanyItemInterface)
│ ├── PricesCompanyItemManagement.php # Management service (bulk ops, calculate)
│ └── PricesCompanyItemRepository.php
├── ResourceModel/PricesCompanyItem/
│ ├── Collection.php
│ └── Grid/Collection.php
├── Ui/Component/Listing/Column/
│ ├── CompanyActions.php
│ └── PricesCompanyItemActions.php
├── view/adminhtml/
│ ├── layout/
│ │ ├── pricescompany_company_edit.xml
│ │ └── pricescompany_company_index.xml
│ ├── ui_component/
│ │ ├── pricescompany_company_form.xml # Company edit form (wraps item grid)
│ │ ├── pricescompany_company_listing.xml # Company index grid
│ │ └── pricescompany_item_listing.xml # Item grid inside company edit
│ └── web/js/
│ └── add-products.js # Product selector + pricing dialog + AJAX save
└── etc/
├── acl.xml
├── adminhtml/
│ ├── menu.xml
│ ├── routes.xml
│ └── system.xml
├── config.xml
├── db_schema.xml
├── di.xml
├── import.xml
├── module.xml
└── webapi.xml
Key Classes
Service Contracts
| Interface | Implementation | Description |
|---|---|---|
Api\Data\PricesCompanyItemInterface |
Model\PricesCompanyItem |
Data object for a single pricing rule |
Api\PricesCompanyItemRepositoryInterface |
Model\PricesCompanyItemRepository |
CRUD: getById, save, delete, deleteById, getList |
Api\PricesCompanyItemManagementInterface |
Model\PricesCompanyItemManagement |
calculateFinalPrice, saveBulk, deleteByCompanyId, deleteBySku |
Calculator
Model\Calculator\PricesCompanyCalculator implements Orangecat\Prices\Api\PriceCalculatorInterface:
public function calculate(string $sku, float $qty, int $companyId, float $basePrice = 0.0): ?float
Selects the highest-matching quantity rule (qty <= $qty, ordered DESC) and applies the discount. Returns null if the module is disabled or no rule matches (the orchestrator then tries other calculators or falls back to the catalog price).
public function getTiers(string $sku, int $companyId, float $basePrice = 0.0): array
Returns all quantity tiers (qty > 1) as [['qty' => float, 'price' => float], ...] for storefront display.
Config
Model\Config provides typed access to the two system settings:
public function isEnabled($storeId = null): bool public function getResolutionMode($storeId = null): string // 'overwrite' | 'stack'
Observers
This module registers no observers.
Plugins
This module registers no plugins.
JS Components (Admin)
| File | Extends | Purpose |
|---|---|---|
view/adminhtml/web/js/add-products.js |
Magento_Ui/js/form/components/button |
Orchestrates the two-step product selection and pricing dialog flow. On click: reads selected product IDs from the UI component selections provider, fetches product details via AJAX (GetProducts), renders a pricing dialog with per-row discount configuration, then POSTs the result to Item/Add. On success, reloads the item listing. |
Email Templates
This module sends no email notifications.
ACL Resources
| Resource ID | Title | Location in ACL tree |
|---|---|---|
Orangecat_PricesCompany::manage |
Manage Company Prices | Admin > Catalog |
Adding Custom Logic
- New discount strategy: Add a constant to
Model\Config\Source\DiscountType, add the label totoOptionArray(), and add acasebranch inPricesCompanyCalculator::calculate()and::getTiers(). - Custom calculator priority: Adjust the
di.xmlCalculatorPoolinjection order or weight to control when this calculator runs relative to other pool members (e.g.,PricesList). - Post-save hook: Add an observer for
Magento\Framework\App\ResourceConnection-level events, or use a plugin onPricesCompanyItemRepository::save()to trigger downstream logic (cache invalidation, ERP sync) after a rule is saved.
REST API
All endpoints except /calculate require the ACL resource Orangecat_PricesCompany::manage. The /calculate endpoint is anonymous — apply token-based access control at the integration level if needed.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/V1/pricescompany/items/search |
Search pricing rules using standard SearchCriteria |
GET |
/V1/pricescompany/calculate |
Calculate the final price for a company/SKU/qty combination |
POST |
/V1/pricescompany/items/bulk |
Bulk upsert pricing rules (uses INSERT ON DUPLICATE KEY UPDATE) |
PUT |
/V1/pricescompany/items/bulk |
Same as POST bulk — idempotent upsert |
DELETE |
/V1/pricescompany/items/company/:companyId |
Delete all pricing rules for a company |
DELETE |
/V1/pricescompany/items/sku/:sku |
Delete all pricing rules for a SKU across all companies |
Authentication
Generate an integration token with the Orangecat_PricesCompany::manage ACL resource, then pass it as a Bearer token.
Examples
Search rules for company 1:
curl -X GET \ 'https://b2bsdk.test/rest/V1/pricescompany/items/search?searchCriteria[filterGroups][0][filters][0][field]=company_id&searchCriteria[filterGroups][0][filters][0][value]=1&searchCriteria[filterGroups][0][filters][0][conditionType]=eq' \ -H 'Authorization: Bearer <token>'
Calculate final price:
curl -X GET \ 'https://b2bsdk.test/rest/V1/pricescompany/calculate?companyId=1&sku=24-MB01&qty=10' \ -H 'Authorization: Bearer <token>'
Response: float — the resolved final price (falls back to catalog base price if no rule matches).
Bulk upsert:
curl -X POST \ 'https://b2bsdk.test/rest/V1/pricescompany/items/bulk' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{ "items": [ { "company_id": 1, "sku": "24-MB01", "qty": 1, "discount_type": "percentage", "amount": 15 }, { "company_id": 1, "sku": "24-MB01", "qty": 10, "discount_type": "percentage", "amount": 25 } ] }'
Response: true on success.
Delete all rules for company 2:
curl -X DELETE \ 'https://b2bsdk.test/rest/V1/pricescompany/items/company/2' \ -H 'Authorization: Bearer <token>'
Delete all rules for a SKU:
curl -X DELETE \ 'https://b2bsdk.test/rest/V1/pricescompany/items/sku/24-MB01' \ -H 'Authorization: Bearer <token>'
Frontend Routes Reference
This module has no frontend routes. All user-facing functionality (price display, tier prices) is delivered transparently through the catalog pricing layer managed by Orangecat_Prices.
DevOps & Integrator Notes
Deployment Checklist
# Enable module bin/magento module:enable Orangecat_PricesCompany # Apply schema and DI bin/magento setup:upgrade bin/magento setup:di:compile bin/magento setup:static-content:deploy -f # Flush cache bin/magento cache:flush
Integration Token Scope
Minimum ACL permission required for API access: Orangecat_PricesCompany::manage (under Magento_Catalog::catalog). The /calculate endpoint is anonymous and does not require a token, but should be protected at the API gateway level in production if price data is sensitive.
Disabling Without Uninstalling
bin/magento module:disable Orangecat_PricesCompany bin/magento setup:upgrade bin/magento cache:flush
When disabled, the PricesCompanyCalculator is still registered in the DI graph but Config::isEnabled() returns false, causing calculate() and getTiers() to return early without touching the database. No pricing rules are lost.
Data Integrity Notes
- Cascade delete: Deleting a company from
mycompanyautomatically removes all its pricing rules frompricescompany_itemvia theON DELETE CASCADEforeign key. There is no soft-delete — removals are permanent. - Unique constraint:
(company_id, sku, qty)is enforced at the database level. The bulk upsert usesINSERT ON DUPLICATE KEY UPDATE, so re-importing the same combination updates the existing rule rather than creating a duplicate. - SKU integrity: The module does not enforce a foreign key to the catalog. Deleting a product does not automatically remove its pricing rules. Orphan rules (rules referencing deleted SKUs) are harmless but should be cleaned up manually or via the
DELETE /V1/pricescompany/items/sku/:skuendpoint. - Module data persists after disable: The
pricescompany_itemtable and its data survivemodule:disable. To fully remove all data, runsetup:uninstallor drop the table manually after disabling.