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 +288 -13 7.1.7 → 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 );
@@ -674,9 +948,9 @@
674 948 if ( in_array( $product->get_id(), $excludedProducts ) || in_array( $parentProduct->get_id(), $excludedProducts ) ) {
675 949 return false;
676 950 }
677 951 }
678 - $excludedProductCategories = $this->translateIds( $this->getExcludedProductCategories(), 'product_cat' );
952 + $excludedProductCategories = $this->translateIds( $this->getEffectiveExcludedCategories(), 'product_cat' );
679 953 if ( !empty( $excludedProductCategories ) ) {
680 954 if ( !empty( array_intersect( $parentProduct->get_category_ids(), $excludedProductCategories ) ) ) {
681 955 return false;
682 956 }
@@ -706,10 +980,11 @@
706 980 */
707 981 if ( in_array( $user->ID, $this->getExcludedUsers() ) ) {
708 982 return false;
709 983 }
984 + $userRoles = self::rolesOf( $user );
710 985 foreach ( $this->getExcludedUserRoles() as $role ) {
711 - if ( in_array( $role, $user->roles ) ) {
986 + if ( in_array( $role, $userRoles ) ) {
712 987 return false;
713 988 }
714 989 }
715 990 /**
@@ -725,9 +1000,9 @@
725 1000 if ( in_array( $product->get_id(), $includedProducts ) || in_array( $parentProduct->get_id(), $includedProducts ) ) {
726 1001 $productMatched = true;
727 1002 }
728 1003 }
729 - $includedProductCategories = $this->translateIds( $this->getIncludedProductCategories(), 'product_cat' );
1004 + $includedProductCategories = $this->translateIds( $this->getEffectiveIncludedCategories(), 'product_cat' );
730 1005 if ( !empty( $includedProductCategories ) ) {
731 1006 $productLimitations = true;
732 1007 if ( !empty( array_intersect( $parentProduct->get_category_ids(), $includedProductCategories ) ) ) {
733 1008 $productMatched = true;
@@ -769,9 +1044,9 @@
769 1044 if ( in_array( $user->ID, $this->getIncludedUsers() ) ) {
770 1045 return true;
771 1046 }
772 1047 foreach ( $this->getIncludedUserRoles() as $role ) {
773 - if ( in_array( $role, $user->roles ) ) {
1048 + if ( in_array( $role, $userRoles ) ) {
774 1049 return true;
775 1050 }
776 1051 }
777 1052 return false;