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 / CategoryBogo.php

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

543 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Category BOGO engine.
4 *
5 * @package Disco
6 * @subpackage \App\Intents\CategoryBogo
7 */
8
9 namespace Disco\App\Intents\CategoryBogo;
10
11 use Disco\App\Features\UserLimit;
12 use Disco\App\Utility\Config;
13 use Disco\App\Utility\Value;
14
15 /**
16 * Category BOGO (Buy X Get Y with a reward category) engine.
17 *
18 * Owns the whole free-item lifecycle for `bogo_type = categories` campaigns
19 * whose rules are `discount_type = free`:
20 *
21 * 1. Buy (X) quantities are collected from the cart per "Count Quantity As"
22 * (`separate` / `combined` / `variations`), always excluding units that are
23 * currently assigned free or already reserved by an earlier campaign.
24 * 2. The qualifying tier is the rule with the highest `min` the pool reaches.
25 * Non-recursive rules cap the eligible buy quantity at `max`; recursive rules
26 * ignore `max` and repeat per complete buy set.
27 * 3. Buy units are RESERVED before any reward is selected, so they always stay
28 * paid and can never be reused by another campaign. Reservation consumes units
29 * outside the reward category first, which is what makes "buy X get Y from the
30 * same category" terminate instead of self-qualifying.
31 * 4. Reward units are taken from the reward category per "Select Item Discount"
32 * (`cart_order` / `lowest` / `highest`).
33 * 5. CategoryBogoCart reconciles the cart: each product keeps exactly the
34 * entitled free quantity and the excess is converted back to paid.
35 *
36 * Every campaign is processed independently against the shared reservation and
37 * free ledgers, so one campaign never overwrites another's assignment.
38 *
39 * @package Disco
40 * @subpackage Disco\App\Intents\CategoryBogo
41 * @category Intention
42 */
43 class CategoryBogo {
44
45 /**
46 * Cart products merged per product key, in cart order.
47 *
48 * @var array<string, array<string, mixed>>
49 */
50 private $cart_products = array();
51
52 /**
53 * Quantities locked as a buy (X) purchase, per cart product key.
54 *
55 * @var array<string, int>
56 */
57 private $reserved_buy_quantities = array();
58
59 /**
60 * Quantities handed out as free (Y), per cart product key.
61 *
62 * @var array<string, int>
63 */
64 private $assigned_free_quantities = array();
65
66 /**
67 * Campaign ids that granted a free item on the last calculation.
68 *
69 * @var array<int, int>
70 */
71 private $applied_campaign_ids = array();
72
73 /**
74 * Cart reader / writer.
75 *
76 * @var \Disco\App\Intents\CategoryBogo\CategoryBogoCart
77 */
78 private $cart_manager;
79
80 /**
81 * Campaign / rule reader.
82 *
83 * @var \Disco\App\Intents\CategoryBogo\CategoryBogoRules
84 */
85 private $campaign_rules;
86
87 /**
88 * Engine constructor.
89 */
90 public function __construct() {
91 $this->cart_manager = new CategoryBogoCart;
92 $this->campaign_rules = new CategoryBogoRules;
93 }
94
95 /**
96 * Whether any category BOGO campaign is active for this request.
97 */
98 public function has_active_category_bogo_campaigns(): bool {
99 return ! empty( $this->campaign_rules->get_active_category_bogo_campaigns() );
100 }
101
102 /**
103 * Whether a non-category BOGO campaign still needs the legacy free-item pass.
104 */
105 public function has_non_category_free_bogo_campaigns(): bool {
106 return $this->campaign_rules->has_non_category_free_bogo_campaigns();
107 }
108
109 /**
110 * Calculate the free quantity every cart product is entitled to.
111 *
112 * @param \WC_Cart $cart Cart object.
113 * @return array<string, int> Map of cart product key (`productId:variationId`) => free quantity.
114 */
115 public function calculate_free_item_entitlements( \WC_Cart $cart ): array {
116 $this->applied_campaign_ids = array();
117 $this->reserved_buy_quantities = array();
118 $this->assigned_free_quantities = array();
119 $this->cart_products = array();
120
121 $campaigns = $this->campaign_rules->get_active_category_bogo_campaigns();
122
123 if ( empty( $campaigns ) ) {
124 return array();
125 }
126
127 $this->cart_products = $this->cart_manager->get_merged_cart_products( $cart );
128
129 if ( empty( $this->cart_products ) ) {
130 return array();
131 }
132
133 foreach ( $campaigns as $campaign ) {
134 $this->calculate_entitlements_for_campaign( $campaign );
135 }
136
137 return array_filter(
138 $this->assigned_free_quantities,
139 static function ( int $free_quantity ): bool {
140 return $free_quantity > 0;
141 }
142 );
143 }
144
145 /**
146 * Calculate entitlements and reconcile the cart to match them.
147 *
148 * @param \WC_Cart $cart Cart object.
149 * @return bool True when a category BOGO campaign is active (the caller must
150 * then leave category free items alone).
151 */
152 public function apply_free_items_to_cart( \WC_Cart $cart ): bool {
153 if ( ! $this->has_active_category_bogo_campaigns() ) {
154 return false;
155 }
156
157 $entitlements = $this->calculate_free_item_entitlements( $cart );
158 $reward_product_map = $this->get_reward_category_product_keys();
159
160 foreach ( $this->cart_products as $product_key => $cart_product ) {
161 // Only products that belong to a reward category are ours to manage;
162 // free items granted by other BOGO types must stay untouched.
163 if ( ! isset( $reward_product_map[ $product_key ] ) ) {
164 continue;
165 }
166
167 $cart_quantity = Value::to_int( $cart_product['quantity'] );
168 $free_quantity = (int) min( $entitlements[ $product_key ] ?? 0, $cart_quantity );
169
170 $this->cart_manager->sync_cart_product_free_quantity( $cart, $cart_product, $free_quantity );
171 }
172
173 foreach ( $this->applied_campaign_ids as $campaign_id ) {
174 ( new UserLimit )->disco_start_session_on_checkout( $campaign_id );
175 }
176
177 return true;
178 }
179
180 /**
181 * Calculate one campaign's entitlements and record it when it granted one.
182 *
183 * @param \Disco\App\Utility\Config $campaign Campaign config.
184 */
185 private function calculate_entitlements_for_campaign( Config $campaign ): void {
186 $granted_before = array_sum( $this->assigned_free_quantities );
187
188 $this->apply_campaign_rules_to_buy_pools( $campaign );
189
190 if ( array_sum( $this->assigned_free_quantities ) <= $granted_before ) {
191 return;
192 }
193
194 $this->applied_campaign_ids[] = $this->campaign_rules->get_campaign_id( $campaign );
195 }
196
197 /**
198 * Run a campaign's tiers against each of its buy pools.
199 *
200 * @param \Disco\App\Utility\Config $campaign Campaign config.
201 */
202 private function apply_campaign_rules_to_buy_pools( Config $campaign ): void {
203 $free_item_rules = $this->campaign_rules->get_free_item_rules( $campaign );
204 $buy_pools = $this->campaign_rules->get_buy_quantity_pools( $campaign, $this->cart_products );
205
206 if ( empty( $free_item_rules ) || empty( $buy_pools ) ) {
207 return;
208 }
209
210 foreach ( $buy_pools as $pool_product_keys ) {
211 $this->calculate_entitlement_for_buy_pool( $campaign, $free_item_rules, $pool_product_keys );
212 }
213 }
214
215 /**
216 * Apply the best tier of one buy pool that can actually pay out.
217 *
218 * Tiers are tried highest `min` first. A tier is kept only when it grants a
219 * free unit: when the buy and reward categories overlap, a high tier can
220 * reserve every unit in the pool and leave nothing to give away, and the
221 * customer must then earn the lower tier instead of nothing at all.
222 *
223 * @param \Disco\App\Utility\Config $campaign Campaign config.
224 * @param array<int, array<string, mixed>> $free_item_rules Free-item tiers.
225 * @param array<int, string> $pool_product_keys Cart product keys pooled together.
226 */
227 private function calculate_entitlement_for_buy_pool(
228 Config $campaign,
229 array $free_item_rules,
230 array $pool_product_keys
231 ): void {
232 $pool_quantity = 0;
233
234 foreach ( $pool_product_keys as $product_key ) {
235 $pool_quantity += $this->get_unclaimed_quantity( $product_key );
236 }
237
238 if ( $pool_quantity <= 0 ) {
239 return;
240 }
241
242 foreach ( $this->campaign_rules->get_qualifying_tiers_for_buy_quantity( $free_item_rules, $pool_quantity ) as $rule ) {
243 $reward_category_ids = $this->campaign_rules->get_reward_category_ids( $rule );
244 $reward_queue = $this->get_reward_products_in_selection_order( $campaign, $reward_category_ids );
245
246 // Nothing from the reward category is in the cart: there is no reward
247 // to hand out, so no buy units are consumed either.
248 if ( empty( $reward_queue ) ) {
249 continue;
250 }
251
252 if ( $this->apply_tier_when_it_grants_a_reward( $rule, $pool_product_keys, $reward_queue, $reward_category_ids ) ) {
253 return;
254 }
255 }
256 }
257
258 /**
259 * Apply a tier, keeping the result only when it granted a free unit.
260 *
261 * The reservation and free ledgers are restored when the tier pays nothing,
262 * so a rejected tier leaves no buy units locked behind it.
263 *
264 * @param array<string, mixed> $rule Tier to try.
265 * @param array<int, string> $pool_product_keys Cart product keys pooled together.
266 * @param array<int, string> $reward_queue Reward product keys, in selection order.
267 * @param array<int, int> $reward_category_ids Reward category ids.
268 */
269 private function apply_tier_when_it_grants_a_reward(
270 array $rule,
271 array $pool_product_keys,
272 array $reward_queue,
273 array $reward_category_ids
274 ): bool {
275 $reserved_before = $this->reserved_buy_quantities;
276 $assigned_before = $this->assigned_free_quantities;
277
278 $this->apply_qualifying_tier( $rule, $pool_product_keys, $reward_queue, $reward_category_ids );
279
280 if ( array_sum( $this->assigned_free_quantities ) > array_sum( $assigned_before ) ) {
281 return true;
282 }
283
284 $this->reserved_buy_quantities = $reserved_before;
285 $this->assigned_free_quantities = $assigned_before;
286
287 return false;
288 }
289
290 /**
291 * Reserve buy sets for a tier, assign the rewards and cap the eligible buys.
292 *
293 * @param array<string, mixed> $rule Qualifying tier.
294 * @param array<int, string> $pool_product_keys Cart product keys pooled together.
295 * @param array<int, string> $reward_queue Reward product keys, in selection order.
296 * @param array<int, int> $reward_category_ids Reward category ids.
297 */
298 private function apply_qualifying_tier(
299 array $rule,
300 array $pool_product_keys,
301 array $reward_queue,
302 array $reward_category_ids
303 ): void {
304 $minimum_quantity = Value::to_int( $rule['min'] );
305 $maximum_quantity = Value::to_int( $rule['max'] ?? 0 );
306 $is_recursive = 'yes' === Value::to_string( $rule['recursive'] );
307 $applied_sets = $this->reserve_buy_sets_and_assign_free_items( $rule, $pool_product_keys, $reward_queue, $reward_category_ids );
308
309 if ( $applied_sets <= 0 || $is_recursive || $maximum_quantity <= $minimum_quantity ) {
310 return;
311 }
312
313 // Non-recursive: the eligible buy quantity is capped at `max`, and those
314 // units stay paid and locked from other campaigns. Anything above `max`
315 // is ignored and stays available.
316 $this->reserve_buy_quantity( $pool_product_keys, $maximum_quantity - $minimum_quantity, $reward_category_ids, true );
317 }
318
319 /**
320 * Reserve buy sets and assign the free quantity each set earns.
321 *
322 * A recursive rule earns one entitlement per complete buy set; a
323 * non-recursive rule is applied exactly once.
324 *
325 * @param array<string, mixed> $rule Qualifying tier.
326 * @param array<int, string> $pool_product_keys Cart product keys pooled together.
327 * @param array<int, string> $reward_queue Reward product keys, in selection order.
328 * @param array<int, int> $reward_category_ids Reward category ids.
329 * @return int Number of buy sets applied.
330 */
331 private function reserve_buy_sets_and_assign_free_items(
332 array $rule,
333 array $pool_product_keys,
334 array $reward_queue,
335 array $reward_category_ids
336 ): int {
337 $minimum_quantity = Value::to_int( $rule['min'] );
338 $reward_quantity = Value::to_int( $rule['get_quantity'] );
339 $is_recursive = 'yes' === Value::to_string( $rule['recursive'] );
340 $applied_sets = 0;
341
342 while ( true ) {
343 // Buy units are reserved before any reward is selected.
344 if ( ! $this->reserve_buy_quantity( $pool_product_keys, $minimum_quantity, $reward_category_ids ) ) {
345 break;
346 }
347
348 ++$applied_sets;
349
350 $assigned_quantity = $this->assign_free_quantity( $reward_queue, $reward_quantity );
351
352 // Stop when the rule is one-shot or the reward category is exhausted.
353 if ( ! $is_recursive || $assigned_quantity <= 0 ) {
354 break;
355 }
356 }
357
358 return $applied_sets;
359 }
360
361 /**
362 * Reserve buy quantity from a pool.
363 *
364 * Units outside the reward category are consumed first, so a campaign whose
365 * buy and get categories overlap still leaves stock to hand out for free.
366 *
367 * @param array<int, string> $pool_product_keys Cart product keys pooled together.
368 * @param int $required_quantity Quantity to reserve.
369 * @param array<int, int> $reward_category_ids Reward category ids.
370 * @param bool $allow_partial Reserve what is available instead of all-or-nothing.
371 * @return bool True when the requested quantity was reserved.
372 */
373 private function reserve_buy_quantity(
374 array $pool_product_keys,
375 int $required_quantity,
376 array $reward_category_ids,
377 bool $allow_partial = false
378 ): bool {
379 if ( $required_quantity <= 0 ) {
380 return false;
381 }
382
383 $reservation_queue = $this->get_reservation_priority_order( $pool_product_keys, $reward_category_ids );
384 $available_quantity = 0;
385
386 foreach ( $reservation_queue as $product_key ) {
387 $available_quantity += $this->get_unclaimed_quantity( $product_key );
388 }
389
390 if ( ! $allow_partial && $available_quantity < $required_quantity ) {
391 return false;
392 }
393
394 $remaining_quantity = (int) min( $required_quantity, $available_quantity );
395
396 foreach ( $reservation_queue as $product_key ) {
397 if ( $remaining_quantity <= 0 ) {
398 break;
399 }
400
401 $reserved_quantity = (int) min( $this->get_unclaimed_quantity( $product_key ), $remaining_quantity );
402
403 if ( $reserved_quantity <= 0 ) {
404 continue;
405 }
406
407 $already_reserved = $this->reserved_buy_quantities[ $product_key ] ?? 0;
408
409 $this->reserved_buy_quantities[ $product_key ] = $already_reserved + $reserved_quantity;
410 $remaining_quantity -= $reserved_quantity;
411 }
412
413 return true;
414 }
415
416 /**
417 * Pool product keys ordered for reservation: outside the reward category first.
418 *
419 * @param array<int, string> $pool_product_keys Cart product keys pooled together.
420 * @param array<int, int> $reward_category_ids Reward category ids.
421 * @return array<int, string>
422 */
423 private function get_reservation_priority_order( array $pool_product_keys, array $reward_category_ids ): array {
424 $outside_reward_category = array();
425 $inside_reward_category = array();
426
427 foreach ( $pool_product_keys as $product_key ) {
428 if ( $this->is_product_in_reward_category( $product_key, $reward_category_ids ) ) {
429 $inside_reward_category[] = $product_key;
430
431 continue;
432 }
433
434 $outside_reward_category[] = $product_key;
435 }
436
437 return array_merge( $outside_reward_category, $inside_reward_category );
438 }
439
440 /**
441 * Assign free quantity to the reward products, in selection order.
442 *
443 * @param array<int, string> $reward_queue Reward product keys, in selection order.
444 * @param int $reward_quantity Quantity to give away.
445 * @return int Quantity actually assigned.
446 */
447 private function assign_free_quantity( array $reward_queue, int $reward_quantity ): int {
448 $remaining_quantity = $reward_quantity;
449 $assigned_quantity = 0;
450
451 foreach ( $reward_queue as $product_key ) {
452 if ( $remaining_quantity <= 0 ) {
453 break;
454 }
455
456 $free_quantity = (int) min( $this->get_unclaimed_quantity( $product_key ), $remaining_quantity );
457
458 if ( $free_quantity <= 0 ) {
459 continue;
460 }
461
462 $already_assigned = $this->assigned_free_quantities[ $product_key ] ?? 0;
463
464 $this->assigned_free_quantities[ $product_key ] = $already_assigned + $free_quantity;
465 $remaining_quantity -= $free_quantity;
466 $assigned_quantity += $free_quantity;
467 }
468
469 return $assigned_quantity;
470 }
471
472 /**
473 * Quantity of a cart product that is neither reserved as a buy nor free yet.
474 *
475 * @param string $product_key Cart product key.
476 */
477 private function get_unclaimed_quantity( string $product_key ): int {
478 if ( ! isset( $this->cart_products[ $product_key ] ) ) {
479 return 0;
480 }
481
482 $claimed_quantity = ( $this->reserved_buy_quantities[ $product_key ] ?? 0 ) + ( $this->assigned_free_quantities[ $product_key ] ?? 0 );
483 $cart_quantity = Value::to_int( $this->cart_products[ $product_key ]['quantity'] );
484
485 return (int) max( 0, $cart_quantity - $claimed_quantity );
486 }
487
488 /**
489 * Reward products ordered by the campaign's "Select Item Discount".
490 *
491 * @param \Disco\App\Utility\Config $campaign Campaign config.
492 * @param array<int, int> $reward_category_ids Reward category ids.
493 * @return array<int, string> Cart product keys, in selection order.
494 */
495 private function get_reward_products_in_selection_order( Config $campaign, array $reward_category_ids ): array {
496 $reward_prices = array();
497
498 foreach ( $this->cart_products as $product_key => $cart_product ) {
499 if ( ! $this->is_product_in_reward_category( (string) $product_key, $reward_category_ids ) ) {
500 continue;
501 }
502
503 $reward_prices[ (string) $product_key ] = Value::to_float( $cart_product['price'] );
504 }
505
506 $free_item_selection = $campaign->get_free_item_selection();
507
508 if ( 'lowest' === $free_item_selection ) {
509 asort( $reward_prices );
510 } elseif ( 'highest' === $free_item_selection ) {
511 arsort( $reward_prices );
512 }
513
514 return array_map( 'strval', array_keys( $reward_prices ) );
515 }
516
517 /**
518 * Cart product keys sitting in a reward category of any active campaign.
519 *
520 * @return array<string, bool>
521 */
522 private function get_reward_category_product_keys(): array {
523 $reward_category_ids = $this->campaign_rules->get_all_reward_category_ids();
524
525 return $this->cart_manager->get_product_keys_in_categories( $this->cart_products, $reward_category_ids );
526 }
527
528 /**
529 * Whether a cart product sits in one of the reward categories.
530 *
531 * @param string $product_key Cart product key.
532 * @param array<int, int> $reward_category_ids Reward category ids.
533 */
534 private function is_product_in_reward_category( string $product_key, array $reward_category_ids ): bool {
535 if ( ! isset( $this->cart_products[ $product_key ] ) ) {
536 return false;
537 }
538
539 return $this->cart_manager->is_product_in_categories( $this->cart_products[ $product_key ], $reward_category_ids );
540 }
541
542 }
543