| 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 |
|