Calculator.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. <?php
  2. /**
  3. * @copyright Vertex. All rights reserved. https://www.vertexinc.com/
  4. * @author Mediotype https://www.mediotype.com/
  5. */
  6. namespace Vertex\Tax\Model;
  7. use Magento\Framework\Message\ManagerInterface;
  8. use Magento\Framework\Pricing\PriceCurrencyInterface;
  9. use Magento\Tax\Api\Data\AppliedTaxInterface;
  10. use Magento\Tax\Api\Data\AppliedTaxInterfaceFactory;
  11. use Magento\Tax\Api\Data\AppliedTaxRateInterface;
  12. use Magento\Tax\Api\Data\AppliedTaxRateInterfaceFactory;
  13. use Magento\Tax\Api\Data\QuoteDetailsInterface;
  14. use Magento\Tax\Api\Data\QuoteDetailsItemInterface;
  15. use Magento\Tax\Api\Data\TaxDetailsInterface;
  16. use Magento\Tax\Api\Data\TaxDetailsInterfaceFactory;
  17. use Magento\Tax\Api\Data\TaxDetailsItemInterface;
  18. use Magento\Tax\Api\Data\TaxDetailsItemInterfaceFactory;
  19. use Vertex\Data\LineItemInterface;
  20. use Vertex\Data\TaxInterface;
  21. use Vertex\Tax\Model\Api\Data\QuotationRequestBuilder;
  22. use Vertex\Tax\Model\Config\Source\SummarizeTax;
  23. use Vertex\Tax\Model\TaxQuote\TaxQuoteRequest;
  24. /**
  25. * Vertex Tax Calculator
  26. */
  27. class Calculator
  28. {
  29. const TAX_TYPE_PRINTED_CARD_GW = 'printed_card_gw';
  30. const TAX_TYPE_QUOTE_GW = 'quote_gw';
  31. const TAX_TYPE_SHIPPING = 'shipping';
  32. const MESSAGE_KEY = 'vertex-messages';
  33. /** @var bool */
  34. private $addMessageToVertexGroup;
  35. /** @var AppliedTaxInterfaceFactory */
  36. private $appliedTaxFactory;
  37. /** @var AppliedTaxRateInterfaceFactory */
  38. private $appliedTaxRateFactory;
  39. /** @var Config */
  40. private $config;
  41. /** @var ExceptionLogger */
  42. private $logger;
  43. /** @var ManagerInterface */
  44. private $messageManager;
  45. /** @var PriceCurrencyInterface */
  46. private $priceCurrency;
  47. /** @var TaxQuoteRequest */
  48. private $quoteRequest;
  49. /** @var QuotationRequestBuilder */
  50. private $requestFactory;
  51. /** @var TaxDetailsInterfaceFactory */
  52. private $taxDetailsFactory;
  53. /** @var TaxDetailsItemInterfaceFactory */
  54. private $taxDetailsItemFactory;
  55. /**
  56. * @param TaxDetailsInterfaceFactory $taxDetailsFactory
  57. * @param TaxDetailsItemInterfaceFactory $taxDetailsItemFactory
  58. * @param QuotationRequestBuilder $requestFactory
  59. * @param TaxQuoteRequest $quoteRequest
  60. * @param AppliedTaxInterfaceFactory $appliedTaxFactory
  61. * @param AppliedTaxRateInterfaceFactory $appliedTaxRateFactory
  62. * @param PriceCurrencyInterface $priceCurrency
  63. * @param ExceptionLogger $logger
  64. * @param Config $config
  65. * @param ManagerInterface $messageManager
  66. * @param bool $addMessageToVertexGroup
  67. */
  68. public function __construct(
  69. TaxDetailsInterfaceFactory $taxDetailsFactory,
  70. TaxDetailsItemInterfaceFactory $taxDetailsItemFactory,
  71. QuotationRequestBuilder $requestFactory,
  72. TaxQuoteRequest $quoteRequest,
  73. AppliedTaxInterfaceFactory $appliedTaxFactory,
  74. AppliedTaxRateInterfaceFactory $appliedTaxRateFactory,
  75. PriceCurrencyInterface $priceCurrency,
  76. ExceptionLogger $logger,
  77. Config $config,
  78. ManagerInterface $messageManager,
  79. $addMessageToVertexGroup = true
  80. ) {
  81. $this->taxDetailsFactory = $taxDetailsFactory;
  82. $this->requestFactory = $requestFactory;
  83. $this->quoteRequest = $quoteRequest;
  84. $this->taxDetailsItemFactory = $taxDetailsItemFactory;
  85. $this->appliedTaxFactory = $appliedTaxFactory;
  86. $this->appliedTaxRateFactory = $appliedTaxRateFactory;
  87. $this->priceCurrency = $priceCurrency;
  88. $this->logger = $logger;
  89. $this->config = $config;
  90. $this->messageManager = $messageManager;
  91. $this->addMessageToVertexGroup = $addMessageToVertexGroup;
  92. }
  93. /**
  94. * Calculate Taxes
  95. *
  96. * @param QuoteDetailsInterface $quoteDetails
  97. * @param string|null $scopeCode
  98. * @param bool $round
  99. * @return TaxDetailsInterface
  100. */
  101. public function calculateTax(QuoteDetailsInterface $quoteDetails, $scopeCode, $round = true)
  102. {
  103. $items = $quoteDetails->getItems();
  104. if (empty($items)
  105. || ($quoteDetails->getBillingAddress() === null && $quoteDetails->getShippingAddress() === null)
  106. || $this->onlyShipping($items)
  107. ) {
  108. /*
  109. * Don't perform calculation when:
  110. * - There are no items
  111. * - There is no address
  112. * - The only item is shipping
  113. */
  114. return $this->createEmptyDetails($quoteDetails);
  115. }
  116. try {
  117. $request = $this->requestFactory->buildFromQuoteDetails($quoteDetails, $scopeCode);
  118. // Send to Vertex!
  119. $result = $this->quoteRequest->taxQuote($request, $scopeCode);
  120. } catch (\Exception $e) {
  121. $this->logger->critical($e);
  122. $group = $this->addMessageToVertexGroup ? self::MESSAGE_KEY : null;
  123. // Clear previous Vertex error messages
  124. $this->messageManager->getMessages(true, $group);
  125. $this->messageManager->addErrorMessage(
  126. __('Unable to calculate taxes. This could be caused by an invalid address provided in checkout.'),
  127. $group
  128. );
  129. return $this->createEmptyDetails($quoteDetails);
  130. }
  131. /** @var LineItemInterface[] $resultItems */
  132. $resultItems = [];
  133. foreach ($result->getLineItems() as $lineItem) {
  134. $resultItems[$lineItem->getLineItemId()] = $lineItem;
  135. }
  136. /** @var TaxDetailsInterface $taxDetails */
  137. $taxDetails = $this->taxDetailsFactory->create();
  138. $taxDetails->setSubtotal(0)
  139. ->setTaxAmount(0)
  140. ->setAppliedTaxes([]);
  141. /** @var QuoteDetailsItemInterface[] $processItems Line items we need to process taxes for */
  142. $processItems = [];
  143. /** @var QuoteDetailsItemInterface[] $childrenByParent Child line items indexed by parent code */
  144. $childrenByParent = [];
  145. /** @var TaxDetailsItemInterface[] $processedItems Processed Line items */
  146. $processedItems = [];
  147. /*
  148. * Here we separate items into top-level and child items. The children will be processed separately and then
  149. * added together for the parent item
  150. */
  151. foreach ($quoteDetails->getItems() as $item) {
  152. if ($item->getParentCode()) {
  153. $childrenByParent[$item->getParentCode()][] = $item;
  154. } else {
  155. $processItems[$item->getCode()] = $item;
  156. }
  157. }
  158. foreach ($processItems as $item) {
  159. if (isset($childrenByParent[$item->getCode()])) { // If this top-level item has child products
  160. /** @var TaxDetailsItemInterface[] $processedChildren To be used to figure out our top-level details */
  161. $processedChildren = [];
  162. // Process the children first, our top-level product will be the combination of them
  163. foreach ($childrenByParent[$item->getCode()] as $child) {
  164. /** @var QuoteDetailsItemInterface $child */
  165. $resultItem = $resultItems[$child->getCode()];
  166. $processedItem = $resultItem
  167. ? $this->createTaxDetailsItem($child, $resultItem, $round)
  168. : $this->createEmptyDetailsTaxItem($child);
  169. // Add this item's tax information to the quote aggregate
  170. $this->aggregateTaxData($taxDetails, $processedItem);
  171. $processedItems[$processedItem->getCode()] = $processedItem;
  172. $processedChildren[] = $processedItem;
  173. }
  174. /** @var TaxDetailsItemInterface $processedItem */
  175. $processedItem = $this->taxDetailsItemFactory->create();
  176. $processedItem->setCode($item->getCode())
  177. ->setType($item->getType());
  178. $rowTotal = 0.0;
  179. $rowTotalInclTax = 0.0;
  180. $rowTax = 0.0;
  181. // Combine the totals from the children
  182. foreach ($processedChildren as $child) {
  183. $rowTotal += $child->getRowTotal();
  184. $rowTotalInclTax += $child->getRowTotalInclTax();
  185. $rowTax += $child->getRowTax();
  186. }
  187. $price = $rowTotal / $item->getQuantity();
  188. $priceInclTax = $rowTotalInclTax / $item->getQuantity();
  189. $processedItem->setPrice($this->optionalRound($price, $round))
  190. ->setPriceInclTax($this->optionalRound($priceInclTax, $round))
  191. ->setRowTotal($this->optionalRound($rowTotal, $round))
  192. ->setRowTotalInclTax($this->optionalRound($rowTotalInclTax, $round))
  193. ->setRowTax($this->optionalRound($rowTax, $round));
  194. // Aggregation to $taxDetails takes place on the child level
  195. } else {
  196. $resultItem = $resultItems[$item->getCode()];
  197. $processedItem = $resultItem
  198. ? $this->createTaxDetailsItem($item, $resultItem, $round)
  199. : $this->createEmptyDetailsTaxItem($item);
  200. $this->aggregateTaxData($taxDetails, $processedItem);
  201. }
  202. $processedItems[$item->getCode()] = $processedItem;
  203. }
  204. $taxDetails->setItems($processedItems);
  205. return $taxDetails;
  206. }
  207. /**
  208. * Add tax details from an item to the overall tax details
  209. *
  210. * @param TaxDetailsInterface $taxDetails
  211. * @param TaxDetailsItemInterface $taxItemDetails
  212. * @return void
  213. */
  214. private function aggregateTaxData(TaxDetailsInterface $taxDetails, TaxDetailsItemInterface $taxItemDetails)
  215. {
  216. $taxDetails->setSubtotal($taxDetails->getSubtotal() + $taxItemDetails->getRowTotal());
  217. $taxDetails->setTaxAmount($taxDetails->getTaxAmount() + $taxItemDetails->getRowTax());
  218. $itemAppliedTaxes = $taxItemDetails->getAppliedTaxes();
  219. if (empty($itemAppliedTaxes)) {
  220. return;
  221. }
  222. $appliedTaxes = $taxDetails->getAppliedTaxes();
  223. foreach ($itemAppliedTaxes as $taxId => $itemAppliedTax) {
  224. if (!isset($appliedTaxes[$taxId])) {
  225. $rates = [];
  226. $itemRates = $itemAppliedTax->getRates();
  227. foreach ($itemRates as $rate) {
  228. /** @var AppliedTaxRateInterface $newRate */
  229. $newRate = $this->appliedTaxRateFactory->create();
  230. $newRate->setPercent($rate->getPercent())
  231. ->setTitle($rate->getTitle())
  232. ->setCode($rate->getCode());
  233. $rates[] = $newRate;
  234. }
  235. /** @var AppliedTaxInterface $appliedTax */
  236. $appliedTax = $this->appliedTaxFactory->create();
  237. $appliedTax->setPercent($itemAppliedTax->getPercent())
  238. ->setAmount($itemAppliedTax->getAmount())
  239. ->setTaxRateKey($itemAppliedTax->getTaxRateKey())
  240. ->setRates($rates);
  241. } else {
  242. $appliedTaxes[$taxId]->setAmount($appliedTaxes[$taxId]->getAmount() + $itemAppliedTax->getAmount());
  243. }
  244. }
  245. $taxDetails->setAppliedTaxes($appliedTaxes);
  246. }
  247. /**
  248. * Format an array of {@see TaxInterface} into applied taxes
  249. *
  250. * @param TaxInterface[] $taxes
  251. * @param string $lineItemId
  252. * @return AppliedTaxInterface[]
  253. */
  254. private function createAppliedTaxes(array $taxes, $lineItemId)
  255. {
  256. $taxDetailType = SummarizeTax::PRODUCT_AND_SHIPPING;
  257. if ($lineItemId === static::TAX_TYPE_SHIPPING) {
  258. $taxDetailType = static::TAX_TYPE_SHIPPING;
  259. } elseif ($lineItemId === static::TAX_TYPE_QUOTE_GW
  260. || $lineItemId === static::TAX_TYPE_PRINTED_CARD_GW
  261. || strpos($lineItemId, 'item_gw') === 0) {
  262. $taxDetailType = static::TAX_TYPE_QUOTE_GW;
  263. }
  264. $appliedTaxes = [];
  265. foreach ($taxes as $tax) {
  266. $jurisdiction = $tax->getJurisdiction();
  267. if (!$jurisdiction) {
  268. continue;
  269. }
  270. if ($this->config->getSummarizeTax() === SummarizeTax::JURISDICTION) {
  271. $taxDetailType = $jurisdiction->getName();
  272. }
  273. /** @var AppliedTaxInterface $appliedTax */
  274. /** @var AppliedTaxRateInterface $rate */
  275. if (isset($appliedTaxes[$taxDetailType])) {
  276. $appliedTax = $appliedTaxes[$taxDetailType];
  277. } else {
  278. $appliedTax = $this->appliedTaxFactory->create();
  279. $appliedTax->setAmount(0);
  280. $appliedTax->setPercent(0);
  281. $appliedTax->setTaxRateKey($taxDetailType);
  282. $rate = $this->appliedTaxRateFactory->create();
  283. $rate->setPercent(0)
  284. ->setCode($taxDetailType);
  285. $rate->setTitle($this->getTaxLabel($taxDetailType));
  286. $appliedTax->setRates([$rate]);
  287. $appliedTaxes[$taxDetailType] = $appliedTax;
  288. }
  289. $rate = $appliedTax->getRates()[0];
  290. $rate->setPercent($rate->getPercent() + ($tax->getEffectiveRate() * 100));
  291. $appliedTax->setAmount($appliedTax->getAmount() + $tax->getAmount());
  292. $appliedTax->setPercent($appliedTax->getPercent() + ($tax->getEffectiveRate() * 100));
  293. }
  294. return $appliedTaxes;
  295. }
  296. /**
  297. * Create an empty {@see TaxDetailsInterface}
  298. *
  299. * This method is used to provide Magento the information it expects while
  300. * avoiding a costly tax calculation when we don't want one (or think it
  301. * will provide no value)
  302. *
  303. * @param QuoteDetailsInterface $quoteDetails
  304. * @return TaxDetailsInterface
  305. */
  306. private function createEmptyDetails(QuoteDetailsInterface $quoteDetails)
  307. {
  308. /** @var TaxDetailsInterface $details */
  309. $details = $this->taxDetailsFactory->create();
  310. $subtotal = 0;
  311. $items = [];
  312. foreach ($quoteDetails->getItems() as $quoteItem) {
  313. $taxItem = $this->createEmptyDetailsTaxItem($quoteItem);
  314. $subtotal += $taxItem->getRowTotal();
  315. // Magento has an undocumented assumption that tax detail items are indexed by code
  316. $items[$taxItem->getCode()] = $taxItem;
  317. }
  318. $details->setSubtotal($subtotal)
  319. ->setTaxAmount(0)
  320. ->setDiscountTaxCompensationAmount(0)
  321. ->setAppliedTaxes([])
  322. ->setItems($items);
  323. return $details;
  324. }
  325. /**
  326. * Create an empty {@see TaxDetailsItemInterface}
  327. *
  328. * This is used by {@see self::createEmptyDetails()}
  329. *
  330. * @param QuoteDetailsItemInterface $quoteDetailsItem
  331. * @return TaxDetailsItemInterface
  332. */
  333. private function createEmptyDetailsTaxItem(QuoteDetailsItemInterface $quoteDetailsItem)
  334. {
  335. /** @var TaxDetailsItemInterface $taxDetailsItem */
  336. $taxDetailsItem = $this->taxDetailsItemFactory->create();
  337. $rowTotal = ($quoteDetailsItem->getUnitPrice() * $quoteDetailsItem->getQuantity()) -
  338. $quoteDetailsItem->getDiscountAmount();
  339. $taxDetailsItem->setCode($quoteDetailsItem->getCode())
  340. ->setType($quoteDetailsItem->getType())
  341. ->setRowTax(0)
  342. ->setPrice($quoteDetailsItem->getUnitPrice())
  343. ->setPriceInclTax($quoteDetailsItem->getUnitPrice())
  344. ->setRowTotal($rowTotal)
  345. ->setRowTotalInclTax($rowTotal)
  346. ->setDiscountTaxCompensationAmount(0)
  347. ->setDiscountAmount($quoteDetailsItem->getDiscountAmount())
  348. ->setAssociatedItemCode($quoteDetailsItem->getAssociatedItemCode())
  349. ->setTaxPercent(0)
  350. ->setAppliedTaxes([]);
  351. return $taxDetailsItem;
  352. }
  353. /**
  354. * Create a {@see TaxDetailsItemInterface}
  355. *
  356. * Combines information from the {@see QuoteDetailsItemInterface} and resulting {@see LineItemInterface} to assemble
  357. * a complete {@see TaxDetailsItemInterface}
  358. *
  359. * @param QuoteDetailsItemInterface $quoteDetailsItem
  360. * @param LineItemInterface $vertexLineItem
  361. * @param bool $round
  362. * @return TaxDetailsItemInterface
  363. */
  364. private function createTaxDetailsItem(
  365. QuoteDetailsItemInterface $quoteDetailsItem,
  366. LineItemInterface $vertexLineItem,
  367. $round = true
  368. ) {
  369. /** @var TaxDetailsItemInterface $taxDetailsItem */
  370. $taxDetailsItem = $this->taxDetailsItemFactory->create();
  371. // Combine the rates of all taxes applicable to the Line Item
  372. $effectiveRate = array_reduce(
  373. $vertexLineItem->getTaxes(),
  374. function ($result, TaxInterface $tax) {
  375. return $result + $tax->getEffectiveRate();
  376. },
  377. 0
  378. );
  379. $perItemTax = $vertexLineItem->getTotalTax() / $vertexLineItem->getQuantity();
  380. $unitPrice = $vertexLineItem->getUnitPrice();
  381. // Vertex extended price is less discount - so add it back
  382. $extendedPrice = $vertexLineItem->getExtendedPrice() + $quoteDetailsItem->getDiscountAmount();
  383. $taxDetailsItem->setCode($vertexLineItem->getLineItemId())
  384. ->setType($quoteDetailsItem->getType())
  385. ->setRowTax($this->optionalRound($vertexLineItem->getTotalTax(), $round))
  386. ->setPrice($this->optionalRound($unitPrice, $round))
  387. ->setPriceInclTax($this->optionalRound($unitPrice + $perItemTax, $round))
  388. ->setRowTotal($this->optionalRound($extendedPrice, $round))
  389. ->setRowTotalInclTax($this->optionalRound($extendedPrice + $vertexLineItem->getTotalTax(), $round))
  390. ->setDiscountTaxCompensationAmount(0)
  391. ->setAssociatedItemCode($quoteDetailsItem->getAssociatedItemCode())
  392. ->setTaxPercent($effectiveRate * 100)
  393. ->setAppliedTaxes(
  394. $this->createAppliedTaxes(
  395. $vertexLineItem->getTaxes(),
  396. $vertexLineItem->getLineItemId()
  397. )
  398. );
  399. return $taxDetailsItem;
  400. }
  401. /**
  402. * Determine if an array of QuoteDetailsItemInterface contains only shipping entries
  403. *
  404. * @param QuoteDetailsItemInterface[] $items
  405. * @return bool
  406. */
  407. private function onlyShipping(array $items)
  408. {
  409. foreach ($items as $item) {
  410. if ($item->getCode() !== 'shipping') {
  411. return false;
  412. }
  413. }
  414. return true;
  415. }
  416. /**
  417. * Round a number
  418. *
  419. * @param number $number
  420. * @param bool $round
  421. * @return float
  422. */
  423. private function optionalRound($number, $round = true)
  424. {
  425. return $round ? $this->priceCurrency->round($number) : $number;
  426. }
  427. /**
  428. * Retrieve tax label
  429. *
  430. * @param $code
  431. * @return string
  432. */
  433. private function getTaxLabel($code)
  434. {
  435. switch ($code) {
  436. case SummarizeTax::PRODUCT_AND_SHIPPING:
  437. $title = __('Sales and Use')->render();
  438. break;
  439. case static::TAX_TYPE_QUOTE_GW:
  440. case static::TAX_TYPE_PRINTED_CARD_GW:
  441. $title = __('Gift Options')->render();
  442. break;
  443. case static::TAX_TYPE_SHIPPING:
  444. $title = __('Shipping')->render();
  445. break;
  446. default:
  447. $title = $code;
  448. break;
  449. }
  450. return $title;
  451. }
  452. }