Skip to content

Commit e9bfaa6

Browse files
committed
MAGETWO-82242: Fixed amount discount for whole cart applying an extra cent to the discount amount
- Round price based on previous rounding operation delta applied
1 parent a518a12 commit e9bfaa6

File tree

6 files changed

+384
-7
lines changed

6 files changed

+384
-7
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
/**
3+
* Copyright © 2013-2017 Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
namespace Magento\SalesRule\Model;
7+
8+
use Magento\Framework\Pricing\PriceCurrencyInterface;
9+
10+
/**
11+
* Round price and save rounding operation delta.
12+
*/
13+
class DeltaPriceRound
14+
{
15+
/**
16+
* @var PriceCurrencyInterface
17+
*/
18+
private $priceCurrency;
19+
20+
/**
21+
* @var float[]
22+
*/
23+
private $roundingDeltas;
24+
25+
/**
26+
* @param PriceCurrencyInterface $priceCurrency
27+
*/
28+
public function __construct(
29+
PriceCurrencyInterface $priceCurrency
30+
) {
31+
$this->priceCurrency = $priceCurrency;
32+
}
33+
34+
/**
35+
* Round price based on previous rounding operation delta.
36+
*
37+
* @param float $price
38+
* @param string $type
39+
* @return float
40+
*/
41+
public function round($price, $type)
42+
{
43+
if ($price) {
44+
// initialize the delta to a small number to avoid non-deterministic behavior with rounding of 0.5
45+
$delta = isset($this->roundingDeltas[$type]) ? $this->roundingDeltas[$type] : 0.000001;
46+
$price += $delta;
47+
$roundPrice = $this->priceCurrency->round($price);
48+
$this->roundingDeltas[$type] = $price - $roundPrice;
49+
$price = $roundPrice;
50+
}
51+
52+
return $price;
53+
}
54+
55+
/**
56+
* Reset all deltas.
57+
*
58+
* @return void
59+
*/
60+
public function resetAll()
61+
{
62+
$this->roundingDeltas = [];
63+
}
64+
65+
/**
66+
* Reset deltas by type.
67+
*
68+
* @param string $type
69+
* @return void
70+
*/
71+
public function reset(string $type)
72+
{
73+
if (isset($this->roundingDeltas[$type])) {
74+
unset($this->roundingDeltas[$type]);
75+
}
76+
}
77+
}

app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
*/
66
namespace Magento\SalesRule\Model\Rule\Action\Discount;
77

8+
use Magento\Framework\App\ObjectManager;
9+
use Magento\Framework\Pricing\PriceCurrencyInterface;
10+
use Magento\SalesRule\Model\DeltaPriceRound;
11+
use Magento\SalesRule\Model\Validator;
12+
13+
/**
14+
* Calculates discount for cart item if fixed discount applied on whole cart.
15+
*/
816
class CartFixed extends AbstractDiscount
917
{
1018
/**
@@ -14,6 +22,33 @@ class CartFixed extends AbstractDiscount
1422
*/
1523
protected $_cartFixedRuleUsedForAddress = [];
1624

25+
/**
26+
* @var DeltaPriceRound
27+
*/
28+
private $deltaPriceRound;
29+
30+
/**
31+
* @var string
32+
*/
33+
private static $discountType = 'CartFixed';
34+
35+
/**
36+
* @param Validator $validator
37+
* @param DataFactory $discountDataFactory
38+
* @param PriceCurrencyInterface $priceCurrency
39+
* @param DeltaPriceRound $deltaPriceRound
40+
*/
41+
public function __construct(
42+
Validator $validator,
43+
DataFactory $discountDataFactory,
44+
PriceCurrencyInterface $priceCurrency,
45+
DeltaPriceRound $deltaPriceRound = null
46+
) {
47+
$this->deltaPriceRound = $deltaPriceRound ?: ObjectManager::getInstance()->get(DeltaPriceRound::class);
48+
49+
parent::__construct($validator, $discountDataFactory, $priceCurrency);
50+
}
51+
1752
/**
1853
* @param \Magento\SalesRule\Model\Rule $rule
1954
* @param \Magento\Quote\Model\Quote\Item\AbstractItem $item
@@ -51,14 +86,22 @@ public function calculate($rule, $item, $qty)
5186
$cartRules[$rule->getId()] = $rule->getDiscountAmount();
5287
}
5388

54-
if ($cartRules[$rule->getId()] > 0) {
89+
$availableDiscountAmount = (float)$cartRules[$rule->getId()];
90+
$discountType = self::$discountType . $rule->getId();
91+
92+
if ($availableDiscountAmount > 0) {
5593
$store = $quote->getStore();
5694
if ($ruleTotals['items_count'] <= 1) {
57-
$quoteAmount = $this->priceCurrency->convert($cartRules[$rule->getId()], $store);
58-
$baseDiscountAmount = min($baseItemPrice * $qty, $cartRules[$rule->getId()]);
95+
$quoteAmount = $this->priceCurrency->convert($availableDiscountAmount, $store);
96+
$baseDiscountAmount = min($baseItemPrice * $qty, $availableDiscountAmount);
97+
$this->deltaPriceRound->reset($discountType);
5998
} else {
60-
$discountRate = $baseItemPrice * $qty / $ruleTotals['base_items_price'];
61-
$maximumItemDiscount = $rule->getDiscountAmount() * $discountRate;
99+
$ratio = $baseItemPrice * $qty / $ruleTotals['base_items_price'];
100+
$maximumItemDiscount = $this->deltaPriceRound->round(
101+
$rule->getDiscountAmount() * $ratio,
102+
$discountType
103+
);
104+
62105
$quoteAmount = $this->priceCurrency->convert($maximumItemDiscount, $store);
63106

64107
$baseDiscountAmount = min($baseItemPrice * $qty, $maximumItemDiscount);
@@ -67,7 +110,11 @@ public function calculate($rule, $item, $qty)
67110

68111
$baseDiscountAmount = $this->priceCurrency->round($baseDiscountAmount);
69112

70-
$cartRules[$rule->getId()] -= $baseDiscountAmount;
113+
$availableDiscountAmount -= $baseDiscountAmount;
114+
$cartRules[$rule->getId()] = $availableDiscountAmount;
115+
if ($availableDiscountAmount <= 0) {
116+
$this->deltaPriceRound->reset($discountType);
117+
}
71118

72119
$discountData->setAmount($this->priceCurrency->round(min($itemPrice * $qty, $quoteAmount)));
73120
$discountData->setBaseAmount($baseDiscountAmount);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
/**
3+
* Copyright © 2013-2017 Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
namespace Magento\SalesRule\Test\Unit\Model;
7+
8+
use Magento\Framework\Pricing\PriceCurrencyInterface;
9+
use Magento\SalesRule\Model\DeltaPriceRound;
10+
11+
class DeltaPriceRoundTest extends \PHPUnit_Framework_TestCase
12+
{
13+
/**
14+
* @var PriceCurrencyInterface|\PHPUnit_Framework_MockObject_MockObject
15+
*/
16+
private $priceCurrency;
17+
18+
/**
19+
* @var DeltaPriceRound
20+
*/
21+
private $model;
22+
23+
protected function setUp()
24+
{
25+
$this->priceCurrency = $this->getMockForAbstractClass(PriceCurrencyInterface::class);
26+
$this->priceCurrency->method('round')
27+
->willReturnCallback(
28+
function ($amount) {
29+
return round($amount, 2);
30+
}
31+
);
32+
33+
$this->model = new DeltaPriceRound($this->priceCurrency);
34+
}
35+
36+
/**
37+
* Tests rounded price based on previous rounding operation delta.
38+
*
39+
* @param array $prices
40+
* @param array $roundedPrices
41+
* @dataProvider roundDataProvider
42+
*/
43+
public function testRound(array $prices, array $roundedPrices)
44+
{
45+
foreach ($prices as $key => $price) {
46+
$roundedPrice = $this->model->round($price, 'test');
47+
$this->assertEquals($roundedPrices[$key], $roundedPrice);
48+
}
49+
50+
$this->model->reset('test');
51+
}
52+
53+
/**
54+
* @return array
55+
*/
56+
public function roundDataProvider()
57+
{
58+
return [
59+
[
60+
'prices' => [1.004, 1.004],
61+
'rounded prices' => [1.00, 1.01],
62+
],
63+
[
64+
'prices' => [1.005, 1.005],
65+
'rounded prices' => [1.01, 1.0],
66+
]
67+
];
68+
}
69+
70+
public function testReset()
71+
{
72+
$this->assertEquals(1.44, $this->model->round(1.444, 'test'));
73+
$this->model->reset('test');
74+
$this->assertEquals(1.44, $this->model->round(1.444, 'test'));
75+
}
76+
77+
public function testResetAll()
78+
{
79+
$this->assertEquals(1.44, $this->model->round(1.444, 'test1'));
80+
$this->assertEquals(1.44, $this->model->round(1.444, 'test2'));
81+
82+
$this->model->resetAll();
83+
84+
$this->assertEquals(1.44, $this->model->round(1.444, 'test1'));
85+
$this->assertEquals(1.44, $this->model->round(1.444, 'test2'));
86+
}
87+
}

app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,15 @@ protected function setUp()
7474
);
7575
$dataFactory->expects($this->any())->method('create')->will($this->returnValue($this->data));
7676
$this->priceCurrency = $this->getMockBuilder('Magento\Framework\Pricing\PriceCurrencyInterface')->getMock();
77+
$deltaPriceRound = $this->getMockBuilder(\Magento\SalesRule\Model\DeltaPriceRound::class)
78+
->disableOriginalConstructor()
79+
->getMock();
80+
7781
$this->model = new \Magento\SalesRule\Model\Rule\Action\Discount\CartFixed(
7882
$this->validator,
7983
$dataFactory,
80-
$this->priceCurrency
84+
$this->priceCurrency,
85+
$deltaPriceRound
8186
);
8287
}
8388

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
/**
3+
* Copyright © 2013-2017 Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
namespace Magento\SalesRule\Model\Rule\Action\Discount;
7+
8+
use Magento\Catalog\Model\Product;
9+
use Magento\Catalog\Model\ProductRepository;
10+
use Magento\Quote\Api\Data\CartItemInterface;
11+
use Magento\Quote\Api\GuestCartTotalRepositoryInterface;
12+
use Magento\Quote\Api\GuestCouponManagementInterface;
13+
use Magento\Quote\Api\GuestCartItemRepositoryInterface;
14+
use Magento\Quote\Api\GuestCartManagementInterface;
15+
use Magento\TestFramework\Helper\Bootstrap;
16+
17+
class CartFixedTest extends \PHPUnit_Framework_TestCase
18+
{
19+
/**
20+
* @var GuestCartManagementInterface
21+
*/
22+
private $cartManagement;
23+
24+
/**
25+
* @var GuestCartItemRepositoryInterface
26+
*/
27+
private $cartItemRepository;
28+
29+
/**
30+
* @var GuestCouponManagementInterface
31+
*/
32+
private $couponManagement;
33+
34+
protected function setUp()
35+
{
36+
$this->cartManagement = Bootstrap::getObjectManager()->create(GuestCartManagementInterface::class);
37+
$this->couponManagement = Bootstrap::getObjectManager()->create(GuestCouponManagementInterface::class);
38+
$this->cartItemRepository = Bootstrap::getObjectManager()->create(GuestCartItemRepositoryInterface::class);
39+
}
40+
41+
/**
42+
* Applies fixed discount amount on whole cart.
43+
*
44+
* @param array $productPrices
45+
* @magentoDbIsolation enabled
46+
* @magentoDataFixture Magento/SalesRule/_files/coupon_cart_fixed_discount.php
47+
* @dataProvider applyFixedDiscountDataProvider
48+
*/
49+
public function testApplyFixedDiscount(array $productPrices)
50+
{
51+
$expectedDiscount = '-15.00';
52+
$couponCode = 'CART_FIXED_DISCOUNT_15';
53+
$cartId = $this->cartManagement->createEmptyCart();
54+
55+
foreach ($productPrices as $price) {
56+
$product = $this->createProduct($price);
57+
58+
/** @var CartItemInterface $quoteItem */
59+
$quoteItem = Bootstrap::getObjectManager()->create(CartItemInterface::class);
60+
$quoteItem->setQuoteId($cartId);
61+
$quoteItem->setProduct($product);
62+
$quoteItem->setQty(1);
63+
$this->cartItemRepository->save($quoteItem);
64+
}
65+
66+
$this->couponManagement->set($cartId, $couponCode);
67+
68+
/** @var GuestCartTotalRepositoryInterface $cartTotalRepository */
69+
$cartTotalRepository = Bootstrap::getObjectManager()->get(GuestCartTotalRepositoryInterface::class);
70+
$total = $cartTotalRepository->get($cartId);
71+
72+
$this->assertEquals($expectedDiscount, $total->getBaseDiscountAmount());
73+
}
74+
75+
/**
76+
* @return array
77+
*/
78+
public function applyFixedDiscountDataProvider()
79+
{
80+
return [
81+
'prices when discount had wrong value 15.01' => [[22, 14, 43, 7.50, 0.00]],
82+
'prices when discount had wrong value 14.99' => [[47, 33, 9.50, 42, 0.00]],
83+
];
84+
}
85+
86+
/**
87+
* Returns simple product with given price.
88+
*
89+
* @param float $price
90+
* @return \Magento\Catalog\Api\Data\ProductInterface
91+
*/
92+
private function createProduct($price)
93+
{
94+
$name = 'simple-' . $price;
95+
$productRepository = Bootstrap::getObjectManager()->get(ProductRepository::class);
96+
$product = Bootstrap::getObjectManager()->create(Product::class);
97+
$product->setTypeId('simple')
98+
->setAttributeSetId(4)
99+
->setWebsiteIds([1])
100+
->setName($name)
101+
->setSku(uniqid($name))
102+
->setPrice($price)
103+
->setMetaTitle('meta title')
104+
->setMetaKeyword('meta keyword')
105+
->setMetaDescription('meta description')
106+
->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH)
107+
->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED)
108+
->setStockData(['qty' => 1, 'is_in_stock' => 1])
109+
->setWeight(1);
110+
111+
return $productRepository->save($product);
112+
}
113+
}

0 commit comments

Comments
 (0)