← 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). |