PluginProbe
Tiered Pricing Table for WooCommerce / 8.1.0
Tiered Pricing Table for WooCommerce v8.1.0
8.1.0 8.0.2 7.1.7 7.1.5 7.1.4 7.1.1 6.5.0 6.4.0 6.1.0 trunk 1.0 1.1 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2 2.2.3 2.3.0 2.3.1 2.3.2 All 92 releases
tier-pricing-table / src / Addons / GlobalTieredPricing / GlobalPricingRule.php

GlobalPricingRule.php in Tiered Pricing Table for WooCommerce 8.1.0, at src/Addons/GlobalTieredPricing/GlobalPricingRule.php

1,092 lines 38.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace TierPricingTable\Addons\GlobalTieredPricing;
4
5 use TierPricingTable\Addons\GlobalTieredPricing\PricingRule\RuleSettings;
6 use TierPricingTable\Forms\Form;
7 use DateTime;
8 use DateTimeZone;
9 use Exception;
10 use WC_Product;
11 use WP_User;
12 class GlobalPricingRule {
13 /**
14 * Rule ID
15 *
16 * @var int
17 */
18 public $id;
19
20 /**
21 * Is suspended
22 *
23 * @var bool
24 */
25 public $isSuspended = false;
26
27 /**
28 * Regular pricing type
29 *
30 * @var string
31 */
32 public $pricingType;
33
34 /**
35 * Regular price
36 *
37 * @var ?float
38 */
39 public $regularPrice;
40
41 /**
42 * Sale price
43 *
44 * @var ?float
45 */
46 public $salePrice;
47
48 /**
49 * Percentage Discount
50 *
51 * @var ?float
52 */
53 public $discount;
54
55 /**
56 * Percentage Discount
57 *
58 * @var string
59 */
60 public $discountType = 'sale_price';
61
62 /**
63 * Applying type
64 *
65 * @var string
66 */
67 public $applyingType;
68
69 /**
70 * Tax status
71 *
72 * @var string
73 */
74 public $taxStatus = '';
75
76 /**
77 * Tax class
78 *
79 * @var string
80 */
81 public $taxClass = '';
82
83 /**
84 * Tiered Pricing type
85 *
86 * @var string
87 */
88 public $tieredPricingType;
89
90 /**
91 * Percentage Tiered Pricing Rules
92 *
93 * @var array
94 */
95 public $percentageTieredPricingRules = array();
96
97 /**
98 * Fixed Tiered Pricing Rules
99 *
100 * @var array
101 */
102 public $fixedTieredPricingRules = array();
103
104 /**
105 * Included categories
106 *
107 * @var array
108 */
109 public $includedProductCategories = array();
110
111 /**
112 * Excluded categories
113 *
114 * @var array
115 */
116 public $excludedProductCategories = array();
117
118 /**
119 * Whether the included and excluded categories cover their subcategories too.
120 *
121 * @var bool
122 */
123 public $includeSubcategories = false;
124
125 /**
126 * Included tags
127 *
128 * @var array
129 */
130 public $includedProductTags = array();
131
132 /**
133 * Excluded tags
134 *
135 * @var array
136 */
137 public $excludedProductTags = array();
138
139 /**
140 * Included brands
141 *
142 * @var array
143 */
144 public $includedProductBrands = array();
145
146 /**
147 * Excluded brands
148 *
149 * @var array
150 */
151 public $excludedProductBrands = array();
152
153 /**
154 * Included products
155 *
156 * @var array
157 */
158 public $includedProducts = array();
159
160 /**
161 * Excluded products
162 *
163 * @var array
164 */
165 public $excludedProducts = array();
166
167 /**
168 * Included product roles
169 *
170 * @var array
171 */
172 public $includedUsersRole = array();
173
174 /**
175 * Excluded product roles
176 *
177 * @var array
178 */
179 public $excludedUsersRole = array();
180
181 /**
182 * Included users
183 *
184 * @var array
185 */
186 public $includedUsers = array();
187
188 /**
189 * Excluded users
190 *
191 * @var array
192 */
193 public $excludedUsers = array();
194
195 /**
196 * Product minimum purchase quantity
197 *
198 * @var int|null
199 */
200 public $minimum;
201
202 /**
203 * Mix and match minimum quantity
204 *
205 * @var bool|null
206 */
207 public $mixAndMatchMinQuantity = null;
208
209 public $priorityOptions;
210
211 const DEFAULT_PRIORITY = 10;
212
213 /**
214 * The pseudo role of a visitor who is not logged in, selectable in the role lists. The same key the
215 * wholesale registration add-on uses (NonLoggedInUsers\Roles::GUEST).
216 */
217 const GUEST_ROLE = 'guest';
218
219 /**
220 * The roles the matcher reads for a user: a visitor who is not logged in has the guest pseudo role.
221 */
222 public static function rolesOf( WP_User $user ) : array {
223 return ( $user->ID ? (array) $user->roles : array(self::GUEST_ROLE) );
224 }
225
226 /**
227 * Whether a role key may be stored in the rule's role lists: an existing WordPress role or the guest pseudo role.
228 */
229 public static function isSelectableRole( $role ) : bool {
230 return self::GUEST_ROLE === $role || function_exists( 'wp_roles' ) && array_key_exists( $role, wp_roles()->roles );
231 }
232
233 /**
234 * Order among global rules: the lowest number is applied first; equal numbers, the newest rule first.
235 *
236 * @var int
237 */
238 public $priority = self::DEFAULT_PRIORITY;
239
240 /**
241 * First day the rule applies (Y-m-d in the site's timezone, inclusive), null = no start.
242 *
243 * @var ?string
244 */
245 public $startDate = null;
246
247 /**
248 * Last day the rule applies (Y-m-d in the site's timezone, inclusive), null = no end.
249 *
250 * @var ?string
251 */
252 public $endDate = null;
253
254 /**
255 * post_date_gmt of the rule: orders rules with the same priority (newest first).
256 *
257 * @var string
258 */
259 public $createdAt = '';
260
261 /**
262 * Array with custom data from 3rd-party addons
263 *
264 * @var array
265 */
266 public $data = array();
267
268 public function getId() : int {
269 return $this->id;
270 }
271
272 public function setId( int $id ) {
273 $this->id = $id;
274 }
275
276 public function getDiscount() : ?float {
277 return $this->discount;
278 }
279
280 public function setDiscount( ?float $discount ) {
281 $this->discount = $discount;
282 }
283
284 public function getDiscountType() : string {
285 return $this->discountType;
286 }
287
288 public function setDiscountType( string $discountType ) {
289 $this->discountType = ( in_array( $discountType, array('sale_price', 'regular_price') ) ? $discountType : 'sale_price' );
290 }
291
292 public function getTieredPricingType() : string {
293 return 'fixed';
294 }
295
296 public function setTieredPricingType( string $tieredPricingType ) {
297 $this->tieredPricingType = ( in_array( $tieredPricingType, array('percentage', 'fixed') ) ? $tieredPricingType : 'fixed' );
298 }
299
300 public function getPercentageTieredPricingRules() : array {
301 return $this->percentageTieredPricingRules;
302 }
303
304 public function setPercentageTieredPricingRules( array $percentageTieredPricingRules ) {
305 $this->percentageTieredPricingRules = $percentageTieredPricingRules;
306 }
307
308 public function getFixedTieredPricingRules() : array {
309 return $this->fixedTieredPricingRules;
310 }
311
312 public function setFixedTieredPricingRules( array $fixedTieredPricingRules ) {
313 $this->fixedTieredPricingRules = $fixedTieredPricingRules;
314 }
315
316 public function getApplyingType() : string {
317 return $this->applyingType;
318 }
319
320 public function setApplyingType( string $applyingType ) {
321 $this->applyingType = ( in_array( $applyingType, array('individual', 'cross') ) ? $applyingType : 'cross' );
322 }
323
324 public function getPricingType() : string {
325 return $this->pricingType;
326 }
327
328 public function setPricingType( string $priceType ) {
329 $this->pricingType = ( in_array( $priceType, array('percentage', 'flat') ) ? $priceType : 'flat' );
330 }
331
332 public function getTieredPricingRules() : array {
333 return $this->getFixedTieredPricingRules();
334 }
335
336 public function getTaxStatus() : string {
337 return $this->taxStatus;
338 }
339
340 public function setTaxStatus( string $taxStatus ) {
341 $this->taxStatus = $taxStatus;
342 }
343
344 public function getTaxClass() : string {
345 return $this->taxClass;
346 }
347
348 public function setTaxClass( string $taxClass ) {
349 $this->taxClass = $taxClass;
350 }
351
352 public function getRegularPrice() : ?float {
353 return $this->regularPrice;
354 }
355
356 public function setRegularPrice( ?float $regularPrice ) {
357 $this->regularPrice = ( !Form::isEmpty( $regularPrice ) ? floatval( $regularPrice ) : null );
358 }
359
360 public function getSalePrice() : ?float {
361 return $this->salePrice;
362 }
363
364 public function setSalePrice( ?float $salePrice ) {
365 $this->salePrice = $salePrice;
366 }
367
368 /**
369 * Create instance from array
370 *
371 * @param array $data
372 *
373 * @return self
374 */
375 public static function fromArray( array $data ) : self {
376 $applyingType = $data['applying_type'] ?? 'individual';
377 $applyingType = ( in_array( $applyingType, array('individual', 'cross') ) ? $applyingType : 'cross' );
378 $tieredPricingType = $data['tiered_pricing_type'] ?? 'fixed';
379 $tieredPricingType = ( in_array( $tieredPricingType, array('flat', 'percentage') ) ? $tieredPricingType : 'fixed' );
380 $percentageRules = ( isset( $data['percentage_rules'] ) ? (array) $data['percentage_rules'] : array() );
381 $fixedRules = ( isset( $data['fixed_rules'] ) ? (array) $data['fixed_rules'] : array() );
382 $pricingType = $data['pricing_type'] ?? 'flat';
383 $pricingType = ( in_array( $pricingType, array('flat', 'percentage') ) ? $pricingType : 'flat' );
384 $regularPrice = $data['regular_price'] ?? null;
385 $salePrice = $data['sale_price'] ?? null;
386 $discount = $data['discount'] ?? null;
387 $discountType = $data['discount_type'] ?? 'sale_price';
388 $minimum = $data['minimum'] ?? null;
389 $mixAndMatchValue = $data['mix_and_match_minimum'] ?? '';
390 $mixAndMatch = ( $mixAndMatchValue === '' ? null : $mixAndMatchValue === 'yes' );
391 $taxStatus = $data['tax_status'] ?? '';
392 $taxClass = $data['tax_class'] ?? '';
393 $self = new self();
394 $self->setTaxStatus( $taxStatus );
395 $self->setTaxClass( $taxClass );
396 $self->setPricingType( (string) $pricingType );
397 $self->setRegularPrice( ( Form::isEmpty( $regularPrice ) ? null : (float) $regularPrice ) );
398 $self->setSalePrice( ( Form::isEmpty( $salePrice ) ? null : (float) $salePrice ) );
399 $self->setDiscount( ( Form::isEmpty( $discount ) ? null : (float) $discount ) );
400 $self->setDiscountType( (string) $discountType );
401 $self->setApplyingType( $applyingType );
402 $self->setTieredPricingType( $tieredPricingType );
403 $self->setPercentageTieredPricingRules( $percentageRules );
404 $self->setFixedTieredPricingRules( $fixedRules );
405 $self->setMinimum( ( Form::isEmpty( $minimum ) ? null : (int) $minimum ) );
406 $self->setMixAndMatchMinQuantity( $mixAndMatch );
407 $self->setPriority( $data['priority'] ?? self::DEFAULT_PRIORITY );
408 $self->setStartDate( $data['start_date'] ?? null );
409 $self->setEndDate( $data['end_date'] ?? null );
410 return $self;
411 }
412
413 /**
414 * Validate
415 *
416 * @throws Exception
417 */
418 public function validatePricing() {
419 // only the fields of the chosen pricing type reach the storefront: a discount left over from
420 // "percentage" changes nothing in "flat" mode, and the other way round
421 if ( 'percentage' === $this->getPricingType() ) {
422 $valid = (float) $this->getDiscount() > 0;
423 } else {
424 $valid = !Form::isEmpty( $this->getRegularPrice() ) || !Form::isEmpty( $this->getSalePrice() );
425 }
426 $valid = $valid || !empty( $this->getTieredPricingRules() );
427 $valid = $valid || !Form::isEmpty( $this->getMinimum() );
428 // a mix & match choice alone changes how product-level minimums count across variations
429 $valid = $valid || !is_null( $this->getMixAndMatchMinQuantity() );
430 $valid = $valid || $this->getSettings()->getPriorityType() === 'flexible';
431 $valid = apply_filters( 'tiered_pricing_table/global_pricing/validation', $valid, $this );
432 if ( !$valid ) {
433 throw new Exception(esc_html__( 'The pricing rule does not affect either prices or product quantity. The rule will be skipped.', 'tier-pricing-table' ));
434 }
435 }
436
437 public function isValidPricing() : bool {
438 try {
439 $this->validatePricing();
440 } catch ( Exception $e ) {
441 return false;
442 }
443 return true;
444 }
445
446 public function getMinimum() : ?int {
447 return $this->minimum;
448 }
449
450 public function setMinimum( ?int $minimum ) {
451 $this->minimum = ( intval( $minimum ) > 1 ? $minimum : null );
452 }
453
454 public function getMixAndMatchMinQuantity() : ?bool {
455 return $this->mixAndMatchMinQuantity;
456 }
457
458 public function setMixAndMatchMinQuantity( ?bool $mixAndMatch ) {
459 $this->mixAndMatchMinQuantity = $mixAndMatch;
460 }
461
462 public function getIncludedProductCategories() : array {
463 return $this->includedProductCategories;
464 }
465
466 public function getExcludedProductCategories() : array {
467 return $this->excludedProductCategories;
468 }
469
470 public function setIncludedProductCategories( array $includedProductCategories ) {
471 $this->includedProductCategories = $includedProductCategories;
472 }
473
474 public function setExcludedProductCategories( array $excludedProductCategories ) {
475 $this->excludedProductCategories = $excludedProductCategories;
476 }
477
478 public function isIncludeSubcategories() : bool {
479 return $this->includeSubcategories;
480 }
481
482 public function setIncludeSubcategories( bool $includeSubcategories ) {
483 $this->includeSubcategories = $includeSubcategories;
484 }
485
486 /**
487 * The included categories as the matcher reads them: with every subcategory when the switch is on.
488 */
489 public function getEffectiveIncludedCategories() : array {
490 return ( $this->includeSubcategories ? self::withSubcategories( $this->includedProductCategories ) : $this->includedProductCategories );
491 }
492
493 /**
494 * The excluded categories as the matcher reads them: with every subcategory when the switch is on.
495 */
496 public function getEffectiveExcludedCategories() : array {
497 return ( $this->includeSubcategories ? self::withSubcategories( $this->excludedProductCategories ) : $this->excludedProductCategories );
498 }
499
500 /**
501 * The ids plus all their descendant product categories.
502 */
503 public static function withSubcategories( array $ids ) : array {
504 $all = array();
505 foreach ( array_map( 'intval', $ids ) as $id ) {
506 $all[] = $id;
507 $children = get_term_children( $id, 'product_cat' );
508 if ( is_array( $children ) ) {
509 $all = array_merge( $all, array_map( 'intval', $children ) );
510 }
511 }
512 return array_values( array_unique( $all ) );
513 }
514
515 public function getIncludedProductTags() : array {
516 return $this->includedProductTags;
517 }
518
519 public function getExcludedProductTags() : array {
520 return $this->excludedProductTags;
521 }
522
523 public function setIncludedProductTags( array $includedProductTags ) {
524 $this->includedProductTags = $includedProductTags;
525 }
526
527 public function setExcludedProductTags( array $excludedProductTags ) {
528 $this->excludedProductTags = $excludedProductTags;
529 }
530
531 public function getIncludedProductBrands() : array {
532 return $this->includedProductBrands;
533 }
534
535 public function getExcludedProductBrands() : array {
536 return $this->excludedProductBrands;
537 }
538
539 public function setIncludedProductBrands( array $includedProductBrands ) {
540 $this->includedProductBrands = $includedProductBrands;
541 }
542
543 public function setExcludedProductBrands( array $excludedProductBrands ) {
544 $this->excludedProductBrands = $excludedProductBrands;
545 }
546
547 public function getIncludedProducts() : array {
548 return $this->includedProducts;
549 }
550
551 public function getExcludedProducts() : array {
552 return $this->excludedProducts;
553 }
554
555 public function setIncludedProducts( array $includedProducts ) {
556 $this->includedProducts = $includedProducts;
557 }
558
559 public function setExcludedProducts( array $excludedProducts ) {
560 $this->excludedProducts = $excludedProducts;
561 }
562
563 public function getIncludedUserRoles() : array {
564 return $this->includedUsersRole;
565 }
566
567 public function getExcludedUserRoles() : array {
568 return $this->excludedUsersRole;
569 }
570
571 public function setIncludedUsersRole( array $includedUsersRole ) {
572 $this->includedUsersRole = $includedUsersRole;
573 }
574
575 public function setExcludedUsersRole( array $excludedUsersRole ) {
576 $this->excludedUsersRole = $excludedUsersRole;
577 }
578
579 public function getIncludedUsers() : array {
580 return $this->includedUsers;
581 }
582
583 public function getExcludedUsers() : array {
584 return $this->excludedUsers;
585 }
586
587 public function setIncludedUsers( array $includedUsers ) {
588 $this->includedUsers = $includedUsers;
589 }
590
591 public function setExcludedUsers( array $excludedUsers ) {
592 $this->excludedUsers = $excludedUsers;
593 }
594
595 public function getPriority() : int {
596 return $this->priority;
597 }
598
599 /**
600 * @param mixed $priority Anything non-numeric (an empty meta row, for instance) falls back to the default.
601 */
602 public function setPriority( $priority ) {
603 $this->priority = ( is_numeric( $priority ) ? max( 0, (int) $priority ) : self::DEFAULT_PRIORITY );
604 }
605
606 public function getStartDate() : ?string {
607 return $this->startDate;
608 }
609
610 public function setStartDate( $date ) {
611 $this->startDate = self::normalizeDate( $date );
612 }
613
614 public function getEndDate() : ?string {
615 return $this->endDate;
616 }
617
618 public function setEndDate( $date ) {
619 $this->endDate = self::normalizeDate( $date );
620 }
621
622 public function hasSchedule() : bool {
623 return null !== $this->startDate || null !== $this->endDate;
624 }
625
626 public function getCreatedAt() : string {
627 return $this->createdAt;
628 }
629
630 public function setCreatedAt( string $createdAt ) {
631 $this->createdAt = $createdAt;
632 }
633
634 /**
635 * A "Y-m-d" string or null; anything else (a partial date, "0000-00-00", a timestamp) is dropped.
636 */
637 protected static function normalizeDate( $date ) : ?string {
638 $date = ( is_string( $date ) ? trim( $date ) : '' );
639 if ( '' === $date ) {
640 return null;
641 }
642 $parsed = DateTime::createFromFormat( '!Y-m-d', $date );
643 return ( $parsed && $parsed->format( 'Y-m-d' ) === $date ? $date : null );
644 }
645
646 /**
647 * 00:00:00 of the start date in the site's timezone, as a timestamp; null without a start date.
648 */
649 public function getStartTimestamp() : ?int {
650 return ( null === $this->startDate ? null : self::dayBoundary( $this->startDate, false ) );
651 }
652
653 /**
654 * 23:59:59 of the end date in the site's timezone, as a timestamp; null without an end date.
655 */
656 public function getEndTimestamp() : ?int {
657 return ( null === $this->endDate ? null : self::dayBoundary( $this->endDate, true ) );
658 }
659
660 protected static function dayBoundary( string $date, bool $endOfDay ) : int {
661 $timezone = ( function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone('UTC') );
662 $moment = new DateTime($date . (( $endOfDay ? ' 23:59:59' : ' 00:00:00' )), $timezone);
663 return $moment->getTimestamp();
664 }
665
666 /**
667 * "active", "scheduled" (starts later) or "expired" (ended) at the given moment (default: now).
668 */
669 public function getScheduleStatus( ?int $now = null ) : string {
670 $now = ( null === $now ? time() : $now );
671 $start = $this->getStartTimestamp();
672 $end = $this->getEndTimestamp();
673 if ( null !== $start && $now < $start ) {
674 return 'scheduled';
675 }
676 if ( null !== $end && $now > $end ) {
677 return 'expired';
678 }
679 return 'active';
680 }
681
682 public function isWithinSchedule( ?int $now = null ) : bool {
683 return 'active' === $this->getScheduleStatus( $now );
684 }
685
686 const EFFECTIVE_STATUSES = array(
687 'active',
688 'scheduled',
689 'expired',
690 'suspended',
691 'skipped'
692 );
693
694 /**
695 * What the storefront makes of a published rule, in order of precedence: "suspended", "expired",
696 * "skipped" (changes neither prices nor quantity limits), "scheduled" (starts later) or "active".
697 */
698 public function getEffectiveStatus( ?int $now = null ) : string {
699 if ( $this->isSuspended() ) {
700 return 'suspended';
701 }
702 $schedule = $this->getScheduleStatus( $now );
703 if ( 'expired' === $schedule ) {
704 return 'expired';
705 }
706 // shown before "scheduled" so an empty campaign rule gets fixed before it starts
707 if ( !$this->isValidPricing() ) {
708 return 'skipped';
709 }
710 return $schedule;
711 }
712
713 /**
714 * Whether a higher-priority rule can take products and customers away from this one: only a rule
715 * that is (or will be) in force. A suspended, expired or skipped rule is not applied anyway.
716 */
717 public function canBeOverridden( ?int $now = null ) : bool {
718 return in_array( $this->getEffectiveStatus( $now ), array('active', 'scheduled'), true );
719 }
720
721 /**
722 * usort() callback: the order the rules are matched in. Lowest priority number first; among equal
723 * numbers the newest rule first (today's behaviour for rules that never had a priority), then by ID.
724 */
725 public static function compareOrder( GlobalPricingRule $a, GlobalPricingRule $b ) : int {
726 if ( $a->getPriority() !== $b->getPriority() ) {
727 return $a->getPriority() <=> $b->getPriority();
728 }
729 if ( $a->getCreatedAt() !== $b->getCreatedAt() ) {
730 return strcmp( $b->getCreatedAt(), $a->getCreatedAt() );
731 }
732 return (int) $b->id <=> (int) $a->id;
733 }
734
735 /**
736 * Whether this rule has a higher priority than another one (and so is applied where both match).
737 */
738 public function outranks( GlobalPricingRule $other ) : bool {
739 return self::compareOrder( $this, $other ) < 0;
740 }
741
742 /**
743 * The rule's title, or "Rule #ID" for a rule saved without one.
744 */
745 public static function titleOf( int $id ) : string {
746 $title = get_the_title( $id );
747 /* translators: %d: rule ID */
748 return ( '' !== trim( (string) $title ) ? $title : sprintf( __( 'Rule #%d', 'tier-pricing-table' ), $id ) );
749 }
750
751 public function getTitle() : string {
752 return self::titleOf( (int) $this->id );
753 }
754
755 public function getSettings() : RuleSettings {
756 if ( !$this->priorityOptions ) {
757 $this->priorityOptions = new RuleSettings($this);
758 }
759 return $this->priorityOptions;
760 }
761
762 public function asArray() : array {
763 return array(
764 'pricing_type' => $this->getPricingType(),
765 'regular_price' => $this->getRegularPrice(),
766 'sale_price' => $this->getSalePrice(),
767 'discount' => $this->getDiscount(),
768 'discount_type' => $this->getDiscountType(),
769 'applying_type' => $this->getApplyingType(),
770 'tiered_pricing_type' => $this->getTieredPricingType(),
771 'percentage_rules' => $this->getPercentageTieredPricingRules(),
772 'fixed_rules' => $this->getFixedTieredPricingRules(),
773 'minimum' => $this->getMinimum(),
774 'mix_and_match_minimum' => $this->getMixAndMatchMinQuantity(),
775 'tax_status' => $this->getTaxStatus(),
776 'tax_class' => $this->getTaxClass(),
777 'priority' => $this->getPriority(),
778 'start_date' => $this->getStartDate(),
779 'end_date' => $this->getEndDate(),
780 'included_categories' => $this->getIncludedProductCategories(),
781 'include_subcategories' => $this->isIncludeSubcategories(),
782 'included_tags' => $this->getIncludedProductTags(),
783 'included_brands' => $this->getIncludedProductBrands(),
784 'included_products' => $this->getIncludedProducts(),
785 'included_users' => $this->getIncludedUsers(),
786 'included_users_role' => $this->getIncludedUserRoles(),
787 'excluded_categories' => $this->getExcludedProductCategories(),
788 'excluded_tags' => $this->getExcludedProductTags(),
789 'excluded_brands' => $this->getExcludedProductBrands(),
790 'excluded_products' => $this->getExcludedProducts(),
791 'excluded_users' => $this->getExcludedUsers(),
792 'excluded_users_role' => $this->getExcludedUserRoles(),
793 'rule_id' => $this->getId(),
794 'is_suspended' => $this->isSuspended(),
795 );
796 }
797
798 public function save() {
799 $dataToUpdate = array(
800 '_tpt_pricing_type' => $this->getPricingType(),
801 '_tpt_regular_price' => $this->getRegularPrice(),
802 '_tpt_sale_price' => $this->getSalePrice(),
803 '_tpt_discount' => $this->getDiscount(),
804 '_tpt_discount_type' => $this->getDiscountType(),
805 '_tpt_applying_type' => $this->getApplyingType(),
806 '_tpt_tiered_pricing_type' => $this->getTieredPricingType(),
807 '_tpt_percentage_rules' => $this->getPercentageTieredPricingRules(),
808 '_tpt_fixed_rules' => $this->getFixedTieredPricingRules(),
809 '_tpt_minimum' => $this->getMinimum(),
810 '_tpt_mix_and_match_minimum' => ( is_null( $this->getMixAndMatchMinQuantity() ) ? '' : wc_bool_to_string( $this->getMixAndMatchMinQuantity() ) ),
811 '_tpt_tax_status' => $this->getTaxStatus(),
812 '_tpt_tax_class' => $this->getTaxClass(),
813 '_tpt_priority' => $this->getPriority(),
814 '_tpt_start_date' => (string) $this->getStartDate(),
815 '_tpt_end_date' => (string) $this->getEndDate(),
816 '_tpt_included_categories' => $this->getIncludedProductCategories(),
817 '_tpt_include_subcategories' => wc_bool_to_string( $this->isIncludeSubcategories() ),
818 '_tpt_included_tags' => $this->getIncludedProductTags(),
819 '_tpt_included_brands' => $this->getIncludedProductBrands(),
820 '_tpt_included_products' => $this->getIncludedProducts(),
821 '_tpt_included_users' => $this->getIncludedUsers(),
822 '_tpt_included_user_roles' => $this->getIncludedUserRoles(),
823 '_tpt_excluded_categories' => $this->getExcludedProductCategories(),
824 '_tpt_excluded_tags' => $this->getExcludedProductTags(),
825 '_tpt_excluded_brands' => $this->getExcludedProductBrands(),
826 '_tpt_excluded_products' => $this->getExcludedProducts(),
827 '_tpt_excluded_users' => $this->getExcludedUsers(),
828 '_tpt_excluded_user_roles' => $this->getExcludedUserRoles(),
829 '_tpt_is_suspended' => wc_bool_to_string( $this->isSuspended() ),
830 );
831 foreach ( $dataToUpdate as $key => $value ) {
832 update_post_meta( $this->getId(), $key, $value );
833 }
834 }
835
836 public static function build( $ruleId ) : self {
837 // Simple data to read
838 $dataToRead = array(
839 '_tpt_pricing_type' => 'pricing_type',
840 '_tpt_sale_price' => 'sale_price',
841 '_tpt_regular_price' => 'regular_price',
842 '_tpt_discount' => 'discount',
843 '_tpt_discount_type' => 'discount_type',
844 '_tpt_applying_type' => 'applying_type',
845 '_tpt_tiered_pricing_type' => 'tiered_pricing_type',
846 '_tpt_minimum' => 'minimum',
847 '_tpt_mix_and_match_minimum' => 'mix_and_match_minimum',
848 '_tpt_tax_status' => 'tax_status',
849 '_tpt_tax_class' => 'tax_class',
850 '_tpt_is_suspended' => 'is_suspended',
851 '_tpt_priority' => 'priority',
852 '_tpt_start_date' => 'start_date',
853 '_tpt_end_date' => 'end_date',
854 );
855 $data = array();
856 foreach ( $dataToRead as $key => $name ) {
857 $data[$name] = get_post_meta( $ruleId, $key, true );
858 }
859 $priceRule = self::fromArray( $data );
860 $priceRule->setCreatedAt( (string) get_post_field( 'post_date_gmt', $ruleId ) );
861 $existingRoles = wp_roles()->roles;
862 $includedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_categories', true ) ) );
863 $includedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_tags', true ) ) );
864 $includedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_brands', true ) ) );
865 $includedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_products', true ) ) );
866 $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), array(self::class, 'isSelectableRole') );
867 $includedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_users', true ) ) );
868 $excludedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_categories', true ) ) );
869 $excludedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_tags', true ) ) );
870 $excludedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_brands', true ) ) );
871 $excludedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_products', true ) ) );
872 $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), array(self::class, 'isSelectableRole') );
873 $excludedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_users', true ) ) );
874 $isSuspended = get_post_meta( $ruleId, '_tpt_is_suspended', true ) === 'yes';
875 $priceRule->setPercentageTieredPricingRules( self::readPricingRules( 'percentage', $ruleId ) );
876 $priceRule->setFixedTieredPricingRules( self::readPricingRules( 'fixed', $ruleId ) );
877 $priceRule->setIncludedProductCategories( $includedCategoriesIds );
878 $priceRule->setIncludeSubcategories( 'yes' === get_post_meta( $ruleId, '_tpt_include_subcategories', true ) );
879 $priceRule->setIncludedProductTags( $includedTagsIds );
880 $priceRule->setIncludedProductBrands( $includedBrandsIds );
881 $priceRule->setIncludedUsers( $includedUsers );
882 $priceRule->setIncludedUsersRole( $includedUsersRole );
883 $priceRule->setIncludedProducts( $includedProductsIds );
884 $priceRule->setExcludedProductCategories( $excludedCategoriesIds );
885 $priceRule->setExcludedProductTags( $excludedTagsIds );
886 $priceRule->setExcludedProductBrands( $excludedBrandsIds );
887 $priceRule->setExcludedUsers( $excludedUsers );
888 $priceRule->setExcludedUsersRole( $excludedUsersRole );
889 $priceRule->setExcludedProducts( $excludedProductsIds );
890 $priceRule->setIsSuspended( $isSuspended );
891 $priceRule->setId( $ruleId );
892 return apply_filters( 'tiered_pricing_table/global_pricing/after_built_rule', $priceRule );
893 }
894
895 protected static function readPricingRules( $type, $id ) : array {
896 $type = ( in_array( $type, array('percentage', 'fixed') ) ? $type : 'fixed' );
897 $rules = get_post_meta( $id, "_tpt_{$type}_rules", true );
898 $rules = ( !empty( $rules ) ? $rules : array() );
899 $rules = ( is_array( $rules ) ? array_filter( $rules ) : array() );
900 ksort( $rules );
901 return $rules;
902 }
903
904 public function setIsSuspended( bool $isSuspended ) {
905 $this->isSuspended = $isSuspended;
906 }
907
908 public function suspend() {
909 $this->setIsSuspended( true );
910 }
911
912 public function reactivate() {
913 $this->setIsSuspended( false );
914 }
915
916 public function isSuspended() : bool {
917 return $this->isSuspended;
918 }
919
920 /**
921 * Wrapper for the main "match" function to provide the hook for 3rd party devs
922 *
923 * @param WP_User $user
924 * @param WC_Product $product
925 *
926 * @return bool
927 */
928 public function matchRequirements( WP_User $user, WC_Product $product ) : bool {
929 $matched = $this->_matchRequirements( $user, $product );
930 return apply_filters(
931 'tiered_pricing_table/global_pricing/match_requirements',
932 $matched,
933 $this,
934 $user,
935 $product
936 );
937 }
938
939 protected function _matchRequirements( WP_User $user, WC_Product $product ) : bool {
940 $parentProduct = ( $product->is_type( array('variation', 'subscription-variation') ) ? wc_get_product( $product->get_parent_id() ) : $product );
941 /**
942 * 1. Check for product exclusion
943 *
944 * If the product in exclusion - pricing rule does not match immediately
945 */
946 $excludedProducts = $this->translateIds( $this->getExcludedProducts(), 'product' );
947 if ( !empty( $excludedProducts ) ) {
948 if ( in_array( $product->get_id(), $excludedProducts ) || in_array( $parentProduct->get_id(), $excludedProducts ) ) {
949 return false;
950 }
951 }
952 $excludedProductCategories = $this->translateIds( $this->getEffectiveExcludedCategories(), 'product_cat' );
953 if ( !empty( $excludedProductCategories ) ) {
954 if ( !empty( array_intersect( $parentProduct->get_category_ids(), $excludedProductCategories ) ) ) {
955 return false;
956 }
957 }
958 $excludedProductTags = $this->translateIds( $this->getExcludedProductTags(), 'product_tag' );
959 if ( !empty( $excludedProductTags ) ) {
960 if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $excludedProductTags ) ) ) {
961 return false;
962 }
963 }
964 $excludedProductBrands = $this->translateIds( $this->getExcludedProductBrands(), 'product_brand' );
965 if ( !empty( $excludedProductBrands ) ) {
966 $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array(
967 'fields' => 'ids',
968 ) );
969 if ( is_wp_error( $productBrands ) ) {
970 $productBrands = [];
971 }
972 if ( !empty( array_intersect( $productBrands, $excludedProductBrands ) ) ) {
973 return false;
974 }
975 }
976 /**
977 * 2. Check for user exclusion
978 *
979 * If users in exclusion - pricing rule does not match immediately
980 */
981 if ( in_array( $user->ID, $this->getExcludedUsers() ) ) {
982 return false;
983 }
984 $userRoles = self::rolesOf( $user );
985 foreach ( $this->getExcludedUserRoles() as $role ) {
986 if ( in_array( $role, $userRoles ) ) {
987 return false;
988 }
989 }
990 /**
991 * 3. Check for rule limitation for specific products
992 *
993 * If yes - match rule only for selected product/product categories
994 */
995 $productMatched = false;
996 $productLimitations = false;
997 $includedProducts = $this->translateIds( $this->getIncludedProducts(), 'product' );
998 if ( !empty( $includedProducts ) ) {
999 $productLimitations = true;
1000 if ( in_array( $product->get_id(), $includedProducts ) || in_array( $parentProduct->get_id(), $includedProducts ) ) {
1001 $productMatched = true;
1002 }
1003 }
1004 $includedProductCategories = $this->translateIds( $this->getEffectiveIncludedCategories(), 'product_cat' );
1005 if ( !empty( $includedProductCategories ) ) {
1006 $productLimitations = true;
1007 if ( !empty( array_intersect( $parentProduct->get_category_ids(), $includedProductCategories ) ) ) {
1008 $productMatched = true;
1009 }
1010 }
1011 $includedProductTags = $this->translateIds( $this->getIncludedProductTags(), 'product_tag' );
1012 if ( !empty( $includedProductTags ) ) {
1013 $productLimitations = true;
1014 if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $includedProductTags ) ) ) {
1015 $productMatched = true;
1016 }
1017 }
1018 $includedProductBrands = $this->translateIds( $this->getIncludedProductBrands(), 'product_brand' );
1019 if ( !empty( $includedProductBrands ) ) {
1020 $productLimitations = true;
1021 $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array(
1022 'fields' => 'ids',
1023 ) );
1024 if ( is_wp_error( $productBrands ) ) {
1025 $productBrands = [];
1026 }
1027 if ( !empty( array_intersect( $productBrands, $includedProductBrands ) ) ) {
1028 $productMatched = true;
1029 }
1030 }
1031 // There is product limitation and the product/category does not match the rule
1032 if ( $productLimitations && !$productMatched ) {
1033 return false;
1034 }
1035 /**
1036 * 4. If there are no user limits - match the rule immediately
1037 */
1038 if ( empty( $this->getIncludedUserRoles() ) && empty( $this->getIncludedUsers() ) ) {
1039 return true;
1040 }
1041 /**
1042 * 5. If there are user limits - check for user ID and user role.
1043 */
1044 if ( in_array( $user->ID, $this->getIncludedUsers() ) ) {
1045 return true;
1046 }
1047 foreach ( $this->getIncludedUserRoles() as $role ) {
1048 if ( in_array( $role, $userRoles ) ) {
1049 return true;
1050 }
1051 }
1052 return false;
1053 }
1054
1055 /**
1056 * Map a rule's stored object IDs to the language currently being viewed.
1057 *
1058 * A rule is created in a single language and stores the product/term IDs of that language. The
1059 * product being priced (and the term IDs returned by WooCommerce for it) are in the current
1060 * language, so under WPML/Polylang a raw ID comparison would never match on a translation. This
1061 * translates the stored IDs into the current language via the shared `wpml_object_id` filter
1062 * (implemented by both WPML and Polylang) so the comparison works across every translation.
1063 *
1064 * When no multilingual plugin is active the filter has no listeners, so the IDs are returned
1065 * unchanged with no overhead.
1066 *
1067 * @param int[] $ids Object IDs as stored on the rule.
1068 * @param string $type Element type: a post type ('product') or taxonomy ('product_cat',
1069 * 'product_tag', 'product_brand').
1070 *
1071 * @return int[]
1072 */
1073 protected function translateIds( array $ids, string $type ) : array {
1074 if ( empty( $ids ) || !has_filter( 'wpml_object_id' ) ) {
1075 return $ids;
1076 }
1077 $translated = array();
1078 foreach ( $ids as $id ) {
1079 // The `true` argument keeps the original ID when the object has no translation in the
1080 // current language, preserving the previous behaviour for untranslated objects.
1081 $translated[] = (int) apply_filters(
1082 'wpml_object_id',
1083 $id,
1084 $type,
1085 true
1086 );
1087 }
1088 return $translated;
1089 }
1090
1091 }
1092