Skip to content

Commit c99d89f

Browse files
committed
ACP2E-1918: Cannot create more than 1 Credit Memo for $0 Order
1 parent 1b6dc48 commit c99d89f

File tree

5 files changed

+273
-82
lines changed

5 files changed

+273
-82
lines changed

app/code/Magento/Sales/Model/Order.php

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,49 @@
55
*/
66
namespace Magento\Sales\Model;
77

8+
use Magento\Catalog\Api\ProductRepositoryInterface;
9+
use Magento\Catalog\Model\Product\Visibility;
810
use Magento\Config\Model\Config\Source\Nooptreq;
911
use Magento\Directory\Model\Currency;
12+
use Magento\Directory\Model\CurrencyFactory;
1013
use Magento\Directory\Model\RegionFactory;
1114
use Magento\Directory\Model\ResourceModel\Region as RegionResource;
1215
use Magento\Framework\Api\AttributeValueFactory;
16+
use Magento\Framework\Api\ExtensionAttributesFactory;
1317
use Magento\Framework\Api\SearchCriteriaBuilder;
1418
use Magento\Framework\App\Config\ScopeConfigInterface;
1519
use Magento\Framework\App\ObjectManager;
20+
use Magento\Framework\Data\Collection\AbstractDb;
1621
use Magento\Framework\Exception\LocalizedException;
1722
use Magento\Framework\Locale\ResolverInterface;
23+
use Magento\Framework\Model\Context;
24+
use Magento\Framework\Model\ResourceModel\AbstractResource;
1825
use Magento\Framework\Pricing\PriceCurrencyInterface;
26+
use Magento\Framework\Registry;
27+
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
1928
use Magento\Sales\Api\Data\OrderInterface;
2029
use Magento\Sales\Api\Data\OrderItemInterface;
2130
use Magento\Sales\Api\Data\OrderStatusHistoryInterface;
31+
use Magento\Sales\Api\InvoiceManagementInterface;
2232
use Magento\Sales\Api\OrderItemRepositoryInterface;
33+
use Magento\Sales\Model\Order\Config;
34+
use Magento\Sales\Model\Order\CreditmemoValidator;
2335
use Magento\Sales\Model\Order\Payment;
2436
use Magento\Sales\Model\Order\ProductOption;
37+
use Magento\Sales\Model\Order\Status\HistoryFactory;
2538
use Magento\Sales\Model\ResourceModel\Order\Address\Collection;
2639
use Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection as CreditmemoCollection;
2740
use Magento\Sales\Model\ResourceModel\Order\Invoice\Collection as InvoiceCollection;
2841
use Magento\Sales\Model\ResourceModel\Order\Item\Collection as ItemCollection;
42+
use Magento\Sales\Model\ResourceModel\Order\Item\CollectionFactory;
2943
use Magento\Sales\Model\ResourceModel\Order\Payment\Collection as PaymentCollection;
3044
use Magento\Sales\Model\ResourceModel\Order\Shipment\Collection as ShipmentCollection;
3145
use Magento\Sales\Model\ResourceModel\Order\Shipment\Track\Collection as TrackCollection;
3246
use Magento\Sales\Model\ResourceModel\Order\Status\History\Collection as HistoryCollection;
3347
use Magento\Store\Model\ScopeInterface;
3448
use Magento\Framework\App\Area;
3549
use Magento\Sales\Model\Order\StatusLabel;
50+
use Magento\Store\Model\StoreManagerInterface;
3651

3752
/**
3853
* Order model
@@ -333,20 +348,25 @@ class Order extends AbstractModel implements EntityInterface, OrderInterface
333348
private $statusLabel;
334349

335350
/**
336-
* @param \Magento\Framework\Model\Context $context
337-
* @param \Magento\Framework\Registry $registry
338-
* @param \Magento\Framework\Api\ExtensionAttributesFactory $extensionFactory
351+
* @var ?CreditmemoValidator
352+
*/
353+
private $creditmemoValidator;
354+
355+
/**
356+
* @param Context $context
357+
* @param Registry $registry
358+
* @param ExtensionAttributesFactory $extensionFactory
339359
* @param AttributeValueFactory $customAttributeFactory
340-
* @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $timezone
341-
* @param \Magento\Store\Model\StoreManagerInterface $storeManager
342-
* @param Order\Config $orderConfig
343-
* @param \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
344-
* @param \Magento\Sales\Model\ResourceModel\Order\Item\CollectionFactory $orderItemCollectionFactory
345-
* @param \Magento\Catalog\Model\Product\Visibility $productVisibility
346-
* @param \Magento\Sales\Api\InvoiceManagementInterface $invoiceManagement
347-
* @param \Magento\Directory\Model\CurrencyFactory $currencyFactory
360+
* @param TimezoneInterface $timezone
361+
* @param StoreManagerInterface $storeManager
362+
* @param Config $orderConfig
363+
* @param ProductRepositoryInterface $productRepository
364+
* @param CollectionFactory $orderItemCollectionFactory
365+
* @param Visibility $productVisibility
366+
* @param InvoiceManagementInterface $invoiceManagement
367+
* @param CurrencyFactory $currencyFactory
348368
* @param \Magento\Eav\Model\Config $eavConfig
349-
* @param Order\Status\HistoryFactory $orderHistoryFactory
369+
* @param HistoryFactory $orderHistoryFactory
350370
* @param \Magento\Sales\Model\ResourceModel\Order\Address\CollectionFactory $addressCollectionFactory
351371
* @param \Magento\Sales\Model\ResourceModel\Order\Payment\CollectionFactory $paymentCollectionFactory
352372
* @param \Magento\Sales\Model\ResourceModel\Order\Status\History\CollectionFactory $historyCollectionFactory
@@ -357,8 +377,8 @@ class Order extends AbstractModel implements EntityInterface, OrderInterface
357377
* @param ResourceModel\Order\CollectionFactory $salesOrderCollectionFactory
358378
* @param PriceCurrencyInterface $priceCurrency
359379
* @param \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productListFactory
360-
* @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource
361-
* @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection
380+
* @param AbstractResource|null $resource
381+
* @param AbstractDb|null $resourceCollection
362382
* @param array $data
363383
* @param ResolverInterface|null $localeResolver
364384
* @param ProductOption|null $productOption
@@ -368,8 +388,10 @@ class Order extends AbstractModel implements EntityInterface, OrderInterface
368388
* @param RegionFactory|null $regionFactory
369389
* @param RegionResource|null $regionResource
370390
* @param StatusLabel|null $statusLabel
391+
* @param CreditmemoValidator|null $creditmemoValidator
371392
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
372393
* @SuppressWarnings(PHPMD.NPathComplexity)
394+
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
373395
*/
374396
public function __construct(
375397
\Magento\Framework\Model\Context $context,
@@ -406,7 +428,8 @@ public function __construct(
406428
ScopeConfigInterface $scopeConfig = null,
407429
RegionFactory $regionFactory = null,
408430
RegionResource $regionResource = null,
409-
StatusLabel $statusLabel = null
431+
StatusLabel $statusLabel = null,
432+
CreditmemoValidator $creditmemoValidator = null
410433
) {
411434
$this->_storeManager = $storeManager;
412435
$this->_orderConfig = $orderConfig;
@@ -439,6 +462,8 @@ public function __construct(
439462
$this->regionResource = $regionResource ?: ObjectManager::getInstance()->get(RegionResource::class);
440463
$this->regionItems = [];
441464
$this->statusLabel = $statusLabel ?: ObjectManager::getInstance()->get(StatusLabel::class);
465+
$this->creditmemoValidator = $creditmemoValidator ?:
466+
ObjectManager::getInstance()->get(CreditmemoValidator::class);
442467
parent::__construct(
443468
$context,
444469
$registry,
@@ -744,18 +769,24 @@ private function canCreditmemoForZeroTotalRefunded($totalRefunded)
744769
*/
745770
private function canCreditmemoForZeroTotal($totalRefunded)
746771
{
772+
foreach ($this->getAllItems() as $orderItem) {
773+
if ($this->creditmemoValidator->canRefundItem($orderItem)) {
774+
return true;
775+
}
776+
}
777+
747778
$totalPaid = $this->getTotalPaid();
748779
//check if total paid is less than grandtotal
749780
$checkAmtTotalPaid = $totalPaid <= $this->getGrandTotal();
750781
//case when amount is due for invoice
751782
$hasDueAmount = $this->canInvoice() && ($checkAmtTotalPaid);
752783
//case when paid amount is refunded and order has creditmemo created
753-
$creditmemos = ($this->getCreditmemosCollection() === false) ?
754-
true : ($this->_memoCollectionFactory->create()->setOrderFilter($this)->getTotalCount() > 0);
784+
$creditmemos = $this->getCreditmemosCollection() === false ||
785+
$this->_memoCollectionFactory->create()->setOrderFilter($this)->getTotalCount() > 0;
755786
$paidAmtIsRefunded = $this->getTotalRefunded() == $totalPaid && $creditmemos;
756-
if (($hasDueAmount || $paidAmtIsRefunded) ||
757-
(!$checkAmtTotalPaid &&
758-
abs($totalRefunded - $this->getAdjustmentNegative()) < .0001)) {
787+
if ($hasDueAmount ||
788+
$paidAmtIsRefunded ||
789+
(!$checkAmtTotalPaid && abs($totalRefunded - $this->getAdjustmentNegative()) < .0001)) {
759790
return false;
760791
}
761792
return true;

app/code/Magento/Sales/Model/Order/CreditmemoFactory.php

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use Magento\Framework\Locale\FormatInterface;
1111
use Magento\Framework\Serialize\Serializer\Json as JsonSerializer;
1212
use Magento\Sales\Api\Data\OrderItemInterface;
13+
use Magento\Sales\Model\Convert\OrderFactory;
14+
use Magento\Tax\Model\Config;
1315

1416
/**
1517
* Factory class for @see \Magento\Sales\Model\Order\Creditmemo
@@ -48,24 +50,33 @@ class CreditmemoFactory
4850
*/
4951
private $serializer;
5052

53+
/**
54+
* @var CreditmemoValidator
55+
*/
56+
private CreditmemoValidator $creditmemoValidator;
57+
5158
/**
5259
* Factory constructor
5360
*
54-
* @param \Magento\Sales\Model\Convert\OrderFactory $convertOrderFactory
55-
* @param \Magento\Tax\Model\Config $taxConfig
56-
* @param JsonSerializer $serializer
57-
* @param FormatInterface $localeFormat
61+
* @param OrderFactory $convertOrderFactory
62+
* @param Config $taxConfig
63+
* @param JsonSerializer|null $serializer
64+
* @param FormatInterface|null $localeFormat
65+
* @param CreditmemoValidator|null $creditmemoValidator
5866
*/
5967
public function __construct(
6068
\Magento\Sales\Model\Convert\OrderFactory $convertOrderFactory,
6169
\Magento\Tax\Model\Config $taxConfig,
6270
JsonSerializer $serializer = null,
63-
FormatInterface $localeFormat = null
71+
FormatInterface $localeFormat = null,
72+
CreditmemoValidator $creditmemoValidator = null
6473
) {
6574
$this->convertor = $convertOrderFactory->create();
6675
$this->taxConfig = $taxConfig;
6776
$this->serializer = $serializer ?: ObjectManager::getInstance()->get(JsonSerializer::class);
6877
$this->localeFormat = $localeFormat ?: ObjectManager::getInstance()->get(FormatInterface::class);
78+
$this->creditmemoValidator = $creditmemoValidator ?
79+
: ObjectManager::getInstance()->get(CreditmemoValidator::class);
6980
}
7081

7182
/**
@@ -152,55 +163,25 @@ public function createByInvoice(\Magento\Sales\Model\Order\Invoice $invoice, arr
152163
* @param \Magento\Sales\Model\Order\Item $item
153164
* @param array $qtys
154165
* @param array $invoiceQtysRefundLimits
166+
*
155167
* @return bool
156-
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
157168
*/
158169
protected function canRefundItem($item, $qtys = [], $invoiceQtysRefundLimits = [])
159170
{
160-
if ($item->isDummy()) {
161-
if ($item->getHasChildren()) {
162-
foreach ($item->getChildrenItems() as $child) {
163-
if (empty($qtys) || (count(array_unique($qtys)) === 1 && (int)end($qtys) === 0)) {
164-
if ($this->canRefundNoDummyItem($child, $invoiceQtysRefundLimits)) {
165-
return true;
166-
}
167-
} else {
168-
if (isset($qtys[$child->getId()]) && $qtys[$child->getId()] > 0) {
169-
return true;
170-
}
171-
}
172-
}
173-
return false;
174-
} elseif ($item->getParentItem()) {
175-
$parent = $item->getParentItem();
176-
if (empty($qtys)) {
177-
return $this->canRefundNoDummyItem($parent, $invoiceQtysRefundLimits);
178-
} else {
179-
return isset($qtys[$parent->getId()]) && $qtys[$parent->getId()] > 0;
180-
}
181-
}
182-
return false;
183-
} else {
184-
return $this->canRefundNoDummyItem($item, $invoiceQtysRefundLimits);
185-
}
171+
return $this->creditmemoValidator->canRefundItem($item, $qtys, $invoiceQtysRefundLimits);
186172
}
187173

188174
/**
189-
* Check if no dummy order item can be refunded
175+
* Check if no dummy order item can be refunded.
190176
*
191177
* @param \Magento\Sales\Model\Order\Item $item
192178
* @param array $invoiceQtysRefundLimits
179+
*
193180
* @return bool
194181
*/
195182
protected function canRefundNoDummyItem($item, $invoiceQtysRefundLimits = [])
196183
{
197-
if ($item->getQtyToRefund() < 0) {
198-
return false;
199-
}
200-
if (isset($invoiceQtysRefundLimits[$item->getId()])) {
201-
return $invoiceQtysRefundLimits[$item->getId()] > 0;
202-
}
203-
return true;
184+
return $this->creditmemoValidator->canRefundNoDummyItem($item, $invoiceQtysRefundLimits);
204185
}
205186

206187
/**
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Sales\Model\Order;
9+
10+
/**
11+
* Order item quantities validation for Creditmemo creation.
12+
*/
13+
class CreditmemoValidator
14+
{
15+
16+
/**
17+
* Check if no dummy order item can be refunded
18+
*
19+
* @param Item $item
20+
* @param ?array $invoiceQtysRefundLimits
21+
* @return bool
22+
*/
23+
public function canRefundNoDummyItem(Item $item, ?array $invoiceQtysRefundLimits = []): bool
24+
{
25+
if ($item->getQtyToRefund() <= 0) {
26+
return false;
27+
}
28+
if (isset($invoiceQtysRefundLimits[$item->getId()])) {
29+
return $invoiceQtysRefundLimits[$item->getId()] > 0;
30+
}
31+
32+
return true;
33+
}
34+
35+
/**
36+
* Check if order item can be refunded
37+
*
38+
* @param Item $item
39+
* @param ?array $qtys
40+
* @param ?array $invoiceQtysRefundLimits
41+
* @return bool
42+
*/
43+
public function canRefundItem(Item $item, ?array $qtys = [], ?array $invoiceQtysRefundLimits = []): bool
44+
{
45+
if ($item->isDummy()) {
46+
if ($item->getHasChildren()) {
47+
return $this->canRefundDummyItemWithChildren($item, $qtys, $invoiceQtysRefundLimits);
48+
} elseif ($item->getParentItem()) {
49+
return $this->canRefundDummyItemWithParent($item, $qtys, $invoiceQtysRefundLimits);
50+
}
51+
return false;
52+
}
53+
54+
return $this->canRefundNoDummyItem($item, $invoiceQtysRefundLimits);
55+
}
56+
57+
/**
58+
* Check if dummy order item which has children can be refunded
59+
*
60+
* @param Item $item
61+
* @param array|null $qtys
62+
* @param array|null $invoiceQtysRefundLimits
63+
* @return bool
64+
*/
65+
private function canRefundDummyItemWithChildren(Item $item, ?array $qtys, ?array $invoiceQtysRefundLimits): bool
66+
{
67+
foreach ($item->getChildrenItems() as $child) {
68+
if (empty($qtys) || (count(array_unique($qtys)) === 1 && (int)end($qtys) === 0)) {
69+
if ($this->canRefundNoDummyItem($child, $invoiceQtysRefundLimits)) {
70+
return true;
71+
}
72+
} else {
73+
if (isset($qtys[$child->getId()]) && $qtys[$child->getId()] > 0) {
74+
return true;
75+
}
76+
}
77+
}
78+
79+
return false;
80+
}
81+
82+
/**
83+
* Check if dummy order item which has parent can be refunded
84+
*
85+
* @param Item $item
86+
* @param array|null $qtys
87+
* @param array|null $invoiceQtysRefundLimits
88+
* @return bool
89+
*/
90+
private function canRefundDummyItemWithParent(Item $item, ?array $qtys, ?array $invoiceQtysRefundLimits): bool
91+
{
92+
$parent = $item->getParentItem();
93+
if (empty($qtys)) {
94+
return $this->canRefundNoDummyItem($parent, $invoiceQtysRefundLimits);
95+
} else {
96+
return isset($qtys[$parent->getId()]) && $qtys[$parent->getId()] > 0;
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)