PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / trunk
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO vtrunk
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 1.3.49 1.3.48 1.3.47 1.3.46 1.3.45 1.3.44 All 176 releases
disco / functions / bogo.php

bogo.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO trunk, at functions/bogo.php

470 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable
3 /**
4 * Disco
5 *
6 * @package Disco
7 * @author Ohidul Islam <wahid0003@gmail.com>
8 * @link http://domain.tld
9 * @license GPL 2.0+
10 * @copyright 2022 WebAppick
11 */
12
13 // Ensure the file is not accessed directly.
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 use Disco\App\Disco;
19 use Disco\App\Intents\CategoryBogo\CategoryBogo;
20 use Disco\App\Intents\CategoryBogo\CategoryBogoCart;
21 use Disco\App\Utility\Helper;
22
23 if ( ! function_exists( 'disco_cart_apply_free_items' ) ) {
24 /**
25 * Apply free items to the cart based on BOGO rules.
26 *
27 * @param \WC_Cart $cart Cart Object.
28 */
29 function disco_cart_apply_free_items( $cart ) {
30 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
31 return;
32 }
33
34 // Prevent multiple executions during the same request
35 static $is_processing = false;
36 if ( $is_processing ) {
37 return;
38 }
39
40 if ( $cart->is_empty() ) {
41 return;
42 }
43
44 $is_processing = true;
45
46 try {
47 /**
48 * Category BOGO owns its own free-item lifecycle: buy reservation,
49 * entitlement, reward selection and paid/free reconciliation all live
50 * in CategoryBogo. When such a campaign is active it fully manages
51 * every cart line of its reward categories.
52 */
53 $category_bogo = new CategoryBogo();
54 $category_active = $category_bogo->apply_free_items_to_cart( $cart );
55
56 /**
57 * Nothing but category campaigns are active, so every free item in the
58 * cart is already reconciled. Skipping the pass below avoids recomputing
59 * a discount set that would only be discarded.
60 */
61 if ( $category_active && ! $category_bogo->has_non_category_free_bogo_campaigns() ) {
62 return;
63 }
64
65 // Calculate discounts - free products are automatically excluded in IntentHelper
66 $disco = new Disco();
67 $discounts = $disco->get_cart_items_discount_for_bogo( $cart );
68 $discounts = is_array( $discounts ) ? $discounts : array();
69
70 $free_ids = $discounts['get_ids'] ?? array();
71 $free_qty = $discounts['get_qty'] ?? 0;
72 $free_qty_map = $discounts['get_qty_map'] ?? array();
73 $bogo_type = $discounts['bogo_type'] ?? 'products';
74 $free_selection = $discounts['free_item_selection'] ?? 'cart_order';
75
76 // The category engine already applied the category rules.
77 if ( $category_active && 'categories' === $bogo_type ) {
78 return;
79 }
80
81 // Only process if we have valid discount data
82 if ( ! empty( $free_qty ) && ! empty( $free_ids ) ) {
83 // Remove invalid free items from cart
84 disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type );
85
86 // Add or update free items
87 if ( $bogo_type === 'categories' ) {
88 disco_apply_category_based_free_items( $cart, $free_ids, $free_qty, $free_selection );
89 } else {
90 disco_apply_product_based_free_items( $cart, $free_ids, $free_qty_map, $free_qty );
91 }
92 } else {
93 // No valid BOGO, remove all free items
94 disco_remove_all_free_items( $cart );
95 }
96 } finally {
97 $is_processing = false;
98 }
99 }
100
101 add_action( 'woocommerce_before_calculate_totals', 'disco_cart_apply_free_items', 5 );
102 }
103
104 if ( ! function_exists( 'disco_remove_all_free_items' ) ) {
105 /**
106 * Remove all free items from cart when BOGO criteria are no longer met.
107 *
108 * @param \WC_Cart $cart Cart Object.
109 */
110 function disco_remove_all_free_items( $cart ) {
111 $items_to_remove = array();
112
113 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
114 // Lines owned by the category engine are managed there.
115 if ( ! empty( $cart_item[ CategoryBogoCart::FREE_ITEM_FLAG ] ) ) {
116 continue;
117 }
118
119 if ( ! empty( $cart_item['is_free_product'] ) ) {
120 $items_to_remove[] = $cart_item_key;
121 }
122 }
123
124 foreach ( $items_to_remove as $cart_item_key ) {
125 $cart->remove_cart_item( $cart_item_key );
126 }
127 }
128 }
129
130 if ( ! function_exists( 'disco_remove_invalid_free_items' ) ) {
131 /**
132 * Remove free items that are no longer valid.
133 *
134 * @param \WC_Cart $cart Cart Object.
135 * @param array $free_ids Valid free product/category IDs.
136 * @param string $bogo_type Type of BOGO (products/categories).
137 */
138 function disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type ) {
139 $items_to_remove = array();
140
141 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
142 if ( empty( $cart_item['is_free_product'] ) || ! empty( $cart_item[ CategoryBogoCart::FREE_ITEM_FLAG ] ) ) {
143 continue;
144 }
145
146 $product_id = $cart_item['product_id'];
147 $should_remove = false;
148
149 if ( empty( $free_ids ) ) {
150 // No valid free items, remove all free products
151 $should_remove = true;
152 } elseif ( $bogo_type === 'categories' ) {
153 // Check if product is in valid categories
154 $should_remove = ! Helper::is_in_category( $product_id, $free_ids );
155 } else {
156 // Check if product ID is in valid list
157 $should_remove = ! in_array( $product_id, $free_ids, true );
158 }
159
160 if ( $should_remove ) {
161 $items_to_remove[] = $cart_item_key;
162 }
163 }
164
165 // Remove items after iteration to avoid modifying array during loop
166 foreach ( $items_to_remove as $cart_item_key ) {
167 $cart->remove_cart_item( $cart_item_key );
168 }
169 }
170 }
171
172 if ( ! function_exists( 'disco_apply_category_based_free_items' ) ) {
173 /**
174 * Apply free items based on category rules.
175 *
176 * @param \WC_Cart $cart Cart Object.
177 * @param array $free_ids Category IDs for free products.
178 * @param int $free_qty Quantity of free items allowed.
179 * @param string $free_selection Which category item to reward: cart_order | lowest | highest.
180 */
181 function disco_apply_category_based_free_items( $cart, $free_ids, $free_qty, $free_selection = 'cart_order' ) {
182 $free_qty = max( 0, (int) $free_qty );
183
184 // Rank category members by the selection strategy, then flag whole lines
185 // free IN PLACE (the bought units become free) up to $free_qty. Only the
186 // is_free_product flag is toggled — cart quantities are never mutated here
187 // (that fires nested recalculations during calculate_totals and corrupts
188 // the cart). The reward count treats a product's paid + free quantity as
189 // its buy quantity, so flagging a line free never drops it from the count.
190 $ordered = disco_order_free_candidates( $cart, $free_ids, $free_selection );
191
192 // Choose whole lines that fit within the free quantity.
193 $target = array();
194 $count = 0;
195
196 foreach ( $ordered as $cart_item_key ) {
197 if ( $count >= $free_qty ) {
198 break;
199 }
200
201 $qty = (int) $cart->cart_contents[ $cart_item_key ]['quantity'];
202
203 if ( $qty <= ( $free_qty - $count ) ) {
204 $target[ $cart_item_key ] = true;
205 $count += $qty;
206 }
207 }
208
209 // Reconcile: flag targets free, clear the flag on any other category line.
210 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
211 if ( ! Helper::is_in_category( $cart_item['product_id'], $free_ids ) ) {
212 continue;
213 }
214
215 $should_be_free = isset( $target[ $cart_item_key ] );
216 $currently_free = ! empty( $cart_item['is_free_product'] );
217
218 if ( $should_be_free && ! $currently_free ) {
219 $cart->cart_contents[ $cart_item_key ]['is_free_product'] = true;
220 } elseif ( ! $should_be_free && $currently_free ) {
221 unset( $cart->cart_contents[ $cart_item_key ]['is_free_product'] );
222 }
223 }
224 }
225 }
226
227 if ( ! function_exists( 'disco_order_free_candidates' ) ) {
228 /**
229 * Return the reward-category cart keys ordered by the selection strategy.
230 *
231 * Includes every in-category cart item (even ones currently flagged free, so
232 * the reward can be re-picked). `cart_order` keeps cart order; `lowest` /
233 * `highest` sort by price (via CalcFactory::get_price, so a previously-freed
234 * item still ranks by its real price). Ties keep cart order (stable on PHP 8+).
235 *
236 * @param \WC_Cart $cart Cart Object.
237 * @param array $free_ids Category IDs for free products.
238 * @param string $free_selection cart_order | lowest | highest.
239 * @return array List of cart item keys.
240 */
241 function disco_order_free_candidates( $cart, $free_ids, $free_selection ) {
242 $candidates = array();
243
244 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
245 if ( ! Helper::is_in_category( $cart_item['product_id'], $free_ids ) ) {
246 continue;
247 }
248
249 $candidates[ $cart_item_key ] = (float) \Disco\App\Calc\CalcFactory::get_price( $cart_item );
250 }
251
252 if ( 'lowest' === $free_selection ) {
253 asort( $candidates );
254 } elseif ( 'highest' === $free_selection ) {
255 arsort( $candidates );
256 }
257
258 return array_keys( $candidates );
259 }
260 }
261
262 if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) {
263 /**
264 * Apply free items based on product rules.
265 *
266 * Every product id in $free_ids gets its own free line, so multi-product
267 * BOGO (e.g. BuyXGetX across several cart products) rewards them all — not
268 * just the last one. The free quantity is per id via $qty_map, falling back
269 * to $fallback_qty.
270 *
271 * @param \WC_Cart $cart Cart Object.
272 * @param array $free_ids Product IDs for free products.
273 * @param array|int $qty_map Map of product id => free quantity (or a plain int for all).
274 * @param int $fallback_qty Quantity to use when a id is absent from the map.
275 */
276 function disco_apply_product_based_free_items( $cart, $free_ids, $qty_map, $fallback_qty = 1 ) {
277 // Allow a plain integer for backward compatibility.
278 if ( ! is_array( $qty_map ) ) {
279 $fallback_qty = (int) $qty_map;
280 $qty_map = array();
281 }
282
283 foreach ( $free_ids as $product_id ) {
284 $free_qty = isset( $qty_map[ (int) $product_id ] ) ? (int) $qty_map[ (int) $product_id ] : (int) $fallback_qty;
285
286 if ( $free_qty <= 0 ) {
287 continue;
288 }
289
290 $found = false;
291
292 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
293 if (
294 intval( $cart_item['product_id'] ) === intval( $product_id )
295 && ! empty( $cart_item['is_free_product'] )
296 ) {
297 $current_qty = $cart_item['quantity'];
298 if ( (int) $current_qty !== $free_qty ) {
299 $cart->set_quantity( $cart_item_key, $free_qty );
300 }
301 $found = true;
302 break;
303 }
304 }
305
306 if ( ! $found ) {
307 $cart->add_to_cart( $product_id, $free_qty, 0, array(), array( 'is_free_product' => true ) );
308 }
309 }
310 }
311 }
312
313 if ( ! function_exists( 'disco_set_free_product_price' ) ) {
314 /**
315 * Set the price of free products to zero.
316 *
317 * @param \WC_Cart $cart Cart Object.
318 */
319 function disco_set_free_product_price( $cart ) {
320 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
321 return;
322 }
323
324 // Drop last pass's ratios: every partly-free line re-registers below.
325 disco_category_bogo_paid_ratio_registry( false );
326
327 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
328 if ( ! empty( $cart_item['is_free_product'] ) ) {
329 $cart_item['data']->set_price( 0 );
330 $cart_item['data']->set_regular_price( 0 );
331 $cart->cart_contents[ $cart_item_key ]['free_product_note'] = __( 'This item is added as free!', 'disco' );
332
333 continue;
334 }
335
336 disco_apply_category_bogo_partial_free_price( $cart, $cart_item_key, $cart_item );
337 }
338 }
339 add_action( 'woocommerce_before_calculate_totals', 'disco_set_free_product_price', 10 );
340 }
341
342 if ( ! function_exists( 'disco_apply_category_bogo_partial_free_price' ) ) {
343 /**
344 * Charge only the paid units of a line that is partly free.
345 *
346 * Category BOGO never adds or removes cart lines, so a line the customer
347 * added can hold both free and paid units. The line keeps its quantity and
348 * its per-unit price is scaled down so the line total covers the paid units
349 * only: qty 3 with 1 free at 20 is charged 40.
350 *
351 * The scale factor is registered against this line's own product object and
352 * applied by {@see disco_apply_category_bogo_paid_ratio} on top of whatever price
353 * Disco's product filters resolve, so it never fights those filters and never
354 * compounds across recalculations.
355 *
356 * @param \WC_Cart $cart Cart Object.
357 * @param string $cart_item_key Cart item key.
358 * @param array $cart_item Cart item.
359 */
360 function disco_apply_category_bogo_partial_free_price( $cart, $cart_item_key, $cart_item ) {
361 $free_qty = isset( $cart_item[ CategoryBogoCart::FREE_QUANTITY_META ] )
362 ? (int) $cart_item[ CategoryBogoCart::FREE_QUANTITY_META ]
363 : 0;
364 $line_qty = (int) $cart_item['quantity'];
365
366 if ( $free_qty <= 0 || $line_qty <= 0 || $free_qty >= $line_qty ) {
367 return;
368 }
369
370 if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof WC_Product ) {
371 return;
372 }
373
374 $paid_qty = $line_qty - $free_qty;
375
376 disco_category_bogo_paid_ratio_registry( $cart_item['data'], $paid_qty / $line_qty );
377
378 $cart->cart_contents[ $cart_item_key ]['free_product_note'] = sprintf(
379 /* translators: %d: number of units given for free. */
380 _n( '%d item is added as free!', '%d items are added as free!', $free_qty, 'disco' ),
381 $free_qty
382 );
383 }
384 }
385
386 if ( ! function_exists( 'disco_category_bogo_paid_ratio_registry' ) ) {
387 /**
388 * Registry of paid-unit ratios, keyed by cart line product object.
389 *
390 * Each cart line owns its own product instance, so the object identity is
391 * what distinguishes "this line of 3 with 1 free" from another line of the
392 * same product. Called with a product to register a ratio, with no arguments
393 * to read the registry, and with null to reset it before a fresh pass.
394 *
395 * @param \WC_Product|null $product Line product object to register.
396 * @param float $ratio Paid units / line quantity.
397 * @return array Registered ratios keyed by object id.
398 */
399 function disco_category_bogo_paid_ratio_registry( $product = null, $ratio = 1.0 ) {
400 static $ratios = array();
401
402 if ( $product instanceof WC_Product ) {
403 $ratios[ spl_object_id( $product ) ] = (float) $ratio;
404 } elseif ( false === $product ) {
405 $ratios = array();
406 }
407
408 return $ratios;
409 }
410 }
411
412 if ( ! function_exists( 'disco_apply_category_bogo_paid_ratio' ) ) {
413 /**
414 * Scale a partly-free cart line's price down to its paid units.
415 *
416 * Runs after Disco's own product price filters (priority 999), so the free
417 * units are removed from whatever price the other campaigns resolved.
418 *
419 * @param float|string $price Product price.
420 * @param \WC_Product $product Product object.
421 * @return float|string
422 */
423 function disco_apply_category_bogo_paid_ratio( $price, $product ) {
424 if ( ! is_numeric( $price ) || ! is_object( $product ) ) {
425 return $price;
426 }
427
428 $ratios = disco_category_bogo_paid_ratio_registry();
429 $object_id = spl_object_id( $product );
430
431 if ( ! isset( $ratios[ $object_id ] ) ) {
432 return $price;
433 }
434
435 return (float) $price * $ratios[ $object_id ];
436 }
437
438 add_filter( 'woocommerce_product_get_price', 'disco_apply_category_bogo_paid_ratio', 1000, 2 );
439 add_filter( 'woocommerce_product_variation_get_price', 'disco_apply_category_bogo_paid_ratio', 1000, 2 );
440 }
441
442 if ( ! function_exists( 'disco_free_product_label' ) ) {
443 /**
444 * Display free product label in cart.
445 *
446 * The note is wrapped in `.disco-free-item-note` so it stands out from the
447 * other item meta rows, which are plain grey text. WooCommerce renders item
448 * data through `wp_kses_post()`, so the span survives; the note itself is
449 * escaped here because it is interpolated into markup.
450 *
451 * @param array $item_data Item data.
452 * @param array $cart_item Cart item.
453 * @return array
454 */
455 function disco_free_product_label( $item_data, $cart_item ) {
456 if ( ! empty( $cart_item['free_product_note'] ) ) {
457 $item_data[] = array(
458 'key' => __( 'Note', 'disco' ),
459 'value' => sprintf(
460 '<span class="disco-free-item-note">%s</span>',
461 esc_html( $cart_item['free_product_note'] )
462 ),
463 );
464 }
465 return $item_data;
466 }
467
468 add_filter( 'woocommerce_get_item_data', 'disco_free_product_label', 10, 2 );
469 }
470