PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Frontend / PlanCheckout.php

PlanCheckout.php in Subscriptions for WooCommerce with Stripe Recurring Payments trunk, at includes/Frontend/PlanCheckout.php

278 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plan checkout consumption (free).
4 *
5 * The plan-gated billing path: cart item → order line item → subscription
6 * snapshot. Every hook here acts ONLY when a plan was chosen (`subscrpt_plan_id`
7 * on the cart / order item), so classic (non-plan) items follow the existing
8 * `Frontend\Checkout` path byte-for-byte. Free handles the **Recurring** type on
9 * **simple** products only; Pro extends this with Subscribe & Save,
10 * Installments, variable/per-variation, one-time, signup fees and live pricing.
11 *
12 * The subscription record is created from the plan term (price + cadence + trial
13 * resolved at add-to-cart), written into the same classic snapshot meta the
14 * engine already renews on — so a plan subscription renews on its snapshot with
15 * no plan-specific renewal code.
16 *
17 * @package SpringDevs\Subscription
18 */
19
20 namespace SpringDevs\Subscription\Frontend;
21
22 use SpringDevs\Subscription\Admin\PlanPresenter;
23 use SpringDevs\Subscription\Illuminate\Helper;
24 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
25
26 /**
27 * Class PlanCheckout
28 */
29 class PlanCheckout {
30
31 /**
32 * Register the plan-gated checkout hooks.
33 */
34 public function __construct() {
35 add_filter( 'woocommerce_add_cart_item_data', array( $this, 'add_plan_to_cart' ), 20, 2 );
36 add_action( 'woocommerce_before_calculate_totals', array( $this, 'set_cart_item_price' ), 20 );
37 add_action( 'woocommerce_checkout_create_order_line_item', array( $this, 'save_plan_order_item' ), 20, 3 );
38
39 // Priority 9: create the subscription before the classic listener runs, so
40 // the classic simple path (which we also gate off in Frontend\Checkout)
41 // never double-creates for a plan item.
42 add_action( 'subscrpt_product_checkout', array( $this, 'create_plan_subscription' ), 9, 3 );
43 }
44
45 /**
46 * Resolve the plan term a shopper chose for a product, validating that the
47 * chosen plan id is actually one of the product's connected terms.
48 *
49 * @param int $product_id Product id.
50 * @param int $plan_id Chosen plan-term id.
51 *
52 * @return array|null Resolved plan row, or null if not valid for the product.
53 */
54 private function resolve_chosen( $product_id, $plan_id ) {
55 $plan_id = absint( $plan_id );
56 if ( ! $plan_id ) {
57 return null;
58 }
59
60 foreach ( PlanRepository::resolve_for_product( $product_id ) as $row ) {
61 if ( (int) $row['plan_id'] === $plan_id ) {
62 return $row;
63 }
64 }
65
66 return null;
67 }
68
69 /**
70 * Resolve a fallback plan for a bare add-to-cart — a direct link with no plan
71 * chosen (`?add-to-cart=ID`). One-time purchase wins when enabled: the item
72 * stays a plain one-time line at the native price (returns 0). Otherwise the
73 * product's first plan is selected, matching the on-page selector's default.
74 *
75 * @param int $product_id Product id.
76 *
77 * @return int Plan-term id, or 0 to leave the item untouched.
78 */
79 private function fallback_plan_id( $product_id ) {
80 if ( ! function_exists( 'subscrpt_product_has_plan' ) || ! subscrpt_product_has_plan( $product_id ) ) {
81 return 0;
82 }
83
84 // One-time enabled → keep it a one-time line at the native price.
85 if ( 'yes' === get_post_meta( $product_id, '_subscrpt_one_time_enabled', true ) ) {
86 return 0;
87 }
88
89 // Default to the product's first plan (same order the selector defaults to).
90 foreach ( PlanRepository::resolve_for_product( $product_id ) as $row ) {
91 return (int) $row['plan_id'];
92 }
93
94 return 0;
95 }
96
97 /**
98 * Map a resolved plan row to its Recurring billing terms.
99 *
100 * @param array $row Resolved plan row.
101 *
102 * @return array{price:float,time:int,option:string,trial:?string}
103 */
104 private function term_terms( $row ) {
105 $data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
106 $regular = isset( $data['regular_price'] ) ? (string) $data['regular_price'] : '';
107 $selling = isset( $data['sale_price'] ) ? (string) $data['sale_price'] : '';
108 $dtype = isset( $data['discount_type'] ) ? (string) $data['discount_type'] : 'percentage';
109 $dvalue = isset( $data['discount_value'] ) ? (string) $data['discount_value'] : '0';
110
111 $trial = null;
112 if ( ! empty( $row['free_trial'] ) && (int) $row['free_trial'] > 0 ) {
113 $trial_interval = isset( $row['plan_data']['free_trial_interval'] ) ? (string) $row['plan_data']['free_trial_interval'] : 'days';
114 $trial = (int) $row['free_trial'] . ' ' . $trial_interval;
115 }
116
117 return array(
118 'price' => (float) PlanPresenter::offer_price( $regular, $selling, $dtype, $dvalue ),
119 'time' => max( 1, (int) $row['billing_frequency'] ),
120 'option' => PlanRepository::interval_to_option( (int) $row['billing_interval'] ),
121 'trial' => $trial,
122 );
123 }
124
125 /**
126 * Stamp the chosen plan onto the cart item.
127 *
128 * Reads `subscrpt_plan_id` from the add-to-cart request, resolves the term,
129 * and writes the plan id + a `subscription` array (same shape the classic
130 * engine uses) so cart display and calculation work unchanged.
131 *
132 * @param array $cart_item_data Cart item data.
133 * @param int $product_id Product id.
134 *
135 * @return array
136 */
137 public function add_plan_to_cart( $cart_item_data, $product_id ) {
138 // Read from the request so both the product-page form (POST) and direct
139 // checkout links (`?add-to-cart=ID&subscrpt_plan_id=TERM`, GET) select a plan.
140 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce verifies the add-to-cart request; we only read a plan id.
141 $plan_id = isset( $_REQUEST['subscrpt_plan_id'] ) ? absint( wp_unslash( $_REQUEST['subscrpt_plan_id'] ) ) : 0;
142 if ( ! $plan_id ) {
143 // Bare add-to-cart (direct link, no plan chosen): fall back to one-time
144 // when enabled, otherwise the product's first plan.
145 $plan_id = $this->fallback_plan_id( $product_id );
146 if ( ! $plan_id ) {
147 return $cart_item_data;
148 }
149 }
150
151 $row = $this->resolve_chosen( $product_id, $plan_id );
152 if ( ! $row ) {
153 return $cart_item_data;
154 }
155
156 $terms = $this->term_terms( $row );
157
158 $cart_item_data['subscrpt_plan_id'] = $plan_id;
159 $cart_item_data['subscrpt_plan_group_id'] = (int) $row['plan_group_id'];
160 $cart_item_data['subscrpt_plan_price'] = $terms['price'];
161 $cart_item_data['subscription'] = array(
162 'time' => $terms['time'],
163 'type' => $terms['option'],
164 'trial' => $terms['trial'],
165 'signup_fee' => null,
166 'per_cost' => $terms['price'],
167 );
168
169 return $cart_item_data;
170 }
171
172 /**
173 * Override the cart line price with the resolved plan price.
174 *
175 * @param \WC_Cart $cart Cart object.
176 *
177 * @return void
178 */
179 public function set_cart_item_price( $cart ) {
180 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
181 return;
182 }
183
184 foreach ( $cart->get_cart() as $cart_item ) {
185 if ( isset( $cart_item['subscrpt_plan_price'], $cart_item['data'] ) ) {
186 $cart_item['data']->set_price( (float) $cart_item['subscrpt_plan_price'] );
187 }
188 }
189 }
190
191 /**
192 * Persist the chosen plan + terms onto the order line item so the resolved
193 * snapshot survives past cart session (the product carries no such meta).
194 *
195 * @param \WC_Order_Item_Product $item Order item.
196 * @param string $cart_item_key Cart item key.
197 * @param array $cart_item Cart item data.
198 *
199 * @return void
200 */
201 public function save_plan_order_item( $item, $cart_item_key, $cart_item ) {
202 if ( empty( $cart_item['subscrpt_plan_id'] ) ) {
203 return;
204 }
205
206 $item->update_meta_data( '_subscrpt_plan_id', (int) $cart_item['subscrpt_plan_id'] );
207 $item->update_meta_data( '_subscrpt_plan_group_id', (int) ( $cart_item['subscrpt_plan_group_id'] ?? 0 ) );
208 $item->update_meta_data( '_subscrpt_plan_price', (float) ( $cart_item['subscrpt_plan_price'] ?? 0 ) );
209 $item->update_meta_data( '_subscrpt_plan_terms', $cart_item['subscription'] ?? array() );
210 }
211
212 /**
213 * Create the subscription record from the order item's plan terms.
214 *
215 * Fires on `subscrpt_product_checkout` for every order item; acts only when
216 * the item carries `_subscrpt_plan_id`. Writes the same classic snapshot meta
217 * the engine renews on, but sourced from the plan term rather than the product.
218 *
219 * @param \WC_Order_Item $order_item Order item.
220 * @param \WC_Product $product Product object.
221 * @param string $post_status Subscription status.
222 *
223 * @return void
224 */
225 public function create_plan_subscription( $order_item, $product, $post_status ) {
226 $plan_id = (int) $order_item->get_meta( '_subscrpt_plan_id' );
227 if ( ! $plan_id ) {
228 return;
229 }
230
231 $terms = $order_item->get_meta( '_subscrpt_plan_terms' );
232 $terms = is_array( $terms ) ? $terms : array();
233
234 $timing_per = (int) ( $terms['time'] ?? 1 );
235 $timing_option = (string) ( $terms['type'] ?? 'months' );
236 $trial = $terms['trial'] ?? null;
237 $type = Helper::get_typos( $timing_per, $timing_option );
238
239 wc_update_order_item_meta(
240 $order_item->get_id(),
241 '_subscrpt_meta',
242 array(
243 'time' => $timing_per,
244 'type' => $timing_option,
245 'trial' => $trial,
246 )
247 );
248
249 $subscription_id = Helper::process_new_subscription_order( $order_item, $post_status, $product );
250 if ( ! $subscription_id ) {
251 return;
252 }
253
254 update_post_meta( $subscription_id, '_subscrpt_timing_per', $timing_per );
255 update_post_meta( $subscription_id, '_subscrpt_timing_option', $timing_option );
256 update_post_meta( $subscription_id, '_subscrpt_price', (float) $order_item->get_meta( '_subscrpt_plan_price' ) );
257 update_post_meta( $subscription_id, '_subscrpt_plan_id', $plan_id );
258 update_post_meta( $subscription_id, '_subscrpt_plan_group_id', (int) $order_item->get_meta( '_subscrpt_plan_group_id' ) );
259 update_post_meta( $subscription_id, '_subscrpt_user_cancel', $product->get_meta( '_subscrpt_user_cancel' ) );
260 update_post_meta( $subscription_id, '_subscrpt_order_id', $order_item->get_order_id() );
261 update_post_meta( $subscription_id, '_subscrpt_order_item_id', $order_item->get_id() );
262 update_post_meta( $subscription_id, '_subscrpt_trial', $trial );
263
264 if ( 'active' === $post_status ) {
265 $start_date = time();
266 $next_date = sdevs_wp_strtotime( $timing_per . ' ' . $type, $start_date );
267 if ( $trial ) {
268 $start_date = sdevs_wp_strtotime( $trial );
269 $next_date = $start_date;
270 }
271 update_post_meta( $subscription_id, '_subscrpt_start_date', $start_date );
272 update_post_meta( $subscription_id, '_subscrpt_next_date', $next_date );
273 }
274
275 do_action( 'subscrpt_order_checkout', $subscription_id, $order_item );
276 }
277 }
278