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
tier-pricing-table / src / Integrations / Plugins / WooCommerceSubscriptions.php

WooCommerceSubscriptions.php in Tiered Pricing Table for WooCommerce 8.0.2, at src/Integrations/Plugins/WooCommerceSubscriptions.php

260 lines 9.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php namespace TierPricingTable\Integrations\Plugins;
2
3 use TierPricingTable\PriceManager;
4 use WC_Product;
5
6 /**
7 * Compatibility with WooCommerce Subscriptions: "All Products for Subscriptions" (APFS) plans,
8 * synchronised/prorated first payments, sign-up fees, free trials and subscription switching.
9 *
10 * APFS applies a subscription plan's price/discount to a product purely through view-context
11 * `woocommerce_product_get_price` filters (it never writes the price prop). WooCommerce Subscriptions
12 * additionally filters non-subscription product prices to 0 during its "recurring totals" pass. Both
13 * are `view`-context filters, so reading `get_price('edit')` returns the raw, unmodified price.
14 *
15 * This integration keeps tiered pricing correct on plan items without changing behaviour for any other
16 * store:
17 *
18 * - Charging: it recomputes a plan item's tiered price in the "edit" context, so a percentage tier is
19 * based on the raw price rather than the plan-discounted (and, in the recurring pass, zeroed) "view"
20 * price. The plan discount is then applied exactly once — by Subscriptions, when the stored tier
21 * price is read during totals — and the tier no longer collapses to false on renewals.
22 * - Display: it defers a plan item's cart price/subtotal to WooCommerce, which already reflects the
23 * plan discount, instead of the plugin rebuilding it from a fresh (plan-unaware) product instance.
24 *
25 * Everything is scoped to items actually purchased on a plan and is a no-op unless APFS is active.
26 */
27 class WooCommerceSubscriptions extends PluginIntegrationAbstract {
28
29 public function run() {
30
31 // These hooks are registered unconditionally: run() executes at plugin-include time, before
32 // WooCommerce Subscriptions lazily loads its "All Products for Subscriptions" cart API on
33 // plugins_loaded. Availability is instead checked inside each callback (isSubscriptionPlanItem),
34 // which runs at cart-calculation time, so the callbacks are safe no-ops when APFS is absent.
35
36 // Charging: recompute plan items in the "edit" context (raw price base). Runs even when the
37 // incoming price is false — during the recurring pass getTierPrice() returns false because the
38 // product's "view" price has been filtered to 0.
39 add_filter( 'tiered_pricing_table/cart/product_cart_price',
40 function ( $price, $cartItem, $cartItemKey, $totalQuantity ) {
41
42 if ( ! $this->isSubscriptionPlanItem( $cartItem ) ) {
43 return $price;
44 }
45
46 return PriceManager::getPriceByRules( $totalQuantity, $cartItem['data']->get_id(), 'edit', 'cart',
47 false );
48 }, 10, 4 );
49
50 // Display: defer the subtotal column to WooCommerce for plan items so it matches the charge.
51 add_filter( 'tiered_pricing_table/cart/recalculate_cart_item_subtotal',
52 function ( $state, $cartItem ) {
53 return $this->isSubscriptionPlanItem( $cartItem ) ? false : $state;
54 }, 10, 2 );
55
56 // Display: defer the price column to WooCommerce for plan items.
57 add_filter( 'tiered_pricing_table/cart/need_price_recalculation/item',
58 function ( $state, $cartItem ) {
59 return $this->isSubscriptionPlanItem( $cartItem ) ? false : $state;
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'] );
208 }
209
210 /**
211 * Whether a cart item is currently purchased on a subscription plan (APFS).
212 *
213 * Reads the scheme stored on the cart item itself, which survives the cloned recurring cart, rather
214 * than the product object's runtime state.
215 *
216 * @param array $cartItem
217 *
218 * @return bool
219 */
220 protected function isSubscriptionPlanItem( $cartItem ): bool {
221
222 if ( ! class_exists( '\WCS_ATT_Cart' ) ) {
223 return false;
224 }
225
226 if ( empty( $cartItem['data'] ) || ! ( $cartItem['data'] instanceof WC_Product ) ) {
227 return false;
228 }
229
230 $scheme = \WCS_ATT_Cart::get_subscription_scheme( $cartItem );
231
232 // A non-empty, non-'0' scheme key means the item is on a plan; '0'/null means a one-time purchase.
233 return ! empty( $scheme ) && '0' !== (string) $scheme;
234 }
235
236 public function getTitle(): string {
237 return 'WooCommerce Subscriptions';
238 }
239
240 public function getDescription(): string {
241 return __( 'Apply tiered pricing correctly to products purchased on a WooCommerce Subscriptions plan, in both the initial and recurring totals.', 'tier-pricing-table' );
242 }
243
244 public function getSlug(): string {
245 return 'woocommerce-subscriptions';
246 }
247
248 public function getAuthorURL(): string {
249 return 'https://woocommerce.com/products/woocommerce-subscriptions/';
250 }
251
252 public function getIconURL(): string {
253 return $this->getContainer()->getFileManager()->locateAsset( 'admin/integrations/woocommerce-develop.jpeg' );
254 }
255
256 public function getIntegrationCategory(): string {
257 return 'custom_product_types';
258 }
259 }
260