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

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

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