PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.11.1
Subscriptions for WooCommerce with Stripe Recurring Payments v1.11.1
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
subscription / includes / Illuminate / Helper.php

Helper.php in Subscriptions for WooCommerce with Stripe Recurring Payments 1.11.1, at includes/Illuminate/Helper.php

1,751 lines 61.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SpringDevs\Subscription\Illuminate;
4
5 use SpringDevs\Subscription\Illuminate\Gateways\Stripe\Stripe;
6 use SpringDevs\Subscription\Illuminate\Subscription\Subscription;
7
8 // HPOS: This file is compatible with WooCommerce High-Performance Order Storage (HPOS).
9 // All WooCommerce order data is accessed via WooCommerce CRUD methods (wc_get_order, wc_get_orders, etc.).
10 // All direct post meta access is for subscription data only, not WooCommerce order data.
11 // If you add new order data access, use WooCommerce CRUD for HPOS compatibility.
12
13 /**
14 * Class Helper || Some Helper Methods
15 *
16 * @package SpringDevs\Subscription\Illuminate
17 */
18 class Helper {
19 /**
20 * Get type's singular or plural from time_per.
21 *
22 * @param int $number timing_per.
23 * @param string $typo timing_option.
24 * @param bool $translate Whether to translate the output.
25 * @return string
26 */
27 public static function get_typos( $number, $typo, $translate = false ) {
28 switch ( strtolower( $typo ) ) {
29 case 'day':
30 case 'days':
31 return $translate
32 ? _n( 'day', 'days', $number, 'subscription' )
33 : ( (int) $number === 1 ? 'day' : 'days' );
34
35 case 'week':
36 case 'weeks':
37 return $translate
38 ? _n( 'week', 'weeks', $number, 'subscription' )
39 : ( (int) $number === 1 ? 'week' : 'weeks' );
40
41 case 'month':
42 case 'months':
43 return $translate
44 ? _n( 'month', 'months', $number, 'subscription' )
45 : ( (int) $number === 1 ? 'month' : 'months' );
46
47 case 'year':
48 case 'years':
49 return $translate
50 ? _n( 'year', 'years', $number, 'subscription' )
51 : ( (int) $number === 1 ? 'year' : 'years' );
52
53 default:
54 return $typo;
55 }
56 }
57
58 /**
59 * Get verbose status from status slug.
60 *
61 * @param string $status Status.
62 * @param bool $return_all Whether to return all statuses or a single status.
63 */
64 public static function get_verbose_status( $status, $return_all = false ): string|array {
65 $statuses = array(
66 'pending' => __( 'Pending', 'subscription' ),
67 'active' => __( 'Active', 'subscription' ),
68 'on-hold' => __( 'On Hold', 'subscription' ),
69 'expired' => __( 'Expired', 'subscription' ),
70 'pe_cancelled' => __( 'Pending Cancellation', 'subscription' ),
71 'cancelled' => __( 'Cancelled', 'subscription' ),
72 'draft' => __( 'Draft', 'subscription' ),
73 'trash' => __( 'Trash', 'subscription' ),
74 );
75
76 if ( $return_all ) {
77 return $statuses;
78 }
79
80 $status = strtolower( $status );
81 return isset( $statuses[ $status ] ) ? $statuses[ $status ] : '';
82 }
83
84 /**
85 * Generate start date
86 *
87 * @param null|string $trial Trial.
88 *
89 * @return string
90 */
91 public static function start_date( $trial = null ) {
92 if ( null === $trial ) {
93 $start_date = time();
94 } else {
95 $start_date = strtotime( $trial );
96 }
97 return wp_date( get_option( 'date_format' ), $start_date );
98 }
99
100 /**
101 * Generate next date
102 *
103 * @param string $time Time.
104 * @param null|string $trial Trial.
105 *
106 * @return string
107 */
108 public static function next_date( $time, $trial = null ) {
109 if ( null === $trial ) {
110 $start_date = time();
111 } else {
112 $start_date = strtotime( $trial );
113 }
114 return wp_date( get_option( 'date_format' ), strtotime( $time, $start_date ) );
115 }
116
117 /**
118 * Get Subscriptions
119 *
120 * Args:
121 * - status => [ any, active, pending, expired, pe_cancelled, cancelled, trash ]
122 * - user_id => user_id, -1 for all users.
123 * - posts_per_page => limit number of subscriptions.
124 * - return => return data: ids, post, subscription_data
125 *
126 * @param array $args Args.
127 */
128 public static function get_subscriptions( array $args = array() ) {
129 $default_args = array(
130 'post_type' => 'subscrpt_order',
131 'post_status' => 'active',
132 'author' => get_current_user_id(),
133 'posts_per_page' => -1,
134 'fields' => 'all',
135 'return' => 'post',
136 );
137
138 // Normalize some args.
139 if ( isset( $args['status'] ) ) {
140 $args['post_status'] = $args['status'];
141 unset( $args['status'] );
142 }
143 if ( isset( $args['user_id'] ) ) {
144 $args['author'] = $args['user_id'];
145 unset( $args['user_id'] );
146 }
147
148 // Merge default args with provided args.
149 $final_args = wp_parse_args( $args, $default_args );
150
151 if ( isset( $args['author'] ) ) {
152 if ( $args['author'] === -1 ) {
153 unset( $final_args['author'] );
154 } else {
155 $final_args['author'] = (int) $args['author'];
156 }
157 }
158
159 if ( isset( $args['product_id'] ) ) {
160 $final_args['meta_query'] = array(
161 array(
162 'key' => '_subscrpt_product_id',
163 'value' => (int) $args['product_id'],
164 ),
165 );
166 unset( $final_args['product_id'] );
167 }
168
169 // Fields check
170 $only_ids = false;
171 if ( $final_args['fields'] === 'ids' || $final_args['return'] === 'ids' ) {
172 $final_args['fields'] = 'all';
173 $only_ids = true;
174 }
175
176 // Status check
177 $statuses = $final_args['post_status'];
178 $final_args['post_status'] = 'any';
179
180 // Get all subscriptions.
181 $subscriptions = get_posts( $final_args );
182
183 // Fallback filtering.
184 // ? Sometime status filtering not works properly. So, we need to filter manually.
185 $filtered_subscriptions = [];
186
187 // Filter by status.
188 foreach ( $subscriptions as $subscription ) {
189 if ( ( is_array( $statuses ) && in_array( 'any', $statuses, true ) ) || $statuses === 'any' ) {
190 $filtered_subscriptions[] = $subscription;
191 continue;
192 }
193
194 if ( ( is_array( $statuses ) && in_array( $subscription->post_status, $statuses, true ) ) || $subscription->post_status === $statuses ) {
195 $filtered_subscriptions[] = $subscription;
196 }
197 }
198
199 // Final filtering (only ids, post, or full data)
200 $subscriptions = [];
201 foreach ( $filtered_subscriptions as $subscription ) {
202 if ( $only_ids ) {
203 $subscriptions[] = $subscription->ID;
204 } elseif ( $final_args['return'] === 'subscription_data' ) {
205 $subs_id = $subscription->ID;
206 $subscription_data = self::get_subscription_data( $subs_id );
207 $subscriptions[] = $subscription_data;
208 } else {
209 $subscriptions[] = $subscription;
210 }
211 }
212
213 return $subscriptions;
214 }
215
216 /**
217 * Check subscription exists by product ID.
218 *
219 * @param int $product_id Product ID.
220 * @param string|array $status Status.
221 *
222 * @return \WP_Post | false
223 */
224 public static function subscription_exists( int $product_id, $status ) {
225 if ( 0 === get_current_user_id() ) {
226 return false;
227 }
228
229 $args = array(
230 'post_status' => $status,
231 'fields' => 'ids',
232 'product_id' => $product_id,
233 );
234
235 $posts = self::get_subscriptions( $args );
236 return count( $posts ) > 0 ? $posts[0] : false;
237 }
238
239 /**
240 * Check if product trial exixts for an user.
241 *
242 * @param int $product_id Product ID.
243 *
244 * @return boolean
245 */
246 public static function check_trial( int $product_id ): bool {
247 return ! self::subscription_exists( $product_id, array( 'expired', 'pending', 'active', 'on-hold', 'pe_cancelled', 'cancelled' ) );
248 }
249
250 /**
251 * Rewew when expired.
252 *
253 * @param int $subscription_id Subscription ID.
254 */
255 public static function renew( int $subscription_id ) {
256 $trial = get_post_meta( $subscription_id, '_subscrpt_trial', true );
257 if ( null !== $trial ) {
258 update_post_meta( $subscription_id, '_subscrpt_trial', null );
259 }
260
261 do_action( 'subscrpt_when_product_expired', $subscription_id, true );
262 }
263
264 /**
265 * Get Subscriptions Histories
266 *
267 * @param int $order_id Order ID.
268 */
269 public static function get_subscriptions_from_order( $order_id ) {
270 global $wpdb;
271 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
272 $histories = $wpdb->get_results(
273 $wpdb->prepare(
274 // @phpcs:ignore
275 'SELECT * FROM %i WHERE order_id=%d',
276 array( $table_name, $order_id )
277 )
278 );
279
280 return $histories;
281 }
282
283 /**
284 * Get Subscriptions Histories
285 *
286 * @param int $order_item_id Order item ID.
287 */
288 public static function get_subscription_from_order_item_id( $order_item_id ) {
289 global $wpdb;
290 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
291 return $wpdb->get_row(
292 $wpdb->prepare(
293 // @phpcs:ignore
294 'SELECT * FROM %i WHERE order_item_id=%d',
295 array( $table_name, $order_item_id )
296 )
297 );
298 }
299
300 /**
301 * Format price with Subscription
302 *
303 * @param string $price Price.
304 * @param int $subscription_id Subscription ID.
305 * @param bool $display_trial True/False.
306 *
307 * @return string
308 */
309 public static function format_price_with_subscription( $price, $subscription_id, $display_trial = false ) {
310 $order_id = get_post_meta( $subscription_id, '_subscrpt_order_id', true );
311 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
312 $item_meta = wc_get_order_item_meta( $order_item_id, '_subscrpt_meta', true );
313
314 $order = wc_get_order( $order_id );
315 $time = '1' === $item_meta['time'] ? null : $item_meta['time'] . ' ';
316 $type = self::get_typos( $item_meta['time'], $item_meta['type'] );
317
318 $formatted_price = wc_price(
319 $price,
320 array(
321 'currency' => $order->get_currency(),
322 )
323 ) . ' / ' . $time . $type;
324
325 if ( $display_trial ) {
326 $trial = $item_meta['trial'];
327 $has_trial = isset( $item_meta['trial'] ) && strlen( $item_meta['trial'] ) > 2;
328
329 if ( $has_trial ) {
330 $trial_html = '<br/><small> + Got ' . $trial . ' free trial!</small>';
331 $formatted_price .= $trial_html;
332 }
333 }
334
335 return apply_filters( 'subscrpt_format_price_with_subscription', $formatted_price, $price, $subscription_id );
336 }
337
338 /**
339 * Format price with order item
340 *
341 * @param string $price Price.
342 * @param int $item_id Item Id.
343 * @param bool $display_trial display trial?.
344 *
345 * @return string
346 */
347 public static function format_price_with_order_item( $price, $item_id, $display_trial = false ) {
348 $order_id = wc_get_order_id_by_order_item_id( $item_id );
349 $order = wc_get_order( $order_id );
350
351 $item_meta = wc_get_order_item_meta( $item_id, '_subscrpt_meta', true );
352
353 if ( ! $item_meta || ! is_array( $item_meta ) ) {
354 return false;
355 }
356
357 $time = 1 === (int) $item_meta['time'] ? null : $item_meta['time'] . '-';
358 $type = self::get_typos( $item_meta['time'], $item_meta['type'], true );
359
360 $formatted_price = wc_price(
361 $price,
362 array(
363 'currency' => $order->get_currency(),
364 )
365 ) . ' / ' . $time . ucfirst( $type );
366
367 if ( $display_trial ) {
368 $has_trial = isset( $item_meta['trial'] ) && strlen( $item_meta['trial'] ) > 2;
369 $trial = $item_meta['trial'] ?? '';
370
371 if ( $has_trial ) {
372 // translators: %s: trial period.
373 $trial_html = '<br/><small> ' . sprintf( __( '+ %s free trial!', 'subscription' ), $trial ) . '</small>';
374 $formatted_price .= $trial_html;
375 }
376 }
377
378 return apply_filters( 'subscrpt_format_price_with_subscription', $formatted_price, $price, $item_id );
379 }
380
381 /**
382 * Get total subscriptions by product ID.
383 *
384 * @param int $product_id Product ID.
385 * @param string | array $status Status.
386 *
387 * @return \WP_Post | false
388 */
389 public static function get_total_subscriptions_from_product( int $product_id, $status = array( 'active', 'pending', 'expired', 'pe_cancelled', 'cancelled' ) ) {
390 $args = array(
391 'post_type' => 'subscrpt_order',
392 'post_status' => $status,
393 'fields' => 'ids',
394 'meta_query' => array(
395 array(
396 'key' => '_subscrpt_product_id',
397 'value' => $product_id,
398 ),
399 ),
400 );
401
402 $posts = get_posts( $args );
403
404 return count( $posts );
405 }
406
407 /**
408 * Process renewal on order.
409 *
410 * @param int $subscription_id Subscription Id.
411 * @param int $order_id Order Id.
412 * @param int $order_item_id Order Item Id.
413 *
414 * @return void
415 */
416 public static function process_order_renewal( $subscription_id, $order_id, $order_item_id ) {
417 global $wpdb;
418 $history_table = $wpdb->prefix . 'subscrpt_order_relation';
419
420 // Check if this is a split payment subscription
421 $payment_type = function_exists( 'subscrpt_get_payment_type' ) ? subscrpt_get_payment_type( $subscription_id ) : 'recurring';
422 $max_payments = function_exists( 'subscrpt_get_max_payments' ) ? subscrpt_get_max_payments( $subscription_id ) : 0;
423 $payments_made = function_exists( 'subscrpt_count_payments_made' ) ? subscrpt_count_payments_made( $subscription_id ) : 0;
424
425 $comment_content = '';
426 $activity_type = '';
427
428 if ( 'split_payment' === $payment_type && $max_payments ) {
429 $comment_content = sprintf(
430 /* translators: %1$s: order id, %2$d: payment number, %3$d: total payments */
431 __( 'Split payment installment %2$d of %3$d. Order %1$s created for subscription.', 'subscription' ),
432 $order_id,
433 $payments_made + 1, // +1 because this is a new renewal
434 $max_payments
435 );
436 $activity_type = __( 'Split Payment - Renewal', 'subscription' );
437 } else {
438 $comment_content = sprintf(
439 /* translators: order id. */
440 __( 'The order %s has been created for the subscription', 'subscription' ),
441 $order_id
442 );
443 $activity_type = __( 'Renewal Order', 'subscription' );
444 }
445
446 $comment_id = wp_insert_comment(
447 array(
448 'comment_author' => 'Subscription for WooCommerce',
449 'comment_content' => $comment_content,
450 'comment_post_ID' => $subscription_id,
451 'comment_type' => 'order_note',
452 )
453 );
454 update_comment_meta( $comment_id, '_subscrpt_activity', $activity_type );
455 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'renewal_order' );
456
457 $wpdb->insert(
458 $history_table,
459 array(
460 'subscription_id' => $subscription_id,
461 'order_id' => $order_id,
462 'order_item_id' => $order_item_id,
463 'type' => 'renew',
464 )
465 );
466
467 // Fire action when split payment is renewed
468 do_action( 'subscrpt_split_payment_renewed', $subscription_id, $order_id, $order_item_id );
469 }
470
471 /**
472 * Process new subscription on order.
473 *
474 * @param \WC_Order_Item $order_item Order Item.
475 * @param string $post_status status.
476 * @param \WC_Product $product Product.
477 *
478 * @return int
479 */
480 public static function process_new_subscription_order( $order_item, $post_status, $product ) {
481 global $wpdb;
482 $history_table = $wpdb->prefix . 'subscrpt_order_relation';
483
484 // Prepare split payment arguments
485 $split_payment_args = array(
486 'product_id' => $product->get_id(),
487 'order_id' => $order_item->get_order_id(),
488 'order_item_id' => $order_item->get_id(),
489 'post_status' => $post_status,
490 'max_payments' => $product->get_meta( '_subscrpt_max_no_payment' ),
491 'timing_per' => $product->get_meta( '_subscrpt_timing_per' ),
492 'timing_option' => $product->get_meta( '_subscrpt_timing_option' ),
493 'price' => $product->get_price(),
494 );
495
496 // Allow modification of split payment arguments
497 $split_payment_args = apply_filters( 'subscrpt_split_payment_args', $split_payment_args, $order_item, $product );
498
499 // Own the subscription from the order's customer, not the current user.
500 $parent_order = wc_get_order( $order_item->get_order_id() );
501
502 $args = array(
503 'post_title' => 'Subscription',
504 'post_type' => 'subscrpt_order',
505 'post_status' => $split_payment_args['post_status'],
506 'post_author' => $parent_order ? (int) $parent_order->get_customer_id() : get_current_user_id(),
507 );
508 $subscription_id = wp_insert_post( $args );
509 wp_update_post(
510 array(
511 'ID' => $subscription_id,
512 'post_title' => "Subscription #{$subscription_id}",
513 )
514 );
515 // Check if this is a split payment subscription
516 $payment_type = $product->get_meta( '_subscrpt_payment_type' ) ?: 'recurring';
517 $max_payments = $product->get_meta( '_subscrpt_max_no_payment' );
518
519 $comment_content = '';
520 $activity_type = '';
521
522 if ( 'split_payment' === $payment_type && $max_payments ) {
523 $comment_content = sprintf(
524 /* translators: %1$s: order id, %2$d: max payments */
525 __( 'Split payment subscription created successfully. Order: %1$s. Total installments: %2$d.', 'subscription' ),
526 $order_item->get_order_id(),
527 $max_payments
528 );
529 $activity_type = __( 'Split Payment - New Subscription', 'subscription' );
530 } else {
531 $comment_content = sprintf(
532 /* translators: Order Id. */
533 __( 'Subscription successfully created. Order is %s', 'subscription' ),
534 $order_item->get_order_id()
535 );
536 $activity_type = __( 'New Subscription', 'subscription' );
537 }
538
539 $comment_id = wp_insert_comment(
540 array(
541 'comment_author' => 'Subscription for WooCommerce',
542 'comment_content' => $comment_content,
543 'comment_post_ID' => $subscription_id,
544 'comment_type' => 'order_note',
545 )
546 );
547 update_comment_meta( $comment_id, '_subscrpt_activity', $activity_type );
548 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'subs_created' );
549
550 update_post_meta( $subscription_id, '_subscrpt_product_id', $product->get_id() );
551
552 $wpdb->insert(
553 $history_table,
554 array(
555 'subscription_id' => $subscription_id,
556 'order_id' => $order_item->get_order_id(),
557 'order_item_id' => $order_item->get_id(),
558 'type' => 'new',
559 )
560 );
561
562 // Fire action when split payment plan is created
563 do_action( 'subscrpt_split_payment_created', $subscription_id, $split_payment_args, $order_item );
564
565 return $subscription_id;
566 }
567
568 /**
569 * Resolve applied coupon discounts for a single cart item, split by whether they recur.
570 *
571 * WooCommerce computes a per-coupon, per-item discount breakdown while calculating cart
572 * totals but never persists it — only the aggregated per-coupon and per-item sums survive.
573 * Replaying the coupons through a fresh WC_Discounts instance recovers that breakdown,
574 * which is what lets recurring and one-time discounts be told apart for one cart line.
575 *
576 * Amounts are returned in the same space WC_Discounts works in, i.e. `get_price() * qty`,
577 * so they are tax-inclusive only when the store's prices include tax.
578 *
579 * @param string $cart_item_key Cart item key.
580 *
581 * @return array{recurring:float,non_recurring:float,total:float,recurring_limit:int}
582 */
583 public static function get_cart_item_coupon_discounts( $cart_item_key ) {
584 $empty = array(
585 'recurring' => 0.0,
586 'non_recurring' => 0.0,
587 'total' => 0.0,
588 'recurring_limit' => 0,
589 );
590
591 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
592 return $empty;
593 }
594
595 $coupons = WC()->cart->get_coupons();
596 if ( empty( $coupons ) ) {
597 return $empty;
598 }
599
600 // Memoized per cart state: recurring totals are read several times per request, and
601 // replaying coupons re-runs coupon validation, which hits the database.
602 static $cache = array();
603
604 $cache_key = md5( WC()->cart->get_cart_hash() . '|' . implode( ',', array_keys( $coupons ) ) );
605
606 if ( ! isset( $cache[ $cache_key ] ) ) {
607 // Replay in cart order so stacked coupons resolve exactly as WC_Cart_Totals resolved them.
608 $discounts = new \WC_Discounts( WC()->cart );
609 foreach ( $coupons as $coupon ) {
610 $discounts->apply_coupon( $coupon );
611 }
612 $cache[ $cache_key ] = $discounts->get_discounts();
613 }
614
615 $result = $empty;
616 $limits = array();
617
618 foreach ( $cache[ $cache_key ] as $coupon_code => $item_discounts ) {
619 $amount = (float) ( $item_discounts[ $cart_item_key ] ?? 0 );
620 if ( 0 >= $amount ) {
621 continue;
622 }
623
624 $coupon = $coupons[ $coupon_code ] ?? new \WC_Coupon( $coupon_code );
625
626 /**
627 * Filters whether a coupon's discount also applies to subscription renewals.
628 *
629 * The free plugin has no recurring-coupon concept, so discounts apply to the
630 * first payment only unless an extension — the pro plugin — says otherwise.
631 *
632 * @param bool $is_recurring Whether the discount recurs. Default false.
633 * @param \WC_Coupon $coupon Coupon object.
634 * @param string $cart_item_key Cart item key the discount applies to.
635 */
636 $is_recurring = (bool) apply_filters( 'subscrpt_coupon_is_recurring', false, $coupon, $cart_item_key );
637
638 if ( ! $is_recurring ) {
639 $result['non_recurring'] += $amount;
640 continue;
641 }
642
643 /**
644 * Filters how many payments a recurring coupon's discount covers.
645 *
646 * @param int $limit Number of payments, including the initial one. 0 means unlimited.
647 * @param \WC_Coupon $coupon Coupon object.
648 * @param string $cart_item_key Cart item key the discount applies to.
649 */
650 $limit = (int) apply_filters( 'subscrpt_coupon_recurring_limit', 0, $coupon, $cart_item_key );
651
652 // A limit of one covers the initial payment only, so it never reaches a renewal —
653 // whatever the coupon is flagged as, its effect here is a one-time discount.
654 if ( 1 === $limit ) {
655 $result['non_recurring'] += $amount;
656 continue;
657 }
658
659 $result['recurring'] += $amount;
660
661 if ( $limit > 0 ) {
662 $limits[] = $limit;
663 }
664 }
665
666 $result['total'] = $result['recurring'] + $result['non_recurring'];
667
668 // The earliest limit to expire is when the discounted figure stops being true.
669 $result['recurring_limit'] = empty( $limits ) ? 0 : min( $limits );
670
671 return $result;
672 }
673
674 /**
675 * Build the discount-aware recurring price figures and markup for one cart item.
676 *
677 * A recurring coupon lowers what every renewal costs, so it is folded into the recurring
678 * figures. A one-time coupon lowers only what is paid today, so the recurring figures keep
679 * the full price and the caller discloses the first-payment amount separately.
680 *
681 * Discounts come back from WC_Discounts in `get_price() * qty` space, so they are converted
682 * to the tax-inclusive display space by ratio rather than by re-deriving tax.
683 *
684 * @param array $cart_item Cart item.
685 * @param string $cart_item_key Cart item key.
686 * @param string $type_label Human readable timing label, e.g. "Month".
687 *
688 * @return array
689 */
690 public static function build_cart_recurring_price_data( $cart_item, $cart_item_key, $type_label ) {
691 $product = $cart_item['data'];
692 $quantity = (int) $cart_item['quantity'];
693 $per_cost = (float) ( $cart_item['subscription']['per_cost'] ?? 0 );
694
695 $full_total = (float) wc_get_price_including_tax( $product, [ 'qty' => $quantity ] );
696 $discounts = self::get_cart_item_coupon_discounts( $cart_item_key );
697
698 // Same basis WC_Discounts used, so the discount and the basis are directly comparable.
699 $basis = (float) $product->get_price() * $quantity;
700 $recurring_ratio = $basis > 0 ? ( $basis - $discounts['recurring'] ) / $basis : 1.0;
701 $first_ratio = $basis > 0 ? ( $basis - $discounts['total'] ) / $basis : 1.0;
702
703 $total = $full_total * $recurring_ratio;
704 $timing_html = "<span class='wpsubs-subscription-timing'>&nbsp;/&nbsp;{$type_label}</span>";
705
706 $has_recurring_discount = $discounts['recurring'] > 0;
707 $full_price_html = wc_price( $full_total ) . $timing_html;
708 $price_html = $has_recurring_discount
709 ? '<del aria-hidden="true">' . wc_price( $full_total ) . '</del> <ins>' . wc_price( $total ) . '</ins>' . $timing_html
710 : $full_price_html;
711
712 return array(
713 'price_html' => $price_html,
714 'full_price_html' => $full_price_html,
715 'price' => $per_cost * $recurring_ratio,
716 'full_price' => $per_cost,
717 'total' => $total,
718 'full_total' => $full_total,
719 'first_total' => $full_total * $first_ratio,
720 'has_recurring_discount' => $has_recurring_discount,
721 'has_one_time_discount' => $discounts['non_recurring'] > 0,
722 'recurring_limit' => $discounts['recurring_limit'],
723 );
724 }
725
726 /**
727 * Resolve the discount that still applies to a subscription's future renewals.
728 *
729 * Only coupons flagged as recurring survive into renewal orders, and only while their
730 * recurring limit holds — this mirrors the skip conditions in the pro plugin's
731 * `Coupon::maybe_add_coupon_to_renewal_order()`, so what is displayed matches what the
732 * next renewal order will actually be charged.
733 *
734 * A subscription order always holds exactly one line item (enforced by
735 * `Frontend\Cart::validate_cart_items()`), so each coupon line's whole discount belongs
736 * to that item.
737 *
738 * @param int $subscription_id Subscription ID.
739 * @param \WC_Order|null $order Source order. Resolved from the subscription when omitted.
740 * @param \WC_Order_Item|null $order_item Source order item. Used to rebase the discount when the
741 * recurring price has since drifted, e.g. after a switch.
742 *
743 * @return array{amount:float,limit:int,exhausted:bool}
744 */
745 public static function get_subscription_recurring_discount( $subscription_id, $order = null, $order_item = null ) {
746 $result = array(
747 'amount' => 0.0,
748 'limit' => 0,
749 'exhausted' => false,
750 );
751
752 // Memoized per request: list views and the single view each resolve the same
753 // subscription two or three times, and a coupon'd subscription costs a query.
754 static $cache = array();
755
756 $cache_key = $subscription_id . '|' . ( $order_item ? $order_item->get_id() : 0 );
757
758 if ( isset( $cache[ $cache_key ] ) ) {
759 return $cache[ $cache_key ];
760 }
761
762 if ( ! $order ) {
763 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
764 $order = $order_item_id ? wc_get_order( wc_get_order_id_by_order_item_id( $order_item_id ) ) : null;
765 }
766
767 if ( ! $order ) {
768 $cache[ $cache_key ] = $result;
769 return $result;
770 }
771
772 $coupon_lines = $order->get_items( 'coupon' );
773 if ( empty( $coupon_lines ) ) {
774 $cache[ $cache_key ] = $result;
775 return $result;
776 }
777
778 /*
779 * Position the next renewal will take in this subscription's order sequence.
780 *
781 * Note this is deliberately one more than the current order count, and so is NOT the
782 * same expression pro's Coupon::maybe_add_coupon_to_renewal_order() evaluates: that
783 * runs after the new order's relation row is already inserted, so its count includes
784 * the order being created. Both mean "is this order still within the limit".
785 */
786 $next_order_position = count( self::get_related_orders( (int) $subscription_id ) ) + 1;
787 $limits = array();
788
789 foreach ( $coupon_lines as $coupon_line ) {
790 $coupon = new \WC_Coupon( $coupon_line->get_code() );
791
792 /** This filter is documented in includes/Illuminate/Helper.php */
793 if ( ! apply_filters( 'subscrpt_coupon_is_recurring', false, $coupon, '' ) ) {
794 continue;
795 }
796
797 /** This filter is documented in includes/Illuminate/Helper.php */
798 $limit = (int) apply_filters( 'subscrpt_coupon_recurring_limit', 0, $coupon, '' );
799
800 if ( $limit > 0 ) {
801 $limits[] = $limit;
802
803 // The next renewal is past the limit, so it will be charged full price.
804 if ( $next_order_position > $limit ) {
805 $result['exhausted'] = true;
806 continue;
807 }
808 }
809
810 $result['amount'] += (float) $coupon_line->get_discount();
811 }
812
813 $result['limit'] = empty( $limits ) ? 0 : min( $limits );
814
815 // Rebase onto the current recurring price when it no longer matches what was discounted.
816 $discounted_subtotal = $order_item ? (float) $order_item->get_subtotal() : 0.0;
817 $recurring_subtotal = $order_item ? (float) self::get_subscription_total( $subscription_id ) * max( 1, (int) $order_item->get_quantity() ) : 0.0;
818
819 if ( $result['amount'] > 0 && $discounted_subtotal > 0 && abs( $discounted_subtotal - $recurring_subtotal ) > 0.01 ) {
820 $result['amount'] = $result['amount'] * ( $recurring_subtotal / $discounted_subtotal );
821 }
822
823 $cache[ $cache_key ] = $result;
824
825 return $result;
826 }
827
828 /**
829 * Build the figures the My Account subscription views display.
830 *
831 * The recurring price in `_subscrpt_price` is always the undiscounted product price, so the
832 * renewal figure has to be derived: full price, minus whatever discount recurs, plus tax on
833 * the discounted amount. Tax is scaled from the order item's own tax ratio rather than
834 * recalculated, which keeps the figures consistent with the order they came from.
835 *
836 * @param int $subscription_id Subscription ID.
837 * @param \WC_Order_Item|null $order_item Source order item.
838 *
839 * @return array{full_excl:float,discount:float,discount_tax:float,tax:float,total:float,has_discount:bool}
840 */
841 public static function get_subscription_display_totals( $subscription_id, $order_item = null ) {
842 $quantity = $order_item ? max( 1, (int) $order_item->get_quantity() ) : 1;
843 $full_excl = (float) self::get_subscription_total( $subscription_id ) * $quantity;
844
845 $item_subtotal = $order_item ? (float) $order_item->get_subtotal() : 0.0;
846 $item_tax = $order_item ? (float) $order_item->get_subtotal_tax() : 0.0;
847 $tax_ratio = $item_subtotal > 0 ? $item_tax / $item_subtotal : 0.0;
848
849 $order = $order_item ? wc_get_order( $order_item->get_order_id() ) : null;
850 $discount = self::get_subscription_recurring_discount( $subscription_id, $order, $order_item );
851 $discount = min( (float) $discount['amount'], $full_excl );
852
853 $discount_tax = $discount * $tax_ratio;
854 $tax = ( $full_excl * $tax_ratio ) - $discount_tax;
855
856 return array(
857 'full_excl' => $full_excl,
858 'discount' => $discount,
859 'discount_tax' => $discount_tax,
860 'tax' => $tax,
861 'total' => $full_excl - $discount + $tax,
862 'has_discount' => $discount > 0,
863 );
864 }
865
866 /**
867 * Resolve the pieces every recurring-amount display needs.
868 *
869 * @param int $subscription_id Subscription ID.
870 * @param \WC_Order_Item|null $order_item Source order item.
871 *
872 * @return array|false {discounted:string,full:string,has_discount:bool}, or false when the
873 * order item is missing or carries no subscription meta.
874 */
875 protected static function get_subscription_recurring_price_parts( $subscription_id, $order_item = null ) {
876 if ( ! $order_item ) {
877 return false;
878 }
879
880 $totals = self::get_subscription_display_totals( $subscription_id, $order_item );
881 $discounted = self::format_price_with_order_item( $totals['total'], $order_item->get_id() );
882
883 if ( ! $discounted ) {
884 return false;
885 }
886
887 // Undiscounted amount including its own tax.
888 $full = $totals['full_excl'] + $totals['tax'] + $totals['discount_tax'];
889 $order = wc_get_order( $order_item->get_order_id() );
890
891 return array(
892 'discounted' => $discounted,
893 'full' => wc_price(
894 $full,
895 array(
896 'currency' => $order ? $order->get_currency() : '',
897 )
898 ),
899 'has_discount' => $totals['has_discount'],
900 );
901 }
902
903 /**
904 * Formatted recurring amount for a subscription, striking the original when a discount recurs.
905 *
906 * Produces the same `<del>` / `<ins>` shape the cart's recurring totals use, so a customer
907 * sees one consistent treatment of a recurring discount from cart through to order details.
908 * Use `get_subscription_recurring_price_text()` anywhere the output may reach a plain-text
909 * context, such as an email that renders in both HTML and plain.
910 *
911 * @param int $subscription_id Subscription ID.
912 * @param \WC_Order_Item|null $order_item Source order item.
913 * @param array $args Optional. 'del_style' is an inline style for the struck-through
914 * amount — email clients strip stylesheets, so email callers
915 * must pass one.
916 *
917 * @return string|false Formatted price, or false when the order item has no subscription meta.
918 */
919 public static function get_subscription_recurring_price_html( $subscription_id, $order_item = null, $args = array() ) {
920 $parts = self::get_subscription_recurring_price_parts( $subscription_id, $order_item );
921
922 if ( ! $parts ) {
923 return false;
924 }
925
926 if ( ! $parts['has_discount'] ) {
927 return $parts['discounted'];
928 }
929
930 $del_style = $args['del_style'] ?? '';
931 $del_attributes = $del_style ? ' style="' . esc_attr( $del_style ) . '"' : '';
932
933 return '<del aria-hidden="true"' . $del_attributes . '>' . $parts['full'] . '</del> <ins>' . $parts['discounted'] . '</ins>';
934 }
935
936 /**
937 * Formatted recurring amount for a subscription, as markup-free text.
938 *
939 * For contexts that cannot render `<del>` — plain-text emails above all, where stripping the
940 * tags would leave two bare amounts side by side and no way to tell which is charged.
941 *
942 * @param int $subscription_id Subscription ID.
943 * @param \WC_Order_Item|null $order_item Source order item.
944 *
945 * @return string|false Formatted price, or false when the order item has no subscription meta.
946 */
947 public static function get_subscription_recurring_price_text( $subscription_id, $order_item = null ) {
948 $parts = self::get_subscription_recurring_price_parts( $subscription_id, $order_item );
949
950 if ( ! $parts ) {
951 return false;
952 }
953
954 $discounted = wp_strip_all_tags( $parts['discounted'] );
955
956 if ( ! $parts['has_discount'] ) {
957 return $discounted;
958 }
959
960 return sprintf(
961 // translators: 1: discounted recurring amount, 2: original amount before the discount.
962 __( '%1$s (discounted from %2$s)', 'subscription' ),
963 $discounted,
964 wp_strip_all_tags( $parts['full'] )
965 );
966 }
967
968 /**
969 * Get recurrings items from cart items.
970 *
971 * @param array $cart_items Cart items.
972 *
973 * @return array
974 */
975 public static function get_recurrs_from_cart( $cart_items ) {
976 $recurrs = array();
977 foreach ( $cart_items as $key => $cart_item ) {
978 $product = $cart_item['data'];
979 if ( $product->is_type( 'simple' ) && isset( $cart_item['subscription'] ) ) {
980 $cart_subscription = $cart_item['subscription'];
981 $type = ucfirst( $cart_subscription['type'] );
982 $price_data = self::build_cart_recurring_price_data( $cart_item, $key, $type );
983
984 $recurrs[ $key ] = array_merge(
985 $price_data,
986 array(
987 'trial_status' => ! is_null( $cart_subscription['trial'] ),
988 'start_date' => self::start_date( $cart_subscription['trial'] ),
989 'next_date' => self::next_date( ( $cart_subscription['time'] ?? 1 ) . ' ' . $cart_subscription['type'], $cart_subscription['trial'] ),
990 'can_user_cancel' => $cart_item['data']->get_meta( '_subscrpt_user_cancel' ),
991 'max_no_payment' => $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
992 'quantity' => (int) $cart_item['quantity'],
993 )
994 );
995 }
996 }
997
998 return apply_filters( 'wpsubs_cart_recurring_items', $recurrs, $cart_items );
999 }
1000
1001 /**
1002 * Check if the order has subscription item.
1003 *
1004 * @param \WC_Order|int $order Order object.
1005 */
1006 public static function order_has_subscription_item( $order ) {
1007 if ( is_int( $order ) ) {
1008 $order = wc_get_order( $order );
1009 }
1010
1011 $is_subscription_order = false;
1012 foreach ( $order->get_items() as $item ) {
1013 $item_data = $item->get_data() ?? array();
1014 $item_product_id = $item_data['product_id'] ?? 0;
1015 $item_variation_id = $item_data['variation_id'] ?? 0;
1016
1017 $product_id = $item_variation_id ? $item_variation_id : $item_product_id;
1018 $product = Subscription::get_subs_product( $product_id );
1019
1020 if ( $product && $product->is_enabled() ) {
1021 $is_subscription_order = true;
1022 break;
1023 }
1024 }
1025 return $is_subscription_order;
1026 }
1027
1028 /**
1029 * Create renewal order when subscription expired. [wip]
1030 *
1031 * @param int $subscription_id Subscription ID.
1032 * @return false|\WC_Order Renewal order object or false on failure.
1033 * @throws \WC_Data_Exception Exception.
1034 * @throws \Exception Exception.
1035 */
1036 public static function create_renewal_order( $subscription_id ) {
1037 // Check if maximum payment limit has been reached
1038 if ( subscrpt_is_max_payments_reached( $subscription_id ) ) {
1039 // Mark subscription as expired due to limit reached
1040 Action::status( 'expired', $subscription_id );
1041
1042 error_log( "WPS: Maximum payment limit reached for subscription #{$subscription_id}. No renewal order created." );
1043 return false;
1044 }
1045
1046 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
1047 $order_id = wc_get_order_id_by_order_item_id( $order_item_id );
1048 $old_order = self::check_order_for_renewal( $order_id );
1049
1050 if ( ! $old_order ) {
1051 // The stored item may belong to a trashed or non-completed order (e.g. a renewal that was deleted). Walk the relation table newest-first to find the last completed order we can use as the renewal source.
1052 foreach ( self::get_related_orders( $subscription_id ) as $row ) {
1053 $candidate = wc_get_order( (int) ( $row->order_id ?? 0 ) );
1054 if ( $candidate && 'completed' === $candidate->get_status() ) {
1055 $old_order = $candidate;
1056 $order_item_id = (int) ( $row->order_item_id ?? 0 );
1057 break;
1058 }
1059 }
1060 }
1061
1062 if ( ! $old_order ) {
1063 subscrpt_write_log( "Old order not found for renewal. Skipping creating renewal order. [ Subscription ID: {$subscription_id} ]" );
1064 return false;
1065 }
1066
1067 $order_item = $old_order->get_item( $order_item_id );
1068 $subscription_price = (float) get_post_meta( $subscription_id, '_subscrpt_price', true );
1069 $qty = $order_item->get_quantity();
1070
1071 // Subtract tax from per-unit subscription price if prices include tax. WC_Order will calculate tax on line total.
1072 if ( wc_prices_include_tax() ) {
1073 $product = ( $order_item instanceof \WC_Order_Item_Product ) ? $order_item->get_product() : null;
1074 $tax_class = $product ? $product->get_tax_class() : '';
1075 $tax_rates = \WC_Tax::get_rates( $tax_class );
1076 $taxes = \WC_Tax::calc_inclusive_tax( $subscription_price, $tax_rates );
1077 $subscription_price = $subscription_price - array_sum( $taxes );
1078 }
1079
1080 $line_total = $subscription_price * $qty;
1081 $product_args = array(
1082 'name' => $order_item->get_name(),
1083 'subtotal' => $line_total,
1084 'total' => $line_total,
1085 );
1086
1087 // creating new order.
1088 $new_order_data = self::create_new_order_for_renewal( $old_order, $order_item, $product_args );
1089 if ( ! $new_order_data ) {
1090 subscrpt_write_log( "Failed to create renewal order. [ Subscription ID: {$subscription_id} ]" );
1091 return false;
1092 }
1093 $new_order = $new_order_data['order'];
1094 $new_order_item_id = $new_order_data['order_item_id'];
1095
1096 self::create_renewal_history( $subscription_id, $new_order->get_id(), $new_order_item_id );
1097 update_post_meta( $subscription_id, '_subscrpt_order_id', $new_order->get_id() );
1098 update_post_meta( $subscription_id, '_subscrpt_order_item_id', $new_order_item_id );
1099
1100 self::clone_order_metadata( $new_order, $old_order );
1101
1102 // Allow modification of the renewal order before saving.
1103 $new_order = apply_filters( 'subscrpt_before_saving_renewal_order', $new_order, $old_order, $subscription_id );
1104
1105 // Save the new order.
1106 $new_order->calculate_totals();
1107 $new_order->save();
1108
1109 if ( ! is_admin() && function_exists( 'wc_add_notice' ) && WC()->session ) {
1110 $message = 'Renewal Order(#' . $new_order->get_id() . ') Created.';
1111 if ( $new_order->has_status( 'pending' ) ) {
1112 $message .= 'Please <a href="' . $new_order->get_checkout_payment_url() . '">Pay now</a>';
1113 }
1114 wc_add_notice( $message, 'success' );
1115 }
1116
1117 do_action( 'subscrpt_after_create_renew_order', $new_order, $old_order, $subscription_id, false );
1118
1119 return $new_order;
1120 }
1121
1122 /**
1123 * Get subscription total price.
1124 *
1125 * @param int $subscription_id Subscription ID.
1126 * @return float
1127 */
1128 public static function get_subscription_total( $subscription_id ) {
1129 return (float) get_post_meta( $subscription_id, '_subscrpt_price', true );
1130 }
1131
1132 /**
1133 * Get subscription status.
1134 *
1135 * @param int $subscription_id Subscription ID.
1136 * @return string
1137 */
1138 public static function get_subscription_status( $subscription_id ) {
1139 return get_post_status( $subscription_id );
1140 }
1141
1142 /**
1143 * Check if subscription has status.
1144 *
1145 * @param int $subscription_id Subscription ID.
1146 * @param string $status Status to check.
1147 * @return bool
1148 */
1149 public static function subscription_has_status( $subscription_id, $status ) {
1150 return self::get_subscription_status( $subscription_id ) === $status;
1151 }
1152
1153 /**
1154 * Check if subscription needs payment.
1155 *
1156 * @param int $subscription_id Subscription ID.
1157 * @return bool
1158 */
1159 public static function subscription_needs_payment( $subscription_id ) {
1160 return true; // Always true for now
1161 }
1162
1163 /**
1164 * Get product period (timing option).
1165 *
1166 * @param int $product_id Product ID.
1167 * @return string
1168 */
1169 public static function get_product_period( $product_id ) {
1170 $product = wc_get_product( $product_id );
1171 return $product ? $product->get_meta( '_subscrpt_timing_option' ) : '';
1172 }
1173
1174 /**
1175 * Get product interval (timing per).
1176 *
1177 * @param int $product_id Product ID.
1178 * @return int
1179 */
1180 public static function get_product_interval( $product_id ) {
1181 $product = wc_get_product( $product_id );
1182 return $product ? (int) $product->get_meta( '_subscrpt_timing_per' ) : 1;
1183 }
1184
1185 /**
1186 * Get product length (max payments).
1187 *
1188 * @param int $product_id Product ID.
1189 * @return int
1190 */
1191 public static function get_product_length( $product_id ) {
1192 $product = wc_get_product( $product_id );
1193 return $product ? (int) $product->get_meta( '_subscrpt_max_no_payment' ) : 0;
1194 }
1195
1196 /**
1197 * Get product trial length.
1198 *
1199 * @param int $product_id Product ID.
1200 * @return int
1201 */
1202 public static function get_product_trial_length( $product_id ) {
1203 $product = wc_get_product( $product_id );
1204 return $product ? (int) $product->get_meta( '_subscrpt_trial_timing_per' ) : 0;
1205 }
1206
1207 /**
1208 * Get product signup fee.
1209 *
1210 * @param int $product_id Product ID.
1211 * @return float
1212 */
1213 public static function get_product_signup_fee( $product_id ) {
1214 $product = wc_get_product( $product_id );
1215 return $product ? (float) $product->get_meta( '_subscrpt_signup_fee' ) : 0.0;
1216 }
1217
1218 /**
1219 * Get first renewal payment time.
1220 *
1221 * @param int $product_id Product ID.
1222 * @return int Timestamp
1223 */
1224 public static function get_first_renewal_payment_time( $product_id ) {
1225 $product = wc_get_product( $product_id );
1226 if ( ! $product ) {
1227 return 0;
1228 }
1229
1230 $trial_period = $product->get_meta( '_subscrpt_trial_timing_per' );
1231 $trial_option = $product->get_meta( '_subscrpt_trial_timing_option' );
1232
1233 if ( ! empty( $trial_period ) && ! empty( $trial_option ) ) {
1234 return strtotime( "+{$trial_period} {$trial_option}" );
1235 }
1236
1237 return 0;
1238 }
1239
1240 /**
1241 * Update subscription next payment date.
1242 *
1243 * @param int $subscription_id Subscription ID.
1244 * @param string $new_date New Date string.
1245 * @return void
1246 */
1247 public static function update_subscription_next_payment_date( $subscription_id, $new_date ) {
1248 update_post_meta( $subscription_id, '_subscrpt_next_date', strtotime( $new_date ) );
1249 }
1250
1251 /**
1252 * Cancel subscription.
1253 *
1254 * @param int $subscription_id Subscription ID.
1255 * @return void
1256 */
1257 public static function cancel_subscription( $subscription_id ) {
1258 Action::status( 'cancelled', $subscription_id );
1259 }
1260
1261 /**
1262 * Pause subscription.
1263 *
1264 * @param int $subscription_id Subscription ID.
1265 * @return void
1266 */
1267 public static function pause_subscription( $subscription_id ) {
1268 Action::status( 'on-hold', $subscription_id );
1269 }
1270
1271 /**
1272 * Resume subscription.
1273 *
1274 * @param int $subscription_id Subscription ID.
1275 * @return void
1276 */
1277 public static function resume_subscription( $subscription_id ) {
1278 Action::status( 'active', $subscription_id );
1279 }
1280
1281 /**
1282 * Mark subscription payment as complete.
1283 *
1284 * @param int $subscription_id Subscription ID.
1285 * @param string $payment_id Payment/Transaction ID.
1286 * @return void
1287 */
1288 public static function subscription_payment_complete( $subscription_id, $payment_id ) {
1289 if ( 'active' !== get_post_status( $subscription_id ) ) {
1290 Action::status( 'active', $subscription_id );
1291 }
1292
1293 // Allow payment gateways to add their own comments/notes
1294 do_action( 'subscrpt_subscription_payment_completed', $subscription_id, $payment_id );
1295 }
1296
1297 /**
1298 * Clone stripe metadata from old order.
1299 *
1300 * @param int $subscription_id Subscription Id.
1301 * @param \WC_Order $old_order Old Order Object.
1302 * @param \WC_Order $new_order New Order Object.
1303 *
1304 * @return void
1305 */
1306 public static function clone_stripe_metadata_for_renewal( $subscription_id, $old_order, $new_order ) {
1307 $is_auto_renew = get_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
1308 if ( empty( $is_auto_renew ) && subscrpt_is_auto_renew_enabled() ) {
1309 $is_auto_renew = true;
1310 update_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
1311 }
1312
1313 $is_auto_renew = get_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
1314 $is_auto_renew = in_array( $is_auto_renew, array( 1, '1' ), true );
1315
1316 $is_global_auto_renew = get_option( 'wp_subscription_stripe_auto_renew', '1' );
1317 $is_global_auto_renew = in_array( $is_global_auto_renew, array( 1, '1' ), true );
1318
1319 $stripe_supported_methods = Stripe::WPSUBS_SUPPORTED_METHODS;
1320 $old_method = $old_order->get_payment_method();
1321 $is_stripe_pm = ! empty( $old_method ) && in_array( $old_method, $stripe_supported_methods, true );
1322
1323 $has_stripe_meta = ! empty( $old_order->get_meta( '_stripe_customer_id' ) ) || ! empty( $old_order->get_meta( '_stripe_source_id' ) );
1324
1325 $stripe_enabled = ( ( $is_stripe_pm || $has_stripe_meta ) && $is_auto_renew && $is_global_auto_renew && subscrpt_is_auto_renew_enabled() );
1326
1327 if ( $stripe_enabled ) {
1328 $new_order->update_meta_data( '_stripe_customer_id', $old_order->get_meta( '_stripe_customer_id' ) );
1329 $new_order->update_meta_data( '_stripe_source_id', $old_order->get_meta( '_stripe_source_id' ) );
1330 $new_order->set_payment_method( $old_order->get_payment_method() );
1331 $new_order->set_payment_method_title( $old_order->get_payment_method_title() );
1332
1333 // Add debug log.
1334 subscrpt_write_debug_log( "Stripe metadata cloned for renewal order #{$new_order->get_id()} from old order #{$old_order->get_id()}" );
1335 } else {
1336 subscrpt_write_log( "Stripe metadata not processed. Auto renewal may fail. [ Renewal order #{$new_order->get_id()}, Old order #{$old_order->get_id()} ]" );
1337 subscrpt_write_debug_log( "Stripe metadata did not clone for renewal order #{$new_order->get_id()} from old order #{$old_order->get_id()}" );
1338 }
1339 }
1340
1341 /**
1342 * Create history for renewal.
1343 *
1344 * @param int $subscription_id Subscription Id.
1345 * @param int $new_order_id New Order Id.
1346 * @param int $new_order_item_id New Order Item Id.
1347 *
1348 * @return void
1349 */
1350 public static function create_renewal_history( $subscription_id, $new_order_id, $new_order_item_id ) {
1351 global $wpdb;
1352 $history_table = $wpdb->prefix . 'subscrpt_order_relation';
1353 $wpdb->insert(
1354 $history_table,
1355 array(
1356 'subscription_id' => $subscription_id,
1357 'order_id' => $new_order_id,
1358 'order_item_id' => $new_order_item_id,
1359 'type' => 'renew',
1360 )
1361 );
1362
1363 $comment_id = wp_insert_comment(
1364 array(
1365 'comment_author' => 'Subscription for WooCommerce',
1366 'comment_content' => sprintf( 'Subscription Renewal order successfully created. order is %s', $new_order_id ),
1367 'comment_post_ID' => $subscription_id,
1368 'comment_type' => 'order_note',
1369 )
1370 );
1371 update_comment_meta( $comment_id, '_subscrpt_activity', 'Renewal Order' );
1372 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'renewal_order' );
1373 }
1374
1375 /**
1376 * Get a subscription data.
1377 *
1378 * @param int $subscription_id Subscription ID.
1379 * @return array|null
1380 */
1381 public static function get_subscription_data( int $subscription_id ): ?array {
1382 if ( empty( get_post_meta( $subscription_id ) ) ) {
1383 return null;
1384 }
1385
1386 $subs_post = get_post( $subscription_id );
1387 $user_id = ! empty( $subs_post ) ? (int) $subs_post->post_author : 0;
1388
1389 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
1390 $product_id = ! empty( $product_id ) ? (int) $product_id : 0;
1391
1392 $variation_id = get_post_meta( $subscription_id, '_subscrpt_variation_id', true );
1393 $variation_id = ! empty( $variation_id ) ? (int) $variation_id : 0;
1394
1395 $chk_product_id = $variation_id ? $variation_id : $product_id;
1396
1397 $status = get_post_status( $subscription_id ); // pending, active, cancelled, pe_cancelled, expired
1398 $price = get_post_meta( $subscription_id, '_subscrpt_price', true );
1399
1400 $signup_fee = get_post_meta( $subscription_id, '_subscrpt_signup_fee', true );
1401 $signup_fee = ! empty( $signup_fee ) ? $signup_fee : 0;
1402
1403 $order_id = get_post_meta( $subscription_id, '_subscrpt_order_id', true );
1404 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
1405
1406 $can_user_cancel = in_array( get_post_meta( $subscription_id, '_subscrpt_user_cancel', true ), array( 1, '1', 'true', 'yes' ), true );
1407
1408 $start_datetime = (int) get_post_meta( $subscription_id, '_subscrpt_start_date', true );
1409 $start_date = ! empty( $start_datetime ) ? gmdate( DATE_RFC2822, $start_datetime ) : null;
1410
1411 $next_datetime = (int) get_post_meta( $subscription_id, '_subscrpt_next_date', true );
1412 $next_date = ! empty( $next_datetime ) ? gmdate( DATE_RFC2822, $next_datetime ) : null;
1413
1414 $timing_per = get_post_meta( $subscription_id, '_subscrpt_timing_per', true );
1415 $timing_per = empty( $timing_per ) ? get_post_meta( $chk_product_id, '_subscrpt_timing_per', true ) : $timing_per;
1416
1417 $timing_option = get_post_meta( $subscription_id, '_subscrpt_timing_option', true );
1418 $timing_option = empty( $timing_option ) ? get_post_meta( $chk_product_id, '_subscrpt_timing_option', true ) : $timing_option;
1419
1420 $trial_timing_per = get_post_meta( $subscription_id, '_subscrpt_trial_timing_per', true );
1421 $trial_timing_per = empty( $trial_timing_per ) ? get_post_meta( $chk_product_id, '_subscrpt_trial_timing_per', true ) : $trial_timing_per;
1422
1423 $trial_timing_option = get_post_meta( $subscription_id, '_subscrpt_trial_timing_option', true );
1424 $trial_timing_option = empty( $trial_timing_option ) ? get_post_meta( $chk_product_id, '_subscrpt_trial_timing_option', true ) : $trial_timing_option;
1425
1426 $is_auto_renew = in_array( get_post_meta( $subscription_id, '_subscrpt_auto_renew', true ), array( 1, '1', 'true', 'yes' ), true );
1427 $is_auto_renew = ! empty( $is_auto_renew ) ? $is_auto_renew : subscrpt_is_auto_renew_enabled();
1428
1429 $default_grace_period = (int) get_option( 'subscrpt_default_payment_grace_period', '7' );
1430 $default_grace_period = subscrpt_pro_activated() ? $default_grace_period : 0;
1431 $grace_end_datetime = $next_datetime + ( $default_grace_period * DAY_IN_SECONDS );
1432 $grace_end_date = gmdate( DATE_RFC2822, $grace_end_datetime );
1433 $grace_remaining_days = ceil( max( 0, $grace_end_datetime - time() ) / DAY_IN_SECONDS );
1434
1435 $subscription_data = array(
1436 'id' => $subscription_id,
1437 'status' => $status,
1438 'schedule' => array(
1439 'timing_per' => $timing_per,
1440 'timing_option' => $timing_option,
1441 ),
1442 'price' => $price,
1443 'signup_fee' => $signup_fee,
1444 'start_date' => $start_date,
1445 'next_date' => $next_date,
1446 'product' => array(
1447 'product_id' => $product_id,
1448 'variation_id' => $variation_id,
1449 ),
1450 'order' => array(
1451 'order_id' => $order_id,
1452 'order_item_id' => $order_item_id,
1453 ),
1454 'can_user_cancel' => $can_user_cancel,
1455 'is_auto_renew' => (bool) $is_auto_renew,
1456 'user_id' => $user_id,
1457 );
1458
1459 if ( ! empty( $trial_timing_per ) ) {
1460 $subscription_data['trial'] = array(
1461 'timing_per' => $trial_timing_per,
1462 'timing_option' => $trial_timing_option,
1463 );
1464 }
1465
1466 if (
1467 ! in_array( strtolower( $status ), array( 'cancelled', 'pending' ), true )
1468 && $next_datetime - time() <= 0
1469 && (int) $default_grace_period > 0
1470 ) {
1471 $subscription_data['grace_period'] = array(
1472 'remaining_days' => $grace_remaining_days,
1473 'end_date' => $grace_end_date,
1474 );
1475 }
1476
1477 return $subscription_data;
1478 }
1479
1480 /**
1481 * Get related orders of a subscription.
1482 *
1483 * @param int $subscription_id Subscription ID.
1484 * @return array
1485 */
1486 public static function get_related_orders( int $subscription_id ): array {
1487 global $wpdb;
1488 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
1489
1490 // @phpcs:ignore
1491 $order_histories = $wpdb->get_results(
1492 $wpdb->prepare(
1493 'SELECT order_id, order_item_id, type FROM %i WHERE subscription_id=%d ORDER BY id DESC',
1494 array(
1495 $table_name,
1496 $subscription_id,
1497 )
1498 )
1499 );
1500
1501 return $order_histories;
1502 }
1503
1504 /**
1505 * Get parent order from subscription.
1506 *
1507 * @param int $subscription_id Subscription ID.
1508 */
1509 public static function get_parent_order( int $subscription_id ) {
1510 $related_orders = self::get_related_orders( $subscription_id );
1511 $last_order = end( $related_orders );
1512
1513 if ( ! $last_order || strtolower( $last_order->type ?? '' ) !== 'new' ) {
1514 foreach ( $related_orders as $order ) {
1515 if ( strtolower( $order->type ?? '' ) === 'new' ) {
1516 $last_order = $order;
1517 break;
1518 }
1519 }
1520 }
1521
1522 $parent_order_id = $last_order->order_id ?? 0;
1523 $parent_order = wc_get_order( $parent_order_id );
1524 return $parent_order;
1525 }
1526
1527 /**
1528 * Create new order for renewal.
1529 *
1530 * @param \WC_Order $old_order Old Order Object.
1531 * @param \WC_Order_Item_Product $order_item Old Order Item Object.
1532 * @param array $product_args Product args for add product.
1533 *
1534 * @return array|false
1535 */
1536 public static function create_new_order_for_renewal( \WC_Order $old_order, \WC_Order_Item_Product $order_item, array $product_args ) {
1537 $product = $order_item->get_product();
1538 $user_id = $old_order->get_user_id();
1539 $new_order = wc_create_order(
1540 array(
1541 'customer_id' => $user_id,
1542 'status' => 'pending',
1543 )
1544 );
1545 $product_meta = apply_filters( 'subscrpt_renewal_item_meta', wc_get_order_item_meta( $order_item->get_id(), '_subscrpt_meta', true ), $product, $order_item );
1546 $product_args = apply_filters( 'subscrpt_renewal_product_args', $product_args, $product, $order_item );
1547 if ( ! $product_args ) {
1548 return false;
1549 }
1550
1551 $new_order_item_id = $new_order->add_product(
1552 $product,
1553 $order_item->get_quantity(),
1554 $product_args
1555 );
1556 wc_update_order_item_meta(
1557 $new_order_item_id,
1558 '_subscrpt_meta',
1559 array(
1560 'time' => $product_meta['time'],
1561 'type' => $product_meta['type'],
1562 'trial' => null,
1563 )
1564 );
1565
1566 // Add debug log.
1567 subscrpt_write_debug_log( "Renewal order #{$new_order->get_id()} created for old order #{$old_order->get_id()}" );
1568
1569 return array(
1570 'order' => $new_order,
1571 'order_item_id' => $new_order_item_id,
1572 );
1573 }
1574
1575 /**
1576 * Check if old order is completed or deleted!
1577 *
1578 * @param mixed $old_order_id Old Order Id.
1579 *
1580 * @return \WC_Order|false
1581 */
1582 public static function check_order_for_renewal( $old_order_id ) {
1583 $old_order = wc_get_order( $old_order_id );
1584 if ( ! $old_order || 'completed' !== $old_order->get_status() ) {
1585 if ( ! is_admin() && function_exists( 'wc_add_notice' ) && WC()->session ) {
1586 return wc_add_notice( __( 'Subscription renewal isn\'t possible due to previous order not completed or deletion.', 'subscription' ), 'error' );
1587 }
1588 return false;
1589 }
1590
1591 return $old_order;
1592 }
1593
1594 /**
1595 * Get delivery info from order.
1596 *
1597 * @param \WC_Order $order Order object.
1598 * @return array
1599 */
1600 public static function get_delivery_info_from_order( \WC_Order $order ) {
1601 $customer_id = $order->get_customer_id();
1602 $customer = new \WC_Customer( $customer_id );
1603 $email = $customer->get_email();
1604
1605 // Billing info (get from order first, if empty get from customer).
1606 $billing_first_name = ! empty( $order->get_billing_first_name() ) ? $order->get_billing_first_name() : $customer->get_billing_first_name();
1607 $billing_last_name = ! empty( $order->get_billing_last_name() ) ? $order->get_billing_last_name() : $customer->get_billing_last_name();
1608 $billing_email = ! empty( $order->get_billing_email() ) ? $order->get_billing_email() : $customer->get_billing_email();
1609 $billing_phone = ! empty( $order->get_billing_phone() ) ? $order->get_billing_phone() : $customer->get_billing_phone();
1610 $billing_company = ! empty( $order->get_billing_company() ) ? $order->get_billing_company() : $customer->get_billing_company();
1611
1612 $billing_city = ! empty( $order->get_billing_city() ) ? $order->get_billing_city() : $customer->get_billing_city();
1613 $billing_state = ! empty( $order->get_billing_state() ) ? $order->get_billing_state() : $customer->get_billing_state();
1614 $billing_country = ! empty( $order->get_billing_country() ) ? $order->get_billing_country() : $customer->get_billing_country();
1615 $billing_postcode = ! empty( $order->get_billing_postcode() ) ? $order->get_billing_postcode() : $customer->get_billing_postcode();
1616 $billing_address_1 = ! empty( $order->get_billing_address_1() ) ? $order->get_billing_address_1() : $customer->get_billing_address_1();
1617 $billing_address_2 = ! empty( $order->get_billing_address_2() ) ? $order->get_billing_address_2() : $customer->get_billing_address_2();
1618
1619 // Shipping info (get from order first, if empty get from customer).
1620 $shipping_first_name = ! empty( $order->get_shipping_first_name() ) ? $order->get_shipping_first_name() : $customer->get_shipping_first_name();
1621 $shipping_last_name = ! empty( $order->get_shipping_last_name() ) ? $order->get_shipping_last_name() : $customer->get_shipping_last_name();
1622 $shipping_phone = ! empty( $order->get_shipping_phone() ) ? $order->get_shipping_phone() : $customer->get_shipping_phone();
1623 $shipping_company = ! empty( $order->get_shipping_company() ) ? $order->get_shipping_company() : $customer->get_shipping_company();
1624
1625 $shipping_city = ! empty( $order->get_shipping_city() ) ? $order->get_shipping_city() : $customer->get_shipping_city();
1626 $shipping_state = ! empty( $order->get_shipping_state() ) ? $order->get_shipping_state() : $customer->get_shipping_state();
1627 $shipping_country = ! empty( $order->get_shipping_country() ) ? $order->get_shipping_country() : $customer->get_shipping_country();
1628 $shipping_postcode = ! empty( $order->get_shipping_postcode() ) ? $order->get_shipping_postcode() : $customer->get_shipping_postcode();
1629 $shipping_address_1 = ! empty( $order->get_shipping_address_1() ) ? $order->get_shipping_address_1() : $customer->get_shipping_address_1();
1630 $shipping_address_2 = ! empty( $order->get_shipping_address_2() ) ? $order->get_shipping_address_2() : $customer->get_shipping_address_2();
1631
1632 $order_meta = [
1633 'customer_id' => $order->get_customer_id(),
1634 'email' => $email,
1635 'billing' => [
1636 'first_name' => $billing_first_name,
1637 'last_name' => $billing_last_name,
1638 'email' => $billing_email,
1639 'phone' => $billing_phone,
1640 'company' => $billing_company,
1641 'city' => $billing_city,
1642 'state' => $billing_state,
1643 'country' => $billing_country,
1644 'postcode' => $billing_postcode,
1645 'address_1' => $billing_address_1,
1646 'address_2' => $billing_address_2,
1647 ],
1648 'shipping' => [
1649 'first_name' => $shipping_first_name,
1650 'last_name' => $shipping_last_name,
1651 'phone' => $shipping_phone,
1652 'company' => $shipping_company,
1653 'city' => $shipping_city,
1654 'state' => $shipping_state,
1655 'country' => $shipping_country,
1656 'postcode' => $shipping_postcode,
1657 'address_1' => $shipping_address_1,
1658 'address_2' => $shipping_address_2,
1659 ],
1660 ];
1661
1662 return $order_meta;
1663 }
1664
1665 /**
1666 * Set delivery info to order.
1667 *
1668 * @param \WC_Order $order Order object.
1669 * @param array $order_meta Order meta data.
1670 */
1671 public static function set_delivery_info_to_order( \WC_Order $order, array $order_meta ) {
1672 // Set Billing Info.
1673 $order->set_billing_first_name( $order_meta['billing']['first_name'] ?? '' );
1674 $order->set_billing_last_name( $order_meta['billing']['last_name'] ?? '' );
1675 $order->set_billing_email( $order_meta['billing']['email'] ?? '' );
1676 $order->set_billing_phone( $order_meta['billing']['phone'] ?? '' );
1677 $order->set_billing_company( $order_meta['billing']['company'] ?? '' );
1678 $order->set_billing_city( $order_meta['billing']['city'] ?? '' );
1679 $order->set_billing_state( $order_meta['billing']['state'] ?? '' );
1680 $order->set_billing_country( $order_meta['billing']['country'] ?? '' );
1681 $order->set_billing_postcode( $order_meta['billing']['postcode'] ?? '' );
1682 $order->set_billing_address_1( $order_meta['billing']['address_1'] ?? '' );
1683 $order->set_billing_address_2( $order_meta['billing']['address_2'] ?? '' );
1684
1685 // Set Shipping Info.
1686 $order->set_shipping_first_name( $order_meta['shipping']['first_name'] ?? '' );
1687 $order->set_shipping_last_name( $order_meta['shipping']['last_name'] ?? '' );
1688 $order->set_shipping_phone( $order_meta['shipping']['phone'] ?? '' );
1689 $order->set_shipping_company( $order_meta['shipping']['company'] ?? '' );
1690 $order->set_shipping_city( $order_meta['shipping']['city'] ?? '' );
1691 $order->set_shipping_state( $order_meta['shipping']['state'] ?? '' );
1692 $order->set_shipping_country( $order_meta['shipping']['country'] ?? '' );
1693 $order->set_shipping_postcode( $order_meta['shipping']['postcode'] ?? '' );
1694 $order->set_shipping_address_1( $order_meta['shipping']['address_1'] ?? '' );
1695 $order->set_shipping_address_2( $order_meta['shipping']['address_2'] ?? '' );
1696 }
1697
1698 /**
1699 * Save meta-data from old order
1700 *
1701 * @param \WC_Order $new_order new order object.
1702 * @param \WC_Order $old_order old order object.
1703 *
1704 * @return void
1705 */
1706 public static function clone_order_metadata( $new_order, $old_order ) {
1707 // Set customer and currency info.
1708 $new_order->set_customer_id( $old_order->get_customer_id() );
1709 $new_order->set_currency( $old_order->get_currency() );
1710
1711 // Get delivery info from old order.
1712 $order_meta = self::get_delivery_info_from_order( $old_order );
1713
1714 // Check for any missing information.
1715 $missing_billing_info = true;
1716 foreach ( $order_meta['billing'] as $key => $value ) {
1717 if ( ! empty( $value ) ) {
1718 $missing_billing_info = false;
1719 break;
1720 }
1721 }
1722 $missing_shipping_info = true;
1723 foreach ( $order_meta['shipping'] as $key => $value ) {
1724 if ( ! empty( $value ) ) {
1725 $missing_shipping_info = false;
1726 break;
1727 }
1728 }
1729 $missing_info = $missing_billing_info || $missing_shipping_info;
1730
1731 // Get info from the parent order if missing.
1732 if ( $missing_info ) {
1733 $subscription = self::get_subscriptions_from_order( $old_order->get_id() );
1734 $subscription = reset( $subscription );
1735 $subscription_id = ! empty( $subscription ) ? $subscription->subscription_id : 0;
1736
1737 subscrpt_write_log( "Missing delivery info in old order #{$old_order->get_id()} for subscription #{$subscription_id}. Trying to get from parent order." );
1738
1739 $parent_order = self::get_parent_order( $subscription_id );
1740 if ( ! empty( $parent_order ) ) {
1741 $order_meta = self::get_delivery_info_from_order( $parent_order );
1742 }
1743 }
1744
1745 // Set delivery info to new order.
1746 self::set_delivery_info_to_order( $new_order, $order_meta );
1747 }
1748 }
1749
1750 // HPOS: All order data access below uses WooCommerce CRUD and is HPOS compatible.
1751