0

I'd like to exclude the added price of custom options from tax calculations. For example, if a custom option adds $10 to the total price, that $10 should not factor into tax calculation.

What would be the best way to approach this?

I've looked into creating an observer for sales_quote_collect_totals_before but I'm not sure of how go about altering the tax in this specific way at that event.

  • 2
    That could be really difficult to implement. What about a separate 10 dollar add on product (bundle product scenario) with a non tax class? – Dominic Xigen Sep 12 '20 at 7:40
  • Thanks @DominicXigen, that bundle approach is a good thought. I unfortunately need to make this work for a large amount of products that are already set up with custom options. – brackfost Sep 12 '20 at 22:42
1
+50

This answer is NOT FULLY COMPLETE! However, I believe this is a way to go. Developer level requirement: middle+ :)

Posting it here in hope that it gives overview of amount of work to be done.

  1. Custom option price is added into quote item price during adding product to cart:

    • \Magento\Quote\Model\Quote::addProduct
    • \Magento\Quote\Model\Quote\Item\Processor::prepare
    • \Magento\Catalog\Model\Product::getFinalPrice
    • \Magento\Catalog\Model\Product\Type\Price::getFinalPrice
    • \Magento\Catalog\Model\Product\Type\Price::_applyOptionsPrice
    $finalPrice += $group->getOptionPrice($confItemOption->getValue(), $basePrice);
    

    To later separate tax calculation for product before custom options, and custom options, I suggest that we calculate custom option value and store it somewhere in quote item - new column or maybe in new quote item option.

    This can be plugin - \Magento\Quote\Model\Quote\Item\Processor::afterPrepare

    $product = $candidate;
    $customOptionPrice = 0.00;
    $optionIds = $product->getCustomOption('option_ids');
    if ($optionIds) {
        $basePrice = $product->getPriceModel()->getBasePrice($product, null);
        foreach (explode(',', $optionIds->getValue()) as $optionId) {
            if ($option = $product->getOptionById($optionId)) {
                $confItemOption = $product->getCustomOption('option_' . $option->getId());
    
                $group = $option->groupFactory($option->getType())
                    ->setOption($option)
                    ->setConfigurationItemOption($confItemOption);
                $customOptionPrice += $group->getOptionPrice($confItemOption->getValue(), $basePrice);
            }
        }
    }
    if ($customOptionPrice > 0) {
        $item->addOption([
            'code' => 'custom_option_price',
            'value' => $customOptionPrice,
        ]);
    }
    
  2. Later, tax total collectors split line item price into price excluding tax and tax.

    • \Magento\Tax\Model\Sales\Total\Quote\Subtotal::collect
    • \Magento\Tax\Model\Sales\Total\Quote\CommonTaxCollector::mapItems
    • \Magento\Tax\Model\Sales\Total\Quote\CommonTaxCollector::processProductItems

    Given product base price 100, custom option price 10, and tax 8.24% (Michigan state in sample data), my suggestion is to hook into mapItems:

    • deduct stashed custom option price from cart item price, e.g. replace amount 110 with 100
    • add additional taxable line with custom option prices, mark as not including tax, e.g. amount 10

    Plugin \Magento\Tax\Model\Sales\Total\Quote\CommonTaxCollector::afterMapItem:

    $option = $item->getOptionByCode('custom_option_price');
    if ($option) {
        // split price - item price without custom options, and custom options as a separate taxable item
        $itemDataObject->setUnitPrice($itemDataObject->getUnitPrice() - $option->getValue()); // TODO: more robust calculation, base/non-base currency
        $extraTaxables = $item->getAssociatedTaxables() ?: [];
        $extraTaxables[] = [
            'price_includes_tax' => false,
            self::KEY_ASSOCIATED_TAXABLE_TYPE => 'custom-options',
            self::KEY_ASSOCIATED_TAXABLE_CODE => 'custom-options-' . $item->getTaxCalculationItemId(),
            self::KEY_ASSOCIATED_TAXABLE_BASE_UNIT_PRICE => 10,
            self::KEY_ASSOCIATED_TAXABLE_UNIT_PRICE => 10,
            self::KEY_ASSOCIATED_TAXABLE_QUANTITY => 1,
            self::KEY_ASSOCIATED_TAXABLE_TAX_CLASS_ID => 0, // TODO: none
        ];
        $item->setAssociatedTaxables($extraTaxables);
    }
    
    return $itemDataObject;
    

    Now, base product price is tax calculated with extracted custom options.

  3. Manually add custom option price to quote item, quote address, quote

    Example - plugin \Magento\Tax\Model\Sales\Total\Quote\Subtotal::afterCollect

    WARNING! Not a fully working code, only crux of it.

    $itemsByType = $this->organizeItemTaxDetailsByType($taxDetails, $baseTaxDetails);
    if (isset($itemsByType['custom-options'])) {
        // @see \Magento\Tax\Model\Sales\Total\Quote\CommonTaxCollector::processProductItems
        /** @var AbstractItem[] $keyedAddressItems */
        $keyedAddressItems = [];
        foreach ($shippingAssignment->getItems() as $addressItem) {
            $keyedAddressItems[$addressItem->getTaxCalculationItemId()] = $addressItem;
        }
    
        foreach ($itemsByType['custom-options'] as $code => $itemTaxDetail) {
            /** @var TaxDetailsItemInterface $taxDetail */
            $taxDetail = $itemTaxDetail[self::KEY_ITEM];
            /** @var TaxDetailsItemInterface $baseTaxDetail */
            $baseTaxDetail = $itemTaxDetail[self::KEY_BASE_ITEM];
    
            $quoteItem = $keyedAddressItems[str_replace('custom-options-', '', $code)] ?? null;
            if (!$quoteItem) {
                // TODO: log error
                continue;
            }
    
            $store = $store = $shippingAssignment->getShipping()->getAddress()->getQuote()->getStore();
            // add product price and custom options price
            $taxDetail->setPrice($taxDetail->getPrice() + $quoteItem->getPrice());
            $baseTaxDetail->setPrice($baseTaxDetail->getPrice() + $quoteItem->getBasePrice());
            $this->updateItemTaxInfo($quoteItem, $taxDetail, $baseTaxDetail, $store);
        }
    }
    

    This is where my out of cuff code started misfiring, and I suggest attentive reader to finish this article, sorry :)

    Also, I started suspecting this we might need to create total collectors for invoice/creditmemo as well, making this a pretty expensive solution.

P.S. All tax-related quote collectors for reference:

  • subtotal Magento\Quote\Model\Quote\Address\Total\Subtotal: quote_item.row_total

  • tax_subtotal \Magento\Tax\Model\Sales\Total\Quote\Subtotal:

    • quote_item.row_total, tax_amount, discount_tax_compensation_amount, discount_calculation_price
    • address.subtotal, tax, discount_tax_compensation
  • weee: address.subtotal_incl_tax

  • discount: item.price * item.qty

  • shipping: address.shipping_amount

  • tax_shipping: address.shipping_amount, shipping_incl_tax, shipping_tax_amount

  • shipping_discount: address.discount_amount

  • pretax_giftwrapping: setAssociatedTaxables

  • tax: item.getAssociatedTaxables, address.getAssociatedTaxables

  • Thanks so much for this thoughtful answer, Roman! I'll give this a thorough look over the next few days. – brackfost Sep 14 '20 at 21:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.