RFC: Arbitrary line items when creating an order in the admin
Target repo: mahocommerce/maho
Type: Design proposal (seeking direction before a PR)
Status: Proof-of-concept running in a project's dev/test shop as a local module; not yet generalized for core.
Disclaimer
Like I said in the discord, I'm not sure if that makes sense to have in the core or what the implications are for refund, cancel, etc.
Summary
Allow adding a line item with a free name, a price, and a chosen tax class to an
order created in the admin (Sales → Orders → Create New Order) - for cases
without a catalog product, such as one-off services, surcharges, deposits, or
made-to-order positions.
Currently the admin order create only adds existing catalog products. An item's
price can be overridden ("Custom Price"), but a line with its own name and tax and
no catalog product cannot be added.
Motivation
The current workaround is to create a catalog product per case. Two drawbacks: it
adds visible entries to the catalog, and the tax is only correct if each product
carries the right tax class. The proposed action covers these cases with a fixed
set of hidden helper products (one per tax class) instead of one visible product
per case.
Core constraints
These shape the design:
- Quote/order items require a
product_id (FK; getProduct() is assumed across
totals, tax, shipment, credit memo, renderers, third-party code). A
product-less item is not feasible without invasive changes.
- Tax is read from the product's tax class, not the item:
Mage_Tax_Model_Sales_Total_Quote_Tax calls
setProductClassId($item->getProduct()->getTaxClassId()) (Tax.php:447,720,1020).
Varying a line's tax means varying its product.
Quote_Item::setProduct() overwrites the item name with the product name
(Quote/Item.php:408) on every quote load and on "Update Items and Qty's"; a
custom name does not survive a reload.
representProduct() / compareOptions() ignore info_buyRequest
($_notRepresentOptions, Quote/Item.php:476). Two items of the same product
merge unless they carry a distinguishing real option.
Proposed design
The feature extends the existing admin order-create flow, so it modifies
Mage_Adminhtml and Mage_Sales directly rather than adding a module (per Maho's
guidance to edit core directly for changes to existing features).
-
UI: an inline "Add custom item" form (name, price, tax-class dropdown over all
product tax classes) in the order-create items area - a new block under
Mage_Adminhtml_Block_Sales_Order_Create_Items_* plus template, wired into the
existing sales.xml handles. It posts through the native item[<productId>][...]
add path (Mage_Adminhtml_Model_Sales_Order_Create::addProducts), so there is no
custom controller.
-
Backing products: one hidden simple product per tax class, created on first use
(SKU maho-custom-item-<taxClassId>) and reused. simple, not virtual: a
virtual-only order is treated as virtual and has no shipping address. Weight 0, no
stock management, not visible individually.
-
Source of truth: the buyRequest (custom, name, price, token). The line
is derived from it, so it survives placement, reorder, and order edit
(initFromOrderItem re-adds via the stored buyRequest).
-
Two observers (attribute methods on a core class):
sales_quote_add_item: set name, custom_price (interpreted per store tax
config), no_discount, and add a unique item option (token) to prevent
merging of two custom lines with the same tax class.
sales_quote_collect_totals_before: re-apply the name from the buyRequest
(counters the setProduct() reset). The stored custom_price already survives
reloads, so only the name needs this.
Events are used so the behavior applies across admin add, order edit and reorder
without inlining at each call site, and to avoid changing setProduct() or the
totals flow for the name reset.
-
canApplyCustomPrice() in Mage_Adminhtml_Block_Sales_Order_Create_Items_Grid:
return false for these items to hide the "Custom Price" checkbox (the entered
value is the price; unchecking it would zero the line). The price field still
submits the stored value, so "Update Items and Qty's" does not reset it.
The line is a fully-formed order item, so invoice, shipment, credit memo, cancel,
reorder, order edit and PDFs work without further changes.
Price/tax: the value is applied as custom_price, interpreted per the store's
tax/calculation/price_includes_tax. In a gross-price store the merchant enters a
gross amount and VAT is derived from the chosen tax class, including 0%/net for
export where the tax rules define it.
Alternatives considered
- Single placeholder + per-item tax class: tax is read from the product, the
product instance is shared per product_id, and setProduct() resets the class
on reload, so a per-item class is unreliable. One product per tax class is
required.
- A dedicated product type (
custom_item): does not remove the per-tax-class
product requirement or the name/merge handling; it would make the placeholders
self-documenting at the cost of a type model, price model and config. Optional,
not required.
- A product-less quote item: rejected as too invasive given the
getProduct()/FK
assumptions.
Known limitations
- No negative amounts:
_parseCustomPrice clamps to >= 0. Credits use a credit
memo.
- The line appears as a row in shipment/packing slip (
simple, weight 0).
- The price is set from the entry (buyRequest); the grid does not expose it as an
adjustable "Custom Price". Changing name or price means remove + re-add.
Open questions
- Are hidden placeholder products (one per tax class, created on first use)
acceptable in core? Preferred creation strategy (lazy vs. on install + observer
for new classes)?
- Where in
Mage_Adminhtml / Mage_Sales should the helper and observer methods
sit?
- Introduce a dedicated product type, or keep
simple + flags?
- Gross/net entry: follow store
price_includes_tax, or expose a toggle?
- Negative amounts: out of scope, or allow as an adjustment line?
Implementation sketch
Add via the native path; the JS posts the placeholder product id for the chosen tax
class plus the custom fields:
// inline in the order-create items area; `order` is the AdminOrder instance
const p = {};
p[`item[${placeholderId}][qty]`] = 1;
p[`item[${placeholderId}][custom]`] = 1;
p[`item[${placeholderId}][name]`] = name;
p[`item[${placeholderId}][price]`] = grossPrice;
p[`item[${placeholderId}][token]`] = uniqueToken;
order.loadArea(['items', 'shipping_method', 'totals', 'billing_method'], true, p);
Server side:
#[Maho\Config\Observer('sales_quote_add_item')]
public function applyCustomData(\Maho\Event\Observer $observer): void
{
$item = $observer->getQuoteItem();
$req = $item?->getBuyRequest();
if (!$req || !$req->getData('custom')) {
return;
}
$price = (float) Mage::app()->getLocale()->getNumber((string) $req->getData('price'));
$item->setName(trim((string) $req->getData('name')))
->setCustomPrice(max(0.0, $price))
->setOriginalCustomPrice(max(0.0, $price))
->setNoDiscount(1);
// representProduct() ignores info_buyRequest; without a real option two
// custom lines of the same tax class would merge.
$token = (string) $req->getData('token');
if ($token !== '' && !$item->getOptionByCode('custom_token')) {
$item->addOption(['product_id' => $item->getProductId(), 'code' => 'custom_token', 'value' => $token]);
}
}
#[Maho\Config\Observer('sales_quote_collect_totals_before')]
public function enforceName(\Maho\Event\Observer $observer): void
{
foreach ($observer->getQuote()?->getAllItems() ?? [] as $item) {
$req = $item->getBuyRequest();
if ($req && $req->getData('custom') && ($name = trim((string) $req->getData('name'))) !== '') {
$item->setName($name);
}
}
}
// Mage_Adminhtml_Block_Sales_Order_Create_Items_Grid
public function canApplyCustomPrice($item)
{
$req = $item->getBuyRequest();
return ($req && $req->getData('custom')) ? false : parent::canApplyCustomPrice($item);
}
Lazy placeholder resolver (one simple product per tax class, reused):
public function getPlaceholderId(int $taxClassId): int
{
$sku = 'maho-custom-item-' . $taxClassId;
if ($id = Mage::getModel('catalog/product')->getIdBySku($sku)) {
return (int) $id;
}
$product = Mage::getModel('catalog/product')
->setStoreId(Mage_Core_Model_App::ADMIN_STORE_ID)
->setWebsiteIds(array_keys(Mage::app()->getWebsites()))
->setAttributeSetId(/* default product set */)
->setTypeId(Mage_Catalog_Model_Product_Type::TYPE_SIMPLE)
->setSku($sku)->setName('Custom order item (' . $taxClassId . ')')
->setStatus(Mage_Catalog_Model_Product_Status::STATUS_ENABLED)
->setVisibility(Mage_Catalog_Model_Product_Visibility::VISIBILITY_NOT_VISIBLE)
->setTaxClassId($taxClassId)->setPrice(0)->setWeight(0)
->setStockData(['use_config_manage_stock' => 0, 'manage_stock' => 0, 'is_in_stock' => 1]);
$product->save();
return (int) $product->getId();
}
Test plan (Pest, MahoBackendTestCase)
- Add a custom item → one order line with the entered name; correct gross/net split
for the chosen tax class; export country → 0%/net.
- Two custom items with the same tax class → two distinct lines (no merge).
- Name survives
collectTotals() / "Update Items and Qty's" / reload.
- Order with only a custom item is not virtual (shipping address required).
- Invoice + credit memo + cancel produce correct totals.
Reference
A proof-of-concept of this design runs in a project's dev/test shop as a local
module (shop code cannot live in the vendor core; the upstream version integrates
into core instead). It was validated end-to-end: tax (gross input, export 0%,
tax-free class), no merge, name persistence, shipping address, and the
credit-memo/cancel paths.
RFC: Arbitrary line items when creating an order in the admin
Target repo:
mahocommerce/mahoType: Design proposal (seeking direction before a PR)
Status: Proof-of-concept running in a project's dev/test shop as a local module; not yet generalized for core.
Disclaimer
Like I said in the discord, I'm not sure if that makes sense to have in the core or what the implications are for refund, cancel, etc.
Summary
Allow adding a line item with a free name, a price, and a chosen tax class to an
order created in the admin (Sales → Orders → Create New Order) - for cases
without a catalog product, such as one-off services, surcharges, deposits, or
made-to-order positions.
Currently the admin order create only adds existing catalog products. An item's
price can be overridden ("Custom Price"), but a line with its own name and tax and
no catalog product cannot be added.
Motivation
The current workaround is to create a catalog product per case. Two drawbacks: it
adds visible entries to the catalog, and the tax is only correct if each product
carries the right tax class. The proposed action covers these cases with a fixed
set of hidden helper products (one per tax class) instead of one visible product
per case.
Core constraints
These shape the design:
product_id(FK;getProduct()is assumed acrosstotals, tax, shipment, credit memo, renderers, third-party code). A
product-less item is not feasible without invasive changes.
Mage_Tax_Model_Sales_Total_Quote_TaxcallssetProductClassId($item->getProduct()->getTaxClassId())(Tax.php:447,720,1020).Varying a line's tax means varying its product.
Quote_Item::setProduct()overwrites the item name with the product name(
Quote/Item.php:408) on every quote load and on "Update Items and Qty's"; acustom name does not survive a reload.
representProduct()/compareOptions()ignoreinfo_buyRequest(
$_notRepresentOptions,Quote/Item.php:476). Two items of the same productmerge unless they carry a distinguishing real option.
Proposed design
The feature extends the existing admin order-create flow, so it modifies
Mage_AdminhtmlandMage_Salesdirectly rather than adding a module (per Maho'sguidance to edit core directly for changes to existing features).
UI: an inline "Add custom item" form (name, price, tax-class dropdown over all
product tax classes) in the order-create items area - a new block under
Mage_Adminhtml_Block_Sales_Order_Create_Items_*plus template, wired into theexisting
sales.xmlhandles. It posts through the nativeitem[<productId>][...]add path (
Mage_Adminhtml_Model_Sales_Order_Create::addProducts), so there is nocustom controller.
Backing products: one hidden
simpleproduct per tax class, created on first use(SKU
maho-custom-item-<taxClassId>) and reused.simple, notvirtual: avirtual-only order is treated as virtual and has no shipping address. Weight 0, no
stock management, not visible individually.
Source of truth: the
buyRequest(custom,name,price,token). The lineis derived from it, so it survives placement, reorder, and order edit
(
initFromOrderItemre-adds via the stored buyRequest).Two observers (attribute methods on a core class):
sales_quote_add_item: set name,custom_price(interpreted per store taxconfig),
no_discount, and add a unique item option (token) to preventmerging of two custom lines with the same tax class.
sales_quote_collect_totals_before: re-apply the name from the buyRequest(counters the
setProduct()reset). The storedcustom_pricealready survivesreloads, so only the name needs this.
Events are used so the behavior applies across admin add, order edit and reorder
without inlining at each call site, and to avoid changing
setProduct()or thetotals flow for the name reset.
canApplyCustomPrice()inMage_Adminhtml_Block_Sales_Order_Create_Items_Grid:return
falsefor these items to hide the "Custom Price" checkbox (the enteredvalue is the price; unchecking it would zero the line). The price field still
submits the stored value, so "Update Items and Qty's" does not reset it.
The line is a fully-formed order item, so invoice, shipment, credit memo, cancel,
reorder, order edit and PDFs work without further changes.
Price/tax: the value is applied as
custom_price, interpreted per the store'stax/calculation/price_includes_tax. In a gross-price store the merchant enters agross amount and VAT is derived from the chosen tax class, including 0%/net for
export where the tax rules define it.
Alternatives considered
product instance is shared per
product_id, andsetProduct()resets the classon reload, so a per-item class is unreliable. One product per tax class is
required.
custom_item): does not remove the per-tax-classproduct requirement or the name/merge handling; it would make the placeholders
self-documenting at the cost of a type model, price model and config. Optional,
not required.
getProduct()/FKassumptions.
Known limitations
_parseCustomPriceclamps to>= 0. Credits use a creditmemo.
simple, weight 0).adjustable "Custom Price". Changing name or price means remove + re-add.
Open questions
acceptable in core? Preferred creation strategy (lazy vs. on install + observer
for new classes)?
Mage_Adminhtml/Mage_Salesshould the helper and observer methodssit?
simple+ flags?price_includes_tax, or expose a toggle?Implementation sketch
Add via the native path; the JS posts the placeholder product id for the chosen tax
class plus the custom fields:
Server side:
Lazy placeholder resolver (one simple product per tax class, reused):
Test plan (Pest,
MahoBackendTestCase)for the chosen tax class; export country → 0%/net.
collectTotals()/ "Update Items and Qty's" / reload.Reference
A proof-of-concept of this design runs in a project's dev/test shop as a local
module (shop code cannot live in the vendor core; the upstream version integrates
into core instead). It was validated end-to-end: tax (gross input, export 0%,
tax-free class), no merge, name persistence, shipping address, and the
credit-memo/cancel paths.