Skip to content

Idea: Arbitrary line items when creating an order in the admin #986

Description

@claudiobianco

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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)?
  2. Where in Mage_Adminhtml / Mage_Sales should the helper and observer methods
    sit?
  3. Introduce a dedicated product type, or keep simple + flags?
  4. Gross/net entry: follow store price_includes_tax, or expose a toggle?
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions