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 +430 -90 6.4.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 {
@@ -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 */
@@ -185,15 +194,72 @@
185 194
186 195 /**
187 196 * Product minimum purchase quantity
188 197 *
189 - * @var int
198 + * @var int|null
190 199 */
191 200 public $minimum;
192 201
202 + /**
203 + * Mix and match minimum quantity
204 + *
205 + * @var bool|null
206 + */
207 + public $mixAndMatchMinQuantity = null;
208 +
193 209 public $priorityOptions;
194 210
211 + const DEFAULT_PRIORITY = 10;
212 +
195 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 + /**
196 262 * Array with custom data from 3rd-party addons
197 263 *
198 264 * @var array
199 265 */
@@ -319,8 +385,10 @@
319 385 $salePrice = $data['sale_price'] ?? null;
320 386 $discount = $data['discount'] ?? null;
321 387 $discountType = $data['discount_type'] ?? 'sale_price';
322 388 $minimum = $data['minimum'] ?? null;
389 + $mixAndMatchValue = $data['mix_and_match_minimum'] ?? '';
390 + $mixAndMatch = ( $mixAndMatchValue === '' ? null : $mixAndMatchValue === 'yes' );
323 391 $taxStatus = $data['tax_status'] ?? '';
324 392 $taxClass = $data['tax_class'] ?? '';
325 393 $self = new self();
326 394 $self->setTaxStatus( $taxStatus );
@@ -334,8 +402,12 @@
334 402 $self->setTieredPricingType( $tieredPricingType );
335 403 $self->setPercentageTieredPricingRules( $percentageRules );
336 404 $self->setFixedTieredPricingRules( $fixedRules );
337 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 );
338 410 return $self;
339 411 }
340 412
341 413 /**
@@ -343,13 +415,19 @@
343 415 *
344 416 * @throws Exception
345 417 */
346 418 public function validatePricing() {
347 - $valid = !Form::isEmpty( $this->getRegularPrice() );
348 - $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 + }
349 426 $valid = $valid || !empty( $this->getTieredPricingRules() );
350 427 $valid = $valid || !Form::isEmpty( $this->getMinimum() );
351 - $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() );
352 430 $valid = $valid || $this->getSettings()->getPriorityType() === 'flexible';
353 431 $valid = apply_filters( 'tiered_pricing_table/global_pricing/validation', $valid, $this );
354 432 if ( !$valid ) {
355 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' ));
@@ -372,8 +450,16 @@
372 450 public function setMinimum( ?int $minimum ) {
373 451 $this->minimum = ( intval( $minimum ) > 1 ? $minimum : null );
374 452 }
375 453
454 + public function getMixAndMatchMinQuantity() : ?bool {
455 + return $this->mixAndMatchMinQuantity;
456 + }
457 +
458 + public function setMixAndMatchMinQuantity( ?bool $mixAndMatch ) {
459 + $this->mixAndMatchMinQuantity = $mixAndMatch;
460 + }
461 +
376 462 public function getIncludedProductCategories() : array {
377 463 return $this->includedProductCategories;
378 464 }
379 465
@@ -388,8 +474,45 @@
388 474 public function setExcludedProductCategories( array $excludedProductCategories ) {
389 475 $this->excludedProductCategories = $excludedProductCategories;
390 476 }
391 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 +
392 515 public function getIncludedProductTags() : array {
393 516 return $this->includedProductTags;
394 517 }
395 518
@@ -468,8 +591,168 @@
468 591 public function setExcludedUsers( array $excludedUsers ) {
469 592 $this->excludedUsers = $excludedUsers;
470 593 }
471 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 +
472 755 public function getSettings() : RuleSettings {
473 756 if ( !$this->priorityOptions ) {
474 757 $this->priorityOptions = new RuleSettings($this);
475 758 }
@@ -477,64 +760,74 @@
477 760 }
478 761
479 762 public function asArray() : array {
480 763 return array(
481 - 'pricing_type' => $this->getPricingType(),
482 - 'regular_price' => $this->getRegularPrice(),
483 - 'sale_price' => $this->getSalePrice(),
484 - 'discount' => $this->getDiscount(),
485 - 'discount_type' => $this->getDiscountType(),
486 - 'applying_type' => $this->getApplyingType(),
487 - 'tiered_pricing_type' => $this->getTieredPricingType(),
488 - 'percentage_rules' => $this->getPercentageTieredPricingRules(),
489 - 'fixed_rules' => $this->getFixedTieredPricingRules(),
490 - 'minimum' => $this->getMinimum(),
491 - 'tax_status' => $this->getTaxStatus(),
492 - 'tax_class' => $this->getTaxClass(),
493 - 'included_categories' => $this->getIncludedProductCategories(),
494 - 'included_tags' => $this->getIncludedProductTags(),
495 - 'included_brands' => $this->getIncludedProductBrands(),
496 - 'included_products' => $this->getIncludedProducts(),
497 - 'included_users' => $this->getIncludedUsers(),
498 - 'included_users_role' => $this->getIncludedUserRoles(),
499 - 'excluded_categories' => $this->getExcludedProductCategories(),
500 - 'excluded_tags' => $this->getExcludedProductTags(),
501 - 'excluded_brands' => $this->getExcludedProductBrands(),
502 - 'excluded_products' => $this->getExcludedProducts(),
503 - 'excluded_users' => $this->getExcludedUsers(),
504 - 'excluded_users_role' => $this->getExcludedUserRoles(),
505 - 'rule_id' => $this->getId(),
506 - '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(),
507 795 );
508 796 }
509 797
510 798 public function save() {
511 799 $dataToUpdate = array(
512 - '_tpt_pricing_type' => $this->getPricingType(),
513 - '_tpt_regular_price' => $this->getRegularPrice(),
514 - '_tpt_sale_price' => $this->getSalePrice(),
515 - '_tpt_discount' => $this->getDiscount(),
516 - '_tpt_discount_type' => $this->getDiscountType(),
517 - '_tpt_applying_type' => $this->getApplyingType(),
518 - '_tpt_tiered_pricing_type' => $this->getTieredPricingType(),
519 - '_tpt_percentage_rules' => $this->getPercentageTieredPricingRules(),
520 - '_tpt_fixed_rules' => $this->getFixedTieredPricingRules(),
521 - '_tpt_minimum' => $this->getMinimum(),
522 - '_tpt_tax_status' => $this->getTaxStatus(),
523 - '_tpt_tax_class' => $this->getTaxClass(),
524 - '_tpt_included_categories' => $this->getIncludedProductCategories(),
525 - '_tpt_included_tags' => $this->getIncludedProductTags(),
526 - '_tpt_included_brands' => $this->getIncludedProductBrands(),
527 - '_tpt_included_products' => $this->getIncludedProducts(),
528 - '_tpt_included_users' => $this->getIncludedUsers(),
529 - '_tpt_included_user_roles' => $this->getIncludedUserRoles(),
530 - '_tpt_excluded_categories' => $this->getExcludedProductCategories(),
531 - '_tpt_excluded_tags' => $this->getExcludedProductTags(),
532 - '_tpt_excluded_brands' => $this->getExcludedProductBrands(),
533 - '_tpt_excluded_products' => $this->getExcludedProducts(),
534 - '_tpt_excluded_users' => $this->getExcludedUsers(),
535 - '_tpt_excluded_user_roles' => $this->getExcludedUserRoles(),
536 - '_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() ),
537 830 );
538 831 foreach ( $dataToUpdate as $key => $value ) {
539 832 update_post_meta( $this->getId(), $key, $value );
540 833 }
@@ -542,19 +835,23 @@
542 835
543 836 public static function build( $ruleId ) : self {
544 837 // Simple data to read
545 838 $dataToRead = array(
546 - '_tpt_pricing_type' => 'pricing_type',
547 - '_tpt_sale_price' => 'sale_price',
548 - '_tpt_regular_price' => 'regular_price',
549 - '_tpt_discount' => 'discount',
550 - '_tpt_discount_type' => 'discount_type',
551 - '_tpt_applying_type' => 'applying_type',
552 - '_tpt_tiered_pricing_type' => 'tiered_pricing_type',
553 - '_tpt_minimum' => 'minimum',
554 - '_tpt_tax_status' => 'tax_status',
555 - '_tpt_tax_class' => 'tax_class',
556 - '_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',
557 854 );
558 855 $data = array();
559 856 foreach ( $dataToRead as $key => $name ) {
560 857 $data[$name] = get_post_meta( $ruleId, $key, true );
@@ -559,29 +856,27 @@
559 856 foreach ( $dataToRead as $key => $name ) {
560 857 $data[$name] = get_post_meta( $ruleId, $key, true );
561 858 }
562 859 $priceRule = self::fromArray( $data );
860 + $priceRule->setCreatedAt( (string) get_post_field( 'post_date_gmt', $ruleId ) );
563 861 $existingRoles = wp_roles()->roles;
564 862 $includedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_categories', true ) ) );
565 863 $includedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_tags', true ) ) );
566 864 $includedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_brands', true ) ) );
567 865 $includedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_products', true ) ) );
568 - $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), function ( $role ) use($existingRoles) {
569 - return array_key_exists( $role, $existingRoles );
570 - } );
866 + $includedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_included_user_roles', true ), array(self::class, 'isSelectableRole') );
571 867 $includedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_included_users', true ) ) );
572 868 $excludedCategoriesIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_categories', true ) ) );
573 869 $excludedTagsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_tags', true ) ) );
574 870 $excludedBrandsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_brands', true ) ) );
575 871 $excludedProductsIds = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_products', true ) ) );
576 - $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), function ( $role ) use($existingRoles) {
577 - return array_key_exists( $role, $existingRoles );
578 - } );
872 + $excludedUsersRole = array_filter( (array) get_post_meta( $ruleId, '_tpt_excluded_user_roles', true ), array(self::class, 'isSelectableRole') );
579 873 $excludedUsers = array_filter( array_map( 'intval', (array) get_post_meta( $ruleId, '_tpt_excluded_users', true ) ) );
580 874 $isSuspended = get_post_meta( $ruleId, '_tpt_is_suspended', true ) === 'yes';
581 875 $priceRule->setPercentageTieredPricingRules( self::readPricingRules( 'percentage', $ruleId ) );
582 876 $priceRule->setFixedTieredPricingRules( self::readPricingRules( 'fixed', $ruleId ) );
583 877 $priceRule->setIncludedProductCategories( $includedCategoriesIds );
878 + $priceRule->setIncludeSubcategories( 'yes' === get_post_meta( $ruleId, '_tpt_include_subcategories', true ) );
584 879 $priceRule->setIncludedProductTags( $includedTagsIds );
585 880 $priceRule->setIncludedProductBrands( $includedBrandsIds );
586 881 $priceRule->setIncludedUsers( $includedUsers );
587 882 $priceRule->setIncludedUsersRole( $includedUsersRole );
@@ -647,24 +942,28 @@
647 942 * 1. Check for product exclusion
648 943 *
649 944 * If the product in exclusion - pricing rule does not match immediately
650 945 */
651 - if ( !empty( $this->getExcludedProducts() ) ) {
652 - 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 ) ) {
653 949 return false;
654 950 }
655 951 }
656 - if ( !empty( $this->getExcludedProductCategories() ) ) {
657 - 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 ) ) ) {
658 955 return false;
659 956 }
660 957 }
661 - if ( !empty( $this->getExcludedProductTags() ) ) {
662 - 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 ) ) ) {
663 961 return false;
664 962 }
665 963 }
666 - if ( !empty( $this->getExcludedProductBrands() ) ) {
964 + $excludedProductBrands = $this->translateIds( $this->getExcludedProductBrands(), 'product_brand' );
965 + if ( !empty( $excludedProductBrands ) ) {
667 966 $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array(
668 967 'fields' => 'ids',
669 968 ) );
670 969 if ( is_wp_error( $productBrands ) ) {
@@ -669,9 +968,9 @@
669 968 ) );
670 969 if ( is_wp_error( $productBrands ) ) {
671 970 $productBrands = [];
672 971 }
673 - if ( !empty( array_intersect( $productBrands, $this->getExcludedProductBrands() ) ) ) {
972 + if ( !empty( array_intersect( $productBrands, $excludedProductBrands ) ) ) {
674 973 return false;
675 974 }
676 975 }
677 976 /**
@@ -681,10 +980,11 @@
681 980 */
682 981 if ( in_array( $user->ID, $this->getExcludedUsers() ) ) {
683 982 return false;
684 983 }
984 + $userRoles = self::rolesOf( $user );
685 985 foreach ( $this->getExcludedUserRoles() as $role ) {
686 - if ( in_array( $role, $user->roles ) ) {
986 + if ( in_array( $role, $userRoles ) ) {
687 987 return false;
688 988 }
689 989 }
690 990 /**
@@ -693,27 +993,31 @@
693 993 * If yes - match rule only for selected product/product categories
694 994 */
695 995 $productMatched = false;
696 996 $productLimitations = false;
697 - if ( !empty( $this->getIncludedProducts() ) ) {
997 + $includedProducts = $this->translateIds( $this->getIncludedProducts(), 'product' );
998 + if ( !empty( $includedProducts ) ) {
698 999 $productLimitations = true;
699 - 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 ) ) {
700 1001 $productMatched = true;
701 1002 }
702 1003 }
703 - if ( !empty( $this->getIncludedProductCategories() ) ) {
1004 + $includedProductCategories = $this->translateIds( $this->getEffectiveIncludedCategories(), 'product_cat' );
1005 + if ( !empty( $includedProductCategories ) ) {
704 1006 $productLimitations = true;
705 - if ( !empty( array_intersect( $parentProduct->get_category_ids(), $this->getIncludedProductCategories() ) ) ) {
1007 + if ( !empty( array_intersect( $parentProduct->get_category_ids(), $includedProductCategories ) ) ) {
706 1008 $productMatched = true;
707 1009 }
708 1010 }
709 - if ( !empty( $this->getIncludedProductTags() ) ) {
1011 + $includedProductTags = $this->translateIds( $this->getIncludedProductTags(), 'product_tag' );
1012 + if ( !empty( $includedProductTags ) ) {
710 1013 $productLimitations = true;
711 - if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $this->getIncludedProductTags() ) ) ) {
1014 + if ( !empty( array_intersect( $parentProduct->get_tag_ids(), $includedProductTags ) ) ) {
712 1015 $productMatched = true;
713 1016 }
714 1017 }
715 - if ( !empty( $this->getIncludedProductBrands() ) ) {
1018 + $includedProductBrands = $this->translateIds( $this->getIncludedProductBrands(), 'product_brand' );
1019 + if ( !empty( $includedProductBrands ) ) {
716 1020 $productLimitations = true;
717 1021 $productBrands = wp_get_post_terms( $parentProduct->get_id(), 'product_brand', array(
718 1022 'fields' => 'ids',
719 1023 ) );
@@ -719,9 +1023,9 @@
719 1023 ) );
720 1024 if ( is_wp_error( $productBrands ) ) {
721 1025 $productBrands = [];
722 1026 }
723 - if ( !empty( array_intersect( $productBrands, $this->getIncludedProductBrands() ) ) ) {
1027 + if ( !empty( array_intersect( $productBrands, $includedProductBrands ) ) ) {
724 1028 $productMatched = true;
725 1029 }
726 1030 }
727 1031 // There is product limitation and the product/category does not match the rule
@@ -740,12 +1044,48 @@
740 1044 if ( in_array( $user->ID, $this->getIncludedUsers() ) ) {
741 1045 return true;
742 1046 }
743 1047 foreach ( $this->getIncludedUserRoles() as $role ) {
744 - if ( in_array( $role, $user->roles ) ) {
1048 + if ( in_array( $role, $userRoles ) ) {
745 1049 return true;
746 1050 }
747 1051 }
748 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;
749 1089 }
750 1090
751 1091 }