PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
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 trunk, at includes/Illuminate/Helper.php

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