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 +346 -27 7.1.1 → 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 {
@@ -113,8 +115,15 @@
113 115 */
114 116 public $excludedProductCategories = array();
115 117
116 118 /**
119 + * Whether the included and excluded categories cover their subcategories too.
120 + *
121 + * @var bool
122 + */
123 + public $includeSubcategories = false;
124 +
125 + /**
117 126 * Included tags
118 127 *
119 128 * @var array
120 129 */
@@ -198,9 +207,59 @@
198 207 public $mixAndMatchMinQuantity = null;
199 208
200 209 public $priorityOptions;
201 210
211 + const DEFAULT_PRIORITY = 10;
212 +
202 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 + /**
203 262 * Array with custom data from 3rd-party addons
204 263 *
205 264 * @var array
206 265 */
@@ -344,8 +403,11 @@
344 403 $self->setPercentageTieredPricingRules( $percentageRules );
345 404 $self->setFixedTieredPricingRules( $fixedRules );
346 405 $self->setMinimum( ( Form::isEmpty( $minimum ) ? null : (int) $minimum ) );
347 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 );
348 410 return $self;
349 411 }
350 412
351 413 /**
@@ -353,13 +415,19 @@
353 415 *
354 416 * @throws Exception
355 417 */
356 418 public function validatePricing() {
357 - $valid = !Form::isEmpty( $this->getRegularPrice() );
358 - $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 + }
359 426 $valid = $valid || !empty( $this->getTieredPricingRules() );
360 427 $valid = $valid || !Form::isEmpty( $this->getMinimum() );
361 - $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() );
362 430 $valid = $valid || $this->getSettings()->getPriorityType() === 'flexible';
363 431 $valid = apply_filters( 'tiered_pricing_table/global_pricing/validation', $valid, $this );
364 432 if ( !$valid ) {
365 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' ));
@@ -406,8 +474,45 @@
406 474 public function setExcludedProductCategories( array $excludedProductCategories ) {
407 475 $this->excludedProductCategories = $excludedProductCategories;
408 476 }
409 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 +
410 515 public function getIncludedProductTags() : array {
411 516 return $this->includedProductTags;
412 517 }
413 518
@@ -486,8 +591,168 @@
486 591 public function setExcludedUsers( array $excludedUsers ) {
487 592 $this->excludedUsers = $excludedUsers;
488 593 }
489 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 +
490 755 public function getSettings() : RuleSettings {
491 756 if ( !$this->priorityOptions ) {
492 757 $this->priorityOptions = new RuleSettings($this);
493 758 }
@@ -508,9 +773,13 @@
508 773 'minimum' => $this->getMinimum(),
509 774 'mix_and_match_minimum' => $this->getMixAndMatchMinQuantity(),
510 775 'tax_status' => $this->getTaxStatus(),
511 776 'tax_class' => $this->getTaxClass(),
777 + 'priority' => $this->getPriority(),
778 + 'start_date' => $this->getStartDate(),
779 + 'end_date' => $this->getEndDate(),
512 780 'included_categories' => $this->getIncludedProductCategories(),
781 + 'include_subcategories' => $this->isIncludeSubcategories(),
513 782 'included_tags' => $this->getIncludedProductTags(),
514 783 'included_brands' => $this->getIncludedProductBrands(),
515 784 'included_products' => $this->getIncludedProducts(),
516 785 'included_users' => $this->getIncludedUsers(),
@@ -540,9 +809,13 @@
540 809 '_tpt_minimum' => $this->getMinimum(),
541 810 '_tpt_mix_and_match_minimum' => ( is_null( $this->getMixAndMatchMinQuantity() ) ? '' : wc_bool_to_string( $this->getMixAndMatchMinQuantity() ) ),
542 811 '_tpt_tax_status' => $this->getTaxStatus(),
543 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(),
544 816 '_tpt_included_categories' => $this->getIncludedProductCategories(),
817 + '_tpt_include_subcategories' => wc_bool_to_string( $this->isIncludeSubcategories() ),
545 818 '_tpt_included_tags' => $this->getIncludedProductTags(),
546 819 '_tpt_included_brands' => $this->getIncludedProductBrands(),
547 820 '_tpt_included_products' => $this->getIncludedProducts(),
548 821 '_tpt_included_users' => $this->getIncludedUsers(),
@@ -574,8 +847,11 @@
574 847 '_tpt_mix_and_match_minimum' => 'mix_and_match_minimum',
575 848 '_tpt_tax_status' => 'tax_status',
576 849 '_tpt_tax_class' => 'tax_class',
577 850 '_tpt_is_suspended' => 'is_suspended',
851 + '_tpt_priority' => 'priority',
852 + '_tpt_start_date' => 'start_date',
853 + '_tpt_end_date' => 'end_date',
578 854 );
579 855 $data = array();
580 856 foreach ( $dataToRead as $key => $name ) {
581 857 $data[$name] = get_post_meta( $ruleId, $key, true );
@@ -580,29 +856,27 @@
580 856 foreach ( $dataToRead as $key => $name ) {
581 857 $data[$name] = get_post_meta( $ruleId, $key, true );
582 858 }
583 859 $priceRule = self::fromArray( $data );
860 + $priceRule->setCreatedAt( (string) get_post_field( 'post_date_gmt', $ruleId ) );
584 861 $existingRoles = wp_roles()->roles;
585 862 $includedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_categories', true ) ) );
586 863 $includedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_tags', true ) ) );
587 864 $includedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_brands', true ) ) );
588 865 $includedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_products', true ) ) );
589 - $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), function ( $role ) use($existingRoles) {
590 - return array_key_exists( $role, $existingRoles );
591 - } );
866 + $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), array(self::class, 'isSelectableRole') );
592 867 $includedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_users', true ) ) );
593 868 $excludedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_categories', true ) ) );
594 869 $excludedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_tags', true ) ) );
595 870 $excludedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_brands', true ) ) );
596 871 $excludedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_products', true ) ) );
597 - $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), function ( $role ) use($existingRoles) {
598 - return array_key_exists( $role, $existingRoles );
599 - } );
872 + $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), array(self::class, 'isSelectableRole') );
600 873 $excludedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_users', true ) ) );
601 874 $isSuspended = get_post_meta( $ruleId, '_tpt_is_suspended', true ) === 'yes';
602 875 $priceRule->setPercentageTieredPricingRules( self::readPricingRules( 'percentage', $ruleId ) );
603 876 $priceRule->setFixedTieredPricingRules( self::readPricingRules( 'fixed', $ruleId ) );
604 877 $priceRule->setIncludedProductCategories( $includedCategoriesIds );
878 + $priceRule->setIncludeSubcategories( 'yes' === get_post_meta( $ruleId, '_tpt_include_subcategories', true ) );
605 879 $priceRule->setIncludedProductTags( $includedTagsIds );
606 880 $priceRule->setIncludedProductBrands( $includedBrandsIds );
607 881 $priceRule->setIncludedUsers( $includedUsers );
608 882 $priceRule->setIncludedUsersRole( $includedUsersRole );
@@ -668,24 +942,28 @@
668 942 * 1. Check for product exclusion
669 943 *
670 944 * If the product in exclusion - pricing rule does not match immediately
671 945 */
672 - if ( !empty( $this->getExcludedProducts() ) ) {
673 - 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 ) ) {
674 949 return false;
675 950 }
676 951 }
677 - if ( !empty( $this->getExcludedProductCategories() ) ) {
678 - 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 ) ) ) {
679 955 return false;
680 956 }
681 957 }
682 - if ( !empty( $this->getExcludedProductTags() ) ) {
683 - if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $this->getExcludedProductTags() ) ) ) {
958 + $excludedProductTags = $this->translateIds( $this->getExcludedProductTags(), 'product_tag' );
959 + if ( !empty( $excludedProductTags ) ) {
960 + if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $excludedProductTags ) ) ) {
684 961 return false;
685 962 }
686 963 }
687 - if ( !empty( $this->getExcludedProductBrands() ) ) {
964 + $excludedProductBrands = $this->translateIds( $this->getExcludedProductBrands(), 'product_brand' );
965 + if ( !empty( $excludedProductBrands ) ) {
688 966 $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array(
689 967 'fields' => 'ids',
690 968 ) );
691 969 if ( is_wp_error( $productBrands ) ) {
@@ -690,9 +968,9 @@
690 968 ) );
691 969 if ( is_wp_error( $productBrands ) ) {
692 970 $productBrands = [];
693 971 }
694 - if ( !empty( array_intersect( $productBrands, $this->getExcludedProductBrands() ) ) ) {
972 + if ( !empty( array_intersect( $productBrands, $excludedProductBrands ) ) ) {
695 973 return false;
696 974 }
697 975 }
698 976 /**
@@ -702,10 +980,11 @@
702 980 */
703 981 if ( in_array( $user->ID, $this->getExcludedUsers() ) ) {
704 982 return false;
705 983 }
984 + $userRoles = self::rolesOf( $user );
706 985 foreach ( $this->getExcludedUserRoles() as $role ) {
707 - if ( in_array( $role, $user->roles ) ) {
986 + if ( in_array( $role, $userRoles ) ) {
708 987 return false;
709 988 }
710 989 }
711 990 /**
@@ -714,27 +993,31 @@
714 993 * If yes - match rule only for selected product/product categories
715 994 */
716 995 $productMatched = false;
717 996 $productLimitations = false;
718 - if ( !empty( $this->getIncludedProducts() ) ) {
997 + $includedProducts = $this->translateIds( $this->getIncludedProducts(), 'product' );
998 + if ( !empty( $includedProducts ) ) {
719 999 $productLimitations = true;
720 - 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 ) ) {
721 1001 $productMatched = true;
722 1002 }
723 1003 }
724 - if ( !empty( $this->getIncludedProductCategories() ) ) {
1004 + $includedProductCategories = $this->translateIds( $this->getEffectiveIncludedCategories(), 'product_cat' );
1005 + if ( !empty( $includedProductCategories ) ) {
725 1006 $productLimitations = true;
726 - if ( !empty( array_intersect( $parentProduct->get_category_ids(), $this->getIncludedProductCategories() ) ) ) {
1007 + if ( !empty( array_intersect( $parentProduct->get_category_ids(), $includedProductCategories ) ) ) {
727 1008 $productMatched = true;
728 1009 }
729 1010 }
730 - if ( !empty( $this->getIncludedProductTags() ) ) {
1011 + $includedProductTags = $this->translateIds( $this->getIncludedProductTags(), 'product_tag' );
1012 + if ( !empty( $includedProductTags ) ) {
731 1013 $productLimitations = true;
732 - if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $this->getIncludedProductTags() ) ) ) {
1014 + if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $includedProductTags ) ) ) {
733 1015 $productMatched = true;
734 1016 }
735 1017 }
736 - if ( !empty( $this->getIncludedProductBrands() ) ) {
1018 + $includedProductBrands = $this->translateIds( $this->getIncludedProductBrands(), 'product_brand' );
1019 + if ( !empty( $includedProductBrands ) ) {
737 1020 $productLimitations = true;
738 1021 $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array(
739 1022 'fields' => 'ids',
740 1023 ) );
@@ -740,9 +1023,9 @@
740 1023 ) );
741 1024 if ( is_wp_error( $productBrands ) ) {
742 1025 $productBrands = [];
743 1026 }
744 - if ( !empty( array_intersect( $productBrands, $this->getIncludedProductBrands() ) ) ) {
1027 + if ( !empty( array_intersect( $productBrands, $includedProductBrands ) ) ) {
745 1028 $productMatched = true;
746 1029 }
747 1030 }
748 1031 // There is product limitation and the product/category does not match the rule
@@ -761,12 +1044,48 @@
761 1044 if ( in_array( $user->ID, $this->getIncludedUsers() ) ) {
762 1045 return true;
763 1046 }
764 1047 foreach ( $this->getIncludedUserRoles() as $role ) {
765 - if ( in_array( $role, $user->roles ) ) {
1048 + if ( in_array( $role, $userRoles ) ) {
766 1049 return true;
767 1050 }
768 1051 }
769 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;
770 1089 }
771 1090
772 1091 }