orangecat/module-prices-company

Per-company, per-SKU, quantity-aware custom pricing rules for Magento 2 B2B

Maintainers

Package info

github.com/olivertar/m2_pricescompany

Type:magento2-module

pkg:composer/orangecat/module-prices-company

Statistics

Installs: 20

Dependents: 1

Suggesters: 2

Stars: 1

Open Issues: 0

0.0.2 2026-06-07 18:28 UTC

This package is auto-updated.

Last update: 2026-06-09 06:12:27 UTC


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

  1. Overview
  2. Theme Compatibility
  3. Requirements
  4. Installation
  5. What Gets Installed
  6. Configuration
  7. Store Admin Guide
  8. Developer Guide
  9. REST API
  10. Frontend Routes Reference
  11. 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_Prices orchestrator 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_id with ON 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:

  1. Click Add Products. A product selector modal opens (standard Magento catalog grid).
  2. Select one or more products and click the Add button.
  3. A pricing dialog appears with one row per selected product, pre-filled with the catalog price.
  4. For each row, choose the Discount Type and enter the Amount. Optionally adjust the Qty threshold (defaults to 1).
  5. 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_id must exist in the mycompany table.
  • sku must exist in the product catalog.
  • discount_type must be one of fixed_price, fixed_amount, percentage.
  • company_id, sku, qty, and amount are 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 to toOptionArray(), and add a case branch in PricesCompanyCalculator::calculate() and ::getTiers().
  • Custom calculator priority: Adjust the di.xml CalculatorPool injection 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 on PricesCompanyItemRepository::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 mycompany automatically removes all its pricing rules from pricescompany_item via the ON DELETE CASCADE foreign key. There is no soft-delete — removals are permanent.
  • Unique constraint: (company_id, sku, qty) is enforced at the database level. The bulk upsert uses INSERT 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/:sku endpoint.
  • Module data persists after disable: The pricescompany_item table and its data survive module:disable. To fully remove all data, run setup:uninstall or drop the table manually after disabling.