PluginProbe
Tiered Pricing Table for WooCommerce / 8.0.2
Tiered Pricing Table for WooCommerce v8.0.2
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 2.3.3 All 91 releases
← All changes | src/Integrations/Plugins/WooCommerceSubscriptions.php +149 -1 7.1.4 → 8.0.2 View file →
@@ -3,9 +3,10 @@
3 3 use TierPricingTable\PriceManager;
4 4 use WC_Product;
5 5
6 6 /**
7 - * Compatibility with WooCommerce Subscriptions "All Products for Subscriptions" (APFS) plans.
7 + * Compatibility with WooCommerce Subscriptions: "All Products for Subscriptions" (APFS) plans,
8 + * synchronised/prorated first payments, sign-up fees, free trials and subscription switching.
8 9 *
9 10 * APFS applies a subscription plan's price/discount to a product purely through view-context
10 11 * `woocommerce_product_get_price` filters (it never writes the price prop). WooCommerce Subscriptions
11 12 * additionally filters non-subscription product prices to 0 during its "recurring totals" pass. Both
@@ -56,8 +57,155 @@
56 57 add_filter( 'tiered_pricing_table/cart/need_price_recalculation/item',
57 58 function ( $state, $cartItem ) {
58 59 return $this->isSubscriptionPlanItem( $cartItem ) ? false : $state;
59 60 }, 10, 2 );
61 +
62 + /**
63 + * Charging: give percentage tiers on ANY subscription product a correct base price.
64 + *
65 + * While the cart totals are being calculated, Subscriptions filters the product's "view" price
66 + * (WC_Subscriptions_Cart::set_subscription_prices_for_calculation) to fold in the sign-up fee,
67 + * replace the price with the fee during a free trial, and prorate it for synchronised products.
68 + * A percentage tier reads that price as its base, and the discounted result is stored with
69 + * set_price() — so Subscriptions applies the very same transformation a second time when the
70 + * price is read again, double-prorating the first payment.
71 + *
72 + * Detach only that one filter while the tiered price is computed. Currency conversion and
73 + * role-based pricing keep filtering the price, so the base stays correct in the customer's
74 + * currency; Subscriptions then applies its own transformation exactly once, afterwards.
75 + *
76 + * Runs at the lowest priority so it sees the final decision: if anything earlier disabled the
77 + * recalculation, getCartItemPrice() returns before product_cart_price fires and the filter would
78 + * never be restored.
79 + */
80 + add_filter( 'tiered_pricing_table/cart/need_price_recalculation',
81 + function ( $state, $cartItem ) {
82 +
83 + if ( $state && $this->isSubscriptionItem( $cartItem ) ) {
84 + $this->detachSubscriptionsPriceFilter();
85 + }
86 +
87 + return $state;
88 + }, PHP_INT_MAX, 2 );
89 +
90 + // Restore it as soon as the tiered price has been computed, before any other handler runs.
91 + add_filter( 'tiered_pricing_table/cart/product_cart_price', function ( $price ) {
92 +
93 + $this->reattachSubscriptionsPriceFilter();
94 +
95 + return $price;
96 + }, 1 );
97 +
98 + /**
99 + * Switching: base the prorated switch cost on the tiered price.
100 + *
101 + * WCS_Switch_Totals_Calculator runs on "woocommerce_before_calculate_totals" at priority 99,
102 + * while the tiered price is applied at 99999, so WCS_Switch_Cart_Item::get_new_price_per_day()
103 + * reads the untiered price. The switch cost — days x (new per day - old per day) — is then
104 + * calculated from the full price, overcharging the customer for the tier discount.
105 + */
106 + add_filter( 'wcs_switch_proration_new_price_per_day',
107 + array( $this, 'useTieredPriceForSwitchProration' ), 10, 4 );
108 + }
109 +
110 + /**
111 + * Replace the switch calculator's per-day price with one based on the tiered price.
112 + *
113 + * @param float $newPricePerDay
114 + * @param mixed $subscription
115 + * @param array $cartItem
116 + * @param int $daysInNewCycle
117 + *
118 + * @return float
119 + */
120 + public function useTieredPriceForSwitchProration( $newPricePerDay, $subscription, $cartItem, $daysInNewCycle ) {
121 +
122 + if ( $daysInNewCycle <= 0 || empty( $cartItem['data'] ) || ! ( $cartItem['data'] instanceof WC_Product ) ) {
123 + return $newPricePerDay;
124 + }
125 +
126 + $quantity = (int) ( $cartItem['quantity'] ?? 0 );
127 +
128 + if ( $quantity < 1 ) {
129 + return $newPricePerDay;
130 + }
131 +
132 + // Subscriptions re-attaches its price filter before firing this filter, so the same base-price
133 + // correction is needed here.
134 + $this->detachSubscriptionsPriceFilter();
135 +
136 + $tieredPrice = PriceManager::getPricingRule( $cartItem['data']->get_id() )
137 + ->getTierPrice( $quantity, false, 'cart' );
138 +
139 + $this->reattachSubscriptionsPriceFilter();
140 +
141 + // No tier applies at this quantity — leave the switch calculation untouched.
142 + if ( false === $tieredPrice ) {
143 + return $newPricePerDay;
144 + }
145 +
146 + return ( (float) $tieredPrice * $quantity ) / $daysInNewCycle;
147 + }
148 +
149 + /**
150 + * Hooks Subscriptions uses to transform the product price while totals are calculated.
151 + */
152 + const SUBSCRIPTIONS_PRICE_HOOKS = array(
153 + 'woocommerce_product_get_price',
154 + 'woocommerce_product_variation_get_price',
155 + );
156 +
157 + const SUBSCRIPTIONS_PRICE_CALLBACK = 'WC_Subscriptions_Cart::set_subscription_prices_for_calculation';
158 +
159 + /**
160 + * Hooks this integration actually detached, so only those are restored.
161 + *
162 + * @var string[]
163 + */
164 + protected $detachedPriceHooks = array();
165 +
166 + protected function detachSubscriptionsPriceFilter() {
167 +
168 + $this->detachedPriceHooks = array();
169 +
170 + foreach ( self::SUBSCRIPTIONS_PRICE_HOOKS as $hook ) {
171 + // remove_filter() reports whether the callback was actually attached. Outside the totals
172 + // calculation Subscriptions removes it itself, and re-adding it there would change prices
173 + // that are only being displayed.
174 + if ( remove_filter( $hook, self::SUBSCRIPTIONS_PRICE_CALLBACK, 100 ) ) {
175 + $this->detachedPriceHooks[] = $hook;
176 + }
177 + }
178 + }
179 +
180 + protected function reattachSubscriptionsPriceFilter() {
181 +
182 + foreach ( $this->detachedPriceHooks as $hook ) {
183 + add_filter( $hook, self::SUBSCRIPTIONS_PRICE_CALLBACK, 100, 2 );
184 + }
185 +
186 + $this->detachedPriceHooks = array();
187 + }
188 +
189 + /**
190 + * Whether a cart item is any kind of subscription: a native subscription product/variation or a
191 + * product purchased on an APFS plan (Subscriptions reports both through the same API).
192 + *
193 + * @param array $cartItem
194 + *
195 + * @return bool
196 + */
197 + protected function isSubscriptionItem( $cartItem ): bool {
198 +
199 + if ( ! class_exists( '\WC_Subscriptions_Product' ) ) {
200 + return false;
201 + }
202 +
203 + if ( empty( $cartItem['data'] ) || ! ( $cartItem['data'] instanceof WC_Product ) ) {
204 + return false;
205 + }
206 +
207 + return (bool) \WC_Subscriptions_Product::is_subscription( $cartItem['data'] );
60 208 }
61 209
62 210 /**
63 211 * Whether a cart item is currently purchased on a subscription plan (APFS).