[BUG] Child products cannot have individual discount rules - product_discount_id ign

Started by sirius, June 24, 2026, 23:22:57 PM

Previous topic - Next topic

sirius

[BUG] Child products cannot have individual discount rules - product_discount_id ignored on save + missing UI field

Affected versions: 4.6.6, 4.6.8, 4.9.3 (confirmed: present in all three, never fixed)
Severity: High - can cause heavily negative calculated prices displayed as "Price on request" on frontend



Description

When a product is assigned as a child of a parent product, three related bugs prevent the discount rule (`product_discount_id`) from being managed independently for that child.



Bug 1 - Missing UI field in the traditional child list (`views/product/tmpl/product_edit_childs.php`)

The child product list displayed in the parent's "Child product" tab has no column or selector for `product_discount_id`.

Bug 2 - Missing UI field in the type C customfield child list (`models/customfields.php`)

When a product uses a type C customfield ("Multi Variant" / child combo selector), VirtueMart hides the traditional "Child product" tab and replaces it with the type C customfield's own child list panel. This panel also has no column or selector for `product_discount_id`.

This is the more critical case: the type C customfield child list is the only child management interface available when type C is active. Saving the parent through this interface submits child prices WITHOUT `product_discount_id`, which leads directly to Bug 3.

Bug 3 - Model resets discount rule to 0 when not submitted (`models/product.php`)

In `models/product.php`, the `product_discount_id` line was originally wrapped inside an `if (!$isChild)` block, meaning it was never saved for children saved through the parent form. Even after moving it outside that block, if the form does not submit `product_discount_id` (Bug 1 or Bug 2), the fallback `!empty(...) ? ... : 0` sets it to 0.

VirtueMart interprets `product_discount_id = 0` as "apply all active calculation rules", cascading every active discount rule simultaneously. With many active rules this produces a heavily negative `salesPrice` displayed on the frontend as "Price on request".



Consequence

Child products end up with `product_discount_id = NULL` or `0` in `#__virtuemart_product_prices`.

With many active discount rules this produces a heavily negative `salesPrice` (observed: -67 000), displayed on the frontend as "Price on request".

Note on `product_discount_id` values:
  • `NULL` or `0` : all active calculation rules applied (dangerous)
  • `-1` : no discount rule at all (safe default for children with no specific rule)
  • `> 0` : specific discount rule ID (intentional)



Steps to reproduce

  • Create a parent product with a type C customfield ("Multi Variant") and at least one child
  • Edit the parent - the type C panel shows the child list but has no discount column
  • Edit the child product directly, assign a discount rule, save
  • Re-open the parent, re-save from the type C panel: the child's `product_discount_id` is reset to 0
  • Check `#__virtuemart_product_prices` for the child: `product_discount_id = 0`
  • Frontend: all active discount rules cascade onto the child, price goes negative

Reproduces also without type C customfield via the traditional "Child product" tab (Bug 1 + Bug 3).



Proposed fix

Fix 1 - `administrator/components/com_virtuemart/views/product/tmpl/product_edit_childs.php`

Add a `<th>` header and `<td>` cell with `$this->renderDiscountList()` in the traditional child list table:

Code (php) Select
// In <thead> - after the price column header:
<th style="text-align: left !important;"><?php echo vmText::_('COM_VIRTUEMART_PRODUCT_DISCOUNT')?></th>

// In each <tr> child row - after the price <td>:
<td><?php echo $this->renderDiscountList(
    isset(
$child->allPrices[$child->selectedPrice]['product_discount_id'])
        ? (int)
$child->allPrices[$child->selectedPrice]['product_discount_id']
        : -
1,
    
'childs['.$child->virtuemart_product_id.'][mprices][product_discount_id][]'
?>
</td>

Fix 2 - `administrator/components/com_virtuemart/models/customfields.php`

In `renderProductChildLine()`, add a `<th>` in the thead and a `<td>` with the discount selector in each child row:

Code (php) Select
// In thead - after COM_VIRTUEMART_PRODUCT_FORM_PRICE_COST:
$html .= '<th style="text-align: left !important;width:80px;">'.vmText::_('COM_VIRTUEMART_PRODUCT_DISCOUNT').'</th>';

// In each child row - after the product_price / virtuemart_product_price_id inputs:
$selectedDiscount = isset($child->allPrices[$child->selectedPrice]['product_discount_id'])
    ? (int)$child->allPrices[$child->selectedPrice]['product_discount_id']
    : -1;
$discountRates = array();
$discountRates[] = JHtml::_('select.option', '-1', vmText::_('COM_VIRTUEMART_PRODUCT_DISCOUNT_NONE'), 'product_discount_id');
$discountRates[] = JHtml::_('select.option', '0', vmText::_('COM_VIRTUEMART_PRODUCT_DISCOUNT_NO_SPECIAL'), 'product_discount_id');
if (!class_exists('VirtueMartModelCalc')) { VmModel::getModel('calc'); }
foreach (VirtueMartModelCalc::getDiscounts() as $disc) {
    $discountRates[] = JHtml::_('select.option', $disc->virtuemart_calc_id, $disc->calc_name, 'product_discount_id');
}
$html .= '<td>'.JHtml::_('select.genericlist', $discountRates,
    'childs['.$child->virtuemart_product_id.'][mprices][product_discount_id][]',
    'class="vm-chzn-add"', 'product_discount_id', 'text', $selectedDiscount, '[').'</td>';

Fix 3 - `administrator/components/com_virtuemart/models/product.php`

Move `product_discount_id` outside the `if (!$isChild)` block, AND change the fallback from hard-coding 0 to only writing the value when explicitly submitted:

Code (php) Select
// BEFORE (buggy - inside if (!$isChild), never saved for children):
if (!$isChild){
    // ...
    $pricesToStore['product_discount_id'] = !empty($data['mprices']['product_discount_id'][$k])
        ? (int)$data['mprices']['product_discount_id'][$k] : 0; // resets to 0 if missing
    // ...
}

// AFTER (outside the block, preserved when not submitted):
if (!$isChild){
    // ... other fields only ...
}
if (isset($data['mprices']['product_discount_id'][$k])) {
    $pricesToStore['product_discount_id'] = (int)$data['mprices']['product_discount_id'][$k];
}
// If not in POST, existing DB value is preserved



Immediate workaround (SQL)

Set `product_discount_id = -1` (no discount rule) for affected child products:

Code (sql) Select
-- Diagnostic
SELECT p.virtuemart_product_id, p.product_parent_id,
       pp.product_price, pp.product_discount_id
FROM #__virtuemart_products p
LEFT JOIN #__virtuemart_product_prices pp USING (virtuemart_product_id)
WHERE p.product_parent_id > 0;

-- Fix: NULL/0 -> -1 for children with no intentional discount
UPDATE #__virtuemart_product_prices
SET product_discount_id = -1
WHERE virtuemart_product_id IN (
    SELECT virtuemart_product_id
    FROM #__virtuemart_products
    WHERE product_parent_id > 0
)
AND (product_discount_id IS NULL OR product_discount_id = 0);
J5.4.6 | PHP 8.4.10 + Redis + APC + Opcode
Litespeed | MariaDB 10.6.22
VM Prod : 4.6.8 11258 | VM Test : 4.9.3 11254