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 -61 1.3.531.4.17 View file →
@@ -15,8 +15,10 @@
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 22
21 23 if ( ! function_exists( 'disco_cart_apply_free_items' ) ) {
22 24 /**
@@ -41,17 +43,42 @@
41 43
42 44 $is_processing = true;
43 45
44 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 +
45 65 // Calculate discounts - free products are automatically excluded in IntentHelper
46 66 $disco = new Disco();
47 67 $discounts = $disco->get_cart_items_discount_for_bogo( $cart );
48 68 $discounts = is_array( $discounts ) ? $discounts : array();
49 69
50 - $free_ids = $discounts['get_ids'] ?? array();
51 - $free_qty = $discounts['get_qty'] ?? 0;
52 - $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';
53 75
76 + // The category engine already applied the category rules.
77 + if ( $category_active && 'categories' === $bogo_type ) {
78 + return;
79 + }
80 +
54 81 // Only process if we have valid discount data
55 82 if ( ! empty( $free_qty ) && ! empty( $free_ids ) ) {
56 83 // Remove invalid free items from cart
57 84 disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type );
@@ -57,11 +84,11 @@
57 84 disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type );
58 85
59 86 // Add or update free items
60 87 if ( $bogo_type === 'categories' ) {
61 - 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 );
62 89 } else {
63 - 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 );
64 91 }
65 92 } else {
66 93 // No valid BOGO, remove all free items
67 94 disco_remove_all_free_items( $cart );
@@ -83,8 +110,13 @@
83 110 function disco_remove_all_free_items( $cart ) {
84 111 $items_to_remove = array();
85 112
86 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 +
87 119 if ( ! empty( $cart_item['is_free_product'] ) ) {
88 120 $items_to_remove[] = $cart_item_key;
89 121 }
90 122 }
@@ -106,9 +138,9 @@
106 138 function disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type ) {
107 139 $items_to_remove = array();
108 140
109 141 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
110 - if ( empty( $cart_item['is_free_product'] ) ) {
142 + if ( empty( $cart_item['is_free_product'] ) || ! empty( $cart_item[ CategoryBogoCart::FREE_ITEM_FLAG ] ) ) {
111 143 continue;
112 144 }
113 145
114 146 $product_id = $cart_item['product_id'];
@@ -140,71 +172,91 @@
140 172 if ( ! function_exists( 'disco_apply_category_based_free_items' ) ) {
141 173 /**
142 174 * Apply free items based on category rules.
143 175 *
144 - * @param \WC_Cart $cart Cart Object.
145 - * @param array $free_ids Category IDs for free products.
146 - * @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.
147 180 */
148 - function disco_apply_category_based_free_items( $cart, $free_ids, $free_qty ) {
149 - // First, enforce quantity limits on existing free items and count them
150 - $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 );
151 183
152 - foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
153 - if ( ! empty( $cart_item['is_free_product'] ) ) {
154 - $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 );
155 191
156 - if ( Helper::is_in_category( $product_id, $free_ids ) ) {
157 - $item_qty = $cart_item['quantity'];
192 + // Choose whole lines that fit within the free quantity.
193 + $target = array();
194 + $count = 0;
158 195
159 - // Check if this item's quantity exceeds remaining allowed free qty
160 - $remaining_allowed = $free_qty - $existing_free_count;
196 + foreach ( $ordered as $cart_item_key ) {
197 + if ( $count >= $free_qty ) {
198 + break;
199 + }
161 200
162 - if ( $item_qty > $remaining_allowed && $remaining_allowed > 0 ) {
163 - // Reduce quantity to allowed amount
164 - $cart->set_quantity( $cart_item_key, $remaining_allowed );
165 - $existing_free_count += $remaining_allowed;
166 - } elseif ( $remaining_allowed <= 0 ) {
167 - // No more free items allowed, remove free flag or remove item
168 - $cart->remove_cart_item( $cart_item_key );
169 - } else {
170 - $existing_free_count += $item_qty;
171 - }
172 - }
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;
173 206 }
174 207 }
175 208
176 - // Calculate how many more items we can mark as free
177 - $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 + }
178 214
179 - if ( $remaining_free_qty <= 0 ) {
180 - 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 + }
181 223 }
224 + }
225 +}
182 226
183 - $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();
184 243
185 244 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
186 - if ( $free_count >= $remaining_free_qty ) {
187 - break;
188 - }
189 -
190 - // Skip items already marked as free
191 - if ( ! empty( $cart_item['is_free_product'] ) ) {
245 + if ( ! Helper::is_in_category( $cart_item['product_id'], $free_ids ) ) {
192 246 continue;
193 247 }
194 248
195 - $product_id = $cart_item['product_id'];
249 + $candidates[ $cart_item_key ] = (float) \Disco\App\Calc\CalcFactory::get_price( $cart_item );
250 + }
196 251
197 - if ( Helper::is_in_category( $product_id, $free_ids ) ) {
198 - $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 + }
199 257
200 - // Only mark item free if the full quantity fits
201 - if ( $cart_item['quantity'] <= $qty_to_mark ) {
202 - $cart->cart_contents[ $cart_item_key ]['is_free_product'] = true;
203 - $free_count += $cart_item['quantity'];
204 - }
205 - }
206 - }
258 + return array_keys( $candidates );
207 259 }
208 260 }
209 261
210 262 if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) {
@@ -210,14 +262,32 @@
210 262 if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) {
211 263 /**
212 264 * Apply free items based on product rules.
213 265 *
214 - * @param \WC_Cart $cart Cart Object.
215 - * @param array $free_ids Product IDs for free products.
216 - * @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.
217 275 */
218 - 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 +
219 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 +
220 290 $found = false;
221 291
222 292 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
223 293 if (
@@ -224,9 +294,9 @@
224 294 intval( $cart_item['product_id'] ) === intval( $product_id )
225 295 && ! empty( $cart_item['is_free_product'] )
226 296 ) {
227 297 $current_qty = $cart_item['quantity'];
228 - if ( $current_qty !== $free_qty ) {
298 + if ( (int) $current_qty !== $free_qty ) {
229 299 $cart->set_quantity( $cart_item_key, $free_qty );
230 300 }
231 301 $found = true;
232 302 break;
@@ -250,25 +320,140 @@
250 320 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
251 321 return;
252 322 }
253 323
324 + // Drop last pass's ratios: every partly-free line re-registers below.
325 + disco_category_bogo_paid_ratio_registry( false );
326 +
254 327 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
255 - 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 +
256 333 continue;
257 334 }
258 335
259 - $cart_item['data']->set_price( 0 );
260 - $cart_item['data']->set_regular_price( 0 );
261 - $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 );
262 337 }
263 338 }
264 339 add_action( 'woocommerce_before_calculate_totals', 'disco_set_free_product_price', 10 );
265 340 }
266 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 +
267 447 if ( ! function_exists( 'disco_free_product_label' ) ) {
268 448 /**
269 449 * Display free product label in cart.
270 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 + *
271 456 * @param array $item_data Item data.
272 457 * @param array $cart_item Cart item.
273 458 * @return array
274 459 */
@@ -275,9 +460,12 @@
275 460 function disco_free_product_label( $item_data, $cart_item ) {
276 461 if ( ! empty( $cart_item['free_product_note'] ) ) {
277 462 $item_data[] = array(
278 463 'key' => __( 'Note', 'disco' ),
279 - '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 + ),
280 468 );
281 469 }
282 470 return $item_data;
283 471 }