PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.19
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.19
1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 All 182 releases
disco / app / Intents / CategoryBogo / CategoryBogoRules.php

CategoryBogoRules.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.19, at app/Intents/CategoryBogo/CategoryBogoRules.php

394 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Category BOGO campaign / rule reader.
4 *
5 * @package Disco
6 * @subpackage \App\Intents\CategoryBogo
7 */
8
9 namespace Disco\App\Intents\CategoryBogo;
10
11 use Disco\App\Campaign;
12 use Disco\App\Features\UserLimit;
13 use Disco\App\Utility\Config;
14 use Disco\App\Utility\Helper;
15 use Disco\App\Utility\Value;
16
17 /**
18 * Selects the campaigns, rules and buy pools the Category BOGO engine acts on.
19 *
20 * A campaign qualifies when it is an active, in-date BOGO campaign of type
21 * `categories` that still has usage left for the customer and holds at least
22 * one free-item tier. Percent / fixed category BOGO keeps using the regular
23 * discount path, so those rules are filtered out here.
24 *
25 * Buy (X) pooling also lives here because it is driven purely by campaign
26 * configuration: product applicability, the condition filters and the
27 * "Count Quantity As" mode.
28 *
29 * @package Disco
30 * @subpackage Disco\App\Intents\CategoryBogo
31 * @category Intention
32 */
33 class CategoryBogoRules {
34
35 /**
36 * Qualifying category BOGO campaigns, lazily resolved.
37 *
38 * @var array<int, \Disco\App\Utility\Config>|null
39 */
40 private $category_bogo_campaigns = null;
41
42 /**
43 * Every active campaign, fetched once per request.
44 *
45 * @var array<int|string, \Disco\App\Utility\Config>|null
46 */
47 private $all_active_campaigns = null;
48
49 /**
50 * Active category BOGO campaigns that grant free items.
51 *
52 * @return array<int, \Disco\App\Utility\Config>
53 */
54 public function get_active_category_bogo_campaigns(): array {
55 if ( null !== $this->category_bogo_campaigns ) {
56 return $this->category_bogo_campaigns;
57 }
58
59 $this->category_bogo_campaigns = array();
60
61 foreach ( $this->get_all_active_campaigns() as $campaign ) {
62 if ( ! $this->is_active_category_free_bogo_campaign( $campaign ) ) {
63 continue;
64 }
65
66 $this->category_bogo_campaigns[] = $campaign;
67 }
68
69 return $this->category_bogo_campaigns;
70 }
71
72 /**
73 * Whether a BOGO campaign that hands out free items outside a reward
74 * category is also active.
75 *
76 * Those campaigns (BuyXGetX and products BuyXGetY) are still applied by the
77 * legacy hook path, so it can only be skipped when none of them exist.
78 */
79 public function has_non_category_free_bogo_campaigns(): bool {
80 foreach ( $this->get_all_active_campaigns() as $campaign ) {
81 if ( 'BOGO' !== $campaign->get_discount_intent() ) {
82 continue;
83 }
84
85 if ( 'categories' === $campaign->get_bogo_type() ) {
86 continue;
87 }
88
89 if ( ! Helper::is_in_valid_date( $campaign ) ) {
90 continue;
91 }
92
93 return true;
94 }
95
96 return false;
97 }
98
99 /**
100 * Free-item tiers of a campaign, normalised to arrays.
101 *
102 * @param \Disco\App\Utility\Config $campaign Campaign config.
103 * @return array<int, array<string, mixed>>
104 */
105 public function get_free_item_rules( Config $campaign ): array {
106 $discount_rules = $campaign->get_discount_rules();
107
108 if ( ! is_array( $discount_rules ) ) {
109 return array();
110 }
111
112 $free_item_rules = array();
113
114 foreach ( $discount_rules as $rule ) {
115 $rule = (array) $rule;
116
117 if ( ! $this->rule_grants_free_item( $rule ) ) {
118 continue;
119 }
120
121 $rule['recursive'] = Value::to_string( $rule['recursive'] ?? 'no' );
122
123 $free_item_rules[] = $rule;
124 }
125
126 return $free_item_rules;
127 }
128
129 /**
130 * Reward (get Y) category ids of a single rule.
131 *
132 * @param array<string, mixed> $rule Discount rule.
133 * @return array<int, int>
134 */
135 public function get_reward_category_ids( array $rule ): array {
136 if ( empty( $rule['get_ids'] ) || ! is_array( $rule['get_ids'] ) ) {
137 return array();
138 }
139
140 $reward_category_ids = array();
141
142 foreach ( $rule['get_ids'] as $rule_entry ) {
143 $reward_category_ids[] = $this->get_category_id_from_rule_entry( $rule_entry );
144 }
145
146 return array_values( array_filter( $reward_category_ids ) );
147 }
148
149 /**
150 * Reward category ids of every active category BOGO campaign, deduplicated.
151 *
152 * @return array<int, int>
153 */
154 public function get_all_reward_category_ids(): array {
155 $reward_category_ids = array();
156
157 foreach ( $this->get_active_category_bogo_campaigns() as $campaign ) {
158 foreach ( $this->get_free_item_rules( $campaign ) as $rule ) {
159 $reward_category_ids = array_merge( $reward_category_ids, $this->get_reward_category_ids( $rule ) );
160 }
161 }
162
163 return array_values( array_unique( $reward_category_ids ) );
164 }
165
166 /**
167 * Buy (X) product keys of a campaign, pooled per "Count Quantity As".
168 *
169 * @param \Disco\App\Utility\Config $campaign Campaign config.
170 * @param array<string, array<string, mixed>> $cart_products Cart products by product key.
171 * @return array<int, array<int, string>> Each entry is one pool of product keys.
172 */
173 public function get_buy_quantity_pools( Config $campaign, array $cart_products ): array {
174 $pools = array();
175 $count_quantity_as = $campaign->get_count_quantity_as();
176
177 foreach ( $this->get_buy_applicable_product_keys( $campaign, $cart_products ) as $product_key ) {
178 $pool_key = $this->get_buy_pool_key( $count_quantity_as, $product_key, $cart_products );
179
180 $pools[ $pool_key ][] = $product_key;
181 }
182
183 return array_values( $pools );
184 }
185
186 /**
187 * Tiers a pooled buy quantity qualifies for, highest `min` first.
188 *
189 * A tier qualifies on `min` alone: the upper `max` caps how much of the pool
190 * is eligible, it never disqualifies the tier, so a quantity that falls in
191 * the gap between two tiers still earns the lower one. The caller walks the
192 * list in order and takes the first tier that can actually hand out a reward.
193 *
194 * @param array<int, array<string, mixed>> $free_item_rules Free-item tiers.
195 * @param int $pool_quantity Pooled buy quantity.
196 * @return array<int, array<string, mixed>>
197 */
198 public function get_qualifying_tiers_for_buy_quantity( array $free_item_rules, int $pool_quantity ): array {
199 $qualifying_tiers = array();
200
201 foreach ( $free_item_rules as $rule ) {
202 $minimum_quantity = Value::to_int( $rule['min'] );
203
204 if ( $minimum_quantity <= 0 || $pool_quantity < $minimum_quantity ) {
205 continue;
206 }
207
208 $qualifying_tiers[] = $rule;
209 }
210
211 usort(
212 $qualifying_tiers,
213 static function ( array $first, array $second ): int {
214 return Value::to_int( $second['min'] ) <=> Value::to_int( $first['min'] );
215 }
216 );
217
218 return $qualifying_tiers;
219 }
220
221 /**
222 * Campaign id as an integer.
223 *
224 * @param \Disco\App\Utility\Config $campaign Campaign config.
225 */
226 public function get_campaign_id( Config $campaign ): int {
227 $config = $campaign->get_config();
228
229 return Value::to_int( $config['id'] ?? 0 );
230 }
231
232 /**
233 * Every active campaign, as Config objects.
234 *
235 * @return array<int|string, \Disco\App\Utility\Config>
236 */
237 private function get_all_active_campaigns(): array {
238 if ( null !== $this->all_active_campaigns ) {
239 return $this->all_active_campaigns;
240 }
241
242 $campaigns = ( new Campaign )->get_campaigns( '1' );
243 $this->all_active_campaigns = array();
244
245 if ( ! is_array( $campaigns ) ) {
246 return $this->all_active_campaigns;
247 }
248
249 foreach ( $campaigns as $campaign_id => $campaign ) {
250 if ( ! $campaign instanceof Config ) {
251 continue;
252 }
253
254 $this->all_active_campaigns[ $campaign_id ] = $campaign;
255 }
256
257 return $this->all_active_campaigns;
258 }
259
260 /**
261 * Product keys a campaign accepts as a buy (X) purchase, in cart order.
262 *
263 * @param \Disco\App\Utility\Config $campaign Campaign config.
264 * @param array<string, array<string, mixed>> $cart_products Cart products by product key.
265 * @return array<int, string>
266 */
267 private function get_buy_applicable_product_keys( Config $campaign, array $cart_products ): array {
268 $buy_applicable_keys = array();
269
270 foreach ( $cart_products as $product_key => $cart_product ) {
271 $effective_product_id = Value::to_int( $cart_product['effective_product_id'] ?? 0 );
272
273 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
274 continue;
275 }
276
277 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => wc_get_product( $effective_product_id ) ) ) ) {
278 continue;
279 }
280
281 $buy_applicable_keys[] = (string) $product_key;
282 }
283
284 return $buy_applicable_keys;
285 }
286
287 /**
288 * Pool key a product key belongs to for a counting mode.
289 *
290 * @param string $count_quantity_as Counting mode.
291 * @param string $product_key Cart product key.
292 * @param array<string, array<string, mixed>> $cart_products Cart products by product key.
293 */
294 private function get_buy_pool_key( string $count_quantity_as, string $product_key, array $cart_products ): string {
295 // Every applicable product shares one pool.
296 if ( 'combined' === $count_quantity_as ) {
297 return 'all';
298 }
299
300 // Variations of the same variable product share a pool.
301 if ( 'variations' === $count_quantity_as ) {
302 return 'parent_' . Value::to_int( $cart_products[ $product_key ]['product_id'] ?? 0 );
303 }
304
305 // `separate`: each product is its own pool.
306 return 'product_' . $product_key;
307 }
308
309 /**
310 * Category id of a single `get_ids` rule entry.
311 *
312 * @param mixed $rule_entry Raw entry: an id, or an array / object with an `id` key.
313 */
314 private function get_category_id_from_rule_entry( $rule_entry ): int {
315 if ( is_object( $rule_entry ) ) {
316 $rule_entry = (array) $rule_entry;
317 }
318
319 if ( ! is_array( $rule_entry ) ) {
320 return Value::to_int( $rule_entry );
321 }
322
323 return Value::to_int( $rule_entry['id'] ?? 0 );
324 }
325
326 /**
327 * Whether a campaign is an active category BOGO that hands out free items.
328 *
329 * @param \Disco\App\Utility\Config $campaign Campaign config.
330 */
331 private function is_active_category_free_bogo_campaign( Config $campaign ): bool {
332 if ( ! in_array( $campaign->get_discount_intent(), array( 'BOGO', 'BuyXGetY' ), true ) ) {
333 return false;
334 }
335
336 if ( 'categories' !== $campaign->get_bogo_type() ) {
337 return false;
338 }
339
340 if ( ! Helper::is_in_valid_date( $campaign ) ) {
341 return false;
342 }
343
344 if ( empty( $this->get_free_item_rules( $campaign ) ) ) {
345 return false;
346 }
347
348 return ! $this->is_user_limit_reached( $campaign );
349 }
350
351 /**
352 * Whether a rule can grant a free reward from a category.
353 *
354 * Only tiers that can actually hand out an item qualify: `discount_type`
355 * free, a reward quantity, a minimum and a reward category.
356 *
357 * @param array<string, mixed> $rule Discount rule.
358 */
359 private function rule_grants_free_item( array $rule ): bool {
360 if ( 'free' !== Value::to_string( $rule['discount_type'] ?? '' ) ) {
361 return false;
362 }
363
364 if ( Value::to_int( $rule['get_quantity'] ?? 0 ) <= 0 ) {
365 return false;
366 }
367
368 if ( Value::to_int( $rule['min'] ?? 0 ) <= 0 ) {
369 return false;
370 }
371
372 return ! empty( $rule['get_ids'] );
373 }
374
375 /**
376 * Whether the per-user usage limit of a campaign is exhausted.
377 *
378 * @param \Disco\App\Utility\Config $campaign Campaign config.
379 */
380 private function is_user_limit_reached( Config $campaign ): bool {
381 $config = $campaign->get_config();
382 $max_user_discounts = Value::to_int( $config['discount_max_user'] ?? 0 );
383
384 if ( $max_user_discounts <= 0 ) {
385 return false;
386 }
387
388 $applied = ( new UserLimit )->disco_get_total_applied_campaign( $this->get_campaign_id( $campaign ) );
389
390 return $applied >= $max_user_discounts;
391 }
392
393 }
394