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

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

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