Skip to content

Customer Pricing Resolvers

Hyvä POS is in closed beta

Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change - possibly in backwards-incompatible ways - before the general release. Want to take part? Sign up at hyva.io/pos.

A B2B customer walks up to the register. The cashier attaches them to the sale, and their negotiated prices apply automatically: the catalog price shows struck through, the contract price is what they pay. A customer pricing resolver is where those prices come from. The POS asks your module for customer-specific prices; you answer from whatever backend you own - a negotiated-contract table, an ERP price list, a custom B2B module.

There is a complete, runnable reference for this provider type: hyva-themes/magento2-hyva-pos-example-price-bridge, a deliberately small proof-of-concept built to be read top to bottom (see Example Bridges). This page walks through it. Install it beside the POS module to follow along:

composer require --dev hyva-themes/magento2-hyva-pos-example-price-bridge

The Interface

Implement Hyva\Pos\Api\CustomerPriceResolverInterface - one method:

public function resolve(
    int $customerId,
    array $skus,
    int $websiteId,
    ?string $storeView
): array;

Return \Hyva\Pos\Api\Data\Customer\CustomerPriceInterface[], keyed by SKU. Each row carries:

Field Meaning
sku The SKU this row prices
basePrice The catalog price, in the store view's display currency
customerPrice Your resolved price - must be strictly below basePrice
currency The store view's display currency code
source A stable token naming your resolver; the receipt uses it to label why the customer got this price
tiers Optional quantity ladder, or null

There is no quantity parameter on resolve(). Quantity-dependent pricing goes through the tier ladder instead: each \Hyva\Pos\Api\Data\Customer\CustomerPriceTierInterface row is a (qty, price) pair meaning "at this quantity or higher, the customer pays this per unit". You return the whole ladder once; the register applies the right tier locally as the cashier changes quantity. The flat customerPrice stays the smallest-qty price for callers that ignore tiers.

Three behaviors are fixed by the contract:

  • Every returned row is a genuine discount. customerPrice must be below basePrice. The POS renders basePrice struck through and customerPrice as the deal, so a row where customerPrice >= basePrice renders a markup as a discount. When you cannot beat the base price for a SKU, omit that SKU from the map.
  • No opinion means absent. A SKU you have no cheaper price for is simply missing from the returned map - never a null value, never a zero-price row.
  • An unknown customer resolves to [] without throwing. Any lookup failure degrades to an empty map.

Contract Rules

Answer the whole batch in one call. The pool passes you every requested SKU at once and never re-invokes a resolver per SKU. Do one backend fetch per customer, then loop the SKUs. One round trip is what keeps the register fast and offline-capable.

First resolver to answer a SKU wins. The pool (Hyva\Pos\Model\Customer\CustomerPriceResolverPool) walks resolvers in declared di.xml order and merges first-write-wins per SKU: once a resolver has priced a SKU, later resolvers cannot override it. The walk stops early once every requested SKU has a price. The core declares two built-ins, in this order: b2b_company (Adobe Commerce B2B company tiers), then customer_group (Magento native customer-group tier prices).

Use the display currency. Set basePrice, customerPrice, and currency in the store view's display currency, not base currency. The built-in resolvers read StoreManager::getStore()->getCurrentCurrencyCode(). The POS renders the two numbers side by side, so two currencies produce a wrong strike-through.

Never throw. The pool does not catch exceptions: a throw aborts the whole walk and denies the cashier prices a later resolver could have supplied. Log a lookup failure and skip that SKU, or return [] for a whole-batch failure.

At the register, the merged map arrives through POST /V1/pos/customer-prices. How often depends on the merchant's fetch mode (Terminal Defaults → Customer Prices): in cart mode you get one small batch per product added plus a full-cart batch when a customer is attached; in browse mode you additionally get one batch per catalog page the cashier views, typically 20 to 60 SKUs. Plan your backend fetch for the page-sized batch. The product grid and product detail show the base price struck through with the customer price next to it, the cart charges the customer price, tier ladders apply locally on quantity edits, and your source token labels the price on the receipt for the audit trail.

Build It

Module scaffolding - composer.json, registration.php, module.xml sequencing after Hyva_Pos, the registration guard for vendor-wrapping bridges - is the same for every bridge and covered in Building a Bridge. This section covers what is specific to a price resolver, using the example bridge's actual files.

The example does the three things every real bridge does: it has a price source (a data patch adds an example_discount_percent customer attribute, 0 to 100, editable on the admin customer form), a resolver that turns that source into price rows, and a di.xml entry that plugs the resolver into the pool. A real bridge changes only the source: instead of reading a customer attribute, you call your ERP or price-list service. Real ERP bridges delete the data patch entirely.

The Resolver

src/Model/PriceResolver/ExampleDiscountPercentPriceResolver.php reads the percentage once for the whole batch, then loops the SKUs:

src/Model/PriceResolver/ExampleDiscountPercentPriceResolver.php (trimmed)
class ExampleDiscountPercentPriceResolver implements CustomerPriceResolverInterface
{
    private const SOURCE_TOKEN = 'example_discount_percent';

    public function __construct(
        private readonly CustomerFactory $customerFactory,
        private readonly ProductRepositoryInterface $productRepository,
        private readonly StoreManagerInterface $storeManager,
        private readonly CustomerPriceInterfaceFactory $priceFactory,
        private readonly LoggerInterface $logger
    ) {
    }

    public function resolve(
        int $customerId,
        array $skus,
        int $websiteId,
        ?string $storeView
    ): array {
        if ($customerId <= 0 || $skus === []) {
            return [];
        }

        // One backend read for the whole batch: your ERP fetch goes here.
        $percent = $this->readDiscountPercent($customerId);
        if ($percent === null || $percent <= 0.0) {
            return [];
        }

        // Clamp to 0-100. A stray 150 would flip the sign.
        $percent = min($percent, 100.0);
        $factor = 1.0 - ($percent / 100.0);

        $currency = $this->resolveCurrency();
        $resolved = [];

        foreach ($skus as $sku) {
            $sku = (string) $sku;
            if ($sku === '') {
                continue;
            }

            try {
                $product = $this->productRepository->get($sku, false, null, true);
            } catch (NoSuchEntityException $e) {
                continue;
            } catch (\Throwable $e) {
                // Never throw: log and skip.
                $this->logger->warning('Hyva_PosExamplePriceBridge: product load failed', [
                    'sku' => $sku,
                    'exception' => $e->getMessage(),
                ]);
                continue;
            }

            $basePrice = (float) $product->getPrice();
            if ($basePrice <= 0.0) {
                continue;
            }

            $customerPrice = round($basePrice * $factor, 4);
            // Only genuine discounts leave the resolver.
            if ($customerPrice <= 0.0 || $customerPrice >= $basePrice) {
                continue;
            }

            $price = $this->priceFactory->create();
            $price->setSku($sku)
                ->setBasePrice($basePrice)
                ->setCustomerPrice($customerPrice)
                ->setCurrency($currency)
                ->setSource(self::SOURCE_TOKEN)
                ->setTiers(null); // Flat percentage: no qty ladder.
            $resolved[$sku] = $price;
        }

        return $resolved;
    }
}

The two trimmed helpers: readDiscountPercent() loads the customer through CustomerFactory and returns the attribute value, or null on any failure so the batch degrades to [] - this is the method a real bridge replaces wholesale with its API client call. resolveCurrency() wraps getCurrentCurrencyCode() in a try/catch.

When you adapt this for an ERP, keep the batch shape - one request per resolve() call, not one per SKU - and build rows only for SKUs your ERP priced below base. The example bridge's README has a worked ERP-shaped variant of this loop under "Adapting for a real ERP".

The Wiring

src/etc/di.xml adds the resolver to the pool. The argument name is resolvers:

src/etc/di.xml
<type name="Hyva\Pos\Model\Customer\CustomerPriceResolverPool">
    <arguments>
        <argument name="resolvers" xsi:type="array">
            <item name="example_discount_percent" xsi:type="object">Hyva\PosExamplePriceBridge\Model\PriceResolver\ExampleDiscountPercentPriceResolver</item>
        </argument>
    </arguments>
</type>

Use xsi:type="object" for this pool. Unlike the credit provider pool, CustomerPriceResolverPool does not resolve string entries lazily - it skips anything that is not already a CustomerPriceResolverInterface instance, so a string entry is silently ignored. If your resolver constructor-injects a third-party vendor's classes, the registration guard from Building a Bridge keeps the object entry safe: when the vendor is absent, the whole bridge deregisters and the entry never instantiates.

Position in the array decides precedence, and a plain <item> cannot reorder it: the array is name-keyed and later files append. The example is therefore declared after the built-ins and only wins SKUs they had no opinion on. To make your ERP price list authoritative, redeclare the whole resolvers argument in your module, listing your resolver first:

src/etc/di.xml (authoritative variant)
<type name="Hyva\Pos\Model\Customer\CustomerPriceResolverPool">
    <arguments>
        <argument name="resolvers" xsi:type="array">
            <item name="my_erp" xsi:type="object">My\Module\Model\ErpPriceResolver</item>
            <item name="b2b_company" xsi:type="object">Hyva\Pos\Model\Customer\PriceResolver\B2BCompanyPriceResolver</item>
            <item name="customer_group" xsi:type="object">Hyva\Pos\Model\Customer\PriceResolver\CustomerGroupPriceResolver</item>
        </argument>
    </arguments>
</type>

Verify at the Register

After setup:upgrade, walk the feature end to end:

  1. In the admin, open a test customer and set Example Discount Percent to 20 (or assign a contract price in your own backend). Save.
  2. In the POS app, start a sale and attach that customer.
  3. Browse the product grid: the base price shows struck through with the cheaper customer price next to it. The product detail shows the same pair, and the cart charges the customer price.
  4. If your resolver returns a tier ladder, change the line quantity at the register: the applicable tier applies immediately, with no new network fetch.

Prove It

The core ships an executable conformance kit: extend Hyva\Pos\TestFramework\Conformance\AbstractCustomerPriceResolverConformanceTest in your bridge's unit suite. It pins the two behaviors the POS depends on: an unknown customer resolves to [] without throwing, and every returned row is a genuine discount (customerPrice < basePrice).

Two hooks wire it to your mocks. createProvider() (required) builds the resolver so the unknown-customer case is reachable; arrangePositiveCase() (override to opt in) arranges a funded, cheaper-than-base result and returns the resolved map - or null, in which case the discount scenario skips instead of failing. The example bridge's subclass is the template:

tests/unit/ExampleDiscountPercentPriceResolverConformanceTest.php (trimmed)
class ExampleDiscountPercentPriceResolverConformanceTest extends AbstractCustomerPriceResolverConformanceTest
{
    protected function createProvider(): CustomerPriceResolverInterface
    {
        // Unknown-customer default: the loaded Customer has no id, so
        // resolve() returns [] without a positive-case arrangement.
        return new ExampleDiscountPercentPriceResolver(
            $this->makeCustomerFactory(hasId: false, discountPercent: null),
            /* product repository, store manager, price factory, logger mocks */
        );
    }

    protected function arrangePositiveCase(CustomerPriceResolverInterface $resolver): ?array
    {
        // Build a fresh resolver whose customer carries a 20% discount and
        // whose product has a positive base price, then return the map.
        $funded = new ExampleDiscountPercentPriceResolver(/* funded mocks */);

        return $funded->resolve(10, ['SKU-1'], 1, null);
    }
}

Run it from your module root with ../../vendor/bin/phpunit. The example's tests/bootstrap.php finds the root Magento vendor/autoload.php automatically (MAGENTO_VENDOR_PATH overrides it); copy it, and add eval() stubs for your vendor's interfaces if you have any, so PHPUnit can mock them without the vendor installed.

Gotchas

  • Do not add a quantity parameter. Re-resolving on every quantity edit round-trips to your ERP on every keystroke, which stalls the register and breaks offline use. Return the whole tier ladder once via setTiers() and let the register pick the tier locally. The core CustomerGroupPriceResolver builds a real ladder from Magento tier prices; read it when you need thresholds.
  • This contract is customer intent, not catalog intent. A resolver supplies prices that exist because of who the customer is - contracts, ERP price lists, company tiers. Catalog-wide promotions (special prices, catalog rules) already reach the register through the catalog; serving them again from a resolver double-labels them as customer deals.
  • xsi:type="object", not string. This pool skips non-instance entries silently - a string entry produces no error and no prices, which looks exactly like your resolver never matching.
  • Watch the rounding edge. After rounding, a tiny discount can land equal to base. The example guards with customerPrice >= $basePrice → omit; keep that guard when you adapt it, or the conformance kit will catch it for you.