PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.3.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.3.0
2.4.0 2.3.0 2.2.0 2.1.0 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 All 65 releases
← All changes | includes/Frontend/Cart.php +197 -34 1.10.0 → 2.3.0 View file →
@@ -1,5 +1,10 @@
1 1 <?php
2 +/**
3 + * Cart handling for subscription products.
4 + *
5 + * @package SpringDevs\Subscription
6 + */
2 7
3 8 namespace SpringDevs\Subscription\Frontend;
4 9
5 10 use SpringDevs\Subscription\Illuminate\Helper;
@@ -29,9 +34,11 @@
29 34 add_filter( 'woocommerce_get_item_data', array( $this, 'set_line_item_meta' ), 10, 2 );
30 35 add_action( 'woocommerce_before_calculate_totals', array( $this, 'add_calculation_price_filter' ) );
31 36 add_action( 'woocommerce_calculate_totals', array( $this, 'remove_calculation_price_filter' ) );
32 37 add_action( 'woocommerce_after_calculate_totals', array( $this, 'remove_calculation_price_filter' ) );
33 - add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'add_to_cart_validation' ), 10, 2 );
38 +
39 + add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'add_to_cart_validation' ), 10, 4 );
40 + add_action( 'woocommerce_store_api_validate_add_to_cart', array( $this, 'add_to_cart_validation_store_api' ), 10, 1 );
34 41 }
35 42
36 43 /**
37 44 * Add to cart validation.
@@ -37,17 +44,72 @@
37 44 * Add to cart validation.
38 45 *
39 46 * @param bool $passed Passed ?.
40 47 * @param int $product_id Product Id.
48 + * @param int $quantity Quantity.
49 + * @param int $variation_id Variation Id.
41 50 *
42 51 * @return bool
43 52 */
44 - public function add_to_cart_validation( bool $passed, int $product_id ): bool {
45 - $cart_items = WC()->cart->cart_contents;
53 + public function add_to_cart_validation( $passed, $product_id, $quantity, $variation_id = 0 ) {
54 + $product_id = (int) $variation_id > 0 ? (int) $variation_id : (int) $product_id;
55 + $validation = $this->validate_cart_items( $product_id );
56 +
57 + if ( $validation['failed'] ) {
58 + $error_notice = empty( $validation['error_notice'] ) ? __( 'This product cannot be added to the cart.', 'subscription' ) : $validation['error_notice'];
59 + wc_add_notice( $error_notice, 'error' );
60 + return false;
61 + }
62 +
63 + // Validation passed.
64 + return $passed;
65 + }
66 +
67 + /**
68 + * Add to cart validation.
69 + *
70 + * @param \WC_Product $product Product.
71 + * @throws \Exception If validation fails.
72 + */
73 + public function add_to_cart_validation_store_api( $product ) {
74 + $product_id = $product->get_id();
75 + $validation = $this->validate_cart_items( $product_id );
76 +
77 + if ( $validation['failed'] ) {
78 + $error_notice = empty( $validation['error_notice'] ) ? __( 'This product cannot be added to the cart.', 'subscription' ) : $validation['error_notice'];
79 + throw new \Exception( esc_html( $error_notice ) );
80 + }
81 + }
82 +
83 + /**
84 + * Validate cart items.
85 + *
86 + * @param int $product_id Product Id.
87 + * @return array
88 + */
89 + public function validate_cart_items( $product_id ) {
90 + /**
91 + * Filters whether the cart may hold more than one subscription, or a
92 + * subscription alongside a normal product, bypassing the restriction below.
93 + *
94 + * @param bool $allow Whether to allow it. Default false.
95 + * @param int $product_id Product being added to the cart.
96 + */
97 + if ( apply_filters( 'subscrpt_allow_multiple_subscriptions_in_cart', false, $product_id ) ) {
98 + return [
99 + 'failed' => false,
100 + 'error_notice' => null,
101 + ];
102 + }
103 +
104 + $cart_items = WC()->cart->cart_contents;
105 +
106 + $product = Subscription::get_subs_product( $product_id );
107 +
46 108 $error_notice = null;
47 109 $failed = false;
48 - $product = Subscription::get_subs_product( $product_id );
49 - $enabled = $product->is_enabled();
110 + // A tied plan counts as a subscription even without classic `_subscrpt_enabled`.
111 + $enabled = $product->is_enabled() || subscrpt_plan_offered( $product_id );
50 112
51 113 foreach ( $cart_items as $key => $cart_item ) {
52 114 if ( isset( $cart_item['subscription'] ) ) {
53 115 if ( $enabled ) {
@@ -61,14 +123,12 @@
61 123 $failed = true;
62 124 }
63 125 }
64 126
65 - if ( $failed ) {
66 - wc_add_notice( $error_notice, 'error' );
67 - return false;
68 - }
69 -
70 - return $passed;
127 + return [
128 + 'failed' => (bool) $failed,
129 + 'error_notice' => $error_notice,
130 + ];
71 131 }
72 132
73 133 /**
74 134 * Add filter before cart calculation.
@@ -142,8 +202,16 @@
142 202 }
143 203 $cart_items = WC()->cart->cart_contents;
144 204 if ( is_array( $cart_items ) ) {
145 205 foreach ( $cart_items as $key => $value ) {
206 + // Plan items were validated against the plan by the resolver at
207 + // add-to-cart; their `subscription` snapshot intentionally differs
208 + // from the product's classic meta, so skip the classic re-check
209 + // (which would otherwise drop them from the cart).
210 + if ( ! empty( $value['subscrpt_plan_id'] ) ) {
211 + continue;
212 + }
213 +
146 214 /**
147 215 * Product Object.
148 216 *
149 217 * @var \WC_Product $product
@@ -161,9 +229,9 @@
161 229 // remove the item.
162 230 wc_add_notice( __( 'An item which is no longer available was removed from your cart.', 'subscription' ), 'error' );
163 231 WC()->cart->remove_cart_item( $key );
164 232 }
165 - } elseif ( $product->get_meta( '_subscrpt_enabled' ) ) {
233 + } elseif ( $product->is_enabled() ) {
166 234 // remove the item.
167 235 wc_add_notice( __( 'An item which is no longer available was removed from your cart.', 'subscription' ), 'error' );
168 236 WC()->cart->remove_cart_item( $key );
169 237 }
@@ -208,38 +276,68 @@
208 276 'description' => __( 'List of recurring totals in cart.', 'subscription' ),
209 277 'type' => 'array',
210 278 'readonly' => true,
211 279 'recurring_totals' => array(
212 - 'price' => array(
213 - 'description' => __( 'price of the subscription.', 'subscription' ),
280 + 'price' => array(
281 + 'description' => __( 'price of the subscription, after any discount that applies to renewals.', 'subscription' ),
214 282 'type' => array( 'string' ),
215 283 'readonly' => true,
216 284 ),
217 - 'time' => array(
285 + 'full_price' => array(
286 + 'description' => __( 'price of the subscription before any discount.', 'subscription' ),
287 + 'type' => array( 'string' ),
288 + 'readonly' => true,
289 + ),
290 + 'first_price' => array(
291 + 'description' => __( 'amount charged today, after all discounts.', 'subscription' ),
292 + 'type' => array( 'string' ),
293 + 'readonly' => true,
294 + ),
295 + 'has_recurring_discount' => array(
296 + 'description' => __( 'Whether a discount applies to renewals.', 'subscription' ),
297 + 'type' => array( 'boolean' ),
298 + 'readonly' => true,
299 + ),
300 + 'has_one_time_discount' => array(
301 + 'description' => __( 'Whether a discount applies to the first payment only.', 'subscription' ),
302 + 'type' => array( 'boolean' ),
303 + 'readonly' => true,
304 + ),
305 + 'recurring_limit' => array(
306 + 'description' => __( 'Number of payments the recurring discount covers. 0 means unlimited.', 'subscription' ),
307 + 'type' => array( 'number' ),
308 + 'readonly' => true,
309 + ),
310 + 'time' => array(
218 311 'description' => __( 'time of the subscription.', 'subscription' ),
219 312 'type' => array( 'number' ),
220 313 'readonly' => true,
221 314 ),
222 - 'type' => array(
315 + 'type' => array(
223 316 'description' => __( 'type of the subscription.', 'subscription' ),
224 317 'type' => array( 'string' ),
225 318 'readonly' => true,
226 319 ),
227 - 'description' => array(
320 + 'description' => array(
228 321 'description' => __( 'price of the subscription description.', 'subscription' ),
229 322 'type' => array( 'string' ),
230 323 'readonly' => true,
231 324 ),
232 - 'can_user_cancel' => array(
325 + 'can_user_cancel' => array(
233 326 'description' => __( 'Allow User Cancellation?', 'subscription' ),
234 327 'type' => array( 'string' ),
235 328 'readonly' => true,
236 329 ),
237 - 'max_no_payment' => array(
330 + 'max_no_payment' => array(
238 331 'description' => __( 'Maximum Total Payments', 'subscription' ),
239 332 'type' => array( 'number' ),
240 333 'readonly' => true,
241 334 ),
335 + 'split_total' => array(
336 + 'description' => __( 'Total price for a split-payment plan (entered plan price).', 'subscription' ),
337 + 'type' => array( 'number', 'null' ),
338 + 'readonly' => true,
339 + ),
242 340 ),
243 341 ),
244 342 );
245 343 }
@@ -252,9 +350,9 @@
252 350 public function extend_cart_data() {
253 351 $cart_items = WC()->cart->cart_contents;
254 352 $recurrings = array();
255 353 if ( $cart_items ) {
256 - foreach ( $cart_items as $cart_item ) {
354 + foreach ( $cart_items as $cart_item_key => $cart_item ) {
257 355 if ( isset( $cart_item['subscription'] ) && $cart_item['subscription']['type'] ) {
258 356 $cart_subscription = $cart_item['subscription'];
259 357 $start_date = Helper::start_date( $cart_subscription['trial'] );
260 358 $next_date = Helper::next_date(
@@ -261,17 +359,15 @@
261 359 ( $cart_subscription['time'] ?? 1 ) . ' ' . $cart_subscription['type'],
262 360 $cart_subscription['trial']
263 361 );
264 362
265 - // Total amount with tax
266 - $product = $cart_item['data'];
267 - $quantity = (int) $cart_item['quantity'];
268 - $total_amount = wc_get_price_including_tax( $product, [ 'qty' => $quantity ] );
269 -
270 363 // Subscription timing & type
271 364 $time = $cart_subscription['time'];
272 365 $type = Helper::get_typos( $time, $cart_subscription['type'], true );
273 366
367 + // Discount-aware totals, shared with the classic cart.
368 + $price_data = Helper::build_cart_recurring_price_data( $cart_item, $cart_item_key, $type );
369 +
274 370 // Description
275 371 $description = empty( $cart_subscription['trial'] )
276 372 ? __( 'Next billing on', 'subscription' ) . ': ' . $next_date
277 373 : __( 'First billing on', 'subscription' ) . ': ' . $start_date;
@@ -278,14 +374,22 @@
278 374
279 375 $recurrings[] = apply_filters(
280 376 'subscrpt_cart_recurring_data',
281 377 array(
282 - 'price' => $total_amount,
283 - 'time' => $time,
284 - 'type' => $type,
285 - 'description' => $description,
286 - 'can_user_cancel' => $cart_item['data']->get_meta( '_subscrpt_user_cancel' ),
287 - 'max_no_payment' => $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
378 + 'price' => $price_data['total'],
379 + 'full_price' => $price_data['full_total'],
380 + 'first_price' => $price_data['first_total'],
381 + 'has_recurring_discount' => $price_data['has_recurring_discount'],
382 + 'has_one_time_discount' => $price_data['has_one_time_discount'],
383 + 'recurring_limit' => $price_data['recurring_limit'],
384 + 'time' => $time,
385 + 'type' => $type,
386 + 'description' => $description,
387 + 'can_user_cancel' => Helper::can_user_cancel( $cart_item['data']->get_meta( '_subscrpt_user_cancel' ) ) ? 'yes' : 'no',
388 + 'max_no_payment' => ! empty( $cart_item['subscrpt_max_no_payment'] )
389 + ? (int) $cart_item['subscrpt_max_no_payment']
390 + : $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
391 + 'split_total' => isset( $cart_item['subscrpt_split_total'] ) ? (float) $cart_item['subscrpt_split_total'] : null,
288 392 ),
289 393 $cart_item
290 394 );
291 395 }
@@ -355,8 +459,25 @@
355 459 if ( isset( $cart_item['subscription'] ) ) {
356 460 $item_data = $cart_item['subscription'];
357 461 unset( $item_data['per_cost'] );
358 462 $item_data['cost'] = (float) $cart_item['subscription']['per_cost'] * $cart_item['quantity'];
463 +
464 + // Plan items don't stamp the installment count into the subscription
465 + // array (it rides the cart item as subscrpt_max_no_payment); classic
466 + // products carry it on the product meta.
467 + if ( ! isset( $item_data['max_no_payment'] ) ) {
468 + $item_data['max_no_payment'] = ! empty( $cart_item['subscrpt_max_no_payment'] )
469 + ? (int) $cart_item['subscrpt_max_no_payment']
470 + : $cart_item['data']->get_meta( '_subscrpt_max_no_payment' );
471 + }
472 +
473 + // Normalise the cadence word to singular/plural by frequency for the
474 + // blocks (Store API) cart — plan items store the raw plural interval
475 + // (e.g. "months"), which the block would otherwise render as-is.
476 + if ( ! empty( $item_data['type'] ) ) {
477 + $sub_time = max( 1, (int) ( $item_data['time'] ?? 1 ) );
478 + $item_data['type'] = Helper::get_typos( $sub_time, $item_data['type'] );
479 + }
359 480 }
360 481 if ( ! subscrpt_pro_activated() ) {
361 482 $item_data['time'] = null;
362 483 $item_data['signup_fee'] = null;
@@ -377,8 +498,15 @@
377 498 $product = Subscription::get_subs_product( $product_id );
378 499 if ( ! $product->is_type( 'simple' ) ) {
379 500 return $cart_item_data;
380 501 }
502 + // Plan products stamp their subscription snapshot in the plan checkout
503 + // (Frontend\PlanCheckout / Pro), gated on the chosen plan id — so a One-Time
504 + // purchase of a plan product is not wrongly tagged as a subscription here
505 + // (which would show a cadence + list it under "Recurring totals").
506 + if ( subscrpt_product_has_plan( $product_id ) ) {
507 + return $cart_item_data;
508 + }
381 509 if ( $product->is_enabled() ) :
382 510 $subscription_data = array();
383 511 $subscription_data['time'] = null;
384 512 $subscription_data['type'] = $product->get_timing_option();
@@ -421,9 +549,12 @@
421 549 if ( ! $product->is_type( 'simple' ) ) {
422 550 return $price;
423 551 }
424 552
425 - if ( $product->is_enabled() ) {
553 + // A tied plan makes it a subscription even without classic `_subscrpt_enabled`;
554 + // get_price_html() already resolves to the plan line (Frontend\Plans), so this
555 + // shows the plan cadence on the cart line without doubling.
556 + if ( $product->is_enabled() || subscrpt_plan_offered( $product->get_id() ) ) {
426 557 return $product->get_price_html();
427 558 }
428 559
429 560 return $price;
@@ -461,9 +592,41 @@
461 592 echo esc_html( $billing_text . ': ' );
462 593 echo esc_html( $recurr['trial_status'] ? $recurr['start_date'] : $recurr['next_date'] );
463 594 ?>
464 595 </small>
465 -
596 +
597 + <?php if ( ! empty( $recurr['has_one_time_discount'] ) ) : ?>
598 + <br />
599 + <small>
600 + <?php
601 + echo wp_kses_post(
602 + sprintf(
603 + // translators: 1: amount paid today, 2: amount charged on each renewal.
604 + __( 'You pay %1$s today. %2$s will be charged from the next renewal.', 'subscription' ),
605 + wc_price( $recurr['first_total'] ?? 0 ),
606 + wc_price( $recurr['total'] ?? 0 )
607 + )
608 + );
609 + ?>
610 + </small>
611 + <?php endif; ?>
612 +
613 + <?php if ( ! empty( $recurr['has_recurring_discount'] ) && (int) ( $recurr['recurring_limit'] ?? 0 ) > 1 ) : ?>
614 + <br />
615 + <small>
616 + <?php
617 + echo wp_kses_post(
618 + sprintf(
619 + // translators: 1: number of discounted payments, 2: full price charged afterwards.
620 + __( 'Discount applies to your first %1$s payments. After that, %2$s.', 'subscription' ),
621 + number_format_i18n( (int) $recurr['recurring_limit'] ),
622 + $recurr['full_price_html'] ?? ''
623 + )
624 + );
625 + ?>
626 + </small>
627 + <?php endif; ?>
628 +
466 629 <?php if ( 'yes' === $recurr['can_user_cancel'] && 0 === (int) $recurr['max_no_payment'] ) : ?>
467 630 <br />
468 631 <small><?php esc_html_e( 'You can cancel subscription at any time!', 'subscription' ); ?></small>
469 632 <?php endif; ?>
@@ -477,9 +640,9 @@
477 640 sprintf(
478 641 // translators: 1: number of installments, 2: total amount.
479 642 __( 'This subscription will be billed in %1$s installments, for a total of %2$s.', 'subscription' ),
480 643 esc_html( $recurr['max_no_payment'] ),
481 - wc_price( $recurr['price'] * (int) $recurr['max_no_payment'] )
644 + wc_price( isset( $recurr['split_total'] ) && null !== $recurr['split_total'] ? $recurr['split_total'] : $recurr['price'] * (int) $recurr['max_no_payment'] )
482 645 )
483 646 );
484 647 ?>
485 648 </small>