PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.21
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.21
1.4.21 1.4.20 1.4.19 1.4.18 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 All 184 releases
disco / app / Disco.php

Disco.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.21, at app/Disco.php

514 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Disco Class File.
4 *
5 * @package Disco
6 * @subpackage \App\Disco.php
7 * @since 1.0.0
8 */
9
10 namespace Disco\App;
11
12 use Disco\App\Calc\CalcFactory;
13 use Disco\App\Features\UserLimit;
14 use Disco\App\Utility\Config;
15 use Disco\App\Utility\Settings;
16
17 /**
18 * Class Disco
19 *
20 * @package Disco
21 * @subpackage \App
22 * @author Ohidul Islam <[email protected]>
23 * @link https://webappick.com
24 * @license https://opensource.org/licenses/gpl-license.php GNU Public License
25 * @category Discount
26 */
27 class Disco {
28
29 use \Disco\App\Intents\IntentHelper;
30
31 /**
32 * @var array $intents Intents.
33 */
34 private $intents;
35
36 /**
37 * @var int $applied_campaign_id Applied Campaign.
38 */
39 private $applied_campaign_id;
40
41
42 /**
43 * Apply discount to product.
44 *
45 * @param float $price Product Price.
46 * @param \WC_Product $product Product Object.
47 * @return float Product Price.
48 * @throws \Exception If setting is not found.
49 */
50 public function get_product_discounted_price( $price, $product ) {//phpcs:ignore
51 if ( $product instanceof \WC_Product ) {
52 // Init product base intents and exclude cart-based intents.
53 $this->intents = $this->prepare_intents( array( 'Product' ) );
54
55 if ( ! $product->is_type( 'variable' ) ) {
56 $price = CalcFactory::get_product_price( $product );
57 }
58
59 // If the product is not on sale, update the meta to indicate it's not on sale.
60 // update_post_meta( $product->get_id(), '_disco_on_sale', 'no' );
61
62 // If no intent is available, return the original price.
63 if ( empty( $this->intents ) ) {
64 return $price;
65 }
66
67 $discounts = array();
68
69 // Foreach intent, apply the discount.
70 foreach ( $this->intents as $intent ) {
71 /**
72 * Get a discount limit form campaign.
73 *
74 * Compare with total product meta and apply discount
75 */
76 if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
77 continue;
78 }
79
80 $discount = $intent->get_discounts( (float) $price, $product );
81
82 if ( empty( $discount ) ) {
83 continue;
84 }
85
86 /**
87 * Normalize each campaign's amount before the amounts compete.
88 *
89 * A percent amount is derived from the product price, which a
90 * currency switcher has already converted, so it arrives in the
91 * active currency. A fixed amount is the rule value as typed, so
92 * it arrives in the store's base currency. Comparing the two
93 * directly picks a winner by exchange rate rather than by size:
94 * percent scales with the rate and fixed does not, so `max` would
95 * select percent on almost every product and `min` would select
96 * fixed. Converting here, with each campaign's own discount type,
97 * puts every amount in the same currency before min/max runs.
98 *
99 * Filtering per campaign also matches what CartIntent and the
100 * Calc classes already do, so every path now compares like for
101 * like.
102 */
103 $discounts[ $intent->campaign->id ] = (float) apply_filters(
104 'disco_final_discounted_amount',
105 (float) $discount,
106 $this->campaign_discount_type( $intent->campaign )
107 );
108 }
109
110 // If no discount is applied, return the original price.
111 if ( empty( $discounts ) ) {
112 return $price;
113 }
114
115 /**
116 * Only amounts the product can actually carry take part in the choice.
117 *
118 * A flat amount larger than the price cannot be applied, and it used
119 * to win the min/max selection anyway and then be rejected by the
120 * guard below, leaving the product at full price even though another
121 * campaign offered a discount that would have fitted. With a 70%
122 * campaign and a 50.00 campaign, everything priced under 50.00 was
123 * discounted by nothing at all while a 50.00 product went free.
124 *
125 * Dropping the ones that do not fit first means `max` picks the
126 * largest discount that can be given rather than the largest that was
127 * offered, which is what asking for the maximum discount means.
128 */
129 $applicable = array_filter(
130 $discounts,
131 static function ( $amount ) use ( $price ) {
132 return (float) $amount <= (float) $price;
133 }
134 );
135
136 if ( empty( $applicable ) ) {
137 return $price;
138 }
139
140 // Pick the winning discount amount, then map it back to the campaign that produced it.
141 $discounted_amount = $this->min_max_average( array_values( $applicable ) );
142 $this->applied_campaign_id = (int) array_search( $discounted_amount, $applicable, true );
143
144 if ( $discounted_amount <= $price ) {
145 // Get an applied campaign from DiscountLimit class.
146 ( new UserLimit )->disco_start_session_on_checkout( $this->applied_campaign_id );
147
148 // Mark as disco custom on sale
149 // update_post_meta( $product->get_id(), '_disco_on_sale', 'yes' );
150
151 return $this->discounted_price( $price, $discounted_amount );
152 }
153 }
154
155 return $price;
156 }
157
158 /** Get Cart Discount.
159 *
160 * @param \WC_Cart $cart Cart Object.
161 * @return \WC_Cart Cart Object.
162 * @throws \Exception If the cart is empty.
163 */
164 public function get_cart_discount( $cart ) { //phpcs:ignore
165 if ( $cart->is_empty() ) {
166 return $cart;
167 }
168
169 // Init Cart - Based Intents except Product & Shipping Intent.
170 $this->intents = $this->prepare_intents( array( 'Cart' ) );
171
172 if ( ! empty( $this->intents ) && $cart->get_cart_contents_count() > 0 ) {
173 $discounts = array();
174
175 foreach ( $this->intents as $intent ) { // Foreach intent, apply the discount.
176
177 /**
178 * Get a discount limit form campaign.
179 *
180 * Compare with total product meta and apply discount
181 */
182 if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
183 continue;
184 }
185
186 $items = $this->get_items_for_discount( $cart, $intent->campaign );
187
188 $discount = $intent->get_discounts( $items, $cart );
189
190 // Get intent id for discount limit.
191 $intent_id = $intent->campaign->id;
192
193 if ( empty( $discount ) ) {
194 continue;
195 }
196
197 // Store discount with intent id key value.
198 $discounts[ $intent_id ] = $discount;
199 }
200
201 // Apply discount to cart subtotal .
202 if ( ! empty( $discounts ) ) {
203 /**
204 * New code.
205 * Get the min or max discount amount according to plugin settings.
206 */
207 $cart_fee = $this->min_max_average( array_values( $discounts ) );
208
209 // Get applied campaign from discount array.
210 $applied_campaign_id = array_search( $cart_fee, $discounts, true );
211 $applied_campaign = ( new Campaign )->get_campaign( $applied_campaign_id );
212 $discount_label = $applied_campaign->discount_rules[0]['discount_label'] ?? '';
213
214 // Get an applied campaign from DiscountLimit class.
215 ( new UserLimit )->disco_start_session_on_checkout( (int) $applied_campaign_id );
216
217 // TODO: The Discount must be less than cart subtotal.
218 $label = __( 'Discount', 'disco' );
219
220 if ( ! empty( $discount_label ) ) {
221 $label = $discount_label;
222 }
223
224 $cart_fee = $this->get_discount_exclude_tax( $cart_fee, $cart );
225
226 $cart->add_fee( $label, -$cart_fee );
227 $cart->set_session();
228 }
229 }
230
231 return $cart;
232 }
233
234 /**
235 * Apply Bulk & Bundle discount to cart items.
236 *
237 * @param \WC_Cart $cart Cart Object.
238 * @return \WC_Cart|void Cart Object.
239 * @throws \Exception If translation not found.
240 */
241 public function get_cart_items_discount( $cart ) { // phpcs:ignore
242 if ( ! $this->cart_is_valid() ) {
243 return $cart;
244 }
245
246 if ( ! defined( 'DOING_AJAX' ) && is_admin() ) {
247 return $cart;
248 }
249
250 // Init Cart - Based Intents except Product & Shipping Intent.
251 $this->intents = $this->prepare_intents( array( 'Bulk', 'Bundle', 'BOGO' ) );
252
253 $discounts = $this->prepare_item_discounts( $this->intents, $cart );
254
255 if ( empty( $discounts ) ) {
256 return $cart;
257 }
258
259 $total_discount = 0;
260 // Identify valid BOGO + category item ID (if any)
261 $found_bogo_category_item_id = $this->get_valid_bogo_category_item_id( $this->intents, $cart, $discounts );
262
263 // Apply only the valid BOGO category discount, or all if none matched
264 foreach ( $cart->get_cart() as $item ) {
265 $id = $this->get_cart_item_id( $item );
266
267 // Skip all others if BOGO + category item is found
268 if ( $found_bogo_category_item_id !== null && $id !== $found_bogo_category_item_id ) {
269 continue;
270 }
271
272 if ( empty( $discounts[ $id ] ) ) {
273 continue;
274 }
275
276 $total_discount += $discounts[ $id ];
277 }
278
279 if ( $total_discount > 0 ) {
280 $total_discount = $this->get_discount_exclude_tax( $total_discount, $cart );
281
282 $cart->add_fee( __( 'Discount', 'disco' ), -$total_discount );
283 }
284
285 $cart->set_session();
286
287 return $cart;
288 }
289
290 /**
291 * Get cart items discount for BOGO free.
292 *
293 * Calculates which items in the cart are eligible for a free product under the BOGO offer.
294 *
295 * @param \WC_Cart $cart WooCommerce cart object to calculate discounts for.
296 * @return array|false|\WC_Cart Cart object with applied discounts or false if no discounts are applicable.
297 */
298 public function get_cart_items_discount_for_bogo( $cart ) {//phpcs:ignore
299 if ( ! $this->cart_is_valid() ) {
300 return $cart;
301 }
302
303 if ( ! defined( 'DOING_AJAX' ) && is_admin() ) {
304 return $cart;
305 }
306
307 // Init Cart - Based Intents except Product & Shipping Intent.
308 $this->intents = $this->prepare_intents( array( 'BOGO' ) );
309
310 return $this->prepare_item_discounts_bogo_free( $this->intents, $cart );
311 }
312
313 /**
314 * Apply the discount to shipping.
315 *
316 * @return bool
317 */
318 public function apply_free_shipping() { //phpcs:ignore
319 return ! empty( $this->get_applied_free_shipping_campaign_ids() );
320 }
321
322 /**
323 * Evaluate the Shipping intents and return the IDs of every campaign that
324 * currently grants free shipping (respecting per-user usage limits).
325 *
326 * This is the single source of truth for "which free-shipping campaigns
327 * apply right now". It is used both to decide whether to add a free shipping
328 * rate and to persist the campaign IDs to the order meta at checkout. It does
329 * NOT rely on the WC session or cached shipping rates, so it is safe to call
330 * during order creation.
331 *
332 * @return array<int> Applied free-shipping campaign IDs.
333 */
334 public function get_applied_free_shipping_campaign_ids() {
335 if ( ! $this->cart_is_valid() ) {
336 return array();
337 }
338
339 $cart = WC()->cart;
340 $this->intents = $this->prepare_intents( array( 'Shipping' ) );
341 $campaign_ids = array();
342
343 if ( empty( $this->intents ) ) {
344 return $campaign_ids;
345 }
346
347 foreach ( $this->intents as $intent ) {
348 /**
349 * Get a discount limit from campaign.
350 *
351 * Compare with total applied count and skip if the user limit is hit.
352 */
353 if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
354 continue;
355 }
356
357 $items = $this->get_items_for_discount( $cart, $intent->campaign );
358
359 if ( 'free_shipping' !== $intent->get_discounts( $items, $cart ) ) {
360 continue;
361 }
362
363 $campaign_ids[] = (int) $intent->campaign->id;
364 }
365
366 return $campaign_ids;
367 }
368
369 /**
370 * Get offers for a product.
371 *
372 * @param array|null $items item ids.
373 * @return string
374 */
375 public function get_offers( $items = null ) {
376 // Init Cart-Based Intents except Product & Shipping Intent.
377 $this->intents = $this->prepare_intents( array( 'Product', 'Shipping' ) );
378 $table = "<table style='border-collapse: collapse;' class=''>";
379 $table .= '<tr><th>Quantity</th><th>Discount</th></tr>';
380
381 $cart = WC()->cart;
382
383 foreach ( $this->intents as $intent ) {
384 if ( is_null( $items ) || ! $cart->is_empty() ) {
385 $items = $this->get_items_for_discount( $cart, $intent->campaign );
386 } else {
387 $items = array();
388 }
389
390 $discounts = $intent->get_offers( $items, $cart );
391
392 if ( empty( $discounts ) ) {
393 continue;
394 }
395
396 foreach ( $discounts as $discount ) {
397 $table .= '<tr style="font-size: smaller;line-height:15px"><td>' . $discount['condition'] . '</td><td>' . $discount['discount'] . '</td></tr>';
398 }
399 }
400
401 return $table . '</table>';
402 }
403
404 /**
405 * Get Premium Status.
406 */
407 public static function is_pro(): bool {
408 /**
409 * Here use class_exists to check whether Disco Pro is active or not.
410 * is_plugin_active() function may not work in some cases.
411 */
412 return class_exists( 'Disco_Pro' );
413 }
414
415 /**
416 * Get shop, category, or checkout page name.
417 *
418 * @return string Page name: 'category', 'shop', 'checkout', or product page if none match.
419 */
420 public static function get_page_name(): string {
421 if ( is_product_category() || is_product_tag() ) {
422 return 'category';
423 }
424
425 if ( is_shop() ) {
426 return 'shop';
427 }
428
429 if ( is_page( 'cart' ) || is_page( 'checkout' ) ) {
430 return 'cart';
431 }
432
433 return 'product';
434 }
435
436 /**
437 * Get pages name where strike through price should be shown.
438 *
439 * @return array Pages where strike through price should be shown.
440 */
441 public static function get_strike_through_pages(): array {
442 $settings = Settings::get( 'show_strike_through' );
443
444 if ( ! is_array( $settings ) ) {
445 return array();
446 }
447
448 $pages = array();
449
450 if ( isset( $settings['shop_page'] ) && $settings['shop_page'] ) {
451 $pages[] = 'shop';
452 }
453
454 if ( isset( $settings['product_pages'] ) && $settings['product_pages'] ) {
455 $pages[] = 'product';
456 }
457
458 if ( isset( $settings['category_page'] ) && $settings['category_page'] ) {
459 $pages[] = 'category';
460 }
461
462 if ( isset( $settings['cart_page'] ) && $settings['cart_page'] ) {
463 $pages[] = 'cart';
464 }
465
466 return $pages;
467 }
468
469 /**
470 * Get is strike through applicable for current page.
471 *
472 * @param string $page_name Page name to check.
473 * @return bool True if strike through is applicable, false otherwise.
474 */
475 public static function is_strike_through_applicable( $page_name ): bool {
476 $pages = self::get_strike_through_pages();
477
478 if ( empty( $pages ) || empty( $page_name ) ) {
479 return false;
480 }
481
482 return in_array( $page_name, $pages, true );
483 }
484
485 /**
486 * Resolve a campaign's discount type.
487 *
488 * Read through get_discount_rules() rather than the raw config property.
489 * That accessor is what the intents use, so the type reported here is the
490 * one the discount was actually calculated with: it decodes a rules payload
491 * stored as JSON and fills in the defaults, neither of which the magic
492 * property does. The property is also undeclared, so PHPStan cannot see it.
493 *
494 * @param \Disco\App\Utility\Config $campaign Campaign config.
495 * @return string Discount type, or an empty string when it cannot be determined.
496 */
497 private function campaign_discount_type( Config $campaign ): string {
498 $rules = $campaign->get_discount_rules();
499
500 if ( ! is_array( $rules ) || ! isset( $rules[0] ) || ! is_object( $rules[0] ) ) {
501 return '';
502 }
503
504 $discount_type = $rules[0]->discount_type ?? '';
505
506 if ( ! is_scalar( $discount_type ) ) {
507 return '';
508 }
509
510 return (string) $discount_type;
511 }
512
513 }
514