PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
← All changes | includes/Frontend/Cart.php +183 -34 1.9.62.0.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,58 @@
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 + $cart_items = WC()->cart->cart_contents;
91 +
92 + $product = Subscription::get_subs_product( $product_id );
93 +
46 94 $error_notice = null;
47 95 $failed = false;
48 - $product = Subscription::get_subs_product( $product_id );
49 - $enabled = $product->is_enabled();
96 + // A tied plan counts as a subscription even without classic `_subscrpt_enabled`.
97 + $enabled = $product->is_enabled() || subscrpt_plan_offered( $product_id );
50 98
51 99 foreach ( $cart_items as $key => $cart_item ) {
52 100 if ( isset( $cart_item['subscription'] ) ) {
53 101 if ( $enabled ) {
@@ -61,14 +109,12 @@
61 109 $failed = true;
62 110 }
63 111 }
64 112
65 - if ( $failed ) {
66 - wc_add_notice( $error_notice, 'error' );
67 - return false;
68 - }
69 -
70 - return $passed;
113 + return [
114 + 'failed' => (bool) $failed,
115 + 'error_notice' => $error_notice,
116 + ];
71 117 }
72 118
73 119 /**
74 120 * Add filter before cart calculation.
@@ -142,8 +188,16 @@
142 188 }
143 189 $cart_items = WC()->cart->cart_contents;
144 190 if ( is_array( $cart_items ) ) {
145 191 foreach ( $cart_items as $key => $value ) {
192 + // Plan items were validated against the plan by the resolver at
193 + // add-to-cart; their `subscription` snapshot intentionally differs
194 + // from the product's classic meta, so skip the classic re-check
195 + // (which would otherwise drop them from the cart).
196 + if ( ! empty( $value['subscrpt_plan_id'] ) ) {
197 + continue;
198 + }
199 +
146 200 /**
147 201 * Product Object.
148 202 *
149 203 * @var \WC_Product $product
@@ -161,9 +215,9 @@
161 215 // remove the item.
162 216 wc_add_notice( __( 'An item which is no longer available was removed from your cart.', 'subscription' ), 'error' );
163 217 WC()->cart->remove_cart_item( $key );
164 218 }
165 - } elseif ( $product->get_meta( '_subscrpt_enabled' ) ) {
219 + } elseif ( $product->is_enabled() ) {
166 220 // remove the item.
167 221 wc_add_notice( __( 'An item which is no longer available was removed from your cart.', 'subscription' ), 'error' );
168 222 WC()->cart->remove_cart_item( $key );
169 223 }
@@ -208,38 +262,68 @@
208 262 'description' => __( 'List of recurring totals in cart.', 'subscription' ),
209 263 'type' => 'array',
210 264 'readonly' => true,
211 265 'recurring_totals' => array(
212 - 'price' => array(
213 - 'description' => __( 'price of the subscription.', 'subscription' ),
266 + 'price' => array(
267 + 'description' => __( 'price of the subscription, after any discount that applies to renewals.', 'subscription' ),
214 268 'type' => array( 'string' ),
215 269 'readonly' => true,
216 270 ),
217 - 'time' => array(
271 + 'full_price' => array(
272 + 'description' => __( 'price of the subscription before any discount.', 'subscription' ),
273 + 'type' => array( 'string' ),
274 + 'readonly' => true,
275 + ),
276 + 'first_price' => array(
277 + 'description' => __( 'amount charged today, after all discounts.', 'subscription' ),
278 + 'type' => array( 'string' ),
279 + 'readonly' => true,
280 + ),
281 + 'has_recurring_discount' => array(
282 + 'description' => __( 'Whether a discount applies to renewals.', 'subscription' ),
283 + 'type' => array( 'boolean' ),
284 + 'readonly' => true,
285 + ),
286 + 'has_one_time_discount' => array(
287 + 'description' => __( 'Whether a discount applies to the first payment only.', 'subscription' ),
288 + 'type' => array( 'boolean' ),
289 + 'readonly' => true,
290 + ),
291 + 'recurring_limit' => array(
292 + 'description' => __( 'Number of payments the recurring discount covers. 0 means unlimited.', 'subscription' ),
293 + 'type' => array( 'number' ),
294 + 'readonly' => true,
295 + ),
296 + 'time' => array(
218 297 'description' => __( 'time of the subscription.', 'subscription' ),
219 298 'type' => array( 'number' ),
220 299 'readonly' => true,
221 300 ),
222 - 'type' => array(
301 + 'type' => array(
223 302 'description' => __( 'type of the subscription.', 'subscription' ),
224 303 'type' => array( 'string' ),
225 304 'readonly' => true,
226 305 ),
227 - 'description' => array(
306 + 'description' => array(
228 307 'description' => __( 'price of the subscription description.', 'subscription' ),
229 308 'type' => array( 'string' ),
230 309 'readonly' => true,
231 310 ),
232 - 'can_user_cancel' => array(
311 + 'can_user_cancel' => array(
233 312 'description' => __( 'Allow User Cancellation?', 'subscription' ),
234 313 'type' => array( 'string' ),
235 314 'readonly' => true,
236 315 ),
237 - 'max_no_payment' => array(
316 + 'max_no_payment' => array(
238 317 'description' => __( 'Maximum Total Payments', 'subscription' ),
239 318 'type' => array( 'number' ),
240 319 'readonly' => true,
241 320 ),
321 + 'split_total' => array(
322 + 'description' => __( 'Total price for a split-payment plan (entered plan price).', 'subscription' ),
323 + 'type' => array( 'number', 'null' ),
324 + 'readonly' => true,
325 + ),
242 326 ),
243 327 ),
244 328 );
245 329 }
@@ -252,9 +336,9 @@
252 336 public function extend_cart_data() {
253 337 $cart_items = WC()->cart->cart_contents;
254 338 $recurrings = array();
255 339 if ( $cart_items ) {
256 - foreach ( $cart_items as $cart_item ) {
340 + foreach ( $cart_items as $cart_item_key => $cart_item ) {
257 341 if ( isset( $cart_item['subscription'] ) && $cart_item['subscription']['type'] ) {
258 342 $cart_subscription = $cart_item['subscription'];
259 343 $start_date = Helper::start_date( $cart_subscription['trial'] );
260 344 $next_date = Helper::next_date(
@@ -261,17 +345,15 @@
261 345 ( $cart_subscription['time'] ?? 1 ) . ' ' . $cart_subscription['type'],
262 346 $cart_subscription['trial']
263 347 );
264 348
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 349 // Subscription timing & type
271 350 $time = $cart_subscription['time'];
272 351 $type = Helper::get_typos( $time, $cart_subscription['type'], true );
273 352
353 + // Discount-aware totals, shared with the classic cart.
354 + $price_data = Helper::build_cart_recurring_price_data( $cart_item, $cart_item_key, $type );
355 +
274 356 // Description
275 357 $description = empty( $cart_subscription['trial'] )
276 358 ? __( 'Next billing on', 'subscription' ) . ': ' . $next_date
277 359 : __( 'First billing on', 'subscription' ) . ': ' . $start_date;
@@ -278,14 +360,22 @@
278 360
279 361 $recurrings[] = apply_filters(
280 362 'subscrpt_cart_recurring_data',
281 363 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' ),
364 + 'price' => $price_data['total'],
365 + 'full_price' => $price_data['full_total'],
366 + 'first_price' => $price_data['first_total'],
367 + 'has_recurring_discount' => $price_data['has_recurring_discount'],
368 + 'has_one_time_discount' => $price_data['has_one_time_discount'],
369 + 'recurring_limit' => $price_data['recurring_limit'],
370 + 'time' => $time,
371 + 'type' => $type,
372 + 'description' => $description,
373 + 'can_user_cancel' => $cart_item['data']->get_meta( '_subscrpt_user_cancel' ),
374 + 'max_no_payment' => ! empty( $cart_item['subscrpt_max_no_payment'] )
375 + ? (int) $cart_item['subscrpt_max_no_payment']
376 + : $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
377 + 'split_total' => isset( $cart_item['subscrpt_split_total'] ) ? (float) $cart_item['subscrpt_split_total'] : null,
288 378 ),
289 379 $cart_item
290 380 );
291 381 }
@@ -355,8 +445,25 @@
355 445 if ( isset( $cart_item['subscription'] ) ) {
356 446 $item_data = $cart_item['subscription'];
357 447 unset( $item_data['per_cost'] );
358 448 $item_data['cost'] = (float) $cart_item['subscription']['per_cost'] * $cart_item['quantity'];
449 +
450 + // Plan items don't stamp the installment count into the subscription
451 + // array (it rides the cart item as subscrpt_max_no_payment); classic
452 + // products carry it on the product meta.
453 + if ( ! isset( $item_data['max_no_payment'] ) ) {
454 + $item_data['max_no_payment'] = ! empty( $cart_item['subscrpt_max_no_payment'] )
455 + ? (int) $cart_item['subscrpt_max_no_payment']
456 + : $cart_item['data']->get_meta( '_subscrpt_max_no_payment' );
457 + }
458 +
459 + // Normalise the cadence word to singular/plural by frequency for the
460 + // blocks (Store API) cart — plan items store the raw plural interval
461 + // (e.g. "months"), which the block would otherwise render as-is.
462 + if ( ! empty( $item_data['type'] ) ) {
463 + $sub_time = max( 1, (int) ( $item_data['time'] ?? 1 ) );
464 + $item_data['type'] = Helper::get_typos( $sub_time, $item_data['type'] );
465 + }
359 466 }
360 467 if ( ! subscrpt_pro_activated() ) {
361 468 $item_data['time'] = null;
362 469 $item_data['signup_fee'] = null;
@@ -377,8 +484,15 @@
377 484 $product = Subscription::get_subs_product( $product_id );
378 485 if ( ! $product->is_type( 'simple' ) ) {
379 486 return $cart_item_data;
380 487 }
488 + // Plan products stamp their subscription snapshot in the plan checkout
489 + // (Frontend\PlanCheckout / Pro), gated on the chosen plan id — so a One-Time
490 + // purchase of a plan product is not wrongly tagged as a subscription here
491 + // (which would show a cadence + list it under "Recurring totals").
492 + if ( subscrpt_product_has_plan( $product_id ) ) {
493 + return $cart_item_data;
494 + }
381 495 if ( $product->is_enabled() ) :
382 496 $subscription_data = array();
383 497 $subscription_data['time'] = null;
384 498 $subscription_data['type'] = $product->get_timing_option();
@@ -421,9 +535,12 @@
421 535 if ( ! $product->is_type( 'simple' ) ) {
422 536 return $price;
423 537 }
424 538
425 - if ( $product->is_enabled() ) {
539 + // A tied plan makes it a subscription even without classic `_subscrpt_enabled`;
540 + // get_price_html() already resolves to the plan line (Frontend\Plans), so this
541 + // shows the plan cadence on the cart line without doubling.
542 + if ( $product->is_enabled() || subscrpt_plan_offered( $product->get_id() ) ) {
426 543 return $product->get_price_html();
427 544 }
428 545
429 546 return $price;
@@ -461,9 +578,41 @@
461 578 echo esc_html( $billing_text . ': ' );
462 579 echo esc_html( $recurr['trial_status'] ? $recurr['start_date'] : $recurr['next_date'] );
463 580 ?>
464 581 </small>
465 -
582 +
583 + <?php if ( ! empty( $recurr['has_one_time_discount'] ) ) : ?>
584 + <br />
585 + <small>
586 + <?php
587 + echo wp_kses_post(
588 + sprintf(
589 + // translators: 1: amount paid today, 2: amount charged on each renewal.
590 + __( 'You pay %1$s today. %2$s will be charged from the next renewal.', 'subscription' ),
591 + wc_price( $recurr['first_total'] ?? 0 ),
592 + wc_price( $recurr['total'] ?? 0 )
593 + )
594 + );
595 + ?>
596 + </small>
597 + <?php endif; ?>
598 +
599 + <?php if ( ! empty( $recurr['has_recurring_discount'] ) && (int) ( $recurr['recurring_limit'] ?? 0 ) > 1 ) : ?>
600 + <br />
601 + <small>
602 + <?php
603 + echo wp_kses_post(
604 + sprintf(
605 + // translators: 1: number of discounted payments, 2: full price charged afterwards.
606 + __( 'Discount applies to your first %1$s payments. After that, %2$s.', 'subscription' ),
607 + number_format_i18n( (int) $recurr['recurring_limit'] ),
608 + $recurr['full_price_html'] ?? ''
609 + )
610 + );
611 + ?>
612 + </small>
613 + <?php endif; ?>
614 +
466 615 <?php if ( 'yes' === $recurr['can_user_cancel'] && 0 === (int) $recurr['max_no_payment'] ) : ?>
467 616 <br />
468 617 <small><?php esc_html_e( 'You can cancel subscription at any time!', 'subscription' ); ?></small>
469 618 <?php endif; ?>
@@ -477,9 +626,9 @@
477 626 sprintf(
478 627 // translators: 1: number of installments, 2: total amount.
479 628 __( 'This subscription will be billed in %1$s installments, for a total of %2$s.', 'subscription' ),
480 629 esc_html( $recurr['max_no_payment'] ),
481 - wc_price( $recurr['price'] * (int) $recurr['max_no_payment'] )
630 + wc_price( isset( $recurr['split_total'] ) && null !== $recurr['split_total'] ? $recurr['split_total'] : $recurr['price'] * (int) $recurr['max_no_payment'] )
482 631 )
483 632 );
484 633 ?>
485 634 </small>