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
← All changes | src/Addons/GlobalTieredPricing/GlobalPricingRule.php +559 -69 6.1.0 → 8.1.0 View file →
@@ -3,8 +3,10 @@
3 3 namespace TierPricingTable\Addons\GlobalTieredPricing;
4 4
5 5 use TierPricingTable\Addons\GlobalTieredPricing\PricingRule\RuleSettings;
6 6 use TierPricingTable\Forms\Form;
7 +use DateTime;
8 +use DateTimeZone;
7 9 use Exception;
8 10 use WC_Product;
9 11 use WP_User;
10 12 class GlobalPricingRule {
@@ -64,8 +66,22 @@
64 66 */
65 67 public $applyingType;
66 68
67 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 + /**
68 84 * Tiered Pricing type
69 85 *
70 86 * @var string
71 87 */
@@ -99,8 +115,43 @@
99 115 */
100 116 public $excludedProductCategories = array();
101 117
102 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 + /**
103 154 * Included products
104 155 *
105 156 * @var array
106 157 */
@@ -143,15 +194,72 @@
143 194
144 195 /**
145 196 * Product minimum purchase quantity
146 197 *
147 - * @var int
198 + * @var int|null
148 199 */
149 200 public $minimum;
150 201
202 + /**
203 + * Mix and match minimum quantity
204 + *
205 + * @var bool|null
206 + */
207 + public $mixAndMatchMinQuantity = null;
208 +
151 209 public $priorityOptions;
152 210
211 + const DEFAULT_PRIORITY = 10;
212 +
153 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 + /**
154 262 * Array with custom data from 3rd-party addons
155 263 *
156 264 * @var array
157 265 */
@@ -224,8 +332,24 @@
224 332 public function getTieredPricingRules() : array {
225 333 return $this->getFixedTieredPricingRules();
226 334 }
227 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 +
228 352 public function getRegularPrice() : ?float {
229 353 return $this->regularPrice;
230 354 }
231 355
@@ -261,9 +385,15 @@
261 385 $salePrice = $data['sale_price'] ?? null;
262 386 $discount = $data['discount'] ?? null;
263 387 $discountType = $data['discount_type'] ?? 'sale_price';
264 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'] ?? '';
265 393 $self = new self();
394 + $self->setTaxStatus( $taxStatus );
395 + $self->setTaxClass( $taxClass );
266 396 $self->setPricingType( (string) $pricingType );
267 397 $self->setRegularPrice( ( Form::isEmpty( $regularPrice ) ? null : (float) $regularPrice ) );
268 398 $self->setSalePrice( ( Form::isEmpty( $salePrice ) ? null : (float) $salePrice ) );
269 399 $self->setDiscount( ( Form::isEmpty( $discount ) ? null : (float) $discount ) );
@@ -272,8 +402,12 @@
272 402 $self->setTieredPricingType( $tieredPricingType );
273 403 $self->setPercentageTieredPricingRules( $percentageRules );
274 404 $self->setFixedTieredPricingRules( $fixedRules );
275 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 );
276 410 return $self;
277 411 }
278 412
279 413 /**
@@ -281,13 +415,19 @@
281 415 *
282 416 * @throws Exception
283 417 */
284 418 public function validatePricing() {
285 - $valid = !Form::isEmpty( $this->getRegularPrice() );
286 - $valid = $valid || !Form::isEmpty( $this->getSalePrice() );
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 + }
287 426 $valid = $valid || !empty( $this->getTieredPricingRules() );
288 427 $valid = $valid || !Form::isEmpty( $this->getMinimum() );
289 - $valid = $valid || !Form::isEmpty( $this->getDiscount() );
428 + // a mix & match choice alone changes how product-level minimums count across variations
429 + $valid = $valid || !is_null( $this->getMixAndMatchMinQuantity() );
290 430 $valid = $valid || $this->getSettings()->getPriorityType() === 'flexible';
291 431 $valid = apply_filters( 'tiered_pricing_table/global_pricing/validation', $valid, $this );
292 432 if ( !$valid ) {
293 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' ));
@@ -310,8 +450,16 @@
310 450 public function setMinimum( ?int $minimum ) {
311 451 $this->minimum = ( intval( $minimum ) > 1 ? $minimum : null );
312 452 }
313 453
454 + public function getMixAndMatchMinQuantity() : ?bool {
455 + return $this->mixAndMatchMinQuantity;
456 + }
457 +
458 + public function setMixAndMatchMinQuantity( ?bool $mixAndMatch ) {
459 + $this->mixAndMatchMinQuantity = $mixAndMatch;
460 + }
461 +
314 462 public function getIncludedProductCategories() : array {
315 463 return $this->includedProductCategories;
316 464 }
317 465
@@ -326,8 +474,77 @@
326 474 public function setExcludedProductCategories( array $excludedProductCategories ) {
327 475 $this->excludedProductCategories = $excludedProductCategories;
328 476 }
329 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 +
330 547 public function getIncludedProducts() : array {
331 548 return $this->includedProducts;
332 549 }
333 550
@@ -374,8 +591,168 @@
374 591 public function setExcludedUsers( array $excludedUsers ) {
375 592 $this->excludedUsers = $excludedUsers;
376 593 }
377 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 +
378 755 public function getSettings() : RuleSettings {
379 756 if ( !$this->priorityOptions ) {
380 757 $this->priorityOptions = new RuleSettings($this);
381 758 }
@@ -383,52 +760,74 @@
383 760 }
384 761
385 762 public function asArray() : array {
386 763 return array(
387 - 'pricing_type' => $this->getPricingType(),
388 - 'regular_price' => $this->getRegularPrice(),
389 - 'sale_price' => $this->getSalePrice(),
390 - 'discount' => $this->getDiscount(),
391 - 'discount_type' => $this->getDiscountType(),
392 - 'applying_type' => $this->getApplyingType(),
393 - 'tiered_pricing_type' => $this->getTieredPricingType(),
394 - 'percentage_rules' => $this->getPercentageTieredPricingRules(),
395 - 'fixed_rules' => $this->getFixedTieredPricingRules(),
396 - 'minimum' => $this->getMinimum(),
397 - 'included_categories' => $this->getIncludedProductCategories(),
398 - 'included_products' => $this->getIncludedProducts(),
399 - 'included_users' => $this->getIncludedUsers(),
400 - 'included_users_role' => $this->getIncludedUserRoles(),
401 - 'excluded_categories' => $this->getExcludedProductCategories(),
402 - 'excluded_products' => $this->getExcludedProducts(),
403 - 'excluded_users' => $this->getExcludedUsers(),
404 - 'excluded_users_role' => $this->getExcludedUserRoles(),
405 - 'rule_id' => $this->getId(),
406 - 'is_suspended' => $this->isSuspended(),
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(),
407 795 );
408 796 }
409 797
410 798 public function save() {
411 799 $dataToUpdate = array(
412 - '_tpt_pricing_type' => $this->getPricingType(),
413 - '_tpt_regular_price' => $this->getRegularPrice(),
414 - '_tpt_sale_price' => $this->getSalePrice(),
415 - '_tpt_discount' => $this->getDiscount(),
416 - '_tpt_discount_type' => $this->getDiscountType(),
417 - '_tpt_applying_type' => $this->getApplyingType(),
418 - '_tpt_tiered_pricing_type' => $this->getTieredPricingType(),
419 - '_tpt_percentage_rules' => $this->getPercentageTieredPricingRules(),
420 - '_tpt_fixed_rules' => $this->getFixedTieredPricingRules(),
421 - '_tpt_minimum' => $this->getMinimum(),
422 - '_tpt_included_categories' => $this->getIncludedProductCategories(),
423 - '_tpt_included_products' => $this->getIncludedProducts(),
424 - '_tpt_included_users' => $this->getIncludedUsers(),
425 - '_tpt_included_user_roles' => $this->getIncludedUserRoles(),
426 - '_tpt_excluded_categories' => $this->getExcludedProductCategories(),
427 - '_tpt_excluded_products' => $this->getExcludedProducts(),
428 - '_tpt_excluded_users' => $this->getExcludedUsers(),
429 - '_tpt_excluded_user_roles' => $this->getExcludedUserRoles(),
430 - '_tpt_is_suspended' => wc_bool_to_string( $this->isSuspended() ),
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() ),
431 830 );
432 831 foreach ( $dataToUpdate as $key => $value ) {
433 832 update_post_meta( $this->getId(), $key, $value );
434 833 }
@@ -436,17 +835,23 @@
436 835
437 836 public static function build( $ruleId ) : self {
438 837 // Simple data to read
439 838 $dataToRead = array(
440 - '_tpt_pricing_type' => 'pricing_type',
441 - '_tpt_sale_price' => 'sale_price',
442 - '_tpt_regular_price' => 'regular_price',
443 - '_tpt_discount' => 'discount',
444 - '_tpt_discount_type' => 'discount_type',
445 - '_tpt_applying_type' => 'applying_type',
446 - '_tpt_tiered_pricing_type' => 'tiered_pricing_type',
447 - '_tpt_minimum' => 'minimum',
448 - '_tpt_is_suspended' => 'is_suspended',
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',
449 854 );
450 855 $data = array();
451 856 foreach ( $dataToRead as $key => $name ) {
452 857 $data[$name] = get_post_meta( $ruleId, $key, true );
@@ -451,29 +856,35 @@
451 856 foreach ( $dataToRead as $key => $name ) {
452 857 $data[$name] = get_post_meta( $ruleId, $key, true );
453 858 }
454 859 $priceRule = self::fromArray( $data );
860 + $priceRule->setCreatedAt( (string) get_post_field( 'post_date_gmt', $ruleId ) );
455 861 $existingRoles = wp_roles()->roles;
456 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 ) ) );
457 865 $includedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_products', true ) ) );
458 - $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), function ( $role ) use($existingRoles) {
459 - return array_key_exists( $role, $existingRoles );
460 - } );
866 + $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), array(self::class, 'isSelectableRole') );
461 867 $includedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_users', true ) ) );
462 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 ) ) );
463 871 $excludedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_products', true ) ) );
464 - $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), function ( $role ) use($existingRoles) {
465 - return array_key_exists( $role, $existingRoles );
466 - } );
872 + $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), array(self::class, 'isSelectableRole') );
467 873 $excludedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_users', true ) ) );
468 874 $isSuspended = get_post_meta( $ruleId, '_tpt_is_suspended', true ) === 'yes';
469 875 $priceRule->setPercentageTieredPricingRules( self::readPricingRules( 'percentage', $ruleId ) );
470 876 $priceRule->setFixedTieredPricingRules( self::readPricingRules( 'fixed', $ruleId ) );
471 877 $priceRule->setIncludedProductCategories( $includedCategoriesIds );
878 + $priceRule->setIncludeSubcategories( 'yes' === get_post_meta( $ruleId, '_tpt_include_subcategories', true ) );
879 + $priceRule->setIncludedProductTags( $includedTagsIds );
880 + $priceRule->setIncludedProductBrands( $includedBrandsIds );
472 881 $priceRule->setIncludedUsers( $includedUsers );
473 882 $priceRule->setIncludedUsersRole( $includedUsersRole );
474 883 $priceRule->setIncludedProducts( $includedProductsIds );
475 884 $priceRule->setExcludedProductCategories( $excludedCategoriesIds );
885 + $priceRule->setExcludedProductTags( $excludedTagsIds );
886 + $priceRule->setExcludedProductBrands( $excludedBrandsIds );
476 887 $priceRule->setExcludedUsers( $excludedUsers );
477 888 $priceRule->setExcludedUsersRole( $excludedUsersRole );
478 889 $priceRule->setExcludedProducts( $excludedProductsIds );
479 890 $priceRule->setIsSuspended( $isSuspended );
@@ -529,20 +940,40 @@
529 940 $parentProduct = ( $product->is_type( array('variation', 'subscription-variation') ) ? wc_get_product( $product->get_parent_id() ) : $product );
530 941 /**
531 942 * 1. Check for product exclusion
532 943 *
533 - * If product in exclusion - pricing rule does not match immediately
944 + * If the product in exclusion - pricing rule does not match immediately
534 945 */
535 - if ( !empty( $this->getExcludedProducts() ) ) {
536 - if ( in_array( $product->get_id(), $this->getExcludedProducts() ) || in_array( $parentProduct->get_id(), $this->getExcludedProducts() ) ) {
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 ) ) {
537 949 return false;
538 950 }
539 951 }
540 - if ( !empty( $this->getExcludedProductCategories() ) ) {
541 - if ( !empty( array_intersect( $parentProduct->get_category_ids(), $this->getExcludedProductCategories() ) ) ) {
952 + $excludedProductCategories = $this->translateIds( $this->getEffectiveExcludedCategories(), 'product_cat' );
953 + if ( !empty( $excludedProductCategories ) ) {
954 + if ( !empty( array_intersect( $parentProduct->get_category_ids(), $excludedProductCategories ) ) ) {
542 955 return false;
543 956 }
544 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 + }
545 976 /**
546 977 * 2. Check for user exclusion
547 978 *
548 979 * If users in exclusion - pricing rule does not match immediately
@@ -549,10 +980,11 @@
549 980 */
550 981 if ( in_array( $user->ID, $this->getExcludedUsers() ) ) {
551 982 return false;
552 983 }
984 + $userRoles = self::rolesOf( $user );
553 985 foreach ( $this->getExcludedUserRoles() as $role ) {
554 - if ( in_array( $role, $user->roles ) ) {
986 + if ( in_array( $role, $userRoles ) ) {
555 987 return false;
556 988 }
557 989 }
558 990 /**
@@ -561,20 +993,42 @@
561 993 * If yes - match rule only for selected product/product categories
562 994 */
563 995 $productMatched = false;
564 996 $productLimitations = false;
565 - if ( !empty( $this->getIncludedProducts() ) ) {
997 + $includedProducts = $this->translateIds( $this->getIncludedProducts(), 'product' );
998 + if ( !empty( $includedProducts ) ) {
566 999 $productLimitations = true;
567 - if ( in_array( $product->get_id(), $this->getIncludedProducts() ) || in_array( $parentProduct->get_id(), $this->getIncludedProducts() ) ) {
1000 + if ( in_array( $product->get_id(), $includedProducts ) || in_array( $parentProduct->get_id(), $includedProducts ) ) {
568 1001 $productMatched = true;
569 1002 }
570 1003 }
571 - if ( !empty( $this->getIncludedProductCategories() ) ) {
1004 + $includedProductCategories = $this->translateIds( $this->getEffectiveIncludedCategories(), 'product_cat' );
1005 + if ( !empty( $includedProductCategories ) ) {
572 1006 $productLimitations = true;
573 - if ( !empty( array_intersect( $parentProduct->get_category_ids(), $this->getIncludedProductCategories() ) ) ) {
1007 + if ( !empty( array_intersect( $parentProduct->get_category_ids(), $includedProductCategories ) ) ) {
574 1008 $productMatched = true;
575 1009 }
576 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 + }
577 1031 // There is product limitation and the product/category does not match the rule
578 1032 if ( $productLimitations && !$productMatched ) {
579 1033 return false;
580 1034 }
@@ -590,12 +1044,48 @@
590 1044 if ( in_array( $user->ID, $this->getIncludedUsers() ) ) {
591 1045 return true;
592 1046 }
593 1047 foreach ( $this->getIncludedUserRoles() as $role ) {
594 - if ( in_array( $role, $user->roles ) ) {
1048 + if ( in_array( $role, $userRoles ) ) {
595 1049 return true;
596 1050 }
597 1051 }
598 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;
599 1089 }
600 1090
601 1091 }