Negotiable Quotes in Magento 2: The B2B Negotiation Workflow Explained
AI generated
M2
di.xml
Magento 2 · B2B Suite
Negotiable Quotes
The B2B negotiation workflow from request to order

Negotiable Quotes turn the Magento cart into a multi-step negotiation process between a company customer and sales, with its own status machine, permissions and data model. Extending the feature means understanding how a quote moves through NegotiableQuoteManagement and where custom status transitions and notifications hook in cleanly.

12 min read Negotiable Quotes · B2B Suite Magento 2.4.x Commerce

1. Placing Negotiable Quotes inside the B2B module

Negotiable Quotes is part of Adobe Commerce's B2B Suite and extends the regular cart with a multi-step negotiation process between a company customer and sales. Technically the feature builds on the existing quote entity, but adds its own table for negotiation data, so an offer is not just a cart snapshot but a state with its own history and lifecycle.

The feature only becomes visible through the company structure: only customers with an active company assignment and the right permission can submit a request as a Negotiable Quote. Anyone building extensions for the negotiation process needs to know three core building blocks: the data model around the negotiable_quote table, the status machine in Magento\NegotiableQuote\Model\Quote\Status, and the service contract NegotiableQuoteManagementInterface, through which almost every status change flows.

One important difference from a normal checkout is that a customer placing a Negotiable Quote does not order directly, but submits a request that can be modified internally before an order ever exists. That intermediate stage is the actual core of the feature, and the reason it needs its own data model rather than a simple checkout extension.

2. The data model behind the negotiation

A Negotiable Quote is not a standalone entity in the classic sense, but an extension of the sales quote through the negotiable_quote table. That table holds quote_id as a foreign key plus negotiation-specific fields such as status, negotiated_price_type, negotiated_price_value, shipping_price, expiration_period and quote_name. The actual cart with its line items still lives fully in quote and quote_item; only the negotiation metadata sits separately.

That separation has a practical reason: line-level price overrides are not stored in negotiable_quote, but appended to quote_item through additional columns such as original_custom_price_amount and original_discount_amount, so the original catalog price stays available next to the negotiated one. Anyone building custom reports or exports around negotiated prices has to join both tables, not just look at negotiable_quote in isolation.

3. The status machine: from created to ordered

The core of the negotiation workflow is a clearly defined status machine, whose constants live in Magento\NegotiableQuote\Model\Quote\Status. A new quote starts in the created status; once a company customer submits a request it moves to submitted_by_customer, and once sales responds it moves to processing_by_admin or submitted_by_admin. At the end sits either ordered, when the customer accepts the final offer, or declined and expired when no agreement is reached.

What matters for custom extensions is that not every status is reachable from every other status. The allowed transitions are hardwired into the management logic, and attempting to set a status that is not allowed from the current position throws a LocalizedException. Custom automation, such as an automatic status change once a deadline passes, has to respect the same transition rules as the standard interface.


<?php

declare(strict_types=1);

namespace Mironsoft\NegotiableQuoteExtension\Model;

use Magento\NegotiableQuote\Model\Quote\Status;

/**
 * Resolves the allowed follow-up statuses for a given negotiation status.
 */
class AllowedTransitions
{
    private const TRANSITIONS = [
        Status::STATUS_CREATED => [Status::STATUS_SUBMITTED_BY_CUSTOMER],
        Status::STATUS_SUBMITTED_BY_CUSTOMER => [
            Status::STATUS_PROCESSING_BY_ADMIN,
            Status::STATUS_DECLINED,
        ],
        Status::STATUS_PROCESSING_BY_ADMIN => [
            Status::STATUS_SUBMITTED_BY_ADMIN,
            Status::STATUS_DECLINED,
        ],
        Status::STATUS_SUBMITTED_BY_ADMIN => [
            Status::STATUS_ORDERED,
            Status::STATUS_SUBMITTED_BY_CUSTOMER,
            Status::STATUS_EXPIRED,
        ],
    ];

    /**
     * Returns the allowed follow-up statuses for the given current status.
     *
     * @param string $currentStatus
     * @return string[]
     */
    public function getAllowedNextStatuses(string $currentStatus): array
    {
        return self::TRANSITIONS[$currentStatus] ?? [];
    }
}

4. Roles and permissions in the negotiation process

Who can trigger which status change depends on two permission layers: the classic Magento ACL on the admin side, and company roles on the customer side. On the admin side, the Magento_NegotiableQuote::manage resource controls whether a sales rep can access Negotiable Quote management at all, while finer-grained resources guard individual actions such as setting a final price.

On the customer side, the company role determines whether a company member can create a Negotiable Quote, comment on it, or place the final order at all. A pure buyer role can often submit requests but not trigger the final order, which remains reserved for an approving role. This split follows the same permission model as the general company structure, but for Negotiable Quotes is extended with its own resource, granted separately from general ordering rights.

5. Driving negotiations through the GraphQL API

For storefront integrations beyond the default interface, the Magento_NegotiableQuoteGraphQl module exposes its own mutations. createNegotiableQuote starts a new negotiation from an existing cart, updateNegotiableQuote adjusts comments, prices or the status, and the negotiableQuote query returns the current negotiation state along with its history.

For custom frontend extensions, such as a dedicated negotiation dashboard, it matters that the GraphQL layer goes through the same permission checks as the storefront UI. A customer without the right company role gets the same authorization error attempting a status change through the API as through the UI, which protects custom frontends from inconsistent behavior while also meaning role checks do not need to be duplicated on the frontend side.


mutation UpdateNegotiableQuoteStatus {
  updateNegotiableQuote(
    input: {
      quote_uid: "MTIz"
      comment: { comment: "Discount adjusted to 12%, requesting approval." }
      status: SUBMITTED_BY_ADMIN
    }
  ) {
    quote {
      uid
      name
      status
      negotiated_price {
        value
      }
    }
  }
}

6. Adding custom status transitions via plugin

To run additional logic on a status change, for example an internal approval once a discount crosses a threshold, a plugin on NegotiableQuoteManagementInterface is the right hook. An around plugin can check before the actual status change whether the conditions for the transition are met, and throw a custom exception that blocks the change before the default logic even runs.

It matters that such a plugin still calls the original method cleanly whenever the custom check passes, so Magento's core logic stays intact and later updates to the B2B module do not collide with skipped steps. For purely observational purposes, such as logging, an after plugin is the more robust choice because it cannot alter the core logic.


<!-- app/code/Mironsoft/NegotiableQuoteExtension/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\NegotiableQuote\Api\NegotiableQuoteManagementInterface">
        <plugin name="mironsoft_approval_threshold"
                type="Mironsoft\NegotiableQuoteExtension\Plugin\ApprovalThresholdPlugin"
                sortOrder="10"/>
    </type>
</config>

7. Adding custom notifications on status changes

The default Negotiable Quote notifications cover the common cases, such as an email to the customer when sales submits a counter-offer. For project-specific channels, such as a message to the sales team in an internal tool or an entry in a CRM, the default logic falls short, and the cleanest way to add it is an after plugin on the same management method.

The advantage of a plugin over a custom observer is that both the new and the previous status are known within the same method call, without having to reload the previous state from the database separately. For asynchronous delivery, for example when the external service is occasionally unreachable, it is worth decoupling the actual notification through a message queue instead of running it synchronously in the plugin and delaying the entire status change.

8. Guarding price floors and negotiation room

Without additional safeguards, a sales rep can in principle enter any price into a counter-offer, which quickly leads to inconsistent discounting in larger teams. A common extension is a custom validation that checks negotiated_price_value against a stored floor before the status change to submitted_by_admin is even allowed.

This check can live cleanly inside the same around plugin that already guards status transitions, but should stay logically separate: a price floor violation is a different failure case than an invalid status transition and deserves its own, clearly worded error message, so sales immediately understands why the approval failed instead of getting a generic exception.

9. Operations: expiry, cron and data volume

Negotiable Quotes that are not answered within the configured deadline automatically move to the expired status, driven by a cron job that regularly scans for expired quotes. On stores with many parallel negotiations it is worth checking the indexing on the negotiable_quote table, particularly on status and expiration_period, so the cron run stays performant as history grows.

Since declined and expired quotes are not automatically deleted by default, the table grows continuously over time. For stores with a high negotiation volume, a custom cleanup routine that archives or removes old, closed negotiations after a defined retention period is worth building, rather than leaving them in the production table indefinitely and slowing down later reports and admin grids.

Status Triggered by Typical action Possible follow-up statuses
created Customer creates request Cart converted into a negotiation submitted_by_customer
submitted_by_customer Customer submits request Sales reviews the request processing_by_admin, declined
processing_by_admin Sales works on the offer Prices and terms adjusted submitted_by_admin, declined
submitted_by_admin Sales sends counter-offer Customer reviews the offer ordered, submitted_by_customer, expired
ordered Customer confirms the offer Order gets created Final state
expired Deadline passed without response Negotiation closes automatically Final state

Mironsoft

Magento development, module consulting, and system architecture

A Magento project that needs a second opinion or experienced execution?

We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.

Architecture Consulting

Have module and system architecture thought through properly before you build.

Custom Module Development

Build custom Magento modules cleanly, following best practices.

Code Review & Audit

Have existing modules reviewed for performance, security, and maintainability.

10. Summary

Negotiable Quotes Workflow

Data model

negotiable_quote extends the sales quote with negotiation metadata, price overrides are additionally stored on quote_item.

Status machine

Fixed transitions from created through submitted_by_customer and submitted_by_admin to ordered, declined or expired.

Extensibility

Plugins on NegotiableQuoteManagementInterface for custom approvals, price floors and notifications.

Operations

Cron closes expired quotes automatically, a custom cleanup routine for old history is recommended.

11. FAQ: Negotiable Quotes Workflow

1What distinguishes a Negotiable Quote from a normal cart?
A Negotiable Quote does not place an order directly but starts a multi-step negotiation process with its own status machine, where the customer and sales align on prices and terms before an order ever exists.
2Which table stores the negotiation data?
The negotiable_quote table holds the negotiation metadata for a quote, while line-level price overrides are stored through additional columns on quote_item.
3Can any company customer create a Negotiable Quote?
No, that requires an active company assignment plus a company role with the matching permission, otherwise the feature is not even visible in the storefront.
4How can an extra approval step be added before a status change?
The cleanest way is an around plugin on NegotiableQuoteManagementInterface that checks custom conditions before the actual status change and throws a custom exception on violation.
5What happens when a negotiation deadline passes without a response?
A cron job regularly scans for expired quotes and automatically sets them to the expired status, without requiring manual intervention.
6Can the negotiation process be driven through GraphQL?
Yes, the Magento_NegotiableQuoteGraphQl module exposes mutations such as createNegotiableQuote and updateNegotiableQuote plus the negotiableQuote query for custom frontend integrations.
7How can custom notification channels be added on status changes?
Through an after plugin on the same management method, which knows both the old and new status, ideally with asynchronous delivery through a message queue for external services.
8Are declined or expired quotes deleted automatically?
No, they remain in the table by default. For high negotiation volume a custom cleanup routine with a defined retention period is recommended.
9How can unrealistically low prices in a counter-offer be prevented?
Through a custom validation in the same plugin that checks negotiated_price_value against a stored floor before the status change to submitted_by_admin is allowed.
10Is every status transition possible from any given status?
No, the allowed transitions are fixed. An invalid status change throws a LocalizedException, which custom automation must respect as well.