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.
Table of Contents
- 1. Placing Negotiable Quotes inside the B2B module
- 2. The data model behind the negotiation
- 3. The status machine: from created to ordered
- 4. Roles and permissions in the negotiation process
- 5. Driving negotiations through the GraphQL API
- 6. Adding custom status transitions via plugin
- 7. Adding custom notifications on status changes
- 8. Guarding price floors and negotiation room
- 9. Operations: expiry, cron and data volume
- 10. Summary
- 11. FAQ
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.