Skip to content

Commit 9f40b30

Browse files
committed
Issue #2981526 by bojanz: Percentage order offers should make sure that the per-item discounts add up to the expected subtotal one
1 parent 070dde5 commit 9f40b30

6 files changed

Lines changed: 336 additions & 14 deletions

File tree

modules/order/commerce_order.services.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ services:
6767
tags:
6868
- { name: commerce_store.store_resolver, priority: 100 }
6969

70+
commerce_order.price_splitter:
71+
class: Drupal\commerce_order\PriceSplitter
72+
arguments: ['@entity_type.manager', '@commerce_price.rounder']
73+
7074
commerce_order.price_calculator:
7175
class: Drupal\commerce_order\PriceCalculator
7276
arguments: ['@commerce_order.adjustment_transformer', '@commerce_order.chain_order_type_resolver', '@commerce_price.chain_price_resolver', '@entity_type.manager', '@request_stack']
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace Drupal\commerce_order;
4+
5+
use Drupal\commerce_order\Entity\OrderInterface;
6+
use Drupal\commerce_price\Calculator;
7+
use Drupal\commerce_price\Price;
8+
use Drupal\commerce_price\RounderInterface;
9+
use Drupal\Core\Entity\EntityTypeManagerInterface;
10+
11+
class PriceSplitter implements PriceSplitterInterface {
12+
13+
/**
14+
* The currency storage.
15+
*
16+
* @var \Drupal\Core\Entity\EntityStorageInterface
17+
*/
18+
protected $currencyStorage;
19+
20+
/**
21+
* The rounder.
22+
*
23+
* @var \Drupal\commerce_price\RounderInterface
24+
*/
25+
protected $rounder;
26+
27+
/**
28+
* Constructs a new PriceSplitter object.
29+
*
30+
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
31+
* The entity type manager.
32+
* @param \Drupal\commerce_price\RounderInterface $rounder
33+
* The rounder.
34+
*/
35+
public function __construct(EntityTypeManagerInterface $entity_type_manager, RounderInterface $rounder) {
36+
$this->currencyStorage = $entity_type_manager->getStorage('commerce_currency');
37+
$this->rounder = $rounder;
38+
}
39+
40+
/**
41+
* {@inheritdoc}
42+
*/
43+
public function split(OrderInterface $order, Price $amount, $percentage = NULL) {
44+
if (!$percentage) {
45+
// The percentage is intentionally not rounded, for maximum precision.
46+
$percentage = Calculator::divide($amount->getNumber(), $order->getSubtotalPrice()->getNumber());
47+
}
48+
49+
// Calculate the initial per-order-item amounts using the percentage.
50+
// Round down to ensure that their sum isn't larger than the full amount.
51+
$amounts = [];
52+
foreach ($order->getItems() as $order_item) {
53+
if (!$order_item->getTotalPrice()->isZero()) {
54+
$individual_amount = $order_item->getTotalPrice()->multiply($percentage);
55+
$individual_amount = $this->rounder->round($individual_amount, PHP_ROUND_HALF_DOWN);
56+
$amounts[$order_item->id()] = $individual_amount;
57+
58+
$amount = $amount->subtract($individual_amount);
59+
}
60+
}
61+
62+
// The individual amounts don't add up to the full amount, distribute
63+
// the reminder among them.
64+
if (!$amount->isZero()) {
65+
/** @var \Drupal\commerce_price\Entity\CurrencyInterface $currency */
66+
$currency = $this->currencyStorage->load($amount->getCurrencyCode());
67+
$precision = $currency->getFractionDigits();
68+
// Use the smallest rounded currency amount (e.g. '0.01' for USD).
69+
$smallest_number = Calculator::divide('1', pow(10, $precision), $precision);
70+
$smallest_amount = new Price($smallest_number, $amount->getCurrencyCode());
71+
while (!$amount->isZero()) {
72+
foreach ($amounts as $order_item_id => $individual_amount) {
73+
$amounts[$order_item_id] = $individual_amount->add($smallest_amount);
74+
$amount = $amount->subtract($smallest_amount);
75+
if ($amount->isZero()) {
76+
break 2;
77+
}
78+
}
79+
}
80+
}
81+
82+
return $amounts;
83+
}
84+
85+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Drupal\commerce_order;
4+
5+
use Drupal\commerce_order\Entity\OrderInterface;
6+
use Drupal\commerce_price\Price;
7+
8+
/**
9+
* Splits price amounts across order items.
10+
*
11+
* Useful for dividing a single order-level promotion or fee into multiple
12+
* order-item-level ones, for easier VAT calculation or refunds.
13+
*/
14+
interface PriceSplitterInterface {
15+
16+
/**
17+
* Splits the given amount across order items.
18+
*
19+
* @param \Drupal\commerce_order\Entity\OrderInterface $order
20+
* The order.
21+
* @param \Drupal\commerce_price\Price $amount
22+
* The amount.
23+
* @param string $percentage
24+
* The percentage used to calculate the amount, as a decimal.
25+
* For example, '0.2' for 20%. When missing, calculated by comparing
26+
* the amount to the order subtotal.
27+
*
28+
* @return \Drupal\commerce_price\Price[]
29+
* An array of amounts keyed by order item ID.
30+
*/
31+
public function split(OrderInterface $order, Price $amount, $percentage = NULL);
32+
33+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
namespace Drupal\Tests\commerce_order\Kernel;
4+
5+
use Drupal\commerce_order\Entity\Order;
6+
use Drupal\commerce_order\Entity\OrderItem;
7+
use Drupal\commerce_order\Entity\OrderItemType;
8+
use Drupal\commerce_price\Price;
9+
use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase;
10+
11+
/**
12+
* Tests the price splitter.
13+
*
14+
* @coversDefaultClass \Drupal\commerce_order\PriceSplitter
15+
*
16+
* @group commerce
17+
*/
18+
class PriceSplitterTest extends CommerceKernelTestBase {
19+
20+
/**
21+
* A sample order.
22+
*
23+
* @var \Drupal\commerce_order\Entity\OrderInterface
24+
*/
25+
protected $order;
26+
27+
/**
28+
* The price splitter.
29+
*
30+
* @var \Drupal\commerce_order\PriceSplitterInterface
31+
*/
32+
protected $splitter;
33+
34+
/**
35+
* Modules to enable.
36+
*
37+
* @var array
38+
*/
39+
public static $modules = [
40+
'entity_reference_revisions',
41+
'path',
42+
'profile',
43+
'state_machine',
44+
'commerce_product',
45+
'commerce_order',
46+
];
47+
48+
/**
49+
* {@inheritdoc}
50+
*/
51+
protected function setUp() {
52+
parent::setUp();
53+
54+
$this->installEntitySchema('profile');
55+
$this->installEntitySchema('commerce_order');
56+
$this->installEntitySchema('commerce_order_item');
57+
$this->installEntitySchema('commerce_product');
58+
$this->installEntitySchema('commerce_product_variation');
59+
$this->installConfig(['commerce_product', 'commerce_order']);
60+
$user = $this->createUser(['mail' => $this->randomString() . '@example.com']);
61+
62+
OrderItemType::create([
63+
'id' => 'test',
64+
'label' => 'Test',
65+
'orderType' => 'default',
66+
])->save();
67+
68+
$order = Order::create([
69+
'type' => 'default',
70+
'state' => 'draft',
71+
'mail' => $user->getEmail(),
72+
'uid' => $user->id(),
73+
'ip_address' => '127.0.0.1',
74+
'order_number' => '6',
75+
'store_id' => $this->store->id(),
76+
]);
77+
$order->save();
78+
$this->order = $this->reloadEntity($order);
79+
80+
$this->splitter = $this->container->get('commerce_order.price_splitter');
81+
}
82+
83+
/**
84+
* @covers ::split
85+
*/
86+
public function testSplit() {
87+
// 6 x 3 + 6 x 3 = 36.
88+
$amount = new Price('6', 'USD');
89+
$order_items = $this->buildOrderItems([$amount, $amount]);
90+
$this->order->setItems($order_items);
91+
$this->order->save();
92+
93+
// Each order item should be discounted by half (9 USD).
94+
$amounts = $this->splitter->split($this->order, new Price('18', 'USD'));
95+
$expected_amount = new Price('9', 'USD');
96+
$this->assertEquals([$expected_amount, $expected_amount], array_values($amounts));
97+
98+
// Same result with an explicit percentage.
99+
$amounts = $this->splitter->split($this->order, new Price('18', 'USD'), '0.5');
100+
$expected_amount = new Price('9', 'USD');
101+
$this->assertEquals([$expected_amount, $expected_amount], array_values($amounts));
102+
103+
// 9.99 x 3 + 1.01 x 3 = 33.
104+
$first_amount = new Price('9.99', 'USD');
105+
$second_amount = new Price('1.01', 'USD');
106+
$order_items = $this->buildOrderItems([$first_amount, $second_amount]);
107+
$this->order->setItems($order_items);
108+
$this->order->save();
109+
110+
$amount = new Price('5', 'USD');
111+
$amounts = $this->splitter->split($this->order, $amount);
112+
$first_expected_amount = new Price('4.54', 'USD');
113+
$second_expected_amount = new Price('0.46', 'USD');
114+
$this->assertEquals($amount, $first_expected_amount->add($second_expected_amount));
115+
$this->assertEquals([$first_expected_amount, $second_expected_amount], array_values($amounts));
116+
117+
// Split an amount that has a reminder.
118+
$amount = new Price('3.98', 'USD');
119+
$amounts = $this->splitter->split($this->order, $amount);
120+
$first_expected_amount = new Price('3.62', 'USD');
121+
$second_expected_amount = new Price('0.36', 'USD');
122+
$this->assertEquals($amount, $first_expected_amount->add($second_expected_amount));
123+
$this->assertEquals([$first_expected_amount, $second_expected_amount], array_values($amounts));
124+
}
125+
126+
/**
127+
* Builds the order items for the given unit prices.
128+
*
129+
* @param \Drupal\commerce_price\Price[] $unit_prices
130+
* The unit prices.
131+
*
132+
* @return \Drupal\commerce_order\Entity\OrderItemInterface[]
133+
* The order items.
134+
*/
135+
protected function buildOrderItems(array $unit_prices) {
136+
$order_items = [];
137+
foreach ($unit_prices as $unit_price) {
138+
$order_item = OrderItem::create([
139+
'type' => 'test',
140+
'unit_price' => $unit_price,
141+
'quantity' => '3',
142+
]);
143+
$order_item->save();
144+
145+
$order_items[] = $order_item;
146+
}
147+
148+
return $order_items;
149+
}
150+
151+
}

modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
44

55
use Drupal\commerce_order\Adjustment;
6+
use Drupal\commerce_order\PriceSplitterInterface;
7+
use Drupal\commerce_price\RounderInterface;
68
use Drupal\commerce_promotion\Entity\PromotionInterface;
79
use Drupal\Core\Entity\EntityInterface;
10+
use Symfony\Component\DependencyInjection\ContainerInterface;
811

912
/**
1013
* Provides the percentage off offer for orders.
1114
*
15+
* The discount is split between order items, to simplify VAT taxes and refunds.
16+
*
1217
* @CommercePromotionOffer(
1318
* id = "order_percentage_off",
1419
* label = @Translation("Percentage off the order subtotal"),
@@ -17,26 +22,70 @@
1722
*/
1823
class OrderPercentageOff extends PercentageOffBase {
1924

25+
/**
26+
* The price splitter.
27+
*
28+
* @var \Drupal\commerce_order\PriceSplitterInterface
29+
*/
30+
protected $splitter;
31+
32+
/**
33+
* Constructs a new OrderPercentageOff object.
34+
*
35+
* @param array $configuration
36+
* A configuration array containing information about the plugin instance.
37+
* @param string $plugin_id
38+
* The pluginId for the plugin instance.
39+
* @param mixed $plugin_definition
40+
* The plugin implementation definition.
41+
* @param \Drupal\commerce_price\RounderInterface $rounder
42+
* The rounder.
43+
* @param \Drupal\commerce_order\PriceSplitterInterface $splitter
44+
* The splitter.
45+
*/
46+
public function __construct(array $configuration, $plugin_id, $plugin_definition, RounderInterface $rounder, PriceSplitterInterface $splitter) {
47+
parent::__construct($configuration, $plugin_id, $plugin_definition, $rounder);
48+
49+
$this->splitter = $splitter;
50+
}
51+
52+
/**
53+
* {@inheritdoc}
54+
*/
55+
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
56+
return new static(
57+
$configuration,
58+
$plugin_id,
59+
$plugin_definition,
60+
$container->get('commerce_price.rounder'),
61+
$container->get('commerce_order.price_splitter')
62+
);
63+
}
64+
2065
/**
2166
* {@inheritdoc}
2267
*/
2368
public function apply(EntityInterface $entity, PromotionInterface $promotion) {
2469
$this->assertEntity($entity);
2570
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
2671
$order = $entity;
27-
// Reduce each individual order item, to simplify VAT taxes, refunds.
72+
$percentage = $this->getPercentage();
73+
// Calculate the order-level discount and split it across order items.
74+
$amount = $order->getSubtotalPrice()->multiply($percentage);
75+
$amount = $this->rounder->round($amount);
76+
$amounts = $this->splitter->split($order, $amount, $percentage);
77+
2878
foreach ($order->getItems() as $order_item) {
29-
$adjustment_amount = $order_item->getTotalPrice()->multiply($this->getPercentage());
30-
$adjustment_amount = $this->rounder->round($adjustment_amount);
31-
32-
$order_item->addAdjustment(new Adjustment([
33-
'type' => 'promotion',
34-
// @todo Change to label from UI when added in #2770731.
35-
'label' => t('Discount'),
36-
'amount' => $adjustment_amount->multiply('-1'),
37-
'percentage' => $this->getPercentage(),
38-
'source_id' => $promotion->id(),
39-
]));
79+
if (isset($amounts[$order_item->id()])) {
80+
$order_item->addAdjustment(new Adjustment([
81+
'type' => 'promotion',
82+
// @todo Change to label from UI when added in #2770731.
83+
'label' => t('Discount'),
84+
'amount' => $amounts[$order_item->id()]->multiply('-1'),
85+
'percentage' => $percentage,
86+
'source_id' => $promotion->id(),
87+
]));
88+
}
4089
}
4190
}
4291

0 commit comments

Comments
 (0)