PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.11.0
Subscriptions for WooCommerce with Stripe Recurring Payments v1.11.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Illuminate / Helper.php

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

1,747 lines 61.2 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 $args = array(
500 'post_title' => 'Subscription',
501 'post_type' => 'subscrpt_order',
502 'post_status' => $split_payment_args['post_status'],
503 );
504 $subscription_id = wp_insert_post( $args );
505 wp_update_post(
506 array(
507 'ID' => $subscription_id,
508 'post_title' => "Subscription #{$subscription_id}",
509 )
510 );
511 // Check if this is a split payment subscription
512 $payment_type = $product->get_meta( '_subscrpt_payment_type' ) ?: 'recurring';
513 $max_payments = $product->get_meta( '_subscrpt_max_no_payment' );
514
515 $comment_content = '';
516 $activity_type = '';
517
518 if ( 'split_payment' === $payment_type && $max_payments ) {
519 $comment_content = sprintf(
520 /* translators: %1$s: order id, %2$d: max payments */
521 __( 'Split payment subscription created successfully. Order: %1$s. Total installments: %2$d.', 'subscription' ),
522 $order_item->get_order_id(),
523 $max_payments
524 );
525 $activity_type = __( 'Split Payment - New Subscription', 'subscription' );
526 } else {
527 $comment_content = sprintf(
528 /* translators: Order Id. */
529 __( 'Subscription successfully created. Order is %s', 'subscription' ),
530 $order_item->get_order_id()
531 );
532 $activity_type = __( 'New Subscription', 'subscription' );
533 }
534
535 $comment_id = wp_insert_comment(
536 array(
537 'comment_author' => 'Subscription for WooCommerce',
538 'comment_content' => $comment_content,
539 'comment_post_ID' => $subscription_id,
540 'comment_type' => 'order_note',
541 )
542 );
543 update_comment_meta( $comment_id, '_subscrpt_activity', $activity_type );
544 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'subs_created' );
545
546 update_post_meta( $subscription_id, '_subscrpt_product_id', $product->get_id() );
547
548 $wpdb->insert(
549 $history_table,
550 array(
551 'subscription_id' => $subscription_id,
552 'order_id' => $order_item->get_order_id(),
553 'order_item_id' => $order_item->get_id(),
554 'type' => 'new',
555 )
556 );
557
558 // Fire action when split payment plan is created
559 do_action( 'subscrpt_split_payment_created', $subscription_id, $split_payment_args, $order_item );
560
561 return $subscription_id;
562 }
563
564 /**
565 * Resolve applied coupon discounts for a single cart item, split by whether they recur.
566 *
567 * WooCommerce computes a per-coupon, per-item discount breakdown while calculating cart
568 * totals but never persists it — only the aggregated per-coupon and per-item sums survive.
569 * Replaying the coupons through a fresh WC_Discounts instance recovers that breakdown,
570 * which is what lets recurring and one-time discounts be told apart for one cart line.
571 *
572 * Amounts are returned in the same space WC_Discounts works in, i.e. `get_price() * qty`,
573 * so they are tax-inclusive only when the store's prices include tax.
574 *
575 * @param string $cart_item_key Cart item key.
576 *
577 * @return array{recurring:float,non_recurring:float,total:float,recurring_limit:int}
578 */
579 public static function get_cart_item_coupon_discounts( $cart_item_key ) {
580 $empty = array(
581 'recurring' => 0.0,
582 'non_recurring' => 0.0,
583 'total' => 0.0,
584 'recurring_limit' => 0,
585 );
586
587 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
588 return $empty;
589 }
590
591 $coupons = WC()->cart->get_coupons();
592 if ( empty( $coupons ) ) {
593 return $empty;
594 }
595
596 // Memoized per cart state: recurring totals are read several times per request, and
597 // replaying coupons re-runs coupon validation, which hits the database.
598 static $cache = array();
599
600 $cache_key = md5( WC()->cart->get_cart_hash() . '|' . implode( ',', array_keys( $coupons ) ) );
601
602 if ( ! isset( $cache[ $cache_key ] ) ) {
603 // Replay in cart order so stacked coupons resolve exactly as WC_Cart_Totals resolved them.
604 $discounts = new \WC_Discounts( WC()->cart );
605 foreach ( $coupons as $coupon ) {
606 $discounts->apply_coupon( $coupon );
607 }
608 $cache[ $cache_key ] = $discounts->get_discounts();
609 }
610
611 $result = $empty;
612 $limits = array();
613
614 foreach ( $cache[ $cache_key ] as $coupon_code => $item_discounts ) {
615 $amount = (float) ( $item_discounts[ $cart_item_key ] ?? 0 );
616 if ( 0 >= $amount ) {
617 continue;
618 }
619
620 $coupon = $coupons[ $coupon_code ] ?? new \WC_Coupon( $coupon_code );
621
622 /**
623 * Filters whether a coupon's discount also applies to subscription renewals.
624 *
625 * The free plugin has no recurring-coupon concept, so discounts apply to the
626 * first payment only unless an extension — the pro plugin — says otherwise.
627 *
628 * @param bool $is_recurring Whether the discount recurs. Default false.
629 * @param \WC_Coupon $coupon Coupon object.
630 * @param string $cart_item_key Cart item key the discount applies to.
631 */
632 $is_recurring = (bool) apply_filters( 'subscrpt_coupon_is_recurring', false, $coupon, $cart_item_key );
633
634 if ( ! $is_recurring ) {
635 $result['non_recurring'] += $amount;
636 continue;
637 }
638
639 /**
640 * Filters how many payments a recurring coupon's discount covers.
641 *
642 * @param int $limit Number of payments, including the initial one. 0 means unlimited.
643 * @param \WC_Coupon $coupon Coupon object.
644 * @param string $cart_item_key Cart item key the discount applies to.
645 */
646 $limit = (int) apply_filters( 'subscrpt_coupon_recurring_limit', 0, $coupon, $cart_item_key );
647
648 // A limit of one covers the initial payment only, so it never reaches a renewal —
649 // whatever the coupon is flagged as, its effect here is a one-time discount.
650 if ( 1 === $limit ) {
651 $result['non_recurring'] += $amount;
652 continue;
653 }
654
655 $result['recurring'] += $amount;
656
657 if ( $limit > 0 ) {
658 $limits[] = $limit;
659 }
660 }
661
662 $result['total'] = $result['recurring'] + $result['non_recurring'];
663
664 // The earliest limit to expire is when the discounted figure stops being true.
665 $result['recurring_limit'] = empty( $limits ) ? 0 : min( $limits );
666
667 return $result;
668 }
669
670 /**
671 * Build the discount-aware recurring price figures and markup for one cart item.
672 *
673 * A recurring coupon lowers what every renewal costs, so it is folded into the recurring
674 * figures. A one-time coupon lowers only what is paid today, so the recurring figures keep
675 * the full price and the caller discloses the first-payment amount separately.
676 *
677 * Discounts come back from WC_Discounts in `get_price() * qty` space, so they are converted
678 * to the tax-inclusive display space by ratio rather than by re-deriving tax.
679 *
680 * @param array $cart_item Cart item.
681 * @param string $cart_item_key Cart item key.
682 * @param string $type_label Human readable timing label, e.g. "Month".
683 *
684 * @return array
685 */
686 public static function build_cart_recurring_price_data( $cart_item, $cart_item_key, $type_label ) {
687 $product = $cart_item['data'];
688 $quantity = (int) $cart_item['quantity'];
689 $per_cost = (float) ( $cart_item['subscription']['per_cost'] ?? 0 );
690
691 $full_total = (float) wc_get_price_including_tax( $product, [ 'qty' => $quantity ] );
692 $discounts = self::get_cart_item_coupon_discounts( $cart_item_key );
693
694 // Same basis WC_Discounts used, so the discount and the basis are directly comparable.
695 $basis = (float) $product->get_price() * $quantity;
696 $recurring_ratio = $basis > 0 ? ( $basis - $discounts['recurring'] ) / $basis : 1.0;
697 $first_ratio = $basis > 0 ? ( $basis - $discounts['total'] ) / $basis : 1.0;
698
699 $total = $full_total * $recurring_ratio;
700 $timing_html = "<span class='wpsubs-subscription-timing'>&nbsp;/&nbsp;{$type_label}</span>";
701
702 $has_recurring_discount = $discounts['recurring'] > 0;
703 $full_price_html = wc_price( $full_total ) . $timing_html;
704 $price_html = $has_recurring_discount
705 ? '<del aria-hidden="true">' . wc_price( $full_total ) . '</del> <ins>' . wc_price( $total ) . '</ins>' . $timing_html
706 : $full_price_html;
707
708 return array(
709 'price_html' => $price_html,
710 'full_price_html' => $full_price_html,
711 'price' => $per_cost * $recurring_ratio,
712 'full_price' => $per_cost,
713 'total' => $total,
714 'full_total' => $full_total,
715 'first_total' => $full_total * $first_ratio,
716 'has_recurring_discount' => $has_recurring_discount,
717 'has_one_time_discount' => $discounts['non_recurring'] > 0,
718 'recurring_limit' => $discounts['recurring_limit'],
719 );
720 }
721
722 /**
723 * Resolve the discount that still applies to a subscription's future renewals.
724 *
725 * Only coupons flagged as recurring survive into renewal orders, and only while their
726 * recurring limit holds — this mirrors the skip conditions in the pro plugin's
727 * `Coupon::maybe_add_coupon_to_renewal_order()`, so what is displayed matches what the
728 * next renewal order will actually be charged.
729 *
730 * A subscription order always holds exactly one line item (enforced by
731 * `Frontend\Cart::validate_cart_items()`), so each coupon line's whole discount belongs
732 * to that item.
733 *
734 * @param int $subscription_id Subscription ID.
735 * @param \WC_Order|null $order Source order. Resolved from the subscription when omitted.
736 * @param \WC_Order_Item|null $order_item Source order item. Used to rebase the discount when the
737 * recurring price has since drifted, e.g. after a switch.
738 *
739 * @return array{amount:float,limit:int,exhausted:bool}
740 */
741 public static function get_subscription_recurring_discount( $subscription_id, $order = null, $order_item = null ) {
742 $result = array(
743 'amount' => 0.0,
744 'limit' => 0,
745 'exhausted' => false,
746 );
747
748 // Memoized per request: list views and the single view each resolve the same
749 // subscription two or three times, and a coupon'd subscription costs a query.
750 static $cache = array();
751
752 $cache_key = $subscription_id . '|' . ( $order_item ? $order_item->get_id() : 0 );
753
754 if ( isset( $cache[ $cache_key ] ) ) {
755 return $cache[ $cache_key ];
756 }
757
758 if ( ! $order ) {
759 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
760 $order = $order_item_id ? wc_get_order( wc_get_order_id_by_order_item_id( $order_item_id ) ) : null;
761 }
762
763 if ( ! $order ) {
764 $cache[ $cache_key ] = $result;
765 return $result;
766 }
767
768 $coupon_lines = $order->get_items( 'coupon' );
769 if ( empty( $coupon_lines ) ) {
770 $cache[ $cache_key ] = $result;
771 return $result;
772 }
773
774 /*
775 * Position the next renewal will take in this subscription's order sequence.
776 *
777 * Note this is deliberately one more than the current order count, and so is NOT the
778 * same expression pro's Coupon::maybe_add_coupon_to_renewal_order() evaluates: that
779 * runs after the new order's relation row is already inserted, so its count includes
780 * the order being created. Both mean "is this order still within the limit".
781 */
782 $next_order_position = count( self::get_related_orders( (int) $subscription_id ) ) + 1;
783 $limits = array();
784
785 foreach ( $coupon_lines as $coupon_line ) {
786 $coupon = new \WC_Coupon( $coupon_line->get_code() );
787
788 /** This filter is documented in includes/Illuminate/Helper.php */
789 if ( ! apply_filters( 'subscrpt_coupon_is_recurring', false, $coupon, '' ) ) {
790 continue;
791 }
792
793 /** This filter is documented in includes/Illuminate/Helper.php */
794 $limit = (int) apply_filters( 'subscrpt_coupon_recurring_limit', 0, $coupon, '' );
795
796 if ( $limit > 0 ) {
797 $limits[] = $limit;
798
799 // The next renewal is past the limit, so it will be charged full price.
800 if ( $next_order_position > $limit ) {
801 $result['exhausted'] = true;
802 continue;
803 }
804 }
805
806 $result['amount'] += (float) $coupon_line->get_discount();
807 }
808
809 $result['limit'] = empty( $limits ) ? 0 : min( $limits );
810
811 // Rebase onto the current recurring price when it no longer matches what was discounted.
812 $discounted_subtotal = $order_item ? (float) $order_item->get_subtotal() : 0.0;
813 $recurring_subtotal = $order_item ? (float) self::get_subscription_total( $subscription_id ) * max( 1, (int) $order_item->get_quantity() ) : 0.0;
814
815 if ( $result['amount'] > 0 && $discounted_subtotal > 0 && abs( $discounted_subtotal - $recurring_subtotal ) > 0.01 ) {
816 $result['amount'] = $result['amount'] * ( $recurring_subtotal / $discounted_subtotal );
817 }
818
819 $cache[ $cache_key ] = $result;
820
821 return $result;
822 }
823
824 /**
825 * Build the figures the My Account subscription views display.
826 *
827 * The recurring price in `_subscrpt_price` is always the undiscounted product price, so the
828 * renewal figure has to be derived: full price, minus whatever discount recurs, plus tax on
829 * the discounted amount. Tax is scaled from the order item's own tax ratio rather than
830 * recalculated, which keeps the figures consistent with the order they came from.
831 *
832 * @param int $subscription_id Subscription ID.
833 * @param \WC_Order_Item|null $order_item Source order item.
834 *
835 * @return array{full_excl:float,discount:float,discount_tax:float,tax:float,total:float,has_discount:bool}
836 */
837 public static function get_subscription_display_totals( $subscription_id, $order_item = null ) {
838 $quantity = $order_item ? max( 1, (int) $order_item->get_quantity() ) : 1;
839 $full_excl = (float) self::get_subscription_total( $subscription_id ) * $quantity;
840
841 $item_subtotal = $order_item ? (float) $order_item->get_subtotal() : 0.0;
842 $item_tax = $order_item ? (float) $order_item->get_subtotal_tax() : 0.0;
843 $tax_ratio = $item_subtotal > 0 ? $item_tax / $item_subtotal : 0.0;
844
845 $order = $order_item ? wc_get_order( $order_item->get_order_id() ) : null;
846 $discount = self::get_subscription_recurring_discount( $subscription_id, $order, $order_item );
847 $discount = min( (float) $discount['amount'], $full_excl );
848
849 $discount_tax = $discount * $tax_ratio;
850 $tax = ( $full_excl * $tax_ratio ) - $discount_tax;
851
852 return array(
853 'full_excl' => $full_excl,
854 'discount' => $discount,
855 'discount_tax' => $discount_tax,
856 'tax' => $tax,
857 'total' => $full_excl - $discount + $tax,
858 'has_discount' => $discount > 0,
859 );
860 }
861
862 /**
863 * Resolve the pieces every recurring-amount display needs.
864 *
865 * @param int $subscription_id Subscription ID.
866 * @param \WC_Order_Item|null $order_item Source order item.
867 *
868 * @return array|false {discounted:string,full:string,has_discount:bool}, or false when the
869 * order item is missing or carries no subscription meta.
870 */
871 protected static function get_subscription_recurring_price_parts( $subscription_id, $order_item = null ) {
872 if ( ! $order_item ) {
873 return false;
874 }
875
876 $totals = self::get_subscription_display_totals( $subscription_id, $order_item );
877 $discounted = self::format_price_with_order_item( $totals['total'], $order_item->get_id() );
878
879 if ( ! $discounted ) {
880 return false;
881 }
882
883 // Undiscounted amount including its own tax.
884 $full = $totals['full_excl'] + $totals['tax'] + $totals['discount_tax'];
885 $order = wc_get_order( $order_item->get_order_id() );
886
887 return array(
888 'discounted' => $discounted,
889 'full' => wc_price(
890 $full,
891 array(
892 'currency' => $order ? $order->get_currency() : '',
893 )
894 ),
895 'has_discount' => $totals['has_discount'],
896 );
897 }
898
899 /**
900 * Formatted recurring amount for a subscription, striking the original when a discount recurs.
901 *
902 * Produces the same `<del>` / `<ins>` shape the cart's recurring totals use, so a customer
903 * sees one consistent treatment of a recurring discount from cart through to order details.
904 * Use `get_subscription_recurring_price_text()` anywhere the output may reach a plain-text
905 * context, such as an email that renders in both HTML and plain.
906 *
907 * @param int $subscription_id Subscription ID.
908 * @param \WC_Order_Item|null $order_item Source order item.
909 * @param array $args Optional. 'del_style' is an inline style for the struck-through
910 * amount — email clients strip stylesheets, so email callers
911 * must pass one.
912 *
913 * @return string|false Formatted price, or false when the order item has no subscription meta.
914 */
915 public static function get_subscription_recurring_price_html( $subscription_id, $order_item = null, $args = array() ) {
916 $parts = self::get_subscription_recurring_price_parts( $subscription_id, $order_item );
917
918 if ( ! $parts ) {
919 return false;
920 }
921
922 if ( ! $parts['has_discount'] ) {
923 return $parts['discounted'];
924 }
925
926 $del_style = $args['del_style'] ?? '';
927 $del_attributes = $del_style ? ' style="' . esc_attr( $del_style ) . '"' : '';
928
929 return '<del aria-hidden="true"' . $del_attributes . '>' . $parts['full'] . '</del> <ins>' . $parts['discounted'] . '</ins>';
930 }
931
932 /**
933 * Formatted recurring amount for a subscription, as markup-free text.
934 *
935 * For contexts that cannot render `<del>` — plain-text emails above all, where stripping the
936 * tags would leave two bare amounts side by side and no way to tell which is charged.
937 *
938 * @param int $subscription_id Subscription ID.
939 * @param \WC_Order_Item|null $order_item Source order item.
940 *
941 * @return string|false Formatted price, or false when the order item has no subscription meta.
942 */
943 public static function get_subscription_recurring_price_text( $subscription_id, $order_item = null ) {
944 $parts = self::get_subscription_recurring_price_parts( $subscription_id, $order_item );
945
946 if ( ! $parts ) {
947 return false;
948 }
949
950 $discounted = wp_strip_all_tags( $parts['discounted'] );
951
952 if ( ! $parts['has_discount'] ) {
953 return $discounted;
954 }
955
956 return sprintf(
957 // translators: 1: discounted recurring amount, 2: original amount before the discount.
958 __( '%1$s (discounted from %2$s)', 'subscription' ),
959 $discounted,
960 wp_strip_all_tags( $parts['full'] )
961 );
962 }
963
964 /**
965 * Get recurrings items from cart items.
966 *
967 * @param array $cart_items Cart items.
968 *
969 * @return array
970 */
971 public static function get_recurrs_from_cart( $cart_items ) {
972 $recurrs = array();
973 foreach ( $cart_items as $key => $cart_item ) {
974 $product = $cart_item['data'];
975 if ( $product->is_type( 'simple' ) && isset( $cart_item['subscription'] ) ) {
976 $cart_subscription = $cart_item['subscription'];
977 $type = ucfirst( $cart_subscription['type'] );
978 $price_data = self::build_cart_recurring_price_data( $cart_item, $key, $type );
979
980 $recurrs[ $key ] = array_merge(
981 $price_data,
982 array(
983 'trial_status' => ! is_null( $cart_subscription['trial'] ),
984 'start_date' => self::start_date( $cart_subscription['trial'] ),
985 'next_date' => self::next_date( ( $cart_subscription['time'] ?? 1 ) . ' ' . $cart_subscription['type'], $cart_subscription['trial'] ),
986 'can_user_cancel' => $cart_item['data']->get_meta( '_subscrpt_user_cancel' ),
987 'max_no_payment' => $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
988 'quantity' => (int) $cart_item['quantity'],
989 )
990 );
991 }
992 }
993
994 return apply_filters( 'wpsubs_cart_recurring_items', $recurrs, $cart_items );
995 }
996
997 /**
998 * Check if the order has subscription item.
999 *
1000 * @param \WC_Order|int $order Order object.
1001 */
1002 public static function order_has_subscription_item( $order ) {
1003 if ( is_int( $order ) ) {
1004 $order = wc_get_order( $order );
1005 }
1006
1007 $is_subscription_order = false;
1008 foreach ( $order->get_items() as $item ) {
1009 $item_data = $item->get_data() ?? array();
1010 $item_product_id = $item_data['product_id'] ?? 0;
1011 $item_variation_id = $item_data['variation_id'] ?? 0;
1012
1013 $product_id = $item_variation_id ? $item_variation_id : $item_product_id;
1014 $product = Subscription::get_subs_product( $product_id );
1015
1016 if ( $product && $product->is_enabled() ) {
1017 $is_subscription_order = true;
1018 break;
1019 }
1020 }
1021 return $is_subscription_order;
1022 }
1023
1024 /**
1025 * Create renewal order when subscription expired. [wip]
1026 *
1027 * @param int $subscription_id Subscription ID.
1028 * @return false|\WC_Order Renewal order object or false on failure.
1029 * @throws \WC_Data_Exception Exception.
1030 * @throws \Exception Exception.
1031 */
1032 public static function create_renewal_order( $subscription_id ) {
1033 // Check if maximum payment limit has been reached
1034 if ( subscrpt_is_max_payments_reached( $subscription_id ) ) {
1035 // Mark subscription as expired due to limit reached
1036 Action::status( 'expired', $subscription_id );
1037
1038 error_log( "WPS: Maximum payment limit reached for subscription #{$subscription_id}. No renewal order created." );
1039 return false;
1040 }
1041
1042 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
1043 $order_id = wc_get_order_id_by_order_item_id( $order_item_id );
1044 $old_order = self::check_order_for_renewal( $order_id );
1045
1046 if ( ! $old_order ) {
1047 // 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.
1048 foreach ( self::get_related_orders( $subscription_id ) as $row ) {
1049 $candidate = wc_get_order( (int) ( $row->order_id ?? 0 ) );
1050 if ( $candidate && 'completed' === $candidate->get_status() ) {
1051 $old_order = $candidate;
1052 $order_item_id = (int) ( $row->order_item_id ?? 0 );
1053 break;
1054 }
1055 }
1056 }
1057
1058 if ( ! $old_order ) {
1059 subscrpt_write_log( "Old order not found for renewal. Skipping creating renewal order. [ Subscription ID: {$subscription_id} ]" );
1060 return false;
1061 }
1062
1063 $order_item = $old_order->get_item( $order_item_id );
1064 $subscription_price = (float) get_post_meta( $subscription_id, '_subscrpt_price', true );
1065 $qty = $order_item->get_quantity();
1066
1067 // Subtract tax from per-unit subscription price if prices include tax. WC_Order will calculate tax on line total.
1068 if ( wc_prices_include_tax() ) {
1069 $product = ( $order_item instanceof \WC_Order_Item_Product ) ? $order_item->get_product() : null;
1070 $tax_class = $product ? $product->get_tax_class() : '';
1071 $tax_rates = \WC_Tax::get_rates( $tax_class );
1072 $taxes = \WC_Tax::calc_inclusive_tax( $subscription_price, $tax_rates );
1073 $subscription_price = $subscription_price - array_sum( $taxes );
1074 }
1075
1076 $line_total = $subscription_price * $qty;
1077 $product_args = array(
1078 'name' => $order_item->get_name(),
1079 'subtotal' => $line_total,
1080 'total' => $line_total,
1081 );
1082
1083 // creating new order.
1084 $new_order_data = self::create_new_order_for_renewal( $old_order, $order_item, $product_args );
1085 if ( ! $new_order_data ) {
1086 subscrpt_write_log( "Failed to create renewal order. [ Subscription ID: {$subscription_id} ]" );
1087 return false;
1088 }
1089 $new_order = $new_order_data['order'];
1090 $new_order_item_id = $new_order_data['order_item_id'];
1091
1092 self::create_renewal_history( $subscription_id, $new_order->get_id(), $new_order_item_id );
1093 update_post_meta( $subscription_id, '_subscrpt_order_id', $new_order->get_id() );
1094 update_post_meta( $subscription_id, '_subscrpt_order_item_id', $new_order_item_id );
1095
1096 self::clone_order_metadata( $new_order, $old_order );
1097
1098 // Allow modification of the renewal order before saving.
1099 $new_order = apply_filters( 'subscrpt_before_saving_renewal_order', $new_order, $old_order, $subscription_id );
1100
1101 // Save the new order.
1102 $new_order->calculate_totals();
1103 $new_order->save();
1104
1105 if ( ! is_admin() && function_exists( 'wc_add_notice' ) && WC()->session ) {
1106 $message = 'Renewal Order(#' . $new_order->get_id() . ') Created.';
1107 if ( $new_order->has_status( 'pending' ) ) {
1108 $message .= 'Please <a href="' . $new_order->get_checkout_payment_url() . '">Pay now</a>';
1109 }
1110 wc_add_notice( $message, 'success' );
1111 }
1112
1113 do_action( 'subscrpt_after_create_renew_order', $new_order, $old_order, $subscription_id, false );
1114
1115 return $new_order;
1116 }
1117
1118 /**
1119 * Get subscription total price.
1120 *
1121 * @param int $subscription_id Subscription ID.
1122 * @return float
1123 */
1124 public static function get_subscription_total( $subscription_id ) {
1125 return (float) get_post_meta( $subscription_id, '_subscrpt_price', true );
1126 }
1127
1128 /**
1129 * Get subscription status.
1130 *
1131 * @param int $subscription_id Subscription ID.
1132 * @return string
1133 */
1134 public static function get_subscription_status( $subscription_id ) {
1135 return get_post_status( $subscription_id );
1136 }
1137
1138 /**
1139 * Check if subscription has status.
1140 *
1141 * @param int $subscription_id Subscription ID.
1142 * @param string $status Status to check.
1143 * @return bool
1144 */
1145 public static function subscription_has_status( $subscription_id, $status ) {
1146 return self::get_subscription_status( $subscription_id ) === $status;
1147 }
1148
1149 /**
1150 * Check if subscription needs payment.
1151 *
1152 * @param int $subscription_id Subscription ID.
1153 * @return bool
1154 */
1155 public static function subscription_needs_payment( $subscription_id ) {
1156 return true; // Always true for now
1157 }
1158
1159 /**
1160 * Get product period (timing option).
1161 *
1162 * @param int $product_id Product ID.
1163 * @return string
1164 */
1165 public static function get_product_period( $product_id ) {
1166 $product = wc_get_product( $product_id );
1167 return $product ? $product->get_meta( '_subscrpt_timing_option' ) : '';
1168 }
1169
1170 /**
1171 * Get product interval (timing per).
1172 *
1173 * @param int $product_id Product ID.
1174 * @return int
1175 */
1176 public static function get_product_interval( $product_id ) {
1177 $product = wc_get_product( $product_id );
1178 return $product ? (int) $product->get_meta( '_subscrpt_timing_per' ) : 1;
1179 }
1180
1181 /**
1182 * Get product length (max payments).
1183 *
1184 * @param int $product_id Product ID.
1185 * @return int
1186 */
1187 public static function get_product_length( $product_id ) {
1188 $product = wc_get_product( $product_id );
1189 return $product ? (int) $product->get_meta( '_subscrpt_max_no_payment' ) : 0;
1190 }
1191
1192 /**
1193 * Get product trial length.
1194 *
1195 * @param int $product_id Product ID.
1196 * @return int
1197 */
1198 public static function get_product_trial_length( $product_id ) {
1199 $product = wc_get_product( $product_id );
1200 return $product ? (int) $product->get_meta( '_subscrpt_trial_timing_per' ) : 0;
1201 }
1202
1203 /**
1204 * Get product signup fee.
1205 *
1206 * @param int $product_id Product ID.
1207 * @return float
1208 */
1209 public static function get_product_signup_fee( $product_id ) {
1210 $product = wc_get_product( $product_id );
1211 return $product ? (float) $product->get_meta( '_subscrpt_signup_fee' ) : 0.0;
1212 }
1213
1214 /**
1215 * Get first renewal payment time.
1216 *
1217 * @param int $product_id Product ID.
1218 * @return int Timestamp
1219 */
1220 public static function get_first_renewal_payment_time( $product_id ) {
1221 $product = wc_get_product( $product_id );
1222 if ( ! $product ) {
1223 return 0;
1224 }
1225
1226 $trial_period = $product->get_meta( '_subscrpt_trial_timing_per' );
1227 $trial_option = $product->get_meta( '_subscrpt_trial_timing_option' );
1228
1229 if ( ! empty( $trial_period ) && ! empty( $trial_option ) ) {
1230 return strtotime( "+{$trial_period} {$trial_option}" );
1231 }
1232
1233 return 0;
1234 }
1235
1236 /**
1237 * Update subscription next payment date.
1238 *
1239 * @param int $subscription_id Subscription ID.
1240 * @param string $new_date New Date string.
1241 * @return void
1242 */
1243 public static function update_subscription_next_payment_date( $subscription_id, $new_date ) {
1244 update_post_meta( $subscription_id, '_subscrpt_next_date', strtotime( $new_date ) );
1245 }
1246
1247 /**
1248 * Cancel subscription.
1249 *
1250 * @param int $subscription_id Subscription ID.
1251 * @return void
1252 */
1253 public static function cancel_subscription( $subscription_id ) {
1254 Action::status( 'cancelled', $subscription_id );
1255 }
1256
1257 /**
1258 * Pause subscription.
1259 *
1260 * @param int $subscription_id Subscription ID.
1261 * @return void
1262 */
1263 public static function pause_subscription( $subscription_id ) {
1264 Action::status( 'on-hold', $subscription_id );
1265 }
1266
1267 /**
1268 * Resume subscription.
1269 *
1270 * @param int $subscription_id Subscription ID.
1271 * @return void
1272 */
1273 public static function resume_subscription( $subscription_id ) {
1274 Action::status( 'active', $subscription_id );
1275 }
1276
1277 /**
1278 * Mark subscription payment as complete.
1279 *
1280 * @param int $subscription_id Subscription ID.
1281 * @param string $payment_id Payment/Transaction ID.
1282 * @return void
1283 */
1284 public static function subscription_payment_complete( $subscription_id, $payment_id ) {
1285 if ( 'active' !== get_post_status( $subscription_id ) ) {
1286 Action::status( 'active', $subscription_id );
1287 }
1288
1289 // Allow payment gateways to add their own comments/notes
1290 do_action( 'subscrpt_subscription_payment_completed', $subscription_id, $payment_id );
1291 }
1292
1293 /**
1294 * Clone stripe metadata from old order.
1295 *
1296 * @param int $subscription_id Subscription Id.
1297 * @param \WC_Order $old_order Old Order Object.
1298 * @param \WC_Order $new_order New Order Object.
1299 *
1300 * @return void
1301 */
1302 public static function clone_stripe_metadata_for_renewal( $subscription_id, $old_order, $new_order ) {
1303 $is_auto_renew = get_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
1304 if ( empty( $is_auto_renew ) && subscrpt_is_auto_renew_enabled() ) {
1305 $is_auto_renew = true;
1306 update_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
1307 }
1308
1309 $is_auto_renew = get_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
1310 $is_auto_renew = in_array( $is_auto_renew, array( 1, '1' ), true );
1311
1312 $is_global_auto_renew = get_option( 'wp_subscription_stripe_auto_renew', '1' );
1313 $is_global_auto_renew = in_array( $is_global_auto_renew, array( 1, '1' ), true );
1314
1315 $stripe_supported_methods = Stripe::WPSUBS_SUPPORTED_METHODS;
1316 $old_method = $old_order->get_payment_method();
1317 $is_stripe_pm = ! empty( $old_method ) && in_array( $old_method, $stripe_supported_methods, true );
1318
1319 $has_stripe_meta = ! empty( $old_order->get_meta( '_stripe_customer_id' ) ) || ! empty( $old_order->get_meta( '_stripe_source_id' ) );
1320
1321 $stripe_enabled = ( ( $is_stripe_pm || $has_stripe_meta ) && $is_auto_renew && $is_global_auto_renew && subscrpt_is_auto_renew_enabled() );
1322
1323 if ( $stripe_enabled ) {
1324 $new_order->update_meta_data( '_stripe_customer_id', $old_order->get_meta( '_stripe_customer_id' ) );
1325 $new_order->update_meta_data( '_stripe_source_id', $old_order->get_meta( '_stripe_source_id' ) );
1326 $new_order->set_payment_method( $old_order->get_payment_method() );
1327 $new_order->set_payment_method_title( $old_order->get_payment_method_title() );
1328
1329 // Add debug log.
1330 subscrpt_write_debug_log( "Stripe metadata cloned for renewal order #{$new_order->get_id()} from old order #{$old_order->get_id()}" );
1331 } else {
1332 subscrpt_write_log( "Stripe metadata not processed. Auto renewal may fail. [ Renewal order #{$new_order->get_id()}, Old order #{$old_order->get_id()} ]" );
1333 subscrpt_write_debug_log( "Stripe metadata did not clone for renewal order #{$new_order->get_id()} from old order #{$old_order->get_id()}" );
1334 }
1335 }
1336
1337 /**
1338 * Create history for renewal.
1339 *
1340 * @param int $subscription_id Subscription Id.
1341 * @param int $new_order_id New Order Id.
1342 * @param int $new_order_item_id New Order Item Id.
1343 *
1344 * @return void
1345 */
1346 public static function create_renewal_history( $subscription_id, $new_order_id, $new_order_item_id ) {
1347 global $wpdb;
1348 $history_table = $wpdb->prefix . 'subscrpt_order_relation';
1349 $wpdb->insert(
1350 $history_table,
1351 array(
1352 'subscription_id' => $subscription_id,
1353 'order_id' => $new_order_id,
1354 'order_item_id' => $new_order_item_id,
1355 'type' => 'renew',
1356 )
1357 );
1358
1359 $comment_id = wp_insert_comment(
1360 array(
1361 'comment_author' => 'Subscription for WooCommerce',
1362 'comment_content' => sprintf( 'Subscription Renewal order successfully created. order is %s', $new_order_id ),
1363 'comment_post_ID' => $subscription_id,
1364 'comment_type' => 'order_note',
1365 )
1366 );
1367 update_comment_meta( $comment_id, '_subscrpt_activity', 'Renewal Order' );
1368 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'renewal_order' );
1369 }
1370
1371 /**
1372 * Get a subscription data.
1373 *
1374 * @param int $subscription_id Subscription ID.
1375 * @return array|null
1376 */
1377 public static function get_subscription_data( int $subscription_id ): ?array {
1378 if ( empty( get_post_meta( $subscription_id ) ) ) {
1379 return null;
1380 }
1381
1382 $subs_post = get_post( $subscription_id );
1383 $user_id = ! empty( $subs_post ) ? (int) $subs_post->post_author : 0;
1384
1385 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
1386 $product_id = ! empty( $product_id ) ? (int) $product_id : 0;
1387
1388 $variation_id = get_post_meta( $subscription_id, '_subscrpt_variation_id', true );
1389 $variation_id = ! empty( $variation_id ) ? (int) $variation_id : 0;
1390
1391 $chk_product_id = $variation_id ? $variation_id : $product_id;
1392
1393 $status = get_post_status( $subscription_id ); // pending, active, cancelled, pe_cancelled, expired
1394 $price = get_post_meta( $subscription_id, '_subscrpt_price', true );
1395
1396 $signup_fee = get_post_meta( $subscription_id, '_subscrpt_signup_fee', true );
1397 $signup_fee = ! empty( $signup_fee ) ? $signup_fee : 0;
1398
1399 $order_id = get_post_meta( $subscription_id, '_subscrpt_order_id', true );
1400 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
1401
1402 $can_user_cancel = in_array( get_post_meta( $subscription_id, '_subscrpt_user_cancel', true ), array( 1, '1', 'true', 'yes' ), true );
1403
1404 $start_datetime = (int) get_post_meta( $subscription_id, '_subscrpt_start_date', true );
1405 $start_date = ! empty( $start_datetime ) ? gmdate( DATE_RFC2822, $start_datetime ) : null;
1406
1407 $next_datetime = (int) get_post_meta( $subscription_id, '_subscrpt_next_date', true );
1408 $next_date = ! empty( $next_datetime ) ? gmdate( DATE_RFC2822, $next_datetime ) : null;
1409
1410 $timing_per = get_post_meta( $subscription_id, '_subscrpt_timing_per', true );
1411 $timing_per = empty( $timing_per ) ? get_post_meta( $chk_product_id, '_subscrpt_timing_per', true ) : $timing_per;
1412
1413 $timing_option = get_post_meta( $subscription_id, '_subscrpt_timing_option', true );
1414 $timing_option = empty( $timing_option ) ? get_post_meta( $chk_product_id, '_subscrpt_timing_option', true ) : $timing_option;
1415
1416 $trial_timing_per = get_post_meta( $subscription_id, '_subscrpt_trial_timing_per', true );
1417 $trial_timing_per = empty( $trial_timing_per ) ? get_post_meta( $chk_product_id, '_subscrpt_trial_timing_per', true ) : $trial_timing_per;
1418
1419 $trial_timing_option = get_post_meta( $subscription_id, '_subscrpt_trial_timing_option', true );
1420 $trial_timing_option = empty( $trial_timing_option ) ? get_post_meta( $chk_product_id, '_subscrpt_trial_timing_option', true ) : $trial_timing_option;
1421
1422 $is_auto_renew = in_array( get_post_meta( $subscription_id, '_subscrpt_auto_renew', true ), array( 1, '1', 'true', 'yes' ), true );
1423 $is_auto_renew = ! empty( $is_auto_renew ) ? $is_auto_renew : subscrpt_is_auto_renew_enabled();
1424
1425 $default_grace_period = (int) get_option( 'subscrpt_default_payment_grace_period', '7' );
1426 $default_grace_period = subscrpt_pro_activated() ? $default_grace_period : 0;
1427 $grace_end_datetime = $next_datetime + ( $default_grace_period * DAY_IN_SECONDS );
1428 $grace_end_date = gmdate( DATE_RFC2822, $grace_end_datetime );
1429 $grace_remaining_days = ceil( max( 0, $grace_end_datetime - time() ) / DAY_IN_SECONDS );
1430
1431 $subscription_data = array(
1432 'id' => $subscription_id,
1433 'status' => $status,
1434 'schedule' => array(
1435 'timing_per' => $timing_per,
1436 'timing_option' => $timing_option,
1437 ),
1438 'price' => $price,
1439 'signup_fee' => $signup_fee,
1440 'start_date' => $start_date,
1441 'next_date' => $next_date,
1442 'product' => array(
1443 'product_id' => $product_id,
1444 'variation_id' => $variation_id,
1445 ),
1446 'order' => array(
1447 'order_id' => $order_id,
1448 'order_item_id' => $order_item_id,
1449 ),
1450 'can_user_cancel' => $can_user_cancel,
1451 'is_auto_renew' => (bool) $is_auto_renew,
1452 'user_id' => $user_id,
1453 );
1454
1455 if ( ! empty( $trial_timing_per ) ) {
1456 $subscription_data['trial'] = array(
1457 'timing_per' => $trial_timing_per,
1458 'timing_option' => $trial_timing_option,
1459 );
1460 }
1461
1462 if (
1463 ! in_array( strtolower( $status ), array( 'cancelled', 'pending' ), true )
1464 && $next_datetime - time() <= 0
1465 && (int) $default_grace_period > 0
1466 ) {
1467 $subscription_data['grace_period'] = array(
1468 'remaining_days' => $grace_remaining_days,
1469 'end_date' => $grace_end_date,
1470 );
1471 }
1472
1473 return $subscription_data;
1474 }
1475
1476 /**
1477 * Get related orders of a subscription.
1478 *
1479 * @param int $subscription_id Subscription ID.
1480 * @return array
1481 */
1482 public static function get_related_orders( int $subscription_id ): array {
1483 global $wpdb;
1484 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
1485
1486 // @phpcs:ignore
1487 $order_histories = $wpdb->get_results(
1488 $wpdb->prepare(
1489 'SELECT order_id, order_item_id, type FROM %i WHERE subscription_id=%d ORDER BY id DESC',
1490 array(
1491 $table_name,
1492 $subscription_id,
1493 )
1494 )
1495 );
1496
1497 return $order_histories;
1498 }
1499
1500 /**
1501 * Get parent order from subscription.
1502 *
1503 * @param int $subscription_id Subscription ID.
1504 */
1505 public static function get_parent_order( int $subscription_id ) {
1506 $related_orders = self::get_related_orders( $subscription_id );
1507 $last_order = end( $related_orders );
1508
1509 if ( ! $last_order || strtolower( $last_order->type ?? '' ) !== 'new' ) {
1510 foreach ( $related_orders as $order ) {
1511 if ( strtolower( $order->type ?? '' ) === 'new' ) {
1512 $last_order = $order;
1513 break;
1514 }
1515 }
1516 }
1517
1518 $parent_order_id = $last_order->order_id ?? 0;
1519 $parent_order = wc_get_order( $parent_order_id );
1520 return $parent_order;
1521 }
1522
1523 /**
1524 * Create new order for renewal.
1525 *
1526 * @param \WC_Order $old_order Old Order Object.
1527 * @param \WC_Order_Item_Product $order_item Old Order Item Object.
1528 * @param array $product_args Product args for add product.
1529 *
1530 * @return array|false
1531 */
1532 public static function create_new_order_for_renewal( \WC_Order $old_order, \WC_Order_Item_Product $order_item, array $product_args ) {
1533 $product = $order_item->get_product();
1534 $user_id = $old_order->get_user_id();
1535 $new_order = wc_create_order(
1536 array(
1537 'customer_id' => $user_id,
1538 'status' => 'pending',
1539 )
1540 );
1541 $product_meta = apply_filters( 'subscrpt_renewal_item_meta', wc_get_order_item_meta( $order_item->get_id(), '_subscrpt_meta', true ), $product, $order_item );
1542 $product_args = apply_filters( 'subscrpt_renewal_product_args', $product_args, $product, $order_item );
1543 if ( ! $product_args ) {
1544 return false;
1545 }
1546
1547 $new_order_item_id = $new_order->add_product(
1548 $product,
1549 $order_item->get_quantity(),
1550 $product_args
1551 );
1552 wc_update_order_item_meta(
1553 $new_order_item_id,
1554 '_subscrpt_meta',
1555 array(
1556 'time' => $product_meta['time'],
1557 'type' => $product_meta['type'],
1558 'trial' => null,
1559 )
1560 );
1561
1562 // Add debug log.
1563 subscrpt_write_debug_log( "Renewal order #{$new_order->get_id()} created for old order #{$old_order->get_id()}" );
1564
1565 return array(
1566 'order' => $new_order,
1567 'order_item_id' => $new_order_item_id,
1568 );
1569 }
1570
1571 /**
1572 * Check if old order is completed or deleted!
1573 *
1574 * @param mixed $old_order_id Old Order Id.
1575 *
1576 * @return \WC_Order|false
1577 */
1578 public static function check_order_for_renewal( $old_order_id ) {
1579 $old_order = wc_get_order( $old_order_id );
1580 if ( ! $old_order || 'completed' !== $old_order->get_status() ) {
1581 if ( ! is_admin() && function_exists( 'wc_add_notice' ) && WC()->session ) {
1582 return wc_add_notice( __( 'Subscription renewal isn\'t possible due to previous order not completed or deletion.', 'subscription' ), 'error' );
1583 }
1584 return false;
1585 }
1586
1587 return $old_order;
1588 }
1589
1590 /**
1591 * Get delivery info from order.
1592 *
1593 * @param \WC_Order $order Order object.
1594 * @return array
1595 */
1596 public static function get_delivery_info_from_order( \WC_Order $order ) {
1597 $customer_id = $order->get_customer_id();
1598 $customer = new \WC_Customer( $customer_id );
1599 $email = $customer->get_email();
1600
1601 // Billing info (get from order first, if empty get from customer).
1602 $billing_first_name = ! empty( $order->get_billing_first_name() ) ? $order->get_billing_first_name() : $customer->get_billing_first_name();
1603 $billing_last_name = ! empty( $order->get_billing_last_name() ) ? $order->get_billing_last_name() : $customer->get_billing_last_name();
1604 $billing_email = ! empty( $order->get_billing_email() ) ? $order->get_billing_email() : $customer->get_billing_email();
1605 $billing_phone = ! empty( $order->get_billing_phone() ) ? $order->get_billing_phone() : $customer->get_billing_phone();
1606 $billing_company = ! empty( $order->get_billing_company() ) ? $order->get_billing_company() : $customer->get_billing_company();
1607
1608 $billing_city = ! empty( $order->get_billing_city() ) ? $order->get_billing_city() : $customer->get_billing_city();
1609 $billing_state = ! empty( $order->get_billing_state() ) ? $order->get_billing_state() : $customer->get_billing_state();
1610 $billing_country = ! empty( $order->get_billing_country() ) ? $order->get_billing_country() : $customer->get_billing_country();
1611 $billing_postcode = ! empty( $order->get_billing_postcode() ) ? $order->get_billing_postcode() : $customer->get_billing_postcode();
1612 $billing_address_1 = ! empty( $order->get_billing_address_1() ) ? $order->get_billing_address_1() : $customer->get_billing_address_1();
1613 $billing_address_2 = ! empty( $order->get_billing_address_2() ) ? $order->get_billing_address_2() : $customer->get_billing_address_2();
1614
1615 // Shipping info (get from order first, if empty get from customer).
1616 $shipping_first_name = ! empty( $order->get_shipping_first_name() ) ? $order->get_shipping_first_name() : $customer->get_shipping_first_name();
1617 $shipping_last_name = ! empty( $order->get_shipping_last_name() ) ? $order->get_shipping_last_name() : $customer->get_shipping_last_name();
1618 $shipping_phone = ! empty( $order->get_shipping_phone() ) ? $order->get_shipping_phone() : $customer->get_shipping_phone();
1619 $shipping_company = ! empty( $order->get_shipping_company() ) ? $order->get_shipping_company() : $customer->get_shipping_company();
1620
1621 $shipping_city = ! empty( $order->get_shipping_city() ) ? $order->get_shipping_city() : $customer->get_shipping_city();
1622 $shipping_state = ! empty( $order->get_shipping_state() ) ? $order->get_shipping_state() : $customer->get_shipping_state();
1623 $shipping_country = ! empty( $order->get_shipping_country() ) ? $order->get_shipping_country() : $customer->get_shipping_country();
1624 $shipping_postcode = ! empty( $order->get_shipping_postcode() ) ? $order->get_shipping_postcode() : $customer->get_shipping_postcode();
1625 $shipping_address_1 = ! empty( $order->get_shipping_address_1() ) ? $order->get_shipping_address_1() : $customer->get_shipping_address_1();
1626 $shipping_address_2 = ! empty( $order->get_shipping_address_2() ) ? $order->get_shipping_address_2() : $customer->get_shipping_address_2();
1627
1628 $order_meta = [
1629 'customer_id' => $order->get_customer_id(),
1630 'email' => $email,
1631 'billing' => [
1632 'first_name' => $billing_first_name,
1633 'last_name' => $billing_last_name,
1634 'email' => $billing_email,
1635 'phone' => $billing_phone,
1636 'company' => $billing_company,
1637 'city' => $billing_city,
1638 'state' => $billing_state,
1639 'country' => $billing_country,
1640 'postcode' => $billing_postcode,
1641 'address_1' => $billing_address_1,
1642 'address_2' => $billing_address_2,
1643 ],
1644 'shipping' => [
1645 'first_name' => $shipping_first_name,
1646 'last_name' => $shipping_last_name,
1647 'phone' => $shipping_phone,
1648 'company' => $shipping_company,
1649 'city' => $shipping_city,
1650 'state' => $shipping_state,
1651 'country' => $shipping_country,
1652 'postcode' => $shipping_postcode,
1653 'address_1' => $shipping_address_1,
1654 'address_2' => $shipping_address_2,
1655 ],
1656 ];
1657
1658 return $order_meta;
1659 }
1660
1661 /**
1662 * Set delivery info to order.
1663 *
1664 * @param \WC_Order $order Order object.
1665 * @param array $order_meta Order meta data.
1666 */
1667 public static function set_delivery_info_to_order( \WC_Order $order, array $order_meta ) {
1668 // Set Billing Info.
1669 $order->set_billing_first_name( $order_meta['billing']['first_name'] ?? '' );
1670 $order->set_billing_last_name( $order_meta['billing']['last_name'] ?? '' );
1671 $order->set_billing_email( $order_meta['billing']['email'] ?? '' );
1672 $order->set_billing_phone( $order_meta['billing']['phone'] ?? '' );
1673 $order->set_billing_company( $order_meta['billing']['company'] ?? '' );
1674 $order->set_billing_city( $order_meta['billing']['city'] ?? '' );
1675 $order->set_billing_state( $order_meta['billing']['state'] ?? '' );
1676 $order->set_billing_country( $order_meta['billing']['country'] ?? '' );
1677 $order->set_billing_postcode( $order_meta['billing']['postcode'] ?? '' );
1678 $order->set_billing_address_1( $order_meta['billing']['address_1'] ?? '' );
1679 $order->set_billing_address_2( $order_meta['billing']['address_2'] ?? '' );
1680
1681 // Set Shipping Info.
1682 $order->set_shipping_first_name( $order_meta['shipping']['first_name'] ?? '' );
1683 $order->set_shipping_last_name( $order_meta['shipping']['last_name'] ?? '' );
1684 $order->set_shipping_phone( $order_meta['shipping']['phone'] ?? '' );
1685 $order->set_shipping_company( $order_meta['shipping']['company'] ?? '' );
1686 $order->set_shipping_city( $order_meta['shipping']['city'] ?? '' );
1687 $order->set_shipping_state( $order_meta['shipping']['state'] ?? '' );
1688 $order->set_shipping_country( $order_meta['shipping']['country'] ?? '' );
1689 $order->set_shipping_postcode( $order_meta['shipping']['postcode'] ?? '' );
1690 $order->set_shipping_address_1( $order_meta['shipping']['address_1'] ?? '' );
1691 $order->set_shipping_address_2( $order_meta['shipping']['address_2'] ?? '' );
1692 }
1693
1694 /**
1695 * Save meta-data from old order
1696 *
1697 * @param \WC_Order $new_order new order object.
1698 * @param \WC_Order $old_order old order object.
1699 *
1700 * @return void
1701 */
1702 public static function clone_order_metadata( $new_order, $old_order ) {
1703 // Set customer and currency info.
1704 $new_order->set_customer_id( $old_order->get_customer_id() );
1705 $new_order->set_currency( $old_order->get_currency() );
1706
1707 // Get delivery info from old order.
1708 $order_meta = self::get_delivery_info_from_order( $old_order );
1709
1710 // Check for any missing information.
1711 $missing_billing_info = true;
1712 foreach ( $order_meta['billing'] as $key => $value ) {
1713 if ( ! empty( $value ) ) {
1714 $missing_billing_info = false;
1715 break;
1716 }
1717 }
1718 $missing_shipping_info = true;
1719 foreach ( $order_meta['shipping'] as $key => $value ) {
1720 if ( ! empty( $value ) ) {
1721 $missing_shipping_info = false;
1722 break;
1723 }
1724 }
1725 $missing_info = $missing_billing_info || $missing_shipping_info;
1726
1727 // Get info from the parent order if missing.
1728 if ( $missing_info ) {
1729 $subscription = self::get_subscriptions_from_order( $old_order->get_id() );
1730 $subscription = reset( $subscription );
1731 $subscription_id = ! empty( $subscription ) ? $subscription->subscription_id : 0;
1732
1733 subscrpt_write_log( "Missing delivery info in old order #{$old_order->get_id()} for subscription #{$subscription_id}. Trying to get from parent order." );
1734
1735 $parent_order = self::get_parent_order( $subscription_id );
1736 if ( ! empty( $parent_order ) ) {
1737 $order_meta = self::get_delivery_info_from_order( $parent_order );
1738 }
1739 }
1740
1741 // Set delivery info to new order.
1742 self::set_delivery_info_to_order( $new_order, $order_meta );
1743 }
1744 }
1745
1746 // HPOS: All order data access below uses WooCommerce CRUD and is HPOS compatible.
1747