| 1 |
<?php |
| 2 |
/** |
| 3 |
* Category BOGO campaign / rule reader. |
| 4 |
* |
| 5 |
* @package Disco |
| 6 |
* @subpackage \App\Intents\CategoryBogo |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace Disco\App\Intents\CategoryBogo; |
| 10 |
|
| 11 |
use Disco\App\Campaign; |
| 12 |
use Disco\App\Features\UserLimit; |
| 13 |
use Disco\App\Utility\Config; |
| 14 |
use Disco\App\Utility\Helper; |
| 15 |
use Disco\App\Utility\Value; |
| 16 |
|
| 17 |
/** |
| 18 |
* Selects the campaigns, rules and buy pools the Category BOGO engine acts on. |
| 19 |
* |
| 20 |
* A campaign qualifies when it is an active, in-date BOGO campaign of type |
| 21 |
* `categories` that still has usage left for the customer and holds at least |
| 22 |
* one free-item tier. Percent / fixed category BOGO keeps using the regular |
| 23 |
* discount path, so those rules are filtered out here. |
| 24 |
* |
| 25 |
* Buy (X) pooling also lives here because it is driven purely by campaign |
| 26 |
* configuration: product applicability, the condition filters and the |
| 27 |
* "Count Quantity As" mode. |
| 28 |
* |
| 29 |
* @package Disco |
| 30 |
* @subpackage Disco\App\Intents\CategoryBogo |
| 31 |
* @category Intention |
| 32 |
*/ |
| 33 |
class CategoryBogoRules { |
| 34 |
|
| 35 |
/** |
| 36 |
* Qualifying category BOGO campaigns, lazily resolved. |
| 37 |
* |
| 38 |
* @var array<int, \Disco\App\Utility\Config>|null |
| 39 |
*/ |
| 40 |
private $category_bogo_campaigns = null; |
| 41 |
|
| 42 |
/** |
| 43 |
* Every active campaign, fetched once per request. |
| 44 |
* |
| 45 |
* @var array<int|string, \Disco\App\Utility\Config>|null |
| 46 |
*/ |
| 47 |
private $all_active_campaigns = null; |
| 48 |
|
| 49 |
/** |
| 50 |
* Active category BOGO campaigns that grant free items. |
| 51 |
* |
| 52 |
* @return array<int, \Disco\App\Utility\Config> |
| 53 |
*/ |
| 54 |
public function get_active_category_bogo_campaigns(): array { |
| 55 |
if ( null !== $this->category_bogo_campaigns ) { |
| 56 |
return $this->category_bogo_campaigns; |
| 57 |
} |
| 58 |
|
| 59 |
$this->category_bogo_campaigns = array(); |
| 60 |
|
| 61 |
foreach ( $this->get_all_active_campaigns() as $campaign ) { |
| 62 |
if ( ! $this->is_active_category_free_bogo_campaign( $campaign ) ) { |
| 63 |
continue; |
| 64 |
} |
| 65 |
|
| 66 |
$this->category_bogo_campaigns[] = $campaign; |
| 67 |
} |
| 68 |
|
| 69 |
return $this->category_bogo_campaigns; |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Whether a BOGO campaign that hands out free items outside a reward |
| 74 |
* category is also active. |
| 75 |
* |
| 76 |
* Those campaigns (BuyXGetX and products BuyXGetY) are still applied by the |
| 77 |
* legacy hook path, so it can only be skipped when none of them exist. |
| 78 |
*/ |
| 79 |
public function has_non_category_free_bogo_campaigns(): bool { |
| 80 |
foreach ( $this->get_all_active_campaigns() as $campaign ) { |
| 81 |
if ( 'BOGO' !== $campaign->get_discount_intent() ) { |
| 82 |
continue; |
| 83 |
} |
| 84 |
|
| 85 |
if ( 'categories' === $campaign->get_bogo_type() ) { |
| 86 |
continue; |
| 87 |
} |
| 88 |
|
| 89 |
if ( ! Helper::is_in_valid_date( $campaign ) ) { |
| 90 |
continue; |
| 91 |
} |
| 92 |
|
| 93 |
return true; |
| 94 |
} |
| 95 |
|
| 96 |
return false; |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Free-item tiers of a campaign, normalised to arrays. |
| 101 |
* |
| 102 |
* @param \Disco\App\Utility\Config $campaign Campaign config. |
| 103 |
* @return array<int, array<string, mixed>> |
| 104 |
*/ |
| 105 |
public function get_free_item_rules( Config $campaign ): array { |
| 106 |
$discount_rules = $campaign->get_discount_rules(); |
| 107 |
|
| 108 |
if ( ! is_array( $discount_rules ) ) { |
| 109 |
return array(); |
| 110 |
} |
| 111 |
|
| 112 |
$free_item_rules = array(); |
| 113 |
|
| 114 |
foreach ( $discount_rules as $rule ) { |
| 115 |
$rule = (array) $rule; |
| 116 |
|
| 117 |
if ( ! $this->rule_grants_free_item( $rule ) ) { |
| 118 |
continue; |
| 119 |
} |
| 120 |
|
| 121 |
$rule['recursive'] = Value::to_string( $rule['recursive'] ?? 'no' ); |
| 122 |
|
| 123 |
$free_item_rules[] = $rule; |
| 124 |
} |
| 125 |
|
| 126 |
return $free_item_rules; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Reward (get Y) category ids of a single rule. |
| 131 |
* |
| 132 |
* @param array<string, mixed> $rule Discount rule. |
| 133 |
* @return array<int, int> |
| 134 |
*/ |
| 135 |
public function get_reward_category_ids( array $rule ): array { |
| 136 |
if ( empty( $rule['get_ids'] ) || ! is_array( $rule['get_ids'] ) ) { |
| 137 |
return array(); |
| 138 |
} |
| 139 |
|
| 140 |
$reward_category_ids = array(); |
| 141 |
|
| 142 |
foreach ( $rule['get_ids'] as $rule_entry ) { |
| 143 |
$reward_category_ids[] = $this->get_category_id_from_rule_entry( $rule_entry ); |
| 144 |
} |
| 145 |
|
| 146 |
return array_values( array_filter( $reward_category_ids ) ); |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Reward category ids of every active category BOGO campaign, deduplicated. |
| 151 |
* |
| 152 |
* @return array<int, int> |
| 153 |
*/ |
| 154 |
public function get_all_reward_category_ids(): array { |
| 155 |
$reward_category_ids = array(); |
| 156 |
|
| 157 |
foreach ( $this->get_active_category_bogo_campaigns() as $campaign ) { |
| 158 |
foreach ( $this->get_free_item_rules( $campaign ) as $rule ) { |
| 159 |
$reward_category_ids = array_merge( $reward_category_ids, $this->get_reward_category_ids( $rule ) ); |
| 160 |
} |
| 161 |
} |
| 162 |
|
| 163 |
return array_values( array_unique( $reward_category_ids ) ); |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Buy (X) product keys of a campaign, pooled per "Count Quantity As". |
| 168 |
* |
| 169 |
* @param \Disco\App\Utility\Config $campaign Campaign config. |
| 170 |
* @param array<string, array<string, mixed>> $cart_products Cart products by product key. |
| 171 |
* @return array<int, array<int, string>> Each entry is one pool of product keys. |
| 172 |
*/ |
| 173 |
public function get_buy_quantity_pools( Config $campaign, array $cart_products ): array { |
| 174 |
$pools = array(); |
| 175 |
$count_quantity_as = $campaign->get_count_quantity_as(); |
| 176 |
|
| 177 |
foreach ( $this->get_buy_applicable_product_keys( $campaign, $cart_products ) as $product_key ) { |
| 178 |
$pool_key = $this->get_buy_pool_key( $count_quantity_as, $product_key, $cart_products ); |
| 179 |
|
| 180 |
$pools[ $pool_key ][] = $product_key; |
| 181 |
} |
| 182 |
|
| 183 |
return array_values( $pools ); |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Tiers a pooled buy quantity qualifies for, highest `min` first. |
| 188 |
* |
| 189 |
* A tier qualifies on `min` alone: the upper `max` caps how much of the pool |
| 190 |
* is eligible, it never disqualifies the tier, so a quantity that falls in |
| 191 |
* the gap between two tiers still earns the lower one. The caller walks the |
| 192 |
* list in order and takes the first tier that can actually hand out a reward. |
| 193 |
* |
| 194 |
* @param array<int, array<string, mixed>> $free_item_rules Free-item tiers. |
| 195 |
* @param int $pool_quantity Pooled buy quantity. |
| 196 |
* @return array<int, array<string, mixed>> |
| 197 |
*/ |
| 198 |
public function get_qualifying_tiers_for_buy_quantity( array $free_item_rules, int $pool_quantity ): array { |
| 199 |
$qualifying_tiers = array(); |
| 200 |
|
| 201 |
foreach ( $free_item_rules as $rule ) { |
| 202 |
$minimum_quantity = Value::to_int( $rule['min'] ); |
| 203 |
|
| 204 |
if ( $minimum_quantity <= 0 || $pool_quantity < $minimum_quantity ) { |
| 205 |
continue; |
| 206 |
} |
| 207 |
|
| 208 |
$qualifying_tiers[] = $rule; |
| 209 |
} |
| 210 |
|
| 211 |
usort( |
| 212 |
$qualifying_tiers, |
| 213 |
static function ( array $first, array $second ): int { |
| 214 |
return Value::to_int( $second['min'] ) <=> Value::to_int( $first['min'] ); |
| 215 |
} |
| 216 |
); |
| 217 |
|
| 218 |
return $qualifying_tiers; |
| 219 |
} |
| 220 |
|
| 221 |
/** |
| 222 |
* Campaign id as an integer. |
| 223 |
* |
| 224 |
* @param \Disco\App\Utility\Config $campaign Campaign config. |
| 225 |
*/ |
| 226 |
public function get_campaign_id( Config $campaign ): int { |
| 227 |
$config = $campaign->get_config(); |
| 228 |
|
| 229 |
return Value::to_int( $config['id'] ?? 0 ); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Every active campaign, as Config objects. |
| 234 |
* |
| 235 |
* @return array<int|string, \Disco\App\Utility\Config> |
| 236 |
*/ |
| 237 |
private function get_all_active_campaigns(): array { |
| 238 |
if ( null !== $this->all_active_campaigns ) { |
| 239 |
return $this->all_active_campaigns; |
| 240 |
} |
| 241 |
|
| 242 |
$campaigns = ( new Campaign )->get_campaigns( '1' ); |
| 243 |
$this->all_active_campaigns = array(); |
| 244 |
|
| 245 |
if ( ! is_array( $campaigns ) ) { |
| 246 |
return $this->all_active_campaigns; |
| 247 |
} |
| 248 |
|
| 249 |
foreach ( $campaigns as $campaign_id => $campaign ) { |
| 250 |
if ( ! $campaign instanceof Config ) { |
| 251 |
continue; |
| 252 |
} |
| 253 |
|
| 254 |
$this->all_active_campaigns[ $campaign_id ] = $campaign; |
| 255 |
} |
| 256 |
|
| 257 |
return $this->all_active_campaigns; |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* Product keys a campaign accepts as a buy (X) purchase, in cart order. |
| 262 |
* |
| 263 |
* @param \Disco\App\Utility\Config $campaign Campaign config. |
| 264 |
* @param array<string, array<string, mixed>> $cart_products Cart products by product key. |
| 265 |
* @return array<int, string> |
| 266 |
*/ |
| 267 |
private function get_buy_applicable_product_keys( Config $campaign, array $cart_products ): array { |
| 268 |
$buy_applicable_keys = array(); |
| 269 |
|
| 270 |
foreach ( $cart_products as $product_key => $cart_product ) { |
| 271 |
$effective_product_id = Value::to_int( $cart_product['effective_product_id'] ?? 0 ); |
| 272 |
|
| 273 |
if ( ! $campaign->product_is_applicable( $effective_product_id ) ) { |
| 274 |
continue; |
| 275 |
} |
| 276 |
|
| 277 |
if ( ! Helper::is_filter_passed( $campaign, array( 'product' => wc_get_product( $effective_product_id ) ) ) ) { |
| 278 |
continue; |
| 279 |
} |
| 280 |
|
| 281 |
$buy_applicable_keys[] = (string) $product_key; |
| 282 |
} |
| 283 |
|
| 284 |
return $buy_applicable_keys; |
| 285 |
} |
| 286 |
|
| 287 |
/** |
| 288 |
* Pool key a product key belongs to for a counting mode. |
| 289 |
* |
| 290 |
* @param string $count_quantity_as Counting mode. |
| 291 |
* @param string $product_key Cart product key. |
| 292 |
* @param array<string, array<string, mixed>> $cart_products Cart products by product key. |
| 293 |
*/ |
| 294 |
private function get_buy_pool_key( string $count_quantity_as, string $product_key, array $cart_products ): string { |
| 295 |
// Every applicable product shares one pool. |
| 296 |
if ( 'combined' === $count_quantity_as ) { |
| 297 |
return 'all'; |
| 298 |
} |
| 299 |
|
| 300 |
// Variations of the same variable product share a pool. |
| 301 |
if ( 'variations' === $count_quantity_as ) { |
| 302 |
return 'parent_' . Value::to_int( $cart_products[ $product_key ]['product_id'] ?? 0 ); |
| 303 |
} |
| 304 |
|
| 305 |
// `separate`: each product is its own pool. |
| 306 |
return 'product_' . $product_key; |
| 307 |
} |
| 308 |
|
| 309 |
/** |
| 310 |
* Category id of a single `get_ids` rule entry. |
| 311 |
* |
| 312 |
* @param mixed $rule_entry Raw entry: an id, or an array / object with an `id` key. |
| 313 |
*/ |
| 314 |
private function get_category_id_from_rule_entry( $rule_entry ): int { |
| 315 |
if ( is_object( $rule_entry ) ) { |
| 316 |
$rule_entry = (array) $rule_entry; |
| 317 |
} |
| 318 |
|
| 319 |
if ( ! is_array( $rule_entry ) ) { |
| 320 |
return Value::to_int( $rule_entry ); |
| 321 |
} |
| 322 |
|
| 323 |
return Value::to_int( $rule_entry['id'] ?? 0 ); |
| 324 |
} |
| 325 |
|
| 326 |
/** |
| 327 |
* Whether a campaign is an active category BOGO that hands out free items. |
| 328 |
* |
| 329 |
* @param \Disco\App\Utility\Config $campaign Campaign config. |
| 330 |
*/ |
| 331 |
private function is_active_category_free_bogo_campaign( Config $campaign ): bool { |
| 332 |
if ( ! in_array( $campaign->get_discount_intent(), array( 'BOGO', 'BuyXGetY' ), true ) ) { |
| 333 |
return false; |
| 334 |
} |
| 335 |
|
| 336 |
if ( 'categories' !== $campaign->get_bogo_type() ) { |
| 337 |
return false; |
| 338 |
} |
| 339 |
|
| 340 |
if ( ! Helper::is_in_valid_date( $campaign ) ) { |
| 341 |
return false; |
| 342 |
} |
| 343 |
|
| 344 |
if ( empty( $this->get_free_item_rules( $campaign ) ) ) { |
| 345 |
return false; |
| 346 |
} |
| 347 |
|
| 348 |
return ! $this->is_user_limit_reached( $campaign ); |
| 349 |
} |
| 350 |
|
| 351 |
/** |
| 352 |
* Whether a rule can grant a free reward from a category. |
| 353 |
* |
| 354 |
* Only tiers that can actually hand out an item qualify: `discount_type` |
| 355 |
* free, a reward quantity, a minimum and a reward category. |
| 356 |
* |
| 357 |
* @param array<string, mixed> $rule Discount rule. |
| 358 |
*/ |
| 359 |
private function rule_grants_free_item( array $rule ): bool { |
| 360 |
if ( 'free' !== Value::to_string( $rule['discount_type'] ?? '' ) ) { |
| 361 |
return false; |
| 362 |
} |
| 363 |
|
| 364 |
if ( Value::to_int( $rule['get_quantity'] ?? 0 ) <= 0 ) { |
| 365 |
return false; |
| 366 |
} |
| 367 |
|
| 368 |
if ( Value::to_int( $rule['min'] ?? 0 ) <= 0 ) { |
| 369 |
return false; |
| 370 |
} |
| 371 |
|
| 372 |
return ! empty( $rule['get_ids'] ); |
| 373 |
} |
| 374 |
|
| 375 |
/** |
| 376 |
* Whether the per-user usage limit of a campaign is exhausted. |
| 377 |
* |
| 378 |
* @param \Disco\App\Utility\Config $campaign Campaign config. |
| 379 |
*/ |
| 380 |
private function is_user_limit_reached( Config $campaign ): bool { |
| 381 |
$config = $campaign->get_config(); |
| 382 |
$max_user_discounts = Value::to_int( $config['discount_max_user'] ?? 0 ); |
| 383 |
|
| 384 |
if ( $max_user_discounts <= 0 ) { |
| 385 |
return false; |
| 386 |
} |
| 387 |
|
| 388 |
$applied = ( new UserLimit )->disco_get_total_applied_campaign( $this->get_campaign_id( $campaign ) ); |
| 389 |
|
| 390 |
return $applied >= $max_user_discounts; |
| 391 |
} |
| 392 |
|
| 393 |
} |
| 394 |
|