Posting it here in hope that it gives overview of amount of work to be done.
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,
]);
}
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.
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: