| 1 |
<?php |
| 2 |
|
| 3 |
namespace StoreEngine\Classes; |
| 4 |
|
| 5 |
use Exception; |
| 6 |
use stdClass; |
| 7 |
use StoreEngine\Classes\Exceptions\StoreEngineException; |
| 8 |
use StoreEngine\Utils\Formatting; |
| 9 |
use StoreEngine\Utils\Helper; |
| 10 |
use StoreEngine\Utils\NumberUtil; |
| 11 |
use WP_Error; |
| 12 |
|
| 13 |
if ( ! defined( 'ABSPATH' ) ) { |
| 14 |
exit; |
| 15 |
} |
| 16 |
|
| 17 |
class Discounts { |
| 18 |
|
| 19 |
/** |
| 20 |
* Reference to cart or order object. |
| 21 |
* |
| 22 |
* @var Cart|Order |
| 23 |
*/ |
| 24 |
protected $object; |
| 25 |
|
| 26 |
/** |
| 27 |
* Items to discount. |
| 28 |
* |
| 29 |
* @var array<int|string,object> |
| 30 |
*/ |
| 31 |
protected array $items = []; |
| 32 |
|
| 33 |
/** |
| 34 |
* An array of discounts which have been applied to items. |
| 35 |
* |
| 36 |
* @var array[] Code => Item Key => Value |
| 37 |
*/ |
| 38 |
protected array $discounts = []; |
| 39 |
|
| 40 |
/** |
| 41 |
* Number of "free"/rewarded units granted per item by a coupon. |
| 42 |
* |
| 43 |
* Recorded by add-ons that hand out whole free units rather than a plain |
| 44 |
* amount off (e.g. the Pro "Advanced Coupons" Buy X Get Y engine). Lets the |
| 45 |
* UI know which line(s) were rewarded and how many units of that line are |
| 46 |
* free, so it can be rendered as a separate FREE line. |
| 47 |
* |
| 48 |
* @var array[] Code => Item Key => units (int) |
| 49 |
*/ |
| 50 |
protected array $reward_units = []; |
| 51 |
|
| 52 |
/** |
| 53 |
* Discounts Constructor. |
| 54 |
* |
| 55 |
* @param Cart|Order $object Cart or order object. |
| 56 |
*/ |
| 57 |
public function __construct( $object = null ) { |
| 58 |
if ( is_a( $object, Cart::class ) ) { |
| 59 |
$this->set_items_from_cart( $object ); |
| 60 |
} elseif ( is_a( $object, Order::class ) ) { |
| 61 |
$this->set_items_from_order( $object ); |
| 62 |
} |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* Set items directly. Used by the cart-totals engine. |
| 67 |
* |
| 68 |
* @param array<int|string,object> $items Items to set. |
| 69 |
*/ |
| 70 |
public function set_items( array $items ) { |
| 71 |
$this->items = $items; |
| 72 |
$this->discounts = []; |
| 73 |
$this->reward_units = []; |
| 74 |
uasort( $this->items, [ $this, 'sort_by_price' ] ); |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Normalise cart items which will be discounted. |
| 79 |
* |
| 80 |
* @param Cart $cart Cart object. |
| 81 |
*/ |
| 82 |
public function set_items_from_cart( Cart $cart ) { |
| 83 |
$this->items = []; |
| 84 |
$this->discounts = []; |
| 85 |
$this->reward_units = []; |
| 86 |
|
| 87 |
if ( ! is_a( $cart, Cart::class ) ) { |
| 88 |
return; |
| 89 |
} |
| 90 |
|
| 91 |
$this->object = $cart; |
| 92 |
|
| 93 |
foreach ( $cart->get_cart_items() as $key => $cart_item ) { |
| 94 |
$item = new stdClass; |
| 95 |
$item->key = $key; |
| 96 |
$item->object = $cart_item; |
| 97 |
$item->product_id = $cart_item->product_id; |
| 98 |
$item->product_parent_id = $cart_item->product_parent_id ?? 0; |
| 99 |
$item->price_id = $cart_item->price_id; |
| 100 |
$item->price = $cart_item->get_price(); |
| 101 |
$item->quantity = $cart_item->quantity; |
| 102 |
$item->price = Formatting::add_number_precision_deep( (float) $item->price * (float) $item->quantity ); |
| 103 |
$item->compare_price = Formatting::add_number_precision_deep( $cart_item->compare_price ); |
| 104 |
$this->items[ $key ] = $item; |
| 105 |
} |
| 106 |
|
| 107 |
uasort( $this->items, [ $this, 'sort_by_price' ] ); |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Normalize order items which will be discounted. |
| 112 |
* |
| 113 |
* @param Order $order Order object. |
| 114 |
*/ |
| 115 |
public function set_items_from_order( Order $order ) { |
| 116 |
$this->items = []; |
| 117 |
$this->discounts = []; |
| 118 |
$this->reward_units = []; |
| 119 |
|
| 120 |
if ( ! is_a( $order, Order::class ) ) { |
| 121 |
return; |
| 122 |
} |
| 123 |
|
| 124 |
$this->object = $order; |
| 125 |
|
| 126 |
foreach ( $order->get_line_product_items() as $order_item ) { |
| 127 |
$item = new stdClass; |
| 128 |
$item->key = $order_item->get_id(); |
| 129 |
$item->object = $order_item; |
| 130 |
$item->product_id = $order_item->get_product_id(); |
| 131 |
$item->product_parent_id = null; |
| 132 |
$item->variation_id = $order_item->get_variation_id(); |
| 133 |
$item->price_id = $order_item->get_price_id(); |
| 134 |
$item->price = Formatting::add_number_precision_deep( $order_item->get_subtotal() ); |
| 135 |
$item->quantity = $order_item->get_quantity(); |
| 136 |
$item->compare_price = null; |
| 137 |
|
| 138 |
if ( $order->get_prices_include_tax() ) { |
| 139 |
$item->price += Formatting::add_number_precision_deep( $order_item->get_subtotal_tax() ); |
| 140 |
} |
| 141 |
|
| 142 |
$this->items[ $order_item->get_id() ] = $item; |
| 143 |
} |
| 144 |
|
| 145 |
uasort( $this->items, [ $this, 'sort_by_price' ] ); |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Get the object concerned. |
| 150 |
* |
| 151 |
* @return Cart|Order |
| 152 |
*/ |
| 153 |
public function get_object() { |
| 154 |
return $this->object; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Get items. |
| 159 |
* |
| 160 |
* @return array<int|string,object> |
| 161 |
*/ |
| 162 |
public function get_items(): array { |
| 163 |
return $this->items; |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Get items to validate. |
| 168 |
* |
| 169 |
* @return array<int|string,object> |
| 170 |
*/ |
| 171 |
public function get_items_to_validate(): array { |
| 172 |
return apply_filters( 'storeengine/coupon_get_items_to_validate', $this->get_items(), $this ); |
| 173 |
} |
| 174 |
|
| 175 |
/** |
| 176 |
* Get discount by key with or without precision. |
| 177 |
* |
| 178 |
* @param string $key name of discount row to return. |
| 179 |
* @param bool $in_cents Should the totals be returned in cents, or without precision. |
| 180 |
* |
| 181 |
* @return float |
| 182 |
*/ |
| 183 |
public function get_discount( string $key, bool $in_cents = false ) { |
| 184 |
$item_discount_totals = $this->get_discounts_by_item( $in_cents ); |
| 185 |
|
| 186 |
return $item_discount_totals[ $key ] ?? 0; |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Get all discount totals. |
| 191 |
* |
| 192 |
* @param bool $in_cents Should the totals be returned in cents, or without precision. |
| 193 |
* |
| 194 |
* @return array |
| 195 |
*/ |
| 196 |
public function get_discounts( bool $in_cents = false ): array { |
| 197 |
$discounts = $this->discounts; |
| 198 |
|
| 199 |
return $in_cents ? $discounts : Formatting::remove_number_precision_deep( $discounts ); |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* Get all discount totals per item. |
| 204 |
* |
| 205 |
* @param bool $in_cents Should the totals be returned in cents, or without precision. |
| 206 |
* |
| 207 |
* @return array |
| 208 |
*/ |
| 209 |
public function get_discounts_by_item( bool $in_cents = false ): array { |
| 210 |
$discounts = $this->discounts; |
| 211 |
$item_discount_totals = (array) array_shift( $discounts ); |
| 212 |
|
| 213 |
foreach ( $discounts as $item_discounts ) { |
| 214 |
foreach ( $item_discounts as $item_key => $item_discount ) { |
| 215 |
$item_discount_totals[ $item_key ] += $item_discount; |
| 216 |
} |
| 217 |
} |
| 218 |
|
| 219 |
return $in_cents ? $item_discount_totals : Formatting::remove_number_precision_deep( $item_discount_totals ); |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Get all discount totals per coupon. |
| 224 |
* |
| 225 |
* @param bool $in_cents Should the totals be returned in cents, or without precision. |
| 226 |
* |
| 227 |
* @return array |
| 228 |
*/ |
| 229 |
public function get_discounts_by_coupon( bool $in_cents = false ): array { |
| 230 |
$coupon_discount_totals = array_map( 'array_sum', $this->discounts ); |
| 231 |
|
| 232 |
return $in_cents ? $coupon_discount_totals : Formatting::remove_number_precision_deep( $coupon_discount_totals ); |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Get discounted price of an item without precision. |
| 237 |
* |
| 238 |
* @param object $item Get data for this item. |
| 239 |
* |
| 240 |
* @return float |
| 241 |
*/ |
| 242 |
public function get_discounted_price( object $item ): float { |
| 243 |
return Formatting::remove_number_precision_deep( $this->get_discounted_price_in_cents( $item ) ); |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* Get discounted price of an item to precision (in cents). |
| 248 |
* |
| 249 |
* @param object $item Get data for this item. |
| 250 |
* |
| 251 |
* @return int |
| 252 |
*/ |
| 253 |
public function get_discounted_price_in_cents( object $item ): int { |
| 254 |
return absint( NumberUtil::round( $item->price - $this->get_discount( $item->key, true ) ) ); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Apply a discount to all items using a coupon. |
| 259 |
* |
| 260 |
* @param Coupon $coupon Coupon object being applied to the items. |
| 261 |
* @param bool $validate Set false to skip coupon validation. |
| 262 |
* |
| 263 |
* @return bool|WP_Error True if applied or WP_Error instance in failure. |
| 264 |
*/ |
| 265 |
public function apply_coupon( Coupon $coupon, bool $validate = true ) { |
| 266 |
if ( ! is_a( $coupon, Coupon::class ) ) { |
| 267 |
return new WP_Error( 'invalid_coupon', __( 'Invalid coupon', 'storeengine' ) ); |
| 268 |
} |
| 269 |
|
| 270 |
$is_coupon_valid = $validate ? $this->is_coupon_valid( $coupon ) : true; |
| 271 |
|
| 272 |
if ( is_wp_error( $is_coupon_valid ) ) { |
| 273 |
return $is_coupon_valid; |
| 274 |
} |
| 275 |
|
| 276 |
$coupon_code = strtolower( $coupon->get_code() ); |
| 277 |
if ( ! isset( $this->discounts[ $coupon_code ] ) || ! is_array( $this->discounts[ $coupon_code ] ) ) { |
| 278 |
$this->discounts[ $coupon_code ] = array_fill_keys( array_keys( $this->items ), 0 ); |
| 279 |
} |
| 280 |
|
| 281 |
$items_to_apply = $this->get_items_to_apply_coupon( $coupon ); |
| 282 |
|
| 283 |
/** |
| 284 |
* Allow add-ons (e.g. StoreEngine Pro "Advanced Coupons") to fully handle |
| 285 |
* a coupon type that core does not implement — such as Buy X Get Y. Return |
| 286 |
* true to signal the coupon was handled; core handling is then skipped. |
| 287 |
* |
| 288 |
* @param bool $handled Whether the coupon was handled. |
| 289 |
* @param Coupon $coupon The coupon being applied. |
| 290 |
* @param array $items_to_apply Items the coupon applies to. |
| 291 |
* @param Discounts $this The discounts instance (use record_discount()). |
| 292 |
*/ |
| 293 |
if ( true === apply_filters( 'storeengine/discounts/handle_coupon', false, $coupon, $items_to_apply, $this ) ) { |
| 294 |
return true; |
| 295 |
} |
| 296 |
|
| 297 |
$discount_type = $coupon->get_discount_type(); |
| 298 |
|
| 299 |
// Core discounts are handled here as of 3.2. |
| 300 |
switch ( $discount_type ) { |
| 301 |
case 'percentage': |
| 302 |
case 'percent': |
| 303 |
$this->apply_coupon_percent( $coupon, $items_to_apply ); |
| 304 |
break; |
| 305 |
case 'fixed_product': |
| 306 |
$this->apply_coupon_fixed_product( $coupon, $items_to_apply ); |
| 307 |
break; |
| 308 |
case 'fixedAmount': |
| 309 |
case 'fixed_cart': |
| 310 |
$this->apply_coupon_fixed_cart( $coupon, $items_to_apply ); |
| 311 |
break; |
| 312 |
default: |
| 313 |
/* |
| 314 |
* A type core does not implement itself. Add-ons own such types |
| 315 |
* and claim them through `storeengine/discounts/handle_coupon` |
| 316 |
* above — when the owning add-on is inactive nothing claims it, |
| 317 |
* and applying nothing is the only safe outcome (guessing at an |
| 318 |
* amount would discount carts the merchant never intended). |
| 319 |
* |
| 320 |
* Types registered through `storeengine/product_coupon_types` |
| 321 |
* opt back in to the generic amount-based handler. |
| 322 |
*/ |
| 323 |
if ( in_array( $discount_type, Helper::get_coupon_types(), true ) ) { |
| 324 |
$this->apply_coupon_custom( $coupon, $items_to_apply ); |
| 325 |
} |
| 326 |
break; |
| 327 |
} |
| 328 |
|
| 329 |
return true; |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Record a discount amount on an item for a coupon. Used by add-ons that |
| 334 |
* handle custom coupon types via `storeengine/discounts/handle_coupon`. |
| 335 |
* |
| 336 |
* @param string $coupon_code Coupon code. |
| 337 |
* @param int|string $item_key Item key. |
| 338 |
* @param int $amount_in_cents Discount amount in cents. |
| 339 |
*/ |
| 340 |
public function record_discount( string $coupon_code, $item_key, int $amount_in_cents ): void { |
| 341 |
$code = strtolower( $coupon_code ); |
| 342 |
if ( ! isset( $this->discounts[ $code ] ) || ! is_array( $this->discounts[ $code ] ) ) { |
| 343 |
$this->discounts[ $code ] = array_fill_keys( array_keys( $this->items ), 0 ); |
| 344 |
} |
| 345 |
if ( ! isset( $this->discounts[ $code ][ $item_key ] ) ) { |
| 346 |
$this->discounts[ $code ][ $item_key ] = 0; |
| 347 |
} |
| 348 |
$this->discounts[ $code ][ $item_key ] += $amount_in_cents; |
| 349 |
} |
| 350 |
|
| 351 |
/** |
| 352 |
* Record rewarded ("free") units on an item for a coupon. |
| 353 |
* |
| 354 |
* Drives the FREE-line UI. Used by add-ons whose coupon type grants whole |
| 355 |
* free units rather than an amount off — record the money separately with |
| 356 |
* record_discount(). |
| 357 |
* |
| 358 |
* @param string $coupon_code Coupon code. |
| 359 |
* @param int|string $item_key Item key. |
| 360 |
* @param int $units Rewarded units to add. |
| 361 |
*/ |
| 362 |
public function record_reward_units( string $coupon_code, $item_key, int $units = 1 ): void { |
| 363 |
$code = strtolower( $coupon_code ); |
| 364 |
if ( ! isset( $this->reward_units[ $code ][ $item_key ] ) ) { |
| 365 |
$this->reward_units[ $code ][ $item_key ] = 0; |
| 366 |
} |
| 367 |
$this->reward_units[ $code ][ $item_key ] += $units; |
| 368 |
} |
| 369 |
|
| 370 |
/** |
| 371 |
* @deprecated Use record_reward_units(). |
| 372 |
* |
| 373 |
* @param string $coupon_code Coupon code. |
| 374 |
* @param int|string $item_key Item key. |
| 375 |
* @param int $units Rewarded units to add. |
| 376 |
*/ |
| 377 |
public function record_bogo_reward( string $coupon_code, $item_key, int $units = 1 ): void { |
| 378 |
$this->record_reward_units( $coupon_code, $item_key, $units ); |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* Public accessor for the items a coupon applies to (post product/category |
| 383 |
* filtering). Used by add-ons handling custom coupon types. |
| 384 |
* |
| 385 |
* @param Coupon $coupon Coupon object. |
| 386 |
* |
| 387 |
* @return array<int|string,object> |
| 388 |
*/ |
| 389 |
public function get_items_to_apply( Coupon $coupon ): array { |
| 390 |
return $this->get_items_to_apply_coupon( $coupon ); |
| 391 |
} |
| 392 |
|
| 393 |
/** |
| 394 |
* Sort by price. |
| 395 |
* |
| 396 |
* @param object $a First element. |
| 397 |
* @param object $b Second element. |
| 398 |
* |
| 399 |
* @return int |
| 400 |
*/ |
| 401 |
protected function sort_by_price( object $a, object $b ): int { |
| 402 |
$price_1 = $a->price * $a->quantity; |
| 403 |
$price_2 = $b->price * $b->quantity; |
| 404 |
if ( $price_1 === $price_2 ) { |
| 405 |
return 0; |
| 406 |
} |
| 407 |
|
| 408 |
return ( $price_1 < $price_2 ) ? 1 : - 1; |
| 409 |
} |
| 410 |
|
| 411 |
/** |
| 412 |
* Filter out all products which have been fully discounted to 0. |
| 413 |
* Used as array_filter callback. |
| 414 |
* |
| 415 |
* @param object $item Get data for this item. |
| 416 |
* |
| 417 |
* @return bool |
| 418 |
*/ |
| 419 |
protected function filter_products_with_price( object $item ): bool { |
| 420 |
return $this->get_discounted_price_in_cents( $item ) > 0; |
| 421 |
} |
| 422 |
|
| 423 |
/** |
| 424 |
* Get items which the coupon should be applied to. |
| 425 |
* |
| 426 |
* @param Coupon $coupon Coupon object. |
| 427 |
* |
| 428 |
* @return array<int|string,object> |
| 429 |
*/ |
| 430 |
protected function get_items_to_apply_coupon( Coupon $coupon ): array { |
| 431 |
$items_to_apply = []; |
| 432 |
|
| 433 |
foreach ( $this->get_items_to_validate() as $item ) { |
| 434 |
$to_apply = clone $item; // Clone the item so changes to this item do not affect the originals. |
| 435 |
|
| 436 |
if ( 0 === $this->get_discounted_price_in_cents( $to_apply ) || 0 >= $to_apply->quantity ) { |
| 437 |
continue; |
| 438 |
} |
| 439 |
|
| 440 |
$product_id = $to_apply->product_id ?? $to_apply->object->product_id; |
| 441 |
$price_id = $to_apply->price_id ?? $to_apply->object->price_id; |
| 442 |
|
| 443 |
if ( ! $coupon->is_valid_for_product( $product_id, $price_id, $to_apply->object ) /*&& ! $coupon->is_valid_for_cart()*/ ) { |
| 444 |
continue; |
| 445 |
} |
| 446 |
|
| 447 |
$items_to_apply[] = $to_apply; |
| 448 |
} |
| 449 |
|
| 450 |
/** |
| 451 |
* Filters the items that a coupon should be applied to. |
| 452 |
* |
| 453 |
* This filter allows you to modify the items that a coupon will be applied to before the discount calculations take place. |
| 454 |
* |
| 455 |
* @param array $items_to_apply The items that the coupon will be applied to. |
| 456 |
* @param Coupon $coupon The coupon object. |
| 457 |
* @param Discounts $this The discounts instance. |
| 458 |
* |
| 459 |
* @return array The modified list of items that the coupon should be applied to. |
| 460 |
*/ |
| 461 |
return apply_filters( 'storeengine/coupon_get_items_to_apply', $items_to_apply, $coupon, $this ); |
| 462 |
} |
| 463 |
|
| 464 |
/** |
| 465 |
* Apply percent discount to items and return an array of discounts granted. |
| 466 |
* |
| 467 |
* @param Coupon $coupon Coupon object. Passed through filters. |
| 468 |
* @param array<int|string,object> $items_to_apply Array of items to apply the coupon to. |
| 469 |
* |
| 470 |
* @return int|float Total discounted. |
| 471 |
*/ |
| 472 |
protected function apply_coupon_percent( Coupon $coupon, array $items_to_apply ) { |
| 473 |
$total_discount = 0; |
| 474 |
$cart_total = 0; |
| 475 |
$limit_usage_qty = 0; |
| 476 |
$applied_count = 0; |
| 477 |
$adjust_final_discount = true; |
| 478 |
|
| 479 |
if ( null !== $coupon->get_limit_usage_to_x_items() ) { |
| 480 |
$limit_usage_qty = $coupon->get_limit_usage_to_x_items(); |
| 481 |
} |
| 482 |
|
| 483 |
$coupon_amount = $coupon->get_amount(); |
| 484 |
|
| 485 |
foreach ( $items_to_apply as $item ) { |
| 486 |
// Find out how much price is available to discount for the item. |
| 487 |
$discounted_price = $this->get_discounted_price_in_cents( $item ); |
| 488 |
|
| 489 |
// Get the price we actually want to discount, based on settings. |
| 490 |
$price_to_discount = ( 'yes' === get_option( 'storeengine/calc_discounts_sequentially', 'no' ) ) ? $discounted_price : NumberUtil::round( $item->price ); |
| 491 |
|
| 492 |
// See how many and what price to apply to. |
| 493 |
$apply_quantity = $limit_usage_qty && ( $limit_usage_qty - $applied_count ) < $item->quantity ? $limit_usage_qty - $applied_count : $item->quantity; |
| 494 |
$apply_quantity = max( 0, apply_filters( 'storeengine/coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) ); |
| 495 |
$price_to_discount = ( $price_to_discount / $item->quantity ) * $apply_quantity; |
| 496 |
|
| 497 |
// Run coupon calculations. |
| 498 |
$discount = floor( $price_to_discount * ( $coupon_amount / 100 ) ); |
| 499 |
|
| 500 |
if ( is_a( $this->object, Cart::class ) && has_filter( 'storeengine/coupon_get_discount_amount' ) ) { |
| 501 |
// Send through the legacy filter, but not as cents. |
| 502 |
$filtered_discount = Formatting::add_number_precision( apply_filters( 'storeengine/coupon_get_discount_amount', Formatting::remove_number_precision( $discount ), Formatting::remove_number_precision( $price_to_discount ), $item->object, false, $coupon ) ); |
| 503 |
|
| 504 |
if ( $filtered_discount !== $discount ) { |
| 505 |
$discount = $filtered_discount; |
| 506 |
$adjust_final_discount = false; |
| 507 |
} |
| 508 |
} |
| 509 |
|
| 510 |
$discount = Formatting::round_discount( min( $discounted_price, $discount ), 0 ); |
| 511 |
$cart_total = $cart_total + $price_to_discount; |
| 512 |
$total_discount = $total_discount + $discount; |
| 513 |
$applied_count = $applied_count + $apply_quantity; |
| 514 |
|
| 515 |
// Store code and discount amount per item. |
| 516 |
$this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount; |
| 517 |
} |
| 518 |
|
| 519 |
// Work out how much discount would have been given to the cart as a whole and compare to what was discounted on all line items. |
| 520 |
$cart_total_discount = Formatting::round_discount( $cart_total * ( $coupon_amount / 100 ), 0 ); |
| 521 |
|
| 522 |
if ( $total_discount < $cart_total_discount && $adjust_final_discount ) { |
| 523 |
$total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $cart_total_discount - $total_discount ); |
| 524 |
} |
| 525 |
|
| 526 |
return $total_discount; |
| 527 |
} |
| 528 |
|
| 529 |
/** |
| 530 |
* Apply fixed product discount to items. |
| 531 |
* |
| 532 |
* @param Coupon $coupon Coupon object. Passed through filters. |
| 533 |
* @param array<int|string,object> $items_to_apply Array of items to apply the coupon to. |
| 534 |
* @param int|float $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon. |
| 535 |
* |
| 536 |
* @return int|float Total discounted. |
| 537 |
*/ |
| 538 |
protected function apply_coupon_fixed_product( Coupon $coupon, array $items_to_apply, $amount = null ) { |
| 539 |
$total_discount = 0; |
| 540 |
/** @noinspection PhpTernaryExpressionCanBeReducedToShortVersionInspection */ |
| 541 |
$amount = $amount ? $amount : Formatting::add_number_precision( $coupon->get_amount() ); |
| 542 |
$limit_usage_qty = 0; |
| 543 |
$applied_count = 0; |
| 544 |
|
| 545 |
if ( null !== $coupon->get_limit_usage_to_x_items() ) { |
| 546 |
$limit_usage_qty = $coupon->get_limit_usage_to_x_items(); |
| 547 |
} |
| 548 |
|
| 549 |
foreach ( $items_to_apply as $item ) { |
| 550 |
// Find out how much price is available to discount for the item. |
| 551 |
$discounted_price = $this->get_discounted_price_in_cents( $item ); |
| 552 |
|
| 553 |
// Get the price we actually want to discount, based on settings. |
| 554 |
$price_to_discount = ( 'yes' === get_option( 'storeengine/calc_discounts_sequentially', 'no' ) ) ? $discounted_price : $item->price; |
| 555 |
|
| 556 |
// Run coupon calculations. |
| 557 |
if ( $limit_usage_qty ) { |
| 558 |
$apply_quantity = min( $limit_usage_qty - $applied_count, $item->quantity ); |
| 559 |
$apply_quantity = max( 0, apply_filters( 'storeengine/coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) ); |
| 560 |
$discount = min( $amount, $item->price / $item->quantity ) * $apply_quantity; |
| 561 |
} else { |
| 562 |
$apply_quantity = apply_filters( 'storeengine/coupon_get_apply_quantity', $item->quantity, $item, $coupon, $this ); |
| 563 |
$discount = $amount * $apply_quantity; |
| 564 |
} |
| 565 |
|
| 566 |
if ( is_a( $this->object, Cart::class ) && has_filter( 'storeengine/coupon_get_discount_amount' ) ) { |
| 567 |
// Send through the legacy filter, but not as cents. |
| 568 |
$discount = Formatting::add_number_precision( apply_filters( 'storeengine/coupon_get_discount_amount', Formatting::remove_number_precision( $discount ), Formatting::remove_number_precision( $price_to_discount ), $item->object, false, $coupon ) ); |
| 569 |
} |
| 570 |
|
| 571 |
$discount = min( $discounted_price, $discount ); |
| 572 |
$total_discount = $total_discount + $discount; |
| 573 |
$applied_count = $applied_count + $apply_quantity; |
| 574 |
|
| 575 |
// Store code and discount amount per item. |
| 576 |
$this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount; |
| 577 |
} |
| 578 |
|
| 579 |
return $total_discount; |
| 580 |
} |
| 581 |
|
| 582 |
/** |
| 583 |
* Apply fixed cart discount to items. |
| 584 |
* |
| 585 |
* @param Coupon $coupon Coupon object. Passed through filters. |
| 586 |
* @param array<int|string,object> $items_to_apply Array of items to apply the coupon to. |
| 587 |
* @param int|float $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon. |
| 588 |
* |
| 589 |
* @return int|float Total discounted. |
| 590 |
*/ |
| 591 |
protected function apply_coupon_fixed_cart( Coupon $coupon, array $items_to_apply, $amount = null ) { |
| 592 |
$total_discount = 0; |
| 593 |
/** @noinspection PhpTernaryExpressionCanBeReducedToShortVersionInspection */ |
| 594 |
$amount = $amount ? $amount : Formatting::add_number_precision( $coupon->get_amount() ); |
| 595 |
$items_to_apply = array_filter( $items_to_apply, array( $this, 'filter_products_with_price' ) ); |
| 596 |
$item_count = array_sum( wp_list_pluck( $items_to_apply, 'quantity' ) ); |
| 597 |
|
| 598 |
if ( ! $item_count ) { |
| 599 |
return $total_discount; |
| 600 |
} |
| 601 |
|
| 602 |
if ( ! $amount ) { |
| 603 |
// If there is no amount we still send it through so filters are fired. |
| 604 |
$total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, 0 ); |
| 605 |
} else { |
| 606 |
$per_item_discount = absint( $amount / $item_count ); // round it down to the nearest cent. |
| 607 |
|
| 608 |
if ( $per_item_discount > 0 ) { |
| 609 |
$total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, $per_item_discount ); |
| 610 |
|
| 611 |
/** |
| 612 |
* If there is still discount remaining, repeat the process. |
| 613 |
*/ |
| 614 |
if ( $total_discount > 0 && $total_discount < $amount ) { |
| 615 |
$total_discount += $this->apply_coupon_fixed_cart( $coupon, $items_to_apply, $amount - $total_discount ); |
| 616 |
} |
| 617 |
} elseif ( $amount > 0 ) { |
| 618 |
$total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $amount ); |
| 619 |
} |
| 620 |
} |
| 621 |
|
| 622 |
return $total_discount; |
| 623 |
} |
| 624 |
|
| 625 |
/** |
| 626 |
* Get the rewarded ("free") unit counts per coupon and item. |
| 627 |
* |
| 628 |
* Populated by add-ons via record_reward_units(); this getter just exposes |
| 629 |
* them to the UI (FREE-line rendering). |
| 630 |
* |
| 631 |
* @return array[] Code => Item Key => units (int) |
| 632 |
*/ |
| 633 |
public function get_reward_units(): array { |
| 634 |
return $this->reward_units; |
| 635 |
} |
| 636 |
|
| 637 |
/** |
| 638 |
* @deprecated Use get_reward_units(). |
| 639 |
* |
| 640 |
* @return array[] Code => Item Key => units (int) |
| 641 |
*/ |
| 642 |
public function get_bogo_rewards(): array { |
| 643 |
return $this->get_reward_units(); |
| 644 |
} |
| 645 |
|
| 646 |
/** |
| 647 |
* Apply custom coupon discount to items. |
| 648 |
* |
| 649 |
* @param Coupon $coupon Coupon object. Passed through filters. |
| 650 |
* @param array<int|string,object> $items_to_apply Array of items to apply the coupon to. |
| 651 |
* |
| 652 |
* @return int|float Total discounted. |
| 653 |
*/ |
| 654 |
protected function apply_coupon_custom( Coupon $coupon, array $items_to_apply ): int { |
| 655 |
$limit_usage_qty = 0; |
| 656 |
$applied_count = 0; |
| 657 |
|
| 658 |
if ( null !== $coupon->get_limit_usage_to_x_items() ) { |
| 659 |
$limit_usage_qty = $coupon->get_limit_usage_to_x_items(); |
| 660 |
} |
| 661 |
|
| 662 |
// Apply the coupon to each item. |
| 663 |
foreach ( $items_to_apply as $item ) { |
| 664 |
// Find out how much price is available to discount for the item. |
| 665 |
$discounted_price = $this->get_discounted_price_in_cents( $item ); |
| 666 |
|
| 667 |
// Get the price we actually want to discount, based on settings. |
| 668 |
$price_to_discount = Formatting::remove_number_precision( ( 'yes' === get_option( 'storeengine/calc_discounts_sequentially', 'no' ) ) ? $discounted_price : $item->price ); |
| 669 |
|
| 670 |
// See how many and what price to apply to. |
| 671 |
$apply_quantity = $limit_usage_qty && ( $limit_usage_qty - $applied_count ) < $item->quantity ? $limit_usage_qty - $applied_count : $item->quantity; |
| 672 |
$apply_quantity = max( 0, apply_filters( 'storeengine/coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) ); |
| 673 |
|
| 674 |
// Run coupon calculations. |
| 675 |
$discount = Formatting::add_number_precision( $coupon->get_discount_amount( $price_to_discount / $item->quantity, $item->object->get_data(), true ) ) * $apply_quantity; |
| 676 |
$discount = Formatting::round_discount( min( $discounted_price, $discount ), 0 ); |
| 677 |
$applied_count = $applied_count + $apply_quantity; |
| 678 |
|
| 679 |
// Store code and discount amount per item. |
| 680 |
$this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount; |
| 681 |
} |
| 682 |
|
| 683 |
// Allow post-processing for custom coupon types (e.g. calculating discrepancy, etc). |
| 684 |
$this->discounts[ strtolower( $coupon->get_code() ) ] = apply_filters( 'storeengine/coupon_custom_discounts_array', $this->discounts[ strtolower( $coupon->get_code() ) ], $coupon ); |
| 685 |
|
| 686 |
return array_sum( $this->discounts[ strtolower( $coupon->get_code() ) ] ); |
| 687 |
} |
| 688 |
|
| 689 |
/** |
| 690 |
* Deal with remaining fractional discounts by splitting it over items |
| 691 |
* until the amount is expired, discounting 1 cent at a time. |
| 692 |
* |
| 693 |
* @param Coupon $coupon Coupon object if applicable. Passed through filters. |
| 694 |
* @param array<int|string,object> $items_to_apply Array of items to apply the coupon to. |
| 695 |
* @param int|float $amount Fixed discount amount to apply. |
| 696 |
* |
| 697 |
* @return int|float Total discounted. |
| 698 |
*/ |
| 699 |
protected function apply_coupon_remainder( Coupon $coupon, array $items_to_apply, $amount ) { |
| 700 |
$total_discount = 0; |
| 701 |
|
| 702 |
foreach ( $items_to_apply as $item ) { |
| 703 |
for ( $i = 0; $i < $item->quantity; $i ++ ) { |
| 704 |
// Find out how much price is available to discount for the item. |
| 705 |
$price_to_discount = $this->get_discounted_price_in_cents( $item ); |
| 706 |
|
| 707 |
// Run coupon calculations. |
| 708 |
$discount = min( $price_to_discount, 1 ); |
| 709 |
|
| 710 |
// Store totals. |
| 711 |
$total_discount += $discount; |
| 712 |
|
| 713 |
// Store code and discount amount per item. |
| 714 |
$this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount; |
| 715 |
|
| 716 |
if ( $total_discount >= $amount ) { |
| 717 |
break 2; |
| 718 |
} |
| 719 |
} |
| 720 |
if ( $total_discount >= $amount ) { |
| 721 |
break; |
| 722 |
} |
| 723 |
} |
| 724 |
|
| 725 |
return $total_discount; |
| 726 |
} |
| 727 |
|
| 728 |
/** |
| 729 |
* Ensure coupon exists or throw exception. |
| 730 |
* |
| 731 |
* A coupon is also considered to no longer exist if it has been placed in the trash, even if the trash has not yet |
| 732 |
* been emptied. |
| 733 |
* |
| 734 |
* @param Coupon $coupon Coupon data. |
| 735 |
* |
| 736 |
* @return bool |
| 737 |
* @throws Exception Error message. |
| 738 |
*/ |
| 739 |
protected function validate_coupon_exists( Coupon $coupon ): bool { |
| 740 |
if ( ! $coupon->get_id() || 'trash' === $coupon->get_status() ) { |
| 741 |
/* translators: %s: coupon code */ |
| 742 |
throw new Exception( sprintf( esc_html__( 'Coupon "%s" does not exist!', 'storeengine' ), esc_html( $coupon->get_code() ) ), 105 ); |
| 743 |
} |
| 744 |
|
| 745 |
return true; |
| 746 |
} |
| 747 |
|
| 748 |
/** |
| 749 |
* Ensure coupon usage limit is valid or throw exception. |
| 750 |
* |
| 751 |
* @param Coupon $coupon Coupon data. |
| 752 |
* |
| 753 |
* @return bool |
| 754 |
* @throws StoreEngineException Error message. |
| 755 |
*/ |
| 756 |
protected function validate_coupon_usage_limit( Coupon $coupon ): bool { |
| 757 |
if ( ! $coupon->get_usage_limit() ) { |
| 758 |
return true; |
| 759 |
} |
| 760 |
|
| 761 |
$usage_count = $coupon->get_usage_count(); |
| 762 |
$held_coupon_count = 0; // @see order held-coupon. |
| 763 |
|
| 764 |
if ( $usage_count + $held_coupon_count < $coupon->get_usage_limit() ) { |
| 765 |
// All good. |
| 766 |
return true; |
| 767 |
} |
| 768 |
|
| 769 |
throw new StoreEngineException( |
| 770 |
sprintf( |
| 771 |
// translators: %s: Coupon Code. |
| 772 |
esc_html__( 'Sorry, Coupon "%s" has reached its limit', 'storeengine' ), |
| 773 |
esc_html( $coupon->get_code() ) |
| 774 |
), |
| 775 |
'coupon-limit-reached', |
| 776 |
null, |
| 777 |
400 |
| 778 |
); |
| 779 |
} |
| 780 |
|
| 781 |
/** |
| 782 |
* Ensure coupon user usage limit is valid or throw exception. |
| 783 |
* |
| 784 |
* Per user usage limit - check here if user is logged in (against user IDs). |
| 785 |
* Checked again for emails later in the cart's customer-coupon check. |
| 786 |
* |
| 787 |
* @param Coupon $coupon Coupon data. |
| 788 |
* @param int $user_id User ID. |
| 789 |
* |
| 790 |
* @throws StoreEngineException Error message. |
| 791 |
*/ |
| 792 |
protected function validate_coupon_user_usage_limit( Coupon $coupon, int $user_id = 0 ): bool { |
| 793 |
if ( empty( $user_id ) ) { |
| 794 |
if ( $this->object instanceof Order ) { |
| 795 |
$user_id = $this->object->get_customer_id(); |
| 796 |
} else { |
| 797 |
$user_id = get_current_user_id(); |
| 798 |
} |
| 799 |
} |
| 800 |
|
| 801 |
if ( $user_id && apply_filters( 'storeengine/coupon/validate_user_usage_limit', $coupon->get_usage_limit_per_user() > 0, $user_id, $coupon, $this ) && $coupon->get_id() ) { |
| 802 |
if ( $coupon->get_usage_by_user_id( $user_id ) >= $coupon->get_usage_limit_per_user() ) { |
| 803 |
// Check if held for the user and change error message. |
| 804 |
throw new StoreEngineException( |
| 805 |
sprintf( |
| 806 |
// translators: %s: Coupon Code. |
| 807 |
esc_html__( 'Sorry, Coupon "%s" has reached its limit', 'storeengine' ), |
| 808 |
esc_html( $coupon->get_code() ) |
| 809 |
), |
| 810 |
'coupon-limit-reached', |
| 811 |
null, |
| 812 |
400 |
| 813 |
); |
| 814 |
} |
| 815 |
} |
| 816 |
|
| 817 |
return true; |
| 818 |
} |
| 819 |
|
| 820 |
/** |
| 821 |
* Ensure coupon date is valid or throw exception. |
| 822 |
* |
| 823 |
* @param Coupon $coupon Coupon data. |
| 824 |
* |
| 825 |
* @return bool |
| 826 |
* @throws Exception Error message. |
| 827 |
*/ |
| 828 |
protected function validate_coupon_expiry_date( Coupon $coupon ): bool { |
| 829 |
if ( $coupon->get_date_expires() && apply_filters( 'storeengine/coupon_validate_expiry_date', time() > $coupon->get_date_expires()->getTimestamp(), $coupon, $this ) ) { |
| 830 |
/* translators: %s: coupon code */ |
| 831 |
throw new Exception( sprintf( esc_html__( 'This coupon (%s) has expired.', 'storeengine' ), esc_html( $coupon->get_code() ) ), 107 ); |
| 832 |
} |
| 833 |
|
| 834 |
return true; |
| 835 |
} |
| 836 |
|
| 837 |
/** |
| 838 |
* Ensure coupon amount is valid or throw exception. |
| 839 |
* |
| 840 |
* @param Coupon $coupon Coupon data. |
| 841 |
* |
| 842 |
* @return bool |
| 843 |
* @throws Exception Error message. |
| 844 |
*/ |
| 845 |
protected function validate_coupon_minimum_amount( Coupon $coupon ): bool { |
| 846 |
$subtotal = Formatting::remove_number_precision( $this->get_object_subtotal() ); |
| 847 |
|
| 848 |
if ( $coupon->get_minimum_amount() > 0 && apply_filters( 'storeengine/coupon_validate_minimum_amount', $coupon->get_minimum_amount() > $subtotal, $coupon, $subtotal ) ) { |
| 849 |
/* translators: %s: coupon minimum amount */ |
| 850 |
throw new Exception( sprintf( esc_html__( 'The minimum spend for this coupon is %s.', 'storeengine' ), wp_kses_post( Formatting::price( $coupon->get_minimum_amount() ) ) ), 108 ); |
| 851 |
} |
| 852 |
|
| 853 |
return true; |
| 854 |
} |
| 855 |
|
| 856 |
/** |
| 857 |
* Ensure coupon amount is valid or throw exception. |
| 858 |
* |
| 859 |
* @param Coupon $coupon Coupon data. |
| 860 |
* |
| 861 |
* @return bool |
| 862 |
* @throws Exception Error message. |
| 863 |
*/ |
| 864 |
protected function validate_coupon_maximum_amount( Coupon $coupon ): bool { |
| 865 |
$subtotal = Formatting::remove_number_precision( $this->get_object_subtotal() ); |
| 866 |
|
| 867 |
if ( $coupon->get_maximum_amount() > 0 && apply_filters( 'storeengine/coupon_validate_maximum_amount', $coupon->get_maximum_amount() < $subtotal, $coupon ) ) { |
| 868 |
/* translators: %s: coupon maximum amount */ |
| 869 |
throw new Exception( sprintf( esc_html__( 'The maximum spend for this coupon is %s.', 'storeengine' ), wp_kses_post( Formatting::price( $coupon->get_maximum_amount() ) ) ), 112 ); |
| 870 |
} |
| 871 |
|
| 872 |
return true; |
| 873 |
} |
| 874 |
|
| 875 |
/** |
| 876 |
* Ensure coupon is valid for products in the list is valid or throw exception. |
| 877 |
* |
| 878 |
* @param Coupon $coupon Coupon data. |
| 879 |
* |
| 880 |
* @return bool |
| 881 |
* @throws Exception Error message. |
| 882 |
*/ |
| 883 |
protected function validate_coupon_product_ids( Coupon $coupon ): bool { |
| 884 |
if ( count( $coupon->get_product_ids() ) > 0 ) { |
| 885 |
$valid = false; |
| 886 |
|
| 887 |
foreach ( $this->get_items_to_validate() as $item ) { |
| 888 |
if ( ( $item->product_id && in_array( $item->product_id, $coupon->get_product_ids(), true ) ) || ( ! empty( $item->product_parent_id ) && in_array( $item->product_parent_id, $coupon->get_product_ids(), true ) ) ) { |
| 889 |
$valid = true; |
| 890 |
break; |
| 891 |
} |
| 892 |
} |
| 893 |
|
| 894 |
if ( ! $valid ) { |
| 895 |
throw new Exception( esc_html__( 'Sorry, this coupon is not applicable to selected products.', 'storeengine' ), 109 ); |
| 896 |
} |
| 897 |
} |
| 898 |
|
| 899 |
return true; |
| 900 |
} |
| 901 |
|
| 902 |
/** |
| 903 |
* Ensure coupon is valid for product categories in the list is valid or throw exception. |
| 904 |
* |
| 905 |
* @param Coupon $coupon Coupon data. |
| 906 |
* |
| 907 |
* @return bool |
| 908 |
* @throws Exception Error message. |
| 909 |
*/ |
| 910 |
protected function validate_coupon_product_categories( Coupon $coupon ): bool { |
| 911 |
if ( count( $coupon->get_product_categories() ) > 0 ) { |
| 912 |
$valid = false; |
| 913 |
|
| 914 |
foreach ( $this->get_items_to_validate() as $item ) { |
| 915 |
if ( $coupon->get_exclude_sale_items() && $item->product_id && $item->price && $item->compare_price > 0 ) { |
| 916 |
continue; |
| 917 |
} |
| 918 |
|
| 919 |
$product_cats = Helper::get_product_cat_ids( $item->product_id ); |
| 920 |
|
| 921 |
if ( ! empty( $item->product_parent_id ) ) { |
| 922 |
$product_cats = array_merge( $product_cats, Helper::get_product_cat_ids( $item->product_parent_id ) ); |
| 923 |
} |
| 924 |
|
| 925 |
// If we find an item with a cat in our allowed cat list, the coupon is valid. |
| 926 |
if ( count( array_intersect( $product_cats, $coupon->get_product_categories() ) ) > 0 ) { |
| 927 |
$valid = true; |
| 928 |
break; |
| 929 |
} |
| 930 |
} |
| 931 |
|
| 932 |
if ( ! $valid ) { |
| 933 |
throw new Exception( esc_html__( 'Sorry, this coupon is not applicable to selected products.', 'storeengine' ), 109 ); |
| 934 |
} |
| 935 |
} |
| 936 |
|
| 937 |
return true; |
| 938 |
} |
| 939 |
|
| 940 |
/** |
| 941 |
* Ensure coupon is valid for sale items in the list is valid or throw exception. |
| 942 |
* |
| 943 |
* @param Coupon $coupon Coupon data. |
| 944 |
* |
| 945 |
* @return bool |
| 946 |
* @throws Exception Error message. |
| 947 |
*/ |
| 948 |
protected function validate_coupon_sale_items( Coupon $coupon ): bool { |
| 949 |
if ( $coupon->get_exclude_sale_items() ) { |
| 950 |
$valid = true; |
| 951 |
|
| 952 |
foreach ( $this->get_items_to_validate() as $item ) { |
| 953 |
if ( $item->product_id && $item->price && $item->compare_price > 0 ) { |
| 954 |
$valid = false; |
| 955 |
break; |
| 956 |
} |
| 957 |
} |
| 958 |
|
| 959 |
if ( ! $valid ) { |
| 960 |
throw new Exception( esc_html__( 'Sorry, this coupon is not valid for sale items.', 'storeengine' ), 110 ); |
| 961 |
} |
| 962 |
} |
| 963 |
|
| 964 |
return true; |
| 965 |
} |
| 966 |
|
| 967 |
/** |
| 968 |
* All exclusion rules must pass at the same time for a product coupon to be valid. |
| 969 |
* |
| 970 |
* @param Coupon $coupon Coupon data. |
| 971 |
* |
| 972 |
* @throws Exception Error message. |
| 973 |
*/ |
| 974 |
protected function validate_coupon_excluded_items( Coupon $coupon ) { |
| 975 |
// @TODO validate excluded product/items. |
| 976 |
} |
| 977 |
|
| 978 |
/** |
| 979 |
* Cart discounts cannot be added if non-eligible product is found. |
| 980 |
* |
| 981 |
* @param Coupon $coupon Coupon data. |
| 982 |
* |
| 983 |
* @return bool |
| 984 |
* @throws Exception Error message. |
| 985 |
*/ |
| 986 |
protected function validate_coupon_eligible_items( Coupon $coupon ): bool { |
| 987 |
// Exclusion rules apply to every coupon type (matching the include-side |
| 988 |
// product/category validators in is_coupon_valid()). The previous |
| 989 |
// `! is_type( get_coupon_types() )` gate skipped these for the two standard |
| 990 |
// types (percentage / fixedAmount), so exclude-product, exclude-category and |
| 991 |
// exclude-sale-items silently did nothing for normal coupons. |
| 992 |
$this->validate_coupon_sale_items( $coupon ); |
| 993 |
$this->validate_coupon_excluded_product_ids( $coupon ); |
| 994 |
$this->validate_coupon_excluded_product_categories( $coupon ); |
| 995 |
|
| 996 |
return true; |
| 997 |
} |
| 998 |
|
| 999 |
/** |
| 1000 |
* Exclude products. |
| 1001 |
* |
| 1002 |
* @param Coupon $coupon Coupon data. |
| 1003 |
* |
| 1004 |
* @return bool |
| 1005 |
* @throws Exception Error message. |
| 1006 |
*/ |
| 1007 |
protected function validate_coupon_excluded_product_ids( Coupon $coupon ): bool { |
| 1008 |
// Exclude Products. |
| 1009 |
if ( count( $coupon->get_excluded_product_ids() ) > 0 ) { |
| 1010 |
$products = []; |
| 1011 |
|
| 1012 |
foreach ( $this->get_items_to_validate() as $item ) { |
| 1013 |
$product_parent_id = $item->product_parent_id ?? 0; |
| 1014 |
if ( ( $item->product_id && in_array( $item->product_id, $coupon->get_excluded_product_ids(), true ) ) || ( $product_parent_id && in_array( $product_parent_id, $coupon->get_excluded_product_ids(), true ) ) ) { |
| 1015 |
$products[] = $item->object->name; |
| 1016 |
} |
| 1017 |
} |
| 1018 |
|
| 1019 |
if ( ! empty( $products ) ) { |
| 1020 |
/* translators: %s: products list */ |
| 1021 |
throw new Exception( sprintf( esc_html__( 'Sorry, this coupon is not applicable to the products: %s.', 'storeengine' ), esc_html( implode( ', ', $products ) ) ), 113 ); |
| 1022 |
} |
| 1023 |
} |
| 1024 |
|
| 1025 |
return true; |
| 1026 |
} |
| 1027 |
|
| 1028 |
/** |
| 1029 |
* Exclude categories from product list. |
| 1030 |
* |
| 1031 |
* @param Coupon $coupon Coupon data. |
| 1032 |
* |
| 1033 |
* @return bool |
| 1034 |
* @throws Exception Error message. |
| 1035 |
*/ |
| 1036 |
protected function validate_coupon_excluded_product_categories( Coupon $coupon ): bool { |
| 1037 |
if ( count( $coupon->get_excluded_product_categories() ) > 0 ) { |
| 1038 |
$categories = []; |
| 1039 |
|
| 1040 |
foreach ( $this->get_items_to_validate() as $item ) { |
| 1041 |
if ( empty( $item->product_id ) ) { |
| 1042 |
continue; |
| 1043 |
} |
| 1044 |
|
| 1045 |
$product_cats = Helper::get_product_cat_ids( $item->product_id ); |
| 1046 |
|
| 1047 |
if ( ! empty( $item->product_parent_id ) ) { |
| 1048 |
$product_cats = array_merge( $product_cats, Helper::get_product_cat_ids( $item->product_parent_id ) ); |
| 1049 |
} |
| 1050 |
|
| 1051 |
$cat_id_list = array_intersect( $product_cats, $coupon->get_excluded_product_categories() ); |
| 1052 |
if ( count( $cat_id_list ) > 0 ) { |
| 1053 |
foreach ( $cat_id_list as $cat_id ) { |
| 1054 |
$cat = get_term( $cat_id, Helper::PRODUCT_CATEGORY_TAXONOMY ); |
| 1055 |
$categories[] = $cat->name; |
| 1056 |
} |
| 1057 |
} |
| 1058 |
} |
| 1059 |
|
| 1060 |
if ( ! empty( $categories ) ) { |
| 1061 |
/* translators: %s: categories list */ |
| 1062 |
throw new Exception( sprintf( esc_html__( 'Sorry, this coupon is not applicable to the categories: %s.', 'storeengine' ), esc_html( implode( ', ', array_unique( $categories ) ) ) ), 114 ); |
| 1063 |
} |
| 1064 |
} |
| 1065 |
|
| 1066 |
return true; |
| 1067 |
} |
| 1068 |
|
| 1069 |
/** |
| 1070 |
* Ensure coupon is valid for allowed emails or throw exception. |
| 1071 |
* |
| 1072 |
* @param Coupon $coupon Coupon data. |
| 1073 |
* |
| 1074 |
* @return bool |
| 1075 |
* @throws Exception Error message. |
| 1076 |
*/ |
| 1077 |
protected function validate_coupon_allowed_emails( Coupon $coupon ): bool { |
| 1078 |
$restrictions = $coupon->get_email_restrictions(); |
| 1079 |
|
| 1080 |
if ( ! is_array( $restrictions ) || empty( $restrictions ) ) { |
| 1081 |
return true; |
| 1082 |
} |
| 1083 |
|
| 1084 |
$user = wp_get_current_user(); |
| 1085 |
$check_emails = array( $user->user_email ); |
| 1086 |
|
| 1087 |
if ( $this->object instanceof Cart ) { |
| 1088 |
$check_emails[] = $this->object->get_customer()->get_billing_email(); |
| 1089 |
} elseif ( $this->object instanceof Order ) { |
| 1090 |
$check_emails[] = $this->object->get_billing_email(); |
| 1091 |
} |
| 1092 |
|
| 1093 |
$check_emails = array_unique( array_filter( array_map( 'strtolower', array_map( 'sanitize_email', $check_emails ) ) ) ); |
| 1094 |
|
| 1095 |
if ( ! self::is_coupon_emails_allowed( $check_emails, $restrictions ) ) { |
| 1096 |
// We check for supplied billing email. On shortcode, this will be present for checkout requests. |
| 1097 |
$billing_email = $_POST['billing_email'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 1098 |
if ( ! is_null( $billing_email ) ) { |
| 1099 |
/* translators: %s: coupon code */ |
| 1100 |
$err = sprintf( __( 'Please enter a valid email to use coupon code "%s".', 'storeengine' ), esc_html( $coupon->get_code() ) ); |
| 1101 |
} else { |
| 1102 |
/* translators: %s: coupon code */ |
| 1103 |
$err = sprintf( __( 'Please enter a valid email at checkout to use coupon code "%s".', 'storeengine' ), esc_html( $coupon->get_code() ) ); |
| 1104 |
} |
| 1105 |
throw new Exception( esc_html( $err ), 102 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 1106 |
} |
| 1107 |
|
| 1108 |
return true; |
| 1109 |
} |
| 1110 |
|
| 1111 |
/** |
| 1112 |
* Get the object subtotal |
| 1113 |
* |
| 1114 |
* @return int |
| 1115 |
*/ |
| 1116 |
protected function get_object_subtotal(): int { |
| 1117 |
if ( is_a( $this->object, Cart::class ) ) { |
| 1118 |
return Formatting::add_number_precision( $this->object->get_displayed_subtotal() ); |
| 1119 |
} elseif ( is_a( $this->object, Order::class ) ) { |
| 1120 |
$subtotal = Formatting::add_number_precision( $this->object->get_subtotal() ); |
| 1121 |
|
| 1122 |
if ( $this->object->get_prices_include_tax() ) { |
| 1123 |
// Add tax to tax-exclusive subtotal. |
| 1124 |
$subtotal = $subtotal + Formatting::add_number_precision( NumberUtil::round( $this->object->get_total_tax(), Formatting::get_price_decimals() ) ); |
| 1125 |
} |
| 1126 |
|
| 1127 |
return $subtotal; |
| 1128 |
} else { |
| 1129 |
return array_sum( wp_list_pluck( $this->items, 'price' ) ); |
| 1130 |
} |
| 1131 |
} |
| 1132 |
|
| 1133 |
/** |
| 1134 |
* Check if a coupon is valid. |
| 1135 |
* |
| 1136 |
* Error Codes: |
| 1137 |
* - 100: Invalid filtered. |
| 1138 |
* - 101: Invalid removed. |
| 1139 |
* - 102: Not yours removed. |
| 1140 |
* - 103: Already applied. |
| 1141 |
* - 104: Individual use only. |
| 1142 |
* - 105: Not exists. |
| 1143 |
* - 106: Usage limit reached. |
| 1144 |
* - 107: Expired. |
| 1145 |
* - 108: Minimum spend limit not met. |
| 1146 |
* - 109: Not applicable. |
| 1147 |
* - 110: Not valid for sale items. |
| 1148 |
* - 111: Missing coupon code. |
| 1149 |
* - 112: Maximum spend limit met. |
| 1150 |
* - 113: Excluded products. |
| 1151 |
* - 114: Excluded categories. |
| 1152 |
* |
| 1153 |
* @param Coupon $coupon Coupon data. |
| 1154 |
* |
| 1155 |
* @return bool|WP_Error |
| 1156 |
*/ |
| 1157 |
public function is_coupon_valid( Coupon $coupon ) { |
| 1158 |
/** |
| 1159 |
* Short-circuit coupon validation. Return a non-null value (bool true or |
| 1160 |
* WP_Error) to bypass the standard checks entirely. Used by the |
| 1161 |
* subscription recurring-discount feature to re-apply a locked-in coupon |
| 1162 |
* at renewal even after it has expired or hit its usage limit. |
| 1163 |
* |
| 1164 |
* @param null|bool|WP_Error $pre Short-circuit value. Default null (run checks). |
| 1165 |
* @param Coupon $coupon The coupon being validated. |
| 1166 |
* @param Discounts $this The discounts context. |
| 1167 |
*/ |
| 1168 |
$pre = apply_filters( 'storeengine/pre_is_coupon_valid', null, $coupon, $this ); |
| 1169 |
if ( null !== $pre ) { |
| 1170 |
return $pre; |
| 1171 |
} |
| 1172 |
|
| 1173 |
try { |
| 1174 |
$this->validate_coupon_exists( $coupon ); |
| 1175 |
$this->validate_coupon_usage_limit( $coupon ); |
| 1176 |
$this->validate_coupon_user_usage_limit( $coupon ); |
| 1177 |
$this->validate_coupon_expiry_date( $coupon ); |
| 1178 |
$this->validate_coupon_minimum_amount( $coupon ); |
| 1179 |
$this->validate_coupon_maximum_amount( $coupon ); |
| 1180 |
$this->validate_coupon_product_ids( $coupon ); |
| 1181 |
$this->validate_coupon_product_categories( $coupon ); |
| 1182 |
$this->validate_coupon_excluded_items( $coupon ); |
| 1183 |
$this->validate_coupon_eligible_items( $coupon ); |
| 1184 |
$this->validate_coupon_allowed_emails( $coupon ); |
| 1185 |
|
| 1186 |
if ( ! apply_filters( 'storeengine/coupon_is_valid', true, $coupon, $this ) ) { |
| 1187 |
throw new Exception( esc_html__( 'Coupon is not valid.', 'storeengine' ), 100 ); |
| 1188 |
} |
| 1189 |
} catch ( StoreEngineException $e ) { |
| 1190 |
// Expected coupon validation failures (not found / expired / usage |
| 1191 |
// limit / not applicable) are returned to the customer as a WP_Error, |
| 1192 |
// not logged as system errors. |
| 1193 |
return $e->get_wp_error(); |
| 1194 |
} catch ( Exception $e ) { |
| 1195 |
// Unexpected failures still get logged. |
| 1196 |
Helper::log_error( $e ); |
| 1197 |
|
| 1198 |
return new WP_Error( 'invalid_coupon', $e->getMessage(), [ 'status' => 400 ] ); |
| 1199 |
} |
| 1200 |
|
| 1201 |
return true; |
| 1202 |
} |
| 1203 |
|
| 1204 |
/** |
| 1205 |
* Checks if the given email address(es) matches the ones specified on the coupon. |
| 1206 |
* |
| 1207 |
* @param array $check_emails Array of customer email addresses. |
| 1208 |
* @param array $restrictions Array of allowed email addresses. |
| 1209 |
* |
| 1210 |
* @return bool |
| 1211 |
*/ |
| 1212 |
public static function is_coupon_emails_allowed( array $check_emails, array $restrictions ): bool { |
| 1213 |
foreach ( $check_emails as $check_email ) { |
| 1214 |
// With a direct match we return true. |
| 1215 |
if ( in_array( $check_email, $restrictions, true ) ) { |
| 1216 |
return true; |
| 1217 |
} |
| 1218 |
|
| 1219 |
// Go through the allowed emails and return true if the email matches a wildcard. |
| 1220 |
foreach ( $restrictions as $restriction ) { |
| 1221 |
// Convert to PHP-regex syntax. |
| 1222 |
$regex = '/^' . str_replace( '*', '(.+)?', $restriction ) . '$/'; |
| 1223 |
preg_match( $regex, $check_email, $match ); |
| 1224 |
if ( ! empty( $match ) ) { |
| 1225 |
return true; |
| 1226 |
} |
| 1227 |
} |
| 1228 |
} |
| 1229 |
|
| 1230 |
// No matches, this one isn't allowed. |
| 1231 |
return false; |
| 1232 |
} |
| 1233 |
} |
| 1234 |
|
| 1235 |
// End of file discounts.php |
| 1236 |
|