PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.17
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.17
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 1.3.49 1.3.48 All 180 releases
← All changes | functions/bogo.php +249 -66 1.3.491.4.17 View file →
@@ -15,10 +15,11 @@
15 15 exit;
16 16 }
17 17
18 18 use Disco\App\Disco;
19 +use Disco\App\Intents\CategoryBogo\CategoryBogo;
20 +use Disco\App\Intents\CategoryBogo\CategoryBogoCart;
19 21 use Disco\App\Utility\Helper;
20 -use Disco\App\Utility\Settings;
21 22
22 23 if ( ! function_exists( 'disco_cart_apply_free_items' ) ) {
23 24 /**
24 25 * Apply free items to the cart based on BOGO rules.
@@ -29,12 +30,8 @@
29 30 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
30 31 return;
31 32 }
32 33
33 - if ( Settings::get( 'discount_priority_type' ) === 'woocommerce_coupon' ) {
34 - return;
35 - }
36 -
37 34 // Prevent multiple executions during the same request
38 35 static $is_processing = false;
39 36 if ( $is_processing ) {
40 37 return;
@@ -46,17 +43,42 @@
46 43
47 44 $is_processing = true;
48 45
49 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 +
50 65 // Calculate discounts - free products are automatically excluded in IntentHelper
51 66 $disco = new Disco();
52 67 $discounts = $disco->get_cart_items_discount_for_bogo( $cart );
53 68 $discounts = is_array( $discounts ) ? $discounts : array();
54 69
55 - $free_ids = $discounts['get_ids'] ?? array();
56 - $free_qty = $discounts['get_qty'] ?? 0;
57 - $bogo_type = $discounts['bogo_type'] ?? 'products';
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';
58 75
76 + // The category engine already applied the category rules.
77 + if ( $category_active && 'categories' === $bogo_type ) {
78 + return;
79 + }
80 +
59 81 // Only process if we have valid discount data
60 82 if ( ! empty( $free_qty ) && ! empty( $free_ids ) ) {
61 83 // Remove invalid free items from cart
62 84 disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type );
@@ -62,11 +84,11 @@
62 84 disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type );
63 85
64 86 // Add or update free items
65 87 if ( $bogo_type === 'categories' ) {
66 - disco_apply_category_based_free_items( $cart, $free_ids, $free_qty );
88 + disco_apply_category_based_free_items( $cart, $free_ids, $free_qty, $free_selection );
67 89 } else {
68 - disco_apply_product_based_free_items( $cart, $free_ids, $free_qty );
90 + disco_apply_product_based_free_items( $cart, $free_ids, $free_qty_map, $free_qty );
69 91 }
70 92 } else {
71 93 // No valid BOGO, remove all free items
72 94 disco_remove_all_free_items( $cart );
@@ -88,8 +110,13 @@
88 110 function disco_remove_all_free_items( $cart ) {
89 111 $items_to_remove = array();
90 112
91 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 +
92 119 if ( ! empty( $cart_item['is_free_product'] ) ) {
93 120 $items_to_remove[] = $cart_item_key;
94 121 }
95 122 }
@@ -111,9 +138,9 @@
111 138 function disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type ) {
112 139 $items_to_remove = array();
113 140
114 141 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
115 - if ( empty( $cart_item['is_free_product'] ) ) {
142 + if ( empty( $cart_item['is_free_product'] ) || ! empty( $cart_item[ CategoryBogoCart::FREE_ITEM_FLAG ] ) ) {
116 143 continue;
117 144 }
118 145
119 146 $product_id = $cart_item['product_id'];
@@ -145,71 +172,91 @@
145 172 if ( ! function_exists( 'disco_apply_category_based_free_items' ) ) {
146 173 /**
147 174 * Apply free items based on category rules.
148 175 *
149 - * @param \WC_Cart $cart Cart Object.
150 - * @param array $free_ids Category IDs for free products.
151 - * @param int $free_qty Quantity of free items allowed.
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.
152 180 */
153 - function disco_apply_category_based_free_items( $cart, $free_ids, $free_qty ) {
154 - // First, enforce quantity limits on existing free items and count them
155 - $existing_free_count = 0;
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 );
156 183
157 - foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
158 - if ( ! empty( $cart_item['is_free_product'] ) ) {
159 - $product_id = $cart_item['product_id'];
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 );
160 191
161 - if ( Helper::is_in_category( $product_id, $free_ids ) ) {
162 - $item_qty = $cart_item['quantity'];
192 + // Choose whole lines that fit within the free quantity.
193 + $target = array();
194 + $count = 0;
163 195
164 - // Check if this item's quantity exceeds remaining allowed free qty
165 - $remaining_allowed = $free_qty - $existing_free_count;
196 + foreach ( $ordered as $cart_item_key ) {
197 + if ( $count >= $free_qty ) {
198 + break;
199 + }
166 200
167 - if ( $item_qty > $remaining_allowed && $remaining_allowed > 0 ) {
168 - // Reduce quantity to allowed amount
169 - $cart->set_quantity( $cart_item_key, $remaining_allowed );
170 - $existing_free_count += $remaining_allowed;
171 - } elseif ( $remaining_allowed <= 0 ) {
172 - // No more free items allowed, remove free flag or remove item
173 - $cart->remove_cart_item( $cart_item_key );
174 - } else {
175 - $existing_free_count += $item_qty;
176 - }
177 - }
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;
178 206 }
179 207 }
180 208
181 - // Calculate how many more items we can mark as free
182 - $remaining_free_qty = $free_qty - $existing_free_count;
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 + }
183 214
184 - if ( $remaining_free_qty <= 0 ) {
185 - return; // Already have enough free items
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 + }
186 223 }
224 + }
225 +}
187 226
188 - $free_count = 0;
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();
189 243
190 244 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
191 - if ( $free_count >= $remaining_free_qty ) {
192 - break;
193 - }
194 -
195 - // Skip items already marked as free
196 - if ( ! empty( $cart_item['is_free_product'] ) ) {
245 + if ( ! Helper::is_in_category( $cart_item['product_id'], $free_ids ) ) {
197 246 continue;
198 247 }
199 248
200 - $product_id = $cart_item['product_id'];
249 + $candidates[ $cart_item_key ] = (float) \Disco\App\Calc\CalcFactory::get_price( $cart_item );
250 + }
201 251
202 - if ( Helper::is_in_category( $product_id, $free_ids ) ) {
203 - $qty_to_mark = $remaining_free_qty - $free_count;
252 + if ( 'lowest' === $free_selection ) {
253 + asort( $candidates );
254 + } elseif ( 'highest' === $free_selection ) {
255 + arsort( $candidates );
256 + }
204 257
205 - // Only mark item free if the full quantity fits
206 - if ( $cart_item['quantity'] <= $qty_to_mark ) {
207 - $cart->cart_contents[ $cart_item_key ]['is_free_product'] = true;
208 - $free_count += $cart_item['quantity'];
209 - }
210 - }
211 - }
258 + return array_keys( $candidates );
212 259 }
213 260 }
214 261
215 262 if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) {
@@ -215,14 +262,32 @@
215 262 if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) {
216 263 /**
217 264 * Apply free items based on product rules.
218 265 *
219 - * @param \WC_Cart $cart Cart Object.
220 - * @param array $free_ids Product IDs for free products.
221 - * @param int $free_qty Quantity of free items.
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.
222 275 */
223 - function disco_apply_product_based_free_items( $cart, $free_ids, $free_qty ) {
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 +
224 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 +
225 290 $found = false;
226 291
227 292 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
228 293 if (
@@ -229,9 +294,9 @@
229 294 intval( $cart_item['product_id'] ) === intval( $product_id )
230 295 && ! empty( $cart_item['is_free_product'] )
231 296 ) {
232 297 $current_qty = $cart_item['quantity'];
233 - if ( $current_qty !== $free_qty ) {
298 + if ( (int) $current_qty !== $free_qty ) {
234 299 $cart->set_quantity( $cart_item_key, $free_qty );
235 300 }
236 301 $found = true;
237 302 break;
@@ -255,25 +320,140 @@
255 320 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
256 321 return;
257 322 }
258 323
324 + // Drop last pass's ratios: every partly-free line re-registers below.
325 + disco_category_bogo_paid_ratio_registry( false );
326 +
259 327 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
260 - if ( empty( $cart_item['is_free_product'] ) ) {
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 +
261 333 continue;
262 334 }
263 335
264 - $cart_item['data']->set_price( 0 );
265 - $cart_item['data']->set_regular_price( 0 );
266 - $cart->cart_contents[ $cart_item_key ]['free_product_note'] = __( 'This item is added as free!', 'disco' );
336 + disco_apply_category_bogo_partial_free_price( $cart, $cart_item_key, $cart_item );
267 337 }
268 338 }
269 339 add_action( 'woocommerce_before_calculate_totals', 'disco_set_free_product_price', 10 );
270 340 }
271 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, so the free units are
417 + * removed from whatever price the other campaigns resolved. Those filters
418 + * sit at PHP_INT_MAX, so this one shares that priority and relies on
419 + * registration order: bogo.php is required after product.php, and WordPress
420 + * runs same-priority callbacks in the order they were added. Registering
421 + * lower would put this first, and disco_discounted_price would then re-read
422 + * the base price and discard the scaling.
423 + *
424 + * @param float|string $price Product price.
425 + * @param \WC_Product $product Product object.
426 + * @return float|string
427 + */
428 + function disco_apply_category_bogo_paid_ratio( $price, $product ) {
429 + if ( ! is_numeric( $price ) || ! is_object( $product ) ) {
430 + return $price;
431 + }
432 +
433 + $ratios = disco_category_bogo_paid_ratio_registry();
434 + $object_id = spl_object_id( $product );
435 +
436 + if ( ! isset( $ratios[ $object_id ] ) ) {
437 + return $price;
438 + }
439 +
440 + return (float) $price * $ratios[ $object_id ];
441 + }
442 +
443 + add_filter( 'woocommerce_product_get_price', 'disco_apply_category_bogo_paid_ratio', PHP_INT_MAX, 2 );
444 + add_filter( 'woocommerce_product_variation_get_price', 'disco_apply_category_bogo_paid_ratio', PHP_INT_MAX, 2 );
445 +}
446 +
272 447 if ( ! function_exists( 'disco_free_product_label' ) ) {
273 448 /**
274 449 * Display free product label in cart.
275 450 *
451 + * The note is wrapped in `.disco-free-item-note` so it stands out from the
452 + * other item meta rows, which are plain grey text. WooCommerce renders item
453 + * data through `wp_kses_post()`, so the span survives; the note itself is
454 + * escaped here because it is interpolated into markup.
455 + *
276 456 * @param array $item_data Item data.
277 457 * @param array $cart_item Cart item.
278 458 * @return array
279 459 */
@@ -280,9 +460,12 @@
280 460 function disco_free_product_label( $item_data, $cart_item ) {
281 461 if ( ! empty( $cart_item['free_product_note'] ) ) {
282 462 $item_data[] = array(
283 463 'key' => __( 'Note', 'disco' ),
284 - 'value' => $cart_item['free_product_note'],
464 + 'value' => sprintf(
465 + '<span class="disco-free-item-note">%s</span>',
466 + esc_html( $cart_item['free_product_note'] )
467 + ),
285 468 );
286 469 }
287 470 return $item_data;
288 471 }