diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3fcd973 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,61 @@ +language: php +php: + - 7.3 +dist: xenial + +env: + matrix: + - TEST_GROUP=magento_latest + - TEST_GROUP=magento_23 +matrix: + exclude: + - php: 7.4 + env: TEST_GROUP=magento_23 + - php: 7.3 + env: TEST_GROUP=magento_latest + +before_install: + - phpenv config-rm xdebug.ini || true + - composer self-update 1.10.16 + +install: + - composer config repositories.foomanmirror composer https://repo-magento-mirror.fooman.co.nz/ # TODO this allows us to composer install, but we only need the dev dependency. Maybe wget it instead? + - composer install --no-interaction + # Install magento + - if [[ $TEST_GROUP = magento_23 ]]; then NAME=snowepr FULL_INSTALL=0 VERSION=2.3.6 . ./vendor/bin/travis-install-magento.sh; fi + - if [[ $TEST_GROUP = magento_latest ]]; then NAME=snowepr FULL_INSTALL=0 . ./vendor/bin/travis-install-magento.sh; fi + # Install this module + - cd vendor/ampersand/travis-vanilla-magento/instances/snowepr + - export COMPOSER_MEMORY_LIMIT=-1 + - composer config repo.snowepr git "../../../../../" + - composer require -vvv snowio/magento2-extended-sales-repositories:"dev-$TRAVIS_BRANCH" || composer require -vvv snowio/magento2-extended-sales-repository:"$TRAVIS_BRANCH" + # Configure for integration tests + - mysql -uroot -e 'SET @@global.sql_mode = NO_ENGINE_SUBSTITUTION; DROP DATABASE IF EXISTS magento_integration_tests; CREATE DATABASE magento_integration_tests;' + - cp dev/tests/integration/etc/install-config-mysql.travis-no-rabbitmq.php.dist dev/tests/integration/etc/install-config-mysql.php + - php $TRAVIS_BUILD_DIR/travis/prepare_phpunit_config.php + +script: + - vendor/bin/phpunit -c $(pwd)/dev/tests/integration/phpunit.xml.dist --testsuite Integration + +addons: + apt: + packages: + - postfix + - apache2 + - libapache2-mod-fastcgi + +services: + - mysql + +cache: + apt: true + directories: + - $HOME/.composer/cache + - $HOME/bin + +after_failure: + - test -d ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/report/ && for r in ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/report/*; do cat $r; done + - test -f ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/log/system.log && grep -v "Broken reference" ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/log/system.log + - test -f ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/log/exception.log && cat ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/log/exception.log + - test -f ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/log/support_report.log && grep -v "Broken reference" ./vendor/ampersand/travis-vanilla-magento/instances/snowepr/var/log/support_report.log + - sleep 10; \ No newline at end of file diff --git a/Exception/SnowCreditMemoException.php b/Exception/SnowCreditMemoException.php new file mode 100644 index 0000000..49e7e71 --- /dev/null +++ b/Exception/SnowCreditMemoException.php @@ -0,0 +1,44 @@ + + */ + public function provide(OrderInterface $order) : array + { + if (array_key_exists($order->getEntityId(), $this->availableByOrder)) { + return $this->availableByOrder[$order->getEntityId()]; + } + + $quantityRefunded = $this->getQuantityRefunded($order); + + return $this->availableByOrder[$order->getEntityId()] = array_reduce( + array_keys($quantityRefunded), + static function (array $quantityAvailable, int $orderItemId) use ($quantityRefunded) : array { + if (!array_key_exists($orderItemId, $quantityAvailable)) { + return $quantityAvailable; + } + + // Sum all quantities for the order item provided by the collected credit memos + $quantityAvailable[$orderItemId] = + max($quantityAvailable[$orderItemId] - array_sum($quantityRefunded[$orderItemId]), 0); + + return $quantityAvailable; + }, + $this->getQuantityAvailable($order) + ); + } + + + /** + * Get quantity of items refunded in all credit memos + * + * @param \Magento\Sales\Api\Data\OrderInterface $order + * @return array + * @author Daniel Doyle + */ + private function getQuantityRefunded(OrderInterface $order) : array + { + $refundedItems = []; + foreach ($order->getCreditmemosCollection() as $creditmemo) { + foreach ($creditmemo->getItems() as $creditmemoItem) { + $quantityRefunded = (float) $creditmemoItem->getQty(); + if ($quantityRefunded < 1) { + continue; + } + + // Dynamically create array of quantities for aggregation later + $refundedItems[$creditmemoItem->getOrderItemId()][] = $quantityRefunded; + } + } + + return $refundedItems; + } + + /** + * Get quantity of available items from order. As we're querying all credit memos (including broken/invalid ones) we + * base the refunded quantity on them instead of relying on `getQtyRefunded` set against an order item + * + * @param \Magento\Sales\Api\Data\OrderInterface $order + * @return array + * @author Daniel Doyle + */ + private function getQuantityAvailable(OrderInterface $order) : array + { + return array_reduce( + $order->getAllItems(), + function (array $refundedItems, OrderItemInterface $orderItem) : array { + $refundedItems[$orderItem->getItemId()] = (float) $orderItem->getQtyOrdered(); + + return $refundedItems; + }, + [] + ); + } +} diff --git a/Model/CreditmemoByOrderIncrementId.php b/Model/CreditmemoByOrderIncrementId.php index 1861459..746441e 100644 --- a/Model/CreditmemoByOrderIncrementId.php +++ b/Model/CreditmemoByOrderIncrementId.php @@ -4,48 +4,76 @@ use Magento\Framework\Api\Filter; use Magento\Framework\Api\Search\FilterGroup; use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Api\Data\CreditmemoInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Api\CreditmemoManagementInterface; use Magento\Sales\Api\CreditmemoRepositoryInterface; -use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Creditmemo; -use Magento\Sales\Model\Order\CreditmemoFactory; +use Magento\Sales\Model\Order\Creditmemo\Item; +use Magento\Sales\Model\Order\Email\Sender\CreditmemoSender; +use Psr\Log\LoggerInterface; use SnowIO\ExtendedSalesRepositories\Api\CreditmemoByOrderIncrementIdInterface; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\Data\CreditmemoItemInterface; use Magento\Sales\Api\Data\OrderItemInterface; +use SnowIO\ExtendedSalesRepositories\Exception\SnowCreditMemoException; class CreditmemoByOrderIncrementId implements CreditmemoByOrderIncrementIdInterface { - /** @var CreditmemoManagementInterface */ + /** + * @var ExtendedCreditMemoFactory + */ + private $extendedCreditMemoFactory; + + /** + * @var CreditmemoSender + */ + private $creditmemoSender; + + /** + * @var CreditmemoManagementInterface + */ private $creditmemoManagement; - /** @var CreditmemoRepositoryInterface */ + /** + * @var CreditmemoRepositoryInterface + */ private $creditmemoRepository; - /** @var OrderRepositoryInterface */ + /** + * @var OrderRepositoryInterface + */ private $orderRepository; - /** @var CreditmemoFactory */ - private $creditmemoFactory; + /** + * @var LoggerInterface + */ + private $logger; /** * CreditmemoByOrderIncrementId constructor. + * @param ExtendedCreditMemoFactory $extendedCreditMemoFactory + * @param CreditmemoSender $creditmemoSender * @param CreditmemoRepositoryInterface $creditmemoRepository * @param CreditmemoManagementInterface $creditmemoManagement * @param OrderRepositoryInterface $orderRepository + * @param LoggerInterface $logger */ public function __construct( + ExtendedCreditMemoFactory $extendedCreditMemoFactory, + CreditmemoSender $creditmemoSender, CreditmemoRepositoryInterface $creditmemoRepository, CreditmemoManagementInterface $creditmemoManagement, OrderRepositoryInterface $orderRepository, - CreditmemoFactory $creditmemoFactory + LoggerInterface $logger ) { + $this->extendedCreditMemoFactory = $extendedCreditMemoFactory; + $this->creditmemoSender = $creditmemoSender; $this->creditmemoRepository = $creditmemoRepository; $this->creditmemoManagement = $creditmemoManagement; $this->orderRepository = $orderRepository; - $this->creditmemoFactory = $creditmemoFactory; + $this->logger = $logger; } /** @@ -54,55 +82,56 @@ public function __construct( * @param string $orderIncrementId * @param CreditmemoInterface $creditmemo * @return CreditmemoInterface - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException */ public function createAndRefund( $orderIncrementId, CreditmemoInterface $creditmemo ) { - /** @var Order $order */ $order = $this->loadOrderByIncrementId($orderIncrementId); if(!$order->canCreditmemo()) { - throw new \Magento\Framework\Exception\LocalizedException( + throw new SnowCreditMemoException( __("This order does not allow creation of creditmemo") ); } - /** @var CreditmemoInterface $creditmemo */ - $newCreditmemo = $this->creditmemoFactory->createByOrder($order, [ - 'qtys' => $this->filterItemsToBeRefunded($order, $creditmemo) - ]); - $newCreditmemo->setState(Creditmemo::STATE_OPEN); + $newCreditmemo = $this->extendedCreditMemoFactory->create($order, $creditmemo); $this->addBackToStockStatus($order, $creditmemo, $newCreditmemo); + $this->applyDefaults($newCreditmemo, $creditmemo); + $newCreditmemo->collectTotals(); + $newCreditmemo->setState(Creditmemo::STATE_OPEN); $this->creditmemoRepository->save($newCreditmemo); - return $this->creditmemoManagement->refund($newCreditmemo); + $newCreditmemo = $this->creditmemoManagement->refund($newCreditmemo); + + /** + * Called directly from with the controller + * + * @see \Magento\Sales\Controller\Adminhtml\Order\Creditmemo\Save + */ + try { + /** @var Creditmemo $newCreditmemo */ + $this->creditmemoSender->send($newCreditmemo); + } catch (\Exception $exception) { + $this->logger->error($exception); + } + + return $newCreditmemo; } /** - * Get the sku and qty from the input payload to decide which item and its quantity to be refunded. - * This allow partial creditmemo/refund - * - * @param Order $order + * Applies defaults to the new credit memo + * @param CreditmemoInterface $newCreditmemo * @param CreditmemoInterface $creditmemo - * @return array */ - public function filterItemsToBeRefunded(Order $order, CreditmemoInterface $creditmemo) + public function applyDefaults(CreditmemoInterface $newCreditmemo, CreditmemoInterface $creditmemo) { - $selectedItemsToRefund = []; - /** @var \Magento\Sales\Model\Order\Item $orderItem */ - foreach($order->getAllItems() as $orderItem){ - /** @var \Magento\Sales\Model\Order\Creditmemo\Item $inputItem */ - foreach($creditmemo->getItems() as $inputItem){ - if($orderItem->getSku() === $inputItem->getSku() && $inputItem->getQty() > 0){ - $selectedItemsToRefund[$orderItem->getId()] = $inputItem->getQty(); - } - } - } - return $selectedItemsToRefund; + ExtendedCreditMemoManagement::applyAmounts($newCreditmemo, $creditmemo); + ExtendedCreditMemoManagement::applyTax($newCreditmemo, $creditmemo); + ExtendedCreditMemoManagement::applyExtensionAttributes($newCreditmemo, $creditmemo); } /** @@ -110,12 +139,14 @@ public function filterItemsToBeRefunded(Order $order, CreditmemoInterface $credi * @param CreditmemoInterface $creditmemo * @param CreditmemoInterface $newCreditmemo */ - protected function addBackToStockStatus(OrderInterface $order, CreditmemoInterface $creditmemo, CreditmemoInterface $newCreditmemo) - { + protected function addBackToStockStatus( + OrderInterface $order, + CreditmemoInterface $creditmemo, + CreditmemoInterface $newCreditmemo + ) { $itemsToBackToStock = []; - /** @var \Magento\Sales\Model\Order\Item $orderItem */ foreach ($order->getAllItems() as $orderItem){ - /** @var \Magento\Sales\Model\Order\Creditmemo\Item $creditmemoItem */ + /** @var Item $creditmemoItem */ foreach ($creditmemo->getItems() as $creditmemoItem){ if (!$this->shouldGoBackToStock($creditmemoItem, $orderItem)) { continue; @@ -127,7 +158,7 @@ protected function addBackToStockStatus(OrderInterface $order, CreditmemoInterfa $itemsToBackToStock = array_unique($itemsToBackToStock); foreach($newCreditmemo->getItems() as $memoItem) { - if (in_array($memoItem->getSku(), $itemsToBackToStock)) { + if (in_array($memoItem->getSku(), $itemsToBackToStock, true)) { $memoItem->setBackToStock(true); } } @@ -138,14 +169,14 @@ protected function addBackToStockStatus(OrderInterface $order, CreditmemoInterfa * @param OrderItemInterface $orderItem * @return bool */ - protected function shouldGoBackToStock(CreditmemoItemInterface $creditmemoItem, OrderItemInterface $orderItem): bool + protected function shouldGoBackToStock(CreditmemoItemInterface $creditmemoItem, OrderItemInterface $orderItem) { - $backToStockStatus = $creditmemoItem->getExtensionAttributes() ? $creditmemoItem->getExtensionAttributes()->getBackToStock() : 0; + $backToStockStatus = $creditmemoItem->getExtensionAttributes() ? + $creditmemoItem->getExtensionAttributes()->getBackToStock() : 0; return $orderItem->getSku() === $creditmemoItem->getSku() && $backToStockStatus; } - protected function loadOrderByIncrementId(string $incrementId) { $searchCriteria = (new SearchCriteria()) diff --git a/Model/ExtendedCreditMemoFactory.php b/Model/ExtendedCreditMemoFactory.php new file mode 100644 index 0000000..5af1bcd --- /dev/null +++ b/Model/ExtendedCreditMemoFactory.php @@ -0,0 +1,101 @@ +refundableItemsFilter = $refundableItemsFilter; + $this->creditmemoFactory = $creditmemoFactory; + } + + /** + * Create a credit memo based on the order + * @param OrderInterface $order + * @param CreditmemoInterface $creditmemo + * @return Creditmemo + * @throws SnowCreditMemoException + * @author Alexander Wanyoike + */ + public function create(OrderInterface $order, CreditmemoInterface $creditmemo) + { + $refundableItems = $this->refundableItemsFilter->filter($order, $creditmemo, $this->hasAdjustments($creditmemo)); + if (empty($refundableItems) && !$this->hasAdjustments($creditmemo)) { + throw new SnowCreditMemoException(__('No items available to refund')); + } + + if ($orderInvoice = $this->getLatestPaidInvoiceForOrder($order)) { + return $this->creditmemoFactory->createByInvoice($orderInvoice, [ + 'qtys' => $refundableItems, + 'shipping_amount' => $creditmemo->getShippingAmount(), + 'adjustment_positive' => $creditmemo->getBaseAdjustmentPositive(), + 'adjustment_negative' => $creditmemo->getBaseAdjustmentNegative() + ]); + } + + return $this->creditmemoFactory->createByOrder($order, [ + 'qtys' => $refundableItems, + 'shipping_amount' => $creditmemo->getShippingAmount(), + 'adjustment_positive' => $creditmemo->getBaseAdjustmentPositive(), + 'adjustment_negative' => $creditmemo->getBaseAdjustmentNegative() + ]); + } + + /** + * @param CreditmemoInterface $creditmemo + * @return bool + * @author Alexander Wanyoike + */ + private function hasAdjustments(CreditmemoInterface $creditmemo) + { + return $creditmemo->getBaseAdjustmentNegative() !== null || + $creditmemo->getBaseAdjustmentPositive() !== null || + $creditmemo->getBaseAdjustment() !== null; + } + + /** + * Get latest invoice for order + * + * @param OrderInterface $order + * @return Invoice|null + */ + private function getLatestPaidInvoiceForOrder(OrderInterface $order) + { + /** @var Invoice $latestInvoice */ + $latestInvoice = $order->getInvoiceCollection() + ->addAttributeToFilter('state', ['eq' => Invoice::STATE_PAID]) + ->setPageSize(1) + ->setCurPage(1) + ->getLastItem(); + + return $latestInvoice->getId() !== null ? $latestInvoice : null; + } +} diff --git a/Model/ExtendedCreditMemoManagement.php b/Model/ExtendedCreditMemoManagement.php new file mode 100644 index 0000000..44083d2 --- /dev/null +++ b/Model/ExtendedCreditMemoManagement.php @@ -0,0 +1,54 @@ + + */ + public static function applyAmounts(CreditmemoInterface $creditmemo, CreditmemoInterface $inputCreditmemo) + { + $creditmemo + ->setShippingAmount($inputCreditmemo->getShippingAmount()) + ->setBaseSubtotal($inputCreditmemo->getBaseSubtotal()) + ->setBaseGrandTotal($inputCreditmemo->getBaseGrandTotal()) + ->setGrandTotal($inputCreditmemo->getGrandTotal()); + } + + /** + * Apply tax calculations from the input + * @param CreditmemoInterface $creditmemo + * @param CreditmemoInterface $inputCreditmemo + * @author Alexander Wanyoike + */ + public static function applyTax(CreditmemoInterface $creditmemo, CreditmemoInterface $inputCreditmemo) + { + $creditmemo->setBaseTaxAmount($inputCreditmemo->getBaseTaxAmount()) + ->setTaxAmount($inputCreditmemo->getTaxAmount()) + ->setBaseShippingTaxAmount($inputCreditmemo->getBaseShippingTaxAmount()) + ->setShippingTaxAmount($inputCreditmemo->getShippingTaxAmount()) + ->setShippingInclTax($inputCreditmemo->getShippingInclTax()) + ->setBaseSubtotalInclTax($inputCreditmemo->getBaseSubtotalInclTax()); + } + + public static function applyExtensionAttributes( + CreditmemoInterface $creditmemo, + CreditmemoInterface $inputCreditMemo + ) { + $extensionAttributes = $inputCreditMemo->getExtensionAttributes(); + if (!$extensionAttributes) { + return; + } + $creditmemo->setExtensionAttributes($extensionAttributes); + } +} diff --git a/Model/LoadOrderByIncrementIdTrait.php b/Model/LoadOrderByIncrementIdTrait.php new file mode 100644 index 0000000..8fdaeb5 --- /dev/null +++ b/Model/LoadOrderByIncrementIdTrait.php @@ -0,0 +1,30 @@ +setFilterGroups([ + (new FilterGroup)->setFilters([ + (new Filter) + ->setField('increment_id') + ->setConditionType('eq') + ->setValue($incrementId) + ]) + ]); + + $order = $this->orderRepository->getList($searchCriteria)->getItems(); + + if (empty($order)) { + throw new \LogicException("No order exists with increment ID '$incrementId'."); + } + + return reset($order); + } +} diff --git a/Model/RefundableItemsFilter.php b/Model/RefundableItemsFilter.php new file mode 100644 index 0000000..62f8651 --- /dev/null +++ b/Model/RefundableItemsFilter.php @@ -0,0 +1,115 @@ +availableQuantityProvider = $availableQuantityProvider; + } + + /** + * Filter valid items to refund + * + * @param OrderInterface $order + * @param CreditmemoInterface $creditmemo + * @param bool $hasAdjustment + * @return array + * @author Daniel Doyle + */ + public function filter(OrderInterface $order, CreditmemoInterface $creditmemo, $hasAdjustment) + { + $availableQuantity = $this->availableQuantityProvider->provide($order); + + if ($hasAdjustment) { + return !empty($creditmemo->getItems()) ? + $this->determineValidItemsToRefund($order, $creditmemo, $availableQuantity, $hasAdjustment) : + $this->getItemsWithoutQuantities($order); + } + + return $this->determineValidItemsToRefund($order, $creditmemo, $availableQuantity); + } + + /** + * @param OrderInterface $order + * @param CreditmemoInterface $creditmemo + * @param array $availableQuantity + * @param bool $isAdjustment + * @return array + * @author Alexander Wanyoike + */ + private function determineValidItemsToRefund( + OrderInterface $order, + CreditmemoInterface $creditmemo, + array $availableQuantity, + $isAdjustment = false + ) { + $validItemsToRefund = []; + foreach ($order->getAllItems() as $orderItem) { + foreach ($creditmemo->getItems() as $creditmemoItem) { + if ($this->cantBeRefunded($orderItem, $creditmemoItem, $availableQuantity)) { + continue; + } + + $validItemsToRefund[$orderItem->getId()] = $isAdjustment ? 0 : $creditmemoItem->getQty(); + } + } + return $validItemsToRefund; + } + + /** + * @param OrderItemInterface $orderItem + * @param CreditmemoItemInterface $creditmemoItem + * @param array $availableQuantity + * @return bool + * @author Alexander Wanyoike + */ + private function cantBeRefunded( + OrderItemInterface $orderItem, + CreditmemoItemInterface $creditmemoItem, + array $availableQuantity + ) { + return $orderItem->getSku() !== $creditmemoItem->getSku() + || $creditmemoItem->getQty() <= 0 + || $creditmemoItem->getQty() > $availableQuantity[$orderItem->getId()]; + } + + /** + * If the credit memo is an adjustment + * We will not do any returns thus all specified skus + * in the order will have a zero quantity. + * @param OrderInterface $order + * @return array + * @author Alexander Wanyoike + */ + private function getItemsWithoutQuantities(OrderInterface $order) + { + $validItemsToRefund = []; + foreach ($order->getAllItems() as $orderItem) { + $validItemsToRefund[$orderItem->getId()] = 0; + } + + return $validItemsToRefund; + } +} diff --git a/Model/ShipOrderByIncrementId.php b/Model/ShipOrderByIncrementId.php index a2edc1e..a233eee 100644 --- a/Model/ShipOrderByIncrementId.php +++ b/Model/ShipOrderByIncrementId.php @@ -1,15 +1,13 @@ setFilterGroups([ - (new FilterGroup)->setFilters([ - (new Filter) - ->setField('increment_id') - ->setConditionType('eq') - ->setValue($incrementId) - ]) - ]); - - $order = $this->orderRepository->getList($searchCriteria)->getItems(); - - if (empty($order)) { - throw new \LogicException("No order exists with increment ID '$incrementId'."); - } - - return reset($order); - } } diff --git a/Test/Integration/Model/CreditmemoByOrderIncrementIdTest.php b/Test/Integration/Model/CreditmemoByOrderIncrementIdTest.php new file mode 100644 index 0000000..e6d1620 --- /dev/null +++ b/Test/Integration/Model/CreditmemoByOrderIncrementIdTest.php @@ -0,0 +1,128 @@ +objectManager = Bootstrap::getObjectManager(); + $this->creditmemoRepository = $this->objectManager->get(CreditmemoRepositoryInterface::class); + $this->creditmemoByOrderIncrementId = $this->objectManager->get(CreditmemoByOrderIncrementIdInterface::class); + } + + /** + * @magentoDataFixture SnowIO_ExtendedSalesRepositories::Test/Integration/_files/order.php + * @dataProvider getStandardCaseTestData + * @param string $orderIncrementId + * @param CreditmemoInterface $creditmemo + * @param callable[] $assertions + */ + public function testStandardCase(CreditmemoInterface $creditmemo, $assertions) + { + /** @var CreditmemoInterface $creditmemo */ + $creditmemo = $this->creditmemoByOrderIncrementId->createAndRefund("100000001", $creditmemo); + foreach ($assertions as $assertion) { + $assertion($creditmemo); + } + } + + + /** + * @magentoDataFixture SnowIO_ExtendedSalesRepositories::Test/Integration/_files/order.php + * @dataProvider getErroneousCaseTestData + */ + public function testErroneousCase(CreditmemoInterface $creditmemo, string $errorClass) + { + self::expectException($errorClass); + $this->creditmemoByOrderIncrementId->createAndRefund("100000001", $creditmemo); + } + + public function getStandardCaseTestData() + { + return [ + "should create and refund the credit memo using the order increment id" => [ + $this->objectManager->create(CreditmemoInterface::class) + ->setItems([ + $this->objectManager->create(CreditmemoItemInterface::class) + ->setSku('simple') + ->setQty(1) + ]), + [ + $this->assertExistsInRepository(), + $this->assertItemsRefunded() + ] + ], + "should create and refund an adhoc adjustment credit memo using the order increment id" => [ + $this->objectManager->create(CreditmemoInterface::class) + ->setAdjustmentPositive(50), + [ + $this->assertExistsInRepository(), + $this->assertAdjustmentAmount(50.0), + ] + ] + ]; + } + + public function getErroneousCaseTestData() + { + return [ + "should throw if no credit memo has no items and is not an adjustment" => [ + $this->objectManager->create(CreditmemoInterface::class), + SnowCreditMemoException::class + ] + ]; + } + + private function assertExistsInRepository() + { + return function (CreditmemoInterface $creditmemo) { + $retrievedCreditmemo = $this->creditmemoRepository->get($creditmemo->getEntityId()); + self::assertNotEmpty($retrievedCreditmemo); + }; + } + + private function assertItemsRefunded() + { + return function (CreditmemoInterface $creditmemo) { + $retrievedCreditmemo = $this->creditmemoRepository->get($creditmemo->getEntityId()); + $itemsBySku = []; + foreach ($retrievedCreditmemo->getItems() as $retrievedCreditMemoItem) { + $itemsBySku[$retrievedCreditMemoItem->getSku()] = [ + 'qty' => $retrievedCreditMemoItem->getQty() + ]; + } + + foreach ($creditmemo->getItems() as $creditmemoItem) { + self::assertNotEmpty($itemsBySku[$creditmemoItem->getSku()]); + self::assertEquals($creditmemoItem->getQty(), $itemsBySku[$creditmemoItem->getSku()]['qty']); + } + }; + } + + private function assertAdjustmentAmount(float $amount) + { + return function (CreditmemoInterface $creditmemo) use ($amount) { + $retrievedCreditmemo = $this->creditmemoRepository->get($creditmemo->getEntityId()); + self::assertEquals($amount, $retrievedCreditmemo->getBaseAdjustmentPositive()); + }; + } +} diff --git a/Test/Integration/Model/ShipOrderByIncrementIdTest.php b/Test/Integration/Model/ShipOrderByIncrementIdTest.php new file mode 100644 index 0000000..0740012 --- /dev/null +++ b/Test/Integration/Model/ShipOrderByIncrementIdTest.php @@ -0,0 +1,103 @@ +objectManager = Bootstrap::getObjectManager(); + $this->shipOrderByIncrementId = $this->objectManager->get(ShipOrderByIncrementIdInterface::class); + $this->orderRepository = $this->objectManager->get(OrderRepositoryInterface::class); + $this->shipmentRepository = $this->objectManager->get(ShipmentRepositoryInterface::class); + } + + /** + * @magentoDataFixture SnowIO_ExtendedSalesRepositories::Test/Integration/_files/order.php + */ + public function testStandardCase() + { + $orderIncrementId = "100000001"; + $order = $this->loadOrderByIncrementId($orderIncrementId); + $items = array_map(function (Item $orderItem) { + return $this->objectManager + ->create(ShipmentItemCreationInterface::class) + ->setQty(1) + ->setOrderItemId($orderItem->getId()); + }, $order->getItems()); + $shipmentId = $this->shipOrderByIncrementId->execute($orderIncrementId, $items); + $this->assertShipmentContainsCorrectItems($items, $shipmentId); + + } + + /** + * @magentoDataFixture SnowIO_ExtendedSalesRepositories::Test/Integration/_files/order.php + * @dataProvider getErroneousCaseTestData + * @param string $orderIncrementId + * @param array $items + * @param string $errorClass + */ + public function testErroneousCase(string $orderIncrementId, array $items, string $errorClass) + { + $this->expectException($errorClass); + $this->shipOrderByIncrementId->execute($orderIncrementId, $items); + } + + public function getErroneousCaseTestData() + { + return [ + "should fail if no order with the increment id was found" => [ + "123131415", + [ + $this->objectManager->create(ShipmentItemCreationInterface::class) + ->setOrderItemId(1) + ->setQty(1) + ], + \LogicException::class, + ] + ]; + } + + public function assertShipmentContainsCorrectItems(array $items, string $shipmentId) + { + /** @var ShipmentInterface $shipment */ + $shipment = $this->shipmentRepository->get($shipmentId); + + $shipmentItemByOrderItemId = []; + foreach ($shipment->getItems() as $shipmentItem) { + $shipmentItemByOrderItemId[$shipmentItem->getOrderItemId()] = [ + 'qty' => $shipmentItem->getQty(), + ]; + } + + $inputItemsBySku = []; + foreach ($items as $item) { + $inputItemsBySku[$item->getOrderItemId()] = [ + 'qty' => $item->getQty() + ]; + } + + self::assertEquals($inputItemsBySku, $shipmentItemByOrderItemId); + } +} diff --git a/Test/Integration/Model/ShipmentRepositoryTest.php b/Test/Integration/Model/ShipmentRepositoryTest.php deleted file mode 100644 index a52fbe8..0000000 --- a/Test/Integration/Model/ShipmentRepositoryTest.php +++ /dev/null @@ -1,47 +0,0 @@ -objectManager = Bootstrap::getObjectManager(); - $this->orderRepository = $this->objectManager->get(OrderRepositoryInterface::class); - } - - /** - * @magentoDataFixture SnowIO/ExtendedSalesRepository/Test/Integration/_files/order.php - */ - public function testStandardCase() - { - $order = $this->orderRepository->get(1); - - $payment = $order->getPayment(); - $paymentInfoBlock = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->get('Magento\Payment\Helper\Data') - ->getInfoBlock($payment); - $payment->setBlockMock($paymentInfoBlock); - - /** @var ShipmentInterface $shipment */ - $shipment = $this->objectManager->create(ShipmentInterface::class); - $shipment->setOrderId($order->getEntityId()); - $shipmentItem = $this->objectManager->create(ShipmentItemInterface::class); - $shipmentItem->setOrderItem($order->getItems()[0]); - $shipment->addItem($shipmentItem); - $shipment->setPackages([['1'], ['2']]); - $shipment->setShipmentStatus(\Magento\Sales\Model\Order\Shipment::STATUS_NEW); - $shipment->save(); - } -} diff --git a/Test/Integration/_files/order.php b/Test/Integration/_files/order.php index fc8ac4a..93747b3 100644 --- a/Test/Integration/_files/order.php +++ b/Test/Integration/_files/order.php @@ -1,37 +1,108 @@ get('Magento\Framework\Registry'); -$registry->unregister('isSecureArea'); -$registry->register('isSecureArea', true); +/** @var \Magento\TestFramework\ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); -/** @var $order \Magento\Sales\Model\Order */ -$orderCollection = Bootstrap::getObjectManager()->create('Magento\Sales\Model\ResourceModel\Order\Collection'); -foreach ($orderCollection as $order) { - $order->delete(); -} +/** @var CategoryLinkManagementInterface $categoryLinkManagement */ +$categoryLinkManagement = $objectManager->get(CategoryLinkManagementInterface::class); -/** @var $product \Magento\Catalog\Model\Product */ -$productCollection = Bootstrap::getObjectManager()->create('Magento\Catalog\Model\ResourceModel\Product\Collection'); -foreach ($productCollection as $product) { - $product->delete(); -} +$tierPrices = []; +/** @var ProductTierPriceInterfaceFactory $tierPriceFactory */ +$tierPriceFactory = $objectManager->get(ProductTierPriceInterfaceFactory::class); +/** @var $tpExtensionAttributes */ +$tpExtensionAttributesFactory = $objectManager->get(ProductTierPriceExtensionFactory::class); +/** @var $productExtensionAttributes */ +$productExtensionAttributesFactory = $objectManager->get(ProductExtensionInterfaceFactory::class); -$registry->unregister('isSecureArea'); -$registry->register('isSecureArea', false); +$adminWebsite = $objectManager->get(WebsiteRepositoryInterface::class)->get('admin'); +$tierPriceExtensionAttributes1 = $tpExtensionAttributesFactory->create() + ->setWebsiteId($adminWebsite->getId()); +$productExtensionAttributesWebsiteIds = $productExtensionAttributesFactory->create( + ['website_ids' => $adminWebsite->getId()] +); +$tierPrices[] = $tierPriceFactory->create( + [ + 'data' => [ + 'customer_group_id' => Group::CUST_GROUP_ALL, + 'qty' => 2, + 'value' => 8 + ] + ] +)->setExtensionAttributes($tierPriceExtensionAttributes1); -/** @var \Magento\TestFramework\ObjectManager $objectManager */ -$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +$tierPrices[] = $tierPriceFactory->create( + [ + 'data' => [ + 'customer_group_id' => Group::CUST_GROUP_ALL, + 'qty' => 5, + 'value' => 5 + ] + ] +)->setExtensionAttributes($tierPriceExtensionAttributes1); + +$tierPrices[] = $tierPriceFactory->create( + [ + 'data' => [ + 'customer_group_id' => Group::NOT_LOGGED_IN_ID, + 'qty' => 3, + 'value' => 5 + ] + ] +)->setExtensionAttributes($tierPriceExtensionAttributes1); + +$tierPrices[] = $tierPriceFactory->create( + [ + 'data' => [ + 'customer_group_id' => Group::NOT_LOGGED_IN_ID, + 'qty' => 3.2, + 'value' => 6, + ] + ] +)->setExtensionAttributes($tierPriceExtensionAttributes1); -/** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ -$categoryLinkManagement = $objectManager->create('Magento\Catalog\Api\CategoryLinkManagementInterface'); +$tierPriceExtensionAttributes2 = $tpExtensionAttributesFactory->create() + ->setWebsiteId($adminWebsite->getId()) + ->setPercentageValue(50); -/** @var $product \Magento\Catalog\Model\Product */ -$product = $objectManager->create('Magento\Catalog\Model\Product'); +$tierPrices[] = $tierPriceFactory->create( + [ + 'data' => [ + 'customer_group_id' => Group::NOT_LOGGED_IN_ID, + 'qty' => 10 + ] + ] +)->setExtensionAttributes($tierPriceExtensionAttributes2); + +/** @var $product Product */ +$product = $objectManager->create(Product::class); $product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) +$product->setTypeId(Type::TYPE_SIMPLE) ->setId(1) ->setAttributeSetId(4) ->setWebsiteIds([1]) @@ -41,34 +112,14 @@ ->setWeight(1) ->setShortDescription("Short description") ->setTaxClassId(0) - ->setTierPrice( - [ - [ - 'website_id' => 0, - 'cust_group' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, - 'price_qty' => 2, - 'price' => 8, - ], - [ - 'website_id' => 0, - 'cust_group' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, - 'price_qty' => 5, - 'price' => 5, - ], - [ - 'website_id' => 0, - 'cust_group' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, - 'price_qty' => 3, - 'price' => 5, - ], - ] - ) + ->setTierPrices($tierPrices) ->setDescription('Description with html tag') + ->setExtensionAttributes($productExtensionAttributesWebsiteIds) ->setMetaTitle('meta title') ->setMetaKeyword('meta keyword') ->setMetaDescription('meta description') - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) ->setStockData( [ 'use_config_manage_stock' => 1, @@ -109,14 +160,14 @@ 'sort_order' => 0, 'values' => [ [ - 'option_type_id' => -1, + 'option_type_id' => null, 'title' => 'Option 1', 'price' => 3, 'price_type' => 'fixed', 'sku' => '3-1-select', ], [ - 'option_type_id' => -1, + 'option_type_id' => null, 'title' => 'Option 2', 'price' => 3, 'price_type' => 'fixed', @@ -132,14 +183,14 @@ 'sort_order' => 0, 'values' => [ [ - 'option_type_id' => -1, + 'option_type_id' => null, 'title' => 'Option 1', 'price' => 3, 'price_type' => 'fixed', 'sku' => '4-1-radio', ], [ - 'option_type_id' => -1, + 'option_type_id' => null, 'title' => 'Option 2', 'price' => 3, 'price_type' => 'fixed', @@ -152,7 +203,7 @@ $options = []; /** @var \Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory $customOptionFactory */ -$customOptionFactory = $objectManager->create('Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory'); +$customOptionFactory = $objectManager->create(\Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory::class); foreach ($oldOptions as $option) { /** @var \Magento\Catalog\Api\Data\ProductCustomOptionInterface $option */ @@ -164,85 +215,81 @@ $product->setOptions($options); -/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepositoryFactory */ -$productRepositoryFactory = $objectManager->create('Magento\Catalog\Api\ProductRepositoryInterface'); -$productRepositoryFactory->save($product); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$product = $productRepository->save($product); +$indexerProcessor = $objectManager->get(Processor::class); +$indexerProcessor->reindexRow($product->getId()); $categoryLinkManagement->assignProductToCategories( $product->getSku(), [2] ); + //Address -$addressData = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create('Magento\Sales\Model\Order\Address'); -$addressData->setRegion( - 'CA' -)->setPostcode( - '90210' -)->setFirstname( - 'a_unique_firstname' -)->setLastname( - 'lastname' -)->setStreet( - 'street' -)->setCity( - 'Beverly Hills' -)->setEmail( - 'admin@example.com' -)->setTelephone( - '1111111111' -)->setCountryId( - 'US' -)->setAddressType( - 'shipping' -)->save(); - -$billingAddress = $objectManager->create('Magento\Sales\Model\Order\Address', ['data' => $addressData]); +$addressData = [ + 'region' => 'CA', + 'region_id' => '12', + 'postcode' => '11111', + 'lastname' => 'lastname', + 'firstname' => 'firstname', + 'street' => 'street', + 'city' => 'Los Angeles', + 'email' => 'admin@example.com', + 'telephone' => '11111111', + 'country_id' => 'US' +]; + +$billingAddress = $objectManager->create(Address::class, ['data' => $addressData]); $billingAddress->setAddressType('billing'); $shippingAddress = clone $billingAddress; $shippingAddress->setId(null)->setAddressType('shipping'); -$payment = $objectManager->create('Magento\Sales\Model\Order\Payment'); +/** @var Payment $payment */ +$payment = $objectManager->create(Payment::class); $payment->setMethod('checkmo'); -/** @var \Magento\Sales\Model\Order\Item $orderItem */ -$orderItem = $objectManager->create('Magento\Sales\Model\Order\Item'); -$orderItem->setProductId($product->getId())->setQtyOrdered(2); -$orderItem->setBasePrice($product->getPrice()); -$orderItem->setPrice($product->getPrice()); -$orderItem->setRowTotal($product->getPrice()); -$orderItem->setProductType('simple'); - -/** @var \Magento\Sales\Model\Order $order */ -$order = $objectManager->create('Magento\Sales\Model\Order'); -$order->setIncrementId( - '100000001' -)->setState( - \Magento\Sales\Model\Order::STATE_PROCESSING -)->setStatus( - $order->getConfig()->getStateDefaultStatus(\Magento\Sales\Model\Order::STATE_PROCESSING) -)->setSubtotal( - 100 -)->setGrandTotal( - 100 -)->setBaseSubtotal( - 100 -)->setBaseGrandTotal( - 100 -)->setCustomerIsGuest( - true -)->setCustomerEmail( - 'customer@null.com' -)->setBillingAddress( - $billingAddress -)->setShippingAddress( - $shippingAddress -)->setStoreId( - $objectManager->get('Magento\Store\Model\StoreManagerInterface')->getStore()->getId() -)->addItem( - $orderItem -)->setPayment( - $payment -); -$order->save(); +/** @var Item $orderItem */ +$orderItem = $objectManager->create(Item::class); +$orderItem->setProductId($product->getId()) + ->setQtyOrdered(2) + ->setBasePrice($product->getPrice()) + ->setPrice($product->getPrice()) + ->setRowTotal($product->getPrice()) + ->setProductType('simple') + ->setName($product->getName()) + ->setSku($product->getSku()); + +/** @var Order $order */ +$order = $objectManager->create(Order::class); +$order->setIncrementId('100000001') + ->setState(Order::STATE_PROCESSING) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)) + ->setSubtotal(100) + ->setGrandTotal(100) + ->setBaseSubtotal(100) + ->setBaseGrandTotal(100) + ->setCustomerIsGuest(true) + ->setCustomerEmail('customer@null.com') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) + ->addItem($orderItem) + ->setPayment($payment); + + + +/** @var OrderRepositoryInterface $orderRepository */ +$orderRepository = $objectManager->create(OrderRepositoryInterface::class); +$orderRepository->save($order); + +/** @var InvoiceManagementInterface $orderService */ +$orderService = $objectManager->create(InvoiceManagementInterface::class); +/** @var InvoiceInterface $invoice */ +$invoice = $orderService->prepareInvoice($order); +$invoice->register(); +$order->setIsInProcess(true); +$transactionSave = $objectManager->create(Transaction::class); +$transactionSave->addObject($invoice)->addObject($order)->save(); \ No newline at end of file diff --git a/Test/TestCase.php b/Test/TestCase.php new file mode 100644 index 0000000..0acae41 --- /dev/null +++ b/Test/TestCase.php @@ -0,0 +1,14 @@ +=5.6", "magento/module-sales": "^100.1.2|^101.0.2|^102.0.1", "magento/framework": "^100.1.2|^101.0.2|^102.0.1" }, + "require-dev": { + "phpunit/phpunit": "^4.1|^6", + "ampersand/travis-vanilla-magento": "^1.0" + }, "autoload": { "files": [ "registration.php" ], "psr-4": { diff --git a/travis/prepare_phpunit_config.php b/travis/prepare_phpunit_config.php new file mode 100644 index 0000000..89cdc01 --- /dev/null +++ b/travis/prepare_phpunit_config.php @@ -0,0 +1,41 @@ +testsuites); +$testsuiteNode = $config->addChild('testsuites')->addChild('testsuite'); +$testsuiteNode->addAttribute('name', 'Integration'); +$testsuiteNode->addChild('directory', "$travisBuildDir/Test/Integration")->addAttribute('suffix', 'Test.php'); + +$codeCoverage = \getenv('CODE_COVERAGE'); +unset($config->logging); +if ($codeCoverage) { + $logNode = $config->addChild('logging')->addChild('log'); + $logNode->addAttribute('type', 'coverage-clover'); + $logNode->addAttribute('target', "$travisBuildDir/coverage.xml"); + + unset($config->filter); + $whitelistNode = $config->addChild('filter')->addChild('whitelist'); + $whitelistNode->addChild('directory', "../../../vendor/$packageName")->addAttribute('suffix', '.php'); + $whitelistNode->addChild('exclude')->addChild('file', "../../../vendor/$packageName/registration.php"); + $whitelistNode->addChild('exclude')->addChild('directory', "../../../vendor/$packageName/Setup"); + $whitelistNode->addChild('exclude')->addChild('directory', "../../../vendor/$packageName/Test"); + $whitelistNode->addChild('exclude')->addChild('directory', "../../../vendor/$packageName/travis"); +} + +$config->asXML($configPath); \ No newline at end of file