Skip to content

Commit 42796fc

Browse files
authored
Issue #2855119 by mglaman, niko-: Use a separate order processor for coupon based promotions
1 parent d96c0e5 commit 42796fc

7 files changed

Lines changed: 240 additions & 29 deletions

File tree

modules/promotion/commerce_promotion.services.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ services:
1111
arguments: ['@entity_type.manager']
1212
tags:
1313
- { name: commerce_order.order_processor, priority: 50 }
14+
15+
commerce_promotion.coupon_order_processor:
16+
class: Drupal\commerce_promotion\CouponOrderProcessor
17+
arguments: ['@entity_type.manager']
18+
tags:
19+
- { name: commerce_order.order_processor, priority: 50 }
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Drupal\commerce_promotion;
4+
5+
use Drupal\commerce_order\Entity\OrderInterface;
6+
use Drupal\commerce_order\OrderProcessorInterface;
7+
use Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface;
8+
use Drupal\Core\Entity\EntityTypeManagerInterface;
9+
10+
/**
11+
* Applies coupon promotions to orders during the order refresh process.
12+
*
13+
* @see \Drupal\commerce_promotion\PromotionOrderProcessor
14+
*/
15+
class CouponOrderProcessor implements OrderProcessorInterface {
16+
17+
/**
18+
* The promotion storage.
19+
*
20+
* @var \Drupal\commerce_promotion\PromotionStorageInterface
21+
*/
22+
protected $promotionStorage;
23+
24+
/**
25+
* The order type storage.
26+
*
27+
* @var \Drupal\Core\Entity\EntityStorageInterface
28+
*/
29+
protected $orderTypeStorage;
30+
31+
/**
32+
* The coupon storage.
33+
*
34+
* @var \Drupal\Core\Entity\EntityStorageInterface
35+
*/
36+
protected $couponStorage;
37+
38+
/**
39+
* Constructs a new CouponOrderProcessor object.
40+
*
41+
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
42+
* The entity type manager.
43+
*/
44+
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
45+
$this->promotionStorage = $entity_type_manager->getStorage('commerce_promotion');
46+
$this->orderTypeStorage = $entity_type_manager->getStorage('commerce_order_type');
47+
$this->couponStorage = $entity_type_manager->getStorage('commerce_promotion_coupon');
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function process(OrderInterface $order) {
54+
if (!$order->hasField('coupons') || $order->get('coupons')->isEmpty()) {
55+
return;
56+
}
57+
58+
$order_type = $this->orderTypeStorage->load($order->bundle());
59+
/** @var \Drupal\commerce_promotion\Entity\CouponInterface[] $coupons */
60+
$coupons = $order->get('coupons')->referencedEntities();
61+
foreach ($coupons as $index => $coupon) {
62+
/** @var \Drupal\commerce_promotion\Entity\PromotionInterface $promotion */
63+
$promotion = $this->promotionStorage->loadByCoupon($order_type, $order->getStore(), $coupon);
64+
65+
// The promotion may have become invalid (inactive/expired), causing the
66+
// query in loadByCoupon() to filter it out.
67+
if (!$promotion) {
68+
$order->get('coupons')->removeItem($index);
69+
continue;
70+
}
71+
72+
/** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface $plugin */
73+
$plugin = $promotion->get('offer')->first()->getTargetInstance();
74+
$target_entity_type = $plugin->getTargetEntityType();
75+
if ($target_entity_type == PromotionOfferInterface::ORDER) {
76+
if ($promotion->applies($order)) {
77+
$promotion->apply($order);
78+
}
79+
}
80+
elseif ($target_entity_type == PromotionOfferInterface::ORDER_ITEM) {
81+
foreach ($order->getItems() as $order_item) {
82+
if ($promotion->applies($order_item)) {
83+
$promotion->apply($order_item);
84+
}
85+
}
86+
}
87+
}
88+
}
89+
90+
}

modules/promotion/src/Entity/PromotionInterface.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
use Drupal\commerce_store\Entity\EntityStoresInterface;
66
use Drupal\Core\Datetime\DrupalDateTime;
7+
use Drupal\Core\Entity\ContentEntityInterface;
78
use Drupal\Core\Entity\EntityInterface;
89

910
/**
1011
* Defines the interface for promotions.
1112
*/
12-
interface PromotionInterface extends EntityInterface, EntityStoresInterface {
13+
interface PromotionInterface extends ContentEntityInterface, EntityStoresInterface {
1314

1415
/**
1516
* Gets the promotion name.

modules/promotion/src/PromotionOrderProcessor.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
/**
1010
* Applies promotions to orders during the order refresh process.
11+
*
12+
* @see \Drupal\commerce_promotion\CouponOrderProcessor
1113
*/
1214
class PromotionOrderProcessor implements OrderProcessorInterface {
1315

modules/promotion/src/PromotionStorage.php

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,14 @@
1212
*/
1313
class PromotionStorage extends CommerceContentEntityStorage implements PromotionStorageInterface {
1414

15-
/**
16-
* Helper method to return base query for valid promotions.
17-
*
18-
* @param \Drupal\commerce_order\Entity\OrderTypeInterface $order_type
19-
* The order type.
20-
* @param \Drupal\commerce_store\Entity\StoreInterface $store
21-
* The store.
22-
*
23-
* @return \Drupal\Core\Entity\Query\QueryInterface
24-
* The entity query.
25-
*/
26-
protected function loadValidQuery(OrderTypeInterface $order_type, StoreInterface $store) {
27-
$query = $this->getQuery();
28-
29-
$or_condition = $query->orConditionGroup()
30-
->condition('end_date', gmdate('Y-m-d'), '>=')
31-
->notExists('end_date', gmdate('Y-m-d'));
32-
$query
33-
->condition('stores', [$store->id()], 'IN')
34-
->condition('order_types', [$order_type->id()], 'IN')
35-
->condition('start_date', gmdate('Y-m-d'), '<=')
36-
->condition('status', TRUE)
37-
->condition($or_condition);
38-
return $query;
39-
}
40-
4115
/**
4216
* {@inheritdoc}
4317
*/
4418
public function loadValid(OrderTypeInterface $order_type, StoreInterface $store) {
45-
$query = $this->loadValidQuery($order_type, $store);
19+
$query = $this->buildLoadQuery($order_type, $store);
20+
// Only load promotions without coupons. Promotions with coupons are loaded
21+
// coupon-first in a different process.
22+
$query->notExists('coupons');
4623
$result = $query->execute();
4724
if (empty($result)) {
4825
return [];
@@ -56,7 +33,7 @@ public function loadValid(OrderTypeInterface $order_type, StoreInterface $store)
5633
* {@inheritdoc}
5734
*/
5835
public function loadByCoupon(OrderTypeInterface $order_type, StoreInterface $store, CouponInterface $coupon) {
59-
$query = $this->loadValidQuery($order_type, $store);
36+
$query = $this->buildLoadQuery($order_type, $store);
6037
$query->condition('coupons', $coupon->id());
6138
$result = $query->execute();
6239
if (empty($result)) {
@@ -68,4 +45,30 @@ public function loadByCoupon(OrderTypeInterface $order_type, StoreInterface $sto
6845

6946
}
7047

48+
/**
49+
* Builds the base query for loading valid promotions.
50+
*
51+
* @param \Drupal\commerce_order\Entity\OrderTypeInterface $order_type
52+
* The order type.
53+
* @param \Drupal\commerce_store\Entity\StoreInterface $store
54+
* The store.
55+
*
56+
* @return \Drupal\Core\Entity\Query\QueryInterface
57+
* The entity query.
58+
*/
59+
protected function buildLoadQuery(OrderTypeInterface $order_type, StoreInterface $store) {
60+
$query = $this->getQuery();
61+
62+
$or_condition = $query->orConditionGroup()
63+
->condition('end_date', gmdate('Y-m-d'), '>=')
64+
->notExists('end_date', gmdate('Y-m-d'));
65+
$query
66+
->condition('stores', [$store->id()], 'IN')
67+
->condition('order_types', [$order_type->id()], 'IN')
68+
->condition('start_date', gmdate('Y-m-d'), '<=')
69+
->condition('status', TRUE)
70+
->condition($or_condition);
71+
return $query;
72+
}
73+
7174
}

modules/promotion/tests/src/Kernel/PromotionOrderProcessorTest.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Drupal\commerce_order\Entity\OrderItem;
77
use Drupal\commerce_order\Entity\OrderItemType;
88
use Drupal\commerce_price\Price;
9+
use Drupal\commerce_promotion\Entity\Coupon;
910
use Drupal\commerce_promotion\Entity\Promotion;
1011
use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase;
1112

@@ -48,6 +49,7 @@ protected function setUp() {
4849
$this->installEntitySchema('commerce_order_type');
4950
$this->installEntitySchema('commerce_order_item');
5051
$this->installEntitySchema('commerce_promotion');
52+
$this->installEntitySchema('commerce_promotion_coupon');
5153
$this->installConfig([
5254
'profile',
5355
'commerce_order',
@@ -124,4 +126,67 @@ public function testOrderTotal() {
124126
$this->assertEquals(new Price('36.00', 'USD'), $this->order->getTotalPrice());
125127
}
126128

129+
/**
130+
* Tests the coupon based promotion processor.
131+
*/
132+
public function testCouponPromotion() {
133+
// Use addOrderItem so the total is calculated.
134+
$order_item = OrderItem::create([
135+
'type' => 'test',
136+
'quantity' => 2,
137+
'unit_price' => [
138+
'number' => '20.00',
139+
'currency_code' => 'USD',
140+
],
141+
]);
142+
$order_item->save();
143+
$this->order->addItem($order_item);
144+
$this->order->save();
145+
146+
// Starts now, enabled. No end time.
147+
$promotion_with_coupon = Promotion::create([
148+
'name' => 'Promotion (with coupon)',
149+
'order_types' => [$this->order->bundle()],
150+
'stores' => [$this->store->id()],
151+
'status' => TRUE,
152+
'offer' => [
153+
'target_plugin_id' => 'commerce_promotion_order_percentage_off',
154+
'target_plugin_configuration' => [
155+
'amount' => '0.10',
156+
],
157+
],
158+
'conditions' => [
159+
[
160+
'target_plugin_id' => 'commerce_promotion_order_total_price',
161+
'target_plugin_configuration' => [
162+
'amount' => [
163+
'number' => '20.00',
164+
'currency_code' => 'USD',
165+
],
166+
],
167+
],
168+
],
169+
]);
170+
$promotion_with_coupon->save();
171+
172+
$coupon = Coupon::create([
173+
'code' => $this->randomString(),
174+
'status' => TRUE,
175+
]);
176+
$coupon->save();
177+
$promotion_with_coupon->get('coupons')->appendItem($coupon);
178+
$promotion_with_coupon->save();
179+
180+
$this->container->get('commerce_order.order_refresh')->refresh($this->order);
181+
182+
$this->assertEquals(0, count($this->order->getAdjustments()));
183+
184+
$this->order->get('coupons')->appendItem($coupon);
185+
$this->order->save();
186+
$this->container->get('commerce_order.order_refresh')->refresh($this->order);
187+
188+
$this->assertEquals(1, count($this->order->getAdjustments()));
189+
$this->assertEquals(new Price('36.00', 'USD'), $this->order->getTotalPrice());
190+
}
191+
127192
}

modules/promotion/tests/src/Kernel/PromotionStorageTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public function testLoadValid() {
7272
$this->assertEquals(SAVED_NEW, $promotion1->save());
7373

7474
// Starts now, disabled. No end time.
75+
/** @var \Drupal\commerce_promotion\Entity\Promotion $promotion2 */
7576
$promotion2 = Promotion::create([
7677
'name' => 'Promotion 2',
7778
'order_types' => [$order_type],
@@ -118,6 +119,49 @@ public function testLoadValid() {
118119
$this->assertEquals(2, count($valid_promotions));
119120
}
120121

122+
/**
123+
* Tests that promotions with coupons do not get loaded PromotionStorage::loadValid.
124+
*/
125+
public function testValidWithCoupons() {
126+
$order_type = OrderType::load('default');
127+
128+
$promotion1 = Promotion::create([
129+
'name' => 'Promotion 1',
130+
'order_types' => [$order_type],
131+
'stores' => [$this->store->id()],
132+
'status' => TRUE,
133+
]);
134+
$promotion1->save();
135+
136+
/** @var \Drupal\commerce_promotion\Entity\Promotion $promotion2 */
137+
$promotion2 = Promotion::create([
138+
'name' => 'Promotion 2',
139+
'order_types' => [$order_type],
140+
'stores' => [$this->store->id()],
141+
'status' => TRUE,
142+
]);
143+
$promotion2->save();
144+
// Add a coupon to promotion2 and validate it does not load.
145+
$coupon = Coupon::create([
146+
'code' => $this->randomString(),
147+
'status' => TRUE,
148+
]);
149+
$coupon->save();
150+
$promotion2->get('coupons')->appendItem($coupon);
151+
$promotion2->save();
152+
$promotion2 = $this->reloadEntity($promotion2);
153+
154+
$promotion3 = Promotion::create([
155+
'name' => 'Promotion 3',
156+
'order_types' => [$order_type],
157+
'stores' => [$this->store->id()],
158+
'status' => TRUE,
159+
]);
160+
$promotion3->save();
161+
162+
$this->assertEquals(2, count($this->promotionStorage->loadValid($order_type, $this->store)));
163+
}
164+
121165
/**
122166
* Tests loading a promotion by a coupon.
123167
*/

0 commit comments

Comments
 (0)