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

functions.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.4.0, at includes/functions.php

882 lines 28.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Global helper functions.
4 *
5 * @package SpringDevs\Subscription
6 */
7
8 // Exit if accessed directly.
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
14 use SpringDevs\Subscription\Illuminate\Multilingual;
15 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
16 use SpringDevs\Subscription\Illuminate\Subscription\Subscription;
17 use SpringDevs\Subscription\Utils\Product;
18
19 /**
20 * Generate URL for Subscription Action.
21 *
22 * @param string $action Action.
23 * @param string $nonce nonce.
24 * @param int $subscription_id Subscription ID.
25 *
26 * @return string
27 */
28 function subscrpt_get_action_url( $action, $nonce, $subscription_id ) {
29 $view_subscription_endpoint = Subscription::get_user_endpoint( 'view_subs' );
30 return add_query_arg(
31 array(
32 'subscrpt_id' => $subscription_id,
33 'action' => $action,
34 'wpnonce' => $nonce,
35 ),
36 wc_get_endpoint_url( $view_subscription_endpoint, $subscription_id, wc_get_page_permalink( 'myaccount' ) )
37 );
38 }
39
40
41 /**
42 * Get typos.
43 *
44 * @param int $number Number.
45 * @param string $typo Typo.
46 *
47 * @return string
48 */
49 function subscrpt_get_typos( $number, $typo ) {
50 if ( $number == 1 && $typo == 'days' ) {
51 return ucfirst( __( 'day', 'subscription' ) );
52 } elseif ( $number == 1 && $typo == 'weeks' ) {
53 return ucfirst( __( 'week', 'subscription' ) );
54 } elseif ( $number == 1 && $typo == 'months' ) {
55 return ucfirst( __( 'month', 'subscription' ) );
56 } elseif ( $number == 1 && $typo == 'years' ) {
57 return ucfirst( __( 'year', 'subscription' ) );
58 } else {
59 return ucfirst( $typo );
60 }
61 }
62
63 /**
64 * Format time with trial.
65 *
66 * @param mixed $time Time.
67 * @param null|string $trial Trial.
68 *
69 * @return string
70 */
71 function subscrpt_next_date( $time, $trial = null ) {
72 if ( null === $trial ) {
73 $start_date = time();
74 } else {
75 $start_date = strtotime( $trial );
76 }
77
78 return gmdate( 'F d, Y', strtotime( $time, $start_date ) );
79 }
80
81 /**
82 * Check if subscription-pro activated.
83 *
84 * @return bool
85 */
86 function subscrpt_pro_activated(): bool {
87 return class_exists( 'Sdevs_Wc_Subscription_Pro' );
88 }
89
90 /**
91 * Whether a product is tied to at least one active subscription plan.
92 *
93 * The single fallback guard every surface (storefront, checkout, admin) branches
94 * on: when this returns false, code must fall back to the classic `_subscrpt_*`
95 * per-product meta and must not read or write any plan table. Keeps plan
96 * detection consistent so no surface invents its own.
97 *
98 * @param int $product_id Product (parent) id.
99 * @param int $variation_id Variation id, or 0 for simple products.
100 *
101 * @return bool
102 */
103 function subscrpt_product_has_plan( $product_id, $variation_id = 0 ): bool {
104 return ! empty(
105 PlanRepository::resolve_for_product( $product_id, $variation_id )
106 );
107 }
108
109 /**
110 * Whether a product / variation is actually offered as a subscription on the
111 * storefront: it must be tied to a plan AND be subscription-enabled
112 * (`_subscrpt_enabled`) on the exact entity — the variation when a variation id
113 * is given, otherwise the product. Storefront surfaces (plan selector, plan
114 * price, variation visibility) branch on this so a plan-tied but disabled
115 * product / variation shows no subscription UI at all.
116 *
117 * @param int $product_id Product (parent) id.
118 * @param int $variation_id Variation id, or 0 for simple products.
119 *
120 * @return bool
121 */
122 function subscrpt_plan_offered( $product_id, $variation_id = 0 ): bool {
123 if ( ! subscrpt_product_has_plan( $product_id, $variation_id ) ) {
124 return false;
125 }
126
127 return subscrpt_is_subscription_enabled( $product_id, $variation_id );
128 }
129
130 /**
131 * Whether a product / variation is subscription-enabled (`_subscrpt_enabled`).
132 *
133 * When the enable meta was never explicitly saved, a connected plan turns the
134 * subscription on by default — so attaching a plan enables it automatically, and
135 * it stays on until a save explicitly clears the toggle (an empty saved value).
136 * With no plan and no saved meta it is off (a fresh product defaults to off).
137 *
138 * @param int $product_id Product (parent) id.
139 * @param int $variation_id Variation id, or 0 for simple products.
140 *
141 * @return bool
142 */
143 function subscrpt_is_subscription_enabled( $product_id, $variation_id = 0 ): bool {
144 $entity_id = $variation_id ? (int) $variation_id : (int) $product_id;
145
146 if ( metadata_exists( 'post', $entity_id, '_subscrpt_enabled' ) ) {
147 return ! empty( get_post_meta( $entity_id, '_subscrpt_enabled', true ) );
148 }
149
150 // Never explicitly set: a connected plan enables the subscription by default.
151 return subscrpt_product_has_plan( $product_id, $variation_id );
152 }
153
154 /**
155 * Discount badge text for a storefront plan selector card.
156 *
157 * The single source both selectors share, so free and Pro word a discount
158 * identically. Returning an empty string from the filter hides the badge.
159 *
160 * @param array $group Plan group (id, type, label, terms, discount_percent, …).
161 * @param \WC_Product $product Product or variation being rendered.
162 * @param int $percent The group's best discount percentage.
163 * @param bool $varying Whether the group's terms discount by differing
164 * amounts, in which case the badge reads "up to".
165 *
166 * @return string
167 */
168 function subscrpt_card_badge_text( $group, $product, $percent = 0, $varying = false ) {
169 if ( $percent > 0 ) {
170 $default = $varying
171 /* translators: %d: discount percentage. */
172 ? sprintf( __( 'Save up to %d%%', 'subscription' ), $percent )
173 /* translators: %d: discount percentage. */
174 : sprintf( __( 'Save %d%%', 'subscription' ), $percent );
175 } else {
176 $default = __( 'Sale', 'subscription' );
177 }
178
179 /**
180 * Filters the discount badge text on a storefront plan selector card.
181 *
182 * @param string $text Badge text (empty string hides the badge).
183 * @param array $group The plan group (id, type, label, terms, discount_percent, …).
184 * @param \WC_Product $product Product or variation being rendered.
185 * @param int $percent Computed discount percentage for the group.
186 */
187 return (string) apply_filters( 'subscrpt_plan_card_badge', $default, $group, $product, $percent );
188 }
189
190 /**
191 * Build the storefront One-Time Purchase card for a product or variation.
192 *
193 * Offered only when the merchant opted in on this exact product or variation:
194 * `_subscrpt_one_time_enabled` is stored per variation, so pass the variation
195 * itself, never its parent, whose flag only means "any variation enabled".
196 *
197 * The single source of the one-time price maths. Both selectors call it so the
198 * free and Pro storefronts can never disagree on a price; Pro layers its
199 * discount badge onto the returned group rather than recomputing anything.
200 *
201 * @param \WC_Product $product Product or variation.
202 *
203 * @return array|null Selector group in plan-selector.php shape, or null when
204 * one-time purchase is not offered for this product.
205 */
206 function subscrpt_one_time_group( $product ) {
207 if ( ! $product instanceof \WC_Product || ! function_exists( 'wc_price' ) ) {
208 return null;
209 }
210
211 if ( 'yes' !== get_post_meta( $product->get_id(), '_subscrpt_one_time_enabled', true ) ) {
212 return null;
213 }
214
215 $regular = (float) $product->get_regular_price();
216 $sale = $product->get_sale_price();
217 $price = '' !== $sale ? (float) $sale : $regular;
218
219 // Strike the regular price through only when one-time is genuinely on sale.
220 $old_price = ( '' !== $sale && (float) $sale < $regular ) ? wc_price( $regular ) : '';
221 $percent = ( '' !== $old_price && $regular > 0 )
222 ? (int) round( ( $regular - $price ) / $regular * 100 )
223 : 0;
224
225 $group = array(
226 'id' => 'one_time',
227 'type' => 'one_time',
228 'label' => __( 'One Time Purchase', 'subscription' ),
229 'price' => wc_price( $price ),
230 'old_price' => $old_price,
231 'terms' => array(),
232 'note' => '',
233 'badge' => '',
234 'discount_percent' => $percent,
235 );
236
237 if ( $percent > 0 ) {
238 $group['badge'] = subscrpt_card_badge_text( $group, $product, $percent, false );
239 }
240
241 return $group;
242 }
243
244 /**
245 * Truncate a string to a max length, appending an ellipsis when shortened.
246 *
247 * Multibyte-safe. Returns the text unchanged when it is within the limit, so
248 * callers can compare the result to the original to detect truncation (e.g. to
249 * add a title attribute with the full text).
250 *
251 * @param string $text Text to truncate.
252 * @param int $length Maximum length before truncation. Default 30.
253 *
254 * @return string
255 */
256 function subscrpt_truncate_text( $text, $length = 30 ) {
257 $text = (string) $text;
258 return mb_strlen( $text ) > $length ? mb_substr( $text, 0, $length ) . '…' : $text;
259 }
260
261 /**
262 * Resolve a setting that was renamed without its readers being updated.
263 *
264 * Commit d4719e1 ("changed SUBSCRPT to WP_SUBSCRIPTION") renamed six option ids
265 * inside Admin/Settings.php and touched no reader. Four were caught later; two
266 * were not, so since 2025-05-08 the settings screen has been writing
267 * `wp_subscription_*` while the code kept reading `subscrpt_*` — the saved value
268 * never reached the feature, and the feature's default never reached the screen.
269 *
270 * Reading both names is what makes the two agree again. It is deliberately a
271 * read and not a migration: `subscrpt_is_auto_renew_enabled()` is called from
272 * the Stripe gateway and the renewal actions, and an option write on that path
273 * to fix a display problem is a bad trade. A site that saves its settings once
274 * writes the current name and never consults the legacy one again.
275 *
276 * @param string $option Current option name.
277 * @param string $legacy_option Name used before the rename.
278 * @param mixed $default_value Value when neither is set.
279 * @return mixed
280 */
281 function subscrpt_get_renamed_option( $option, $legacy_option, $default_value = '' ) {
282 $value = get_option( $option, '' );
283
284 if ( '' !== $value && false !== $value && null !== $value ) {
285 return $value;
286 }
287
288 return get_option( $legacy_option, $default_value );
289 }
290
291 /**
292 * Get renewal process settings.
293 *
294 * Must be used everywhere the renewal process is read, including the settings
295 * field itself — if the screen resolved the value differently from the code it
296 * would show "Automatic" to a site that is in fact set to manual.
297 *
298 * @return string 'auto' or 'manual'.
299 */
300 function subscrpt_get_renewal_process() {
301 return (string) subscrpt_get_renamed_option( 'wp_subscription_renewal_process', 'subscrpt_renewal_process', 'auto' );
302 }
303
304 /**
305 * Notice shown when a manual renewal puts the product in the cart.
306 *
307 * @return string
308 */
309 function subscrpt_get_manual_renew_cart_notice() {
310 return (string) subscrpt_get_renamed_option( 'wp_subscription_manual_renew_cart_notice', 'subscrpt_manual_renew_cart_notice', '' );
311 }
312
313 /**
314 * Get renewal process settings.
315 *
316 * @return bool
317 */
318 function subscrpt_is_auto_renew_enabled() {
319 return 'auto' === subscrpt_get_renewal_process();
320 }
321
322 /**
323 * Split-payment amounts for a given total and installment count.
324 *
325 * Single source of truth for split math so the product page, cart, checkout and
326 * subscription always agree:
327 * - per_installment = total / count, rounded UP to 2 decimals (ceil)
328 * - total = the price exactly as entered (never per × count)
329 *
330 * @param float|string $total Total price as entered on the plan/product.
331 * @param int $count Number of installments (minimum 1).
332 * @return array{total:float,count:int,per_installment:float}
333 */
334 function subscrpt_split_amounts( $total, $count ) {
335 $total = (float) $total;
336 $count = max( 1, (int) $count );
337
338 return array(
339 'total' => $total,
340 'count' => $count,
341 'per_installment' => ceil( $total / $count * 100 ) / 100,
342 );
343 }
344
345 /**
346 * Get maximum payments for a subscription, checking variation, product, and subscription meta.
347 *
348 * @param int $subscription_id Subscription ID.
349 * @return string|int Maximum payments or empty string if not set.
350 */
351 function subscrpt_get_max_payments( $subscription_id ) {
352 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
353 if ( ! $product_id ) {
354 return '';
355 }
356
357 $max_payments = null;
358
359 // Check for variation first
360 $variation_id = get_post_meta( $subscription_id, '_subscrpt_variation_id', true );
361 if ( $variation_id ) {
362 $max_payments = get_post_meta( $variation_id, '_subscrpt_max_no_payment', true );
363 }
364
365 // Fallback to product if variation doesn't have max payments or no variation
366 if ( ! $max_payments ) {
367 $max_payments = get_post_meta( $product_id, '_subscrpt_max_no_payment', true );
368 }
369
370 // Also check subscription's own meta data as final fallback
371 if ( ! $max_payments ) {
372 $max_payments = get_post_meta( $subscription_id, '_subscrpt_max_no_payment', true );
373 }
374
375 return $max_payments ? $max_payments : '';
376 }
377
378 /**
379 * Count total payments made.
380 *
381 * @param int $subscription_id Subscription ID.
382 * @return int Number of payments made.
383 */
384 function subscrpt_count_payments_made( $subscription_id ) {
385 global $wpdb;
386
387 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
388
389 // Query the relation table only. Joining wp_posts would drop every row under
390 // HPOS (orders are not stored there); wc_get_order() below is HPOS-safe.
391 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
392 $relations = $wpdb->get_results(
393 $wpdb->prepare(
394 "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC",
395 $subscription_id
396 )
397 );
398 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
399
400 // Define all payment-related order types (allow filtering for extensibility)
401 $payment_types = apply_filters( 'subscrpt_payment_order_types', array( 'new', 'renew', 'early-renew' ) );
402
403 // Count successful payments
404 $successful_count = 0;
405 foreach ( $relations as $relation ) {
406 // Count all payment-related types
407 if ( in_array( $relation->type, $payment_types ) ) {
408 // Get the actual WooCommerce order
409 $order = wc_get_order( $relation->order_id );
410 if ( $order ) {
411 // Check if order was paid/successful
412 if ( $order->is_paid() || in_array( $order->get_status(), array( 'completed', 'processing', 'on-hold' ) ) ) {
413 ++$successful_count;
414 }
415 }
416 }
417 }
418
419 return $successful_count;
420 }
421
422 /**
423 * Check if subscription has reached its maximum payment limit.
424 *
425 * @param int $subscription_id Subscription ID.
426 * @return bool True if limit reached, false otherwise.
427 */
428 function subscrpt_is_max_payments_reached( $subscription_id ) {
429 // Get the product ID from subscription
430 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
431 if ( ! $product_id ) {
432 return false;
433 }
434
435 // Get maximum payments using helper function
436 $max_payments = subscrpt_get_max_payments( $subscription_id );
437
438 // Allow override of total installments
439 $max_payments = apply_filters( 'subscrpt_split_payment_total_override', $max_payments, $subscription_id, $product_id );
440
441 // If no limit set or unlimited, more payments are allowed
442 if ( ! $max_payments || intval( $max_payments ) <= 0 ) {
443 return false;
444 }
445
446 // Count payments made
447 $payments_made = subscrpt_count_payments_made( $subscription_id );
448
449 // Enhanced completion logic considering failed payments
450 $is_reached = subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments );
451
452 // Fire action when split payment plan is completed (first time only)
453 if ( $is_reached && ! get_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ) ) {
454 // All installments paid: complete the subscription (no renewal / expiry / grace).
455 $expire_status = apply_filters( 'subscrpt_split_payment_expire_status', 'completed', $subscription_id, $payments_made, $max_payments );
456
457 // Update subscription status if different from current
458 $current_status = get_post_status( $subscription_id );
459 if ( $current_status !== $expire_status ) {
460 wp_update_post(
461 array(
462 'ID' => $subscription_id,
463 'post_status' => $expire_status,
464 )
465 );
466 }
467
468 // Clear the next date so cron never expires it into a grace period.
469 delete_post_meta( $subscription_id, '_subscrpt_next_date' );
470
471 do_action( 'subscrpt_split_payment_completed', $subscription_id, $payments_made, $max_payments );
472 update_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true );
473
474 // Handle split payment access timing if Pro version is active
475 if ( function_exists( 'subscrpt_pro_activated' ) && subscrpt_pro_activated() ) {
476 if ( class_exists( '\SpringDevs\SubscriptionPro\Illuminate\SplitPaymentHandler' ) ) {
477 \SpringDevs\SubscriptionPro\Illuminate\SplitPaymentHandler::handle_split_payment_completion( $subscription_id, $payments_made, $max_payments );
478 }
479 }
480 }
481
482 return $is_reached;
483 }
484
485 /**
486 * Get remaining payments for a subscription.
487 *
488 * @param int $subscription_id Subscription ID.
489 * @return int|string Number of remaining payments or 'unlimited'.
490 */
491 function subscrpt_get_remaining_payments( $subscription_id ) {
492 // Get maximum payments using helper function
493 $max_payments = subscrpt_get_max_payments( $subscription_id );
494
495 // If no limit set or unlimited
496 if ( ! $max_payments || intval( $max_payments ) <= 0 ) {
497 return 'unlimited';
498 }
499
500 // Count payments made
501 $payments_made = subscrpt_count_payments_made( $subscription_id );
502
503 // Calculate remaining
504 $remaining = intval( $max_payments ) - intval( $payments_made );
505
506 return max( 0, $remaining );
507 }
508
509 /**
510 * Get payment type for a subscription (handles variations properly).
511 *
512 * @param int $subscription_id Subscription ID.
513 * @return string Payment type ('split_payment' or 'recurring').
514 */
515 function subscrpt_get_payment_type( $subscription_id ) {
516 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
517 $variation_id = get_post_meta( $subscription_id, '_subscrpt_variation_id', true );
518
519 $payment_type = 'recurring'; // Default
520
521 // Check variation first if it exists
522 if ( $variation_id ) {
523 $variation_payment_type = get_post_meta( $variation_id, '_subscrpt_payment_type', true );
524 if ( $variation_payment_type ) {
525 $payment_type = $variation_payment_type;
526 }
527 }
528
529 // Fallback to product if no variation payment type
530 if ( $payment_type === 'recurring' && $product_id ) {
531 $product_payment_type = get_post_meta( $product_id, '_subscrpt_payment_type', true );
532 if ( $product_payment_type ) {
533 $payment_type = $product_payment_type;
534 }
535 }
536
537 // Final fallback: check subscription's own meta data
538 if ( $payment_type === 'recurring' ) {
539 $subscription_payment_type = get_post_meta( $subscription_id, '_subscrpt_payment_type', true );
540 if ( $subscription_payment_type ) {
541 $payment_type = $subscription_payment_type;
542 }
543 }
544
545 return $payment_type;
546 }
547
548 /**
549 * Human-readable label of the plan a subscription was purchased on.
550 *
551 * Combines the plan group name and the plan-term title (e.g. "Split Pay – Every
552 * Day"). Returns an empty string for legacy per-product subscriptions that were
553 * not bought through a plan.
554 *
555 * @param int $subscription_id Subscription ID.
556 * @return string Plan label, or '' when the subscription has no plan.
557 */
558 function subscrpt_get_subscription_plan_label( $subscription_id ) {
559 $plan_id = (int) get_post_meta( $subscription_id, '_subscrpt_plan_id', true );
560 if ( ! $plan_id || ! class_exists( PlanRepository::class ) ) {
561 return '';
562 }
563
564 $plan = PlanRepository::get_plan( $plan_id );
565 if ( ! $plan ) {
566 return '';
567 }
568
569 $term_title = isset( $plan['title'] ) ? trim( Multilingual::translate_string( $plan['title'], Multilingual::plan_string_name( $plan_id ) ) ) : '';
570 $group_title = '';
571 $group_id = (int) ( $plan['plan_group_id'] ?? 0 );
572 if ( $group_id ) {
573 $group = PlanRepository::get_group( $group_id );
574 if ( $group && isset( $group['title'] ) ) {
575 $group_title = trim( Multilingual::translate_string( $group['title'], Multilingual::group_string_name( $group_id ) ) );
576 }
577 }
578
579 $parts = array_filter( array( $group_title, $term_title ) );
580
581 return implode( ' – ', $parts );
582 }
583
584 /**
585 * Enhanced completion check considering failed payments and access suspension.
586 *
587 * @param int $subscription_id Subscription ID.
588 * @param int $payments_made Number of successful payments made.
589 * @param int $max_payments Maximum payments required.
590 * @return bool True if subscription should be considered complete.
591 */
592 function subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments ) {
593 // Standard completion check
594 if ( $payments_made >= $max_payments ) {
595 return true;
596 }
597
598 // Check for access suspension due to payment failures
599 if ( function_exists( '\SpringDevs\SubscriptionPro\Illuminate\PaymentFailureHandler::is_access_suspended' ) ) {
600 $is_suspended = \SpringDevs\SubscriptionPro\Illuminate\PaymentFailureHandler::is_access_suspended( $subscription_id );
601 if ( $is_suspended ) {
602 // If access is suspended, check if we should force completion
603 $force_completion_on_suspension = apply_filters( 'subscrpt_force_completion_on_suspension', false, $subscription_id );
604 if ( $force_completion_on_suspension ) {
605 return true;
606 }
607 }
608 }
609
610 // Check for maximum failure threshold
611 $failure_count = (int) get_post_meta( $subscription_id, '_subscrpt_payment_failure_count', true );
612 $max_failures_before_completion = apply_filters( 'subscrpt_max_failures_before_completion', 0, $subscription_id );
613
614 if ( $max_failures_before_completion > 0 && $failure_count >= $max_failures_before_completion ) {
615 // Force completion after too many failures
616 return true;
617 }
618
619 // Check for time-based completion (e.g., if too much time has passed)
620 $completion_timeout_days = apply_filters( 'subscrpt_completion_timeout_days', 0, $subscription_id );
621 if ( $completion_timeout_days > 0 ) {
622 $start_date = get_post_meta( $subscription_id, '_subscrpt_start_date', true );
623 if ( $start_date ) {
624 $timeout_timestamp = $start_date + ( $completion_timeout_days * DAY_IN_SECONDS );
625 if ( current_time( 'timestamp' ) >= $timeout_timestamp ) {
626 return true;
627 }
628 }
629 }
630
631 return false;
632 }
633
634 /**
635 * Count total payment attempts (including failed ones) for a subscription.
636 *
637 * @param int $subscription_id Subscription ID.
638 * @return array Array with 'successful', 'failed', and 'total' counts.
639 */
640 function subscrpt_count_all_payment_attempts( $subscription_id ) {
641 global $wpdb;
642
643 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
644
645 // Query the relation table only. Joining wp_posts would drop every row under
646 // HPOS (orders are not stored there); wc_get_order() below is HPOS-safe.
647 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
648 $relations = $wpdb->get_results(
649 $wpdb->prepare(
650 "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC",
651 $subscription_id
652 )
653 );
654 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
655
656 // Define all payment-related order types
657 $payment_types = apply_filters( 'subscrpt_payment_order_types', array( 'new', 'renew', 'early-renew' ) );
658
659 $successful_count = 0;
660 $failed_count = 0;
661
662 foreach ( $relations as $relation ) {
663 if ( in_array( $relation->type, $payment_types ) ) {
664 $order = wc_get_order( $relation->order_id );
665 if ( $order ) {
666 if ( $order->is_paid() || in_array( $order->get_status(), array( 'completed', 'processing', 'on-hold' ) ) ) {
667 ++$successful_count;
668 } elseif ( in_array( $order->get_status(), array( 'failed', 'cancelled' ) ) ) {
669 ++$failed_count;
670 }
671 }
672 }
673 }
674
675 return array(
676 'successful' => $successful_count,
677 'failed' => $failed_count,
678 'total' => $successful_count + $failed_count,
679 );
680 }
681
682 if ( ! function_exists( 'wps_subscription_order_relation_type_cast' ) ) {
683 /**
684 * Return Label against key.
685 *
686 * @param string $key Key to return cast Value.
687 *
688 * @return string
689 */
690 function order_relation_type_cast( string $key ) {
691 // add Deprecated notice
692 _deprecated_function( 'order_relation_type_cast', '1.5.3', 'wps_subscription_order_relation_type_cast' );
693 return wps_subscription_order_relation_type_cast( $key );
694 }
695 /**
696 * Order relation type cast.
697 *
698 * @param string $key Key.
699 *
700 * @return string
701 */
702 function wps_subscription_order_relation_type_cast( string $key ) {
703 $relational_type_keys = apply_filters(
704 'subscrpt_order_relational_types',
705 array(
706 'new' => __( 'New Subscription Order', 'subscription' ),
707 'renew' => __( 'Renewal Order', 'subscription' ),
708 )
709 );
710
711 return isset( $relational_type_keys[ $key ] ) ? $relational_type_keys[ $key ] : '-';
712 }
713 }
714
715 if ( ! function_exists( 'wps_subscription_is_wc_order_hpos_enabled' ) ) {
716 /**
717 * Check if HPOS enabled.
718 */
719 function is_wc_order_hpos_enabled() {
720 // add Deprecated notice
721 _deprecated_function( 'is_wc_order_hpos_enabled', '1.5.3', 'wps_subscription_is_wc_order_hpos_enabled' );
722 return wps_subscription_is_wc_order_hpos_enabled();
723 }
724 /**
725 * Check if HPOS enabled.
726 *
727 * @return bool
728 */
729 function wps_subscription_is_wc_order_hpos_enabled() {
730 return function_exists( 'wc_get_container' ) ?
731 wc_get_container()
732 ->get( CustomOrdersTableController::class )
733 ->custom_orders_table_usage_is_enabled()
734 : false;
735 }
736 }
737
738 if ( ! function_exists( 'sdevs_wp_strtotime' ) ) {
739 /**
740 * Resolve a relative date string against a base timestamp, in site timezone.
741 *
742 * The relative interval is applied to the site-local wall clock (so "+1 month"
743 * keeps the same local time across DST changes), and a real UTC timestamp is
744 * returned.
745 *
746 * Do not reimplement this as strtotime( wp_date( ... ) ): wp_date() renders the
747 * site-local wall clock while strtotime() parses it as UTC (WP sets PHP's default
748 * timezone to UTC), so the site's UTC offset gets added on every call. For
749 * recurring dates that compounds — a daily subscription on a UTC+7 site renews
750 * every 31 hours and skips a calendar day every few renewals.
751 *
752 * @param string $str string.
753 * @param int|null $base_timestamp base timestamp.
754 *
755 * @return int
756 */
757 function sdevs_wp_strtotime( $str, $base_timestamp = null ) {
758 $base = null === $base_timestamp ? time() : (int) $base_timestamp;
759
760 try {
761 $date = new DateTime( '@' . $base );
762 $modified = $date->setTimezone( wp_timezone() )->modify( $str );
763
764 if ( $modified instanceof DateTime ) {
765 return $modified->getTimestamp();
766 }
767 } catch ( Exception $e ) {
768 // Unparsable string — fall through to strtotime().
769 return strtotime( $str, $base );
770 }
771
772 return strtotime( $str, $base );
773 }
774 }
775
776 if ( ! function_exists( 'sdevs_order_status_label' ) ) {
777 /**
778 * Get order status label from slug.
779 *
780 * @param string $status Status.
781 *
782 * @return string
783 */
784 function sdevs_order_status_label( $status ) {
785 $order_statuses = wc_get_order_statuses();
786
787 return ( isset( $order_statuses[ "wc-{$status}" ] ) ? $order_statuses[ "wc-{$status}" ] : $status );
788 }
789 }
790
791 if ( ! function_exists( 'wps_subscription_get_timing_types' ) ) {
792 /**
793 * Get labels.
794 *
795 * @param bool $key_value key_value.
796 *
797 * @return array
798 */
799 function get_timing_types( $key_value = false ): array {
800 // add Deprecated notice
801 _deprecated_function( 'get_timing_types', '1.5.3', 'wps_subscription_get_timing_types' );
802 return wps_subscription_get_timing_types( $key_value );
803 }
804 /**
805 * Get timing types.
806 *
807 * @param bool $key_value Key value.
808 *
809 * @return array
810 */
811 function wps_subscription_get_timing_types( $key_value = false ): array {
812 return $key_value ? array(
813 'days' => 'Daily',
814 'weeks' => 'Weekly',
815 'months' => 'Monthly',
816 'years' => 'Yearly',
817 ) : array(
818 array(
819 'label' => __( 'Day', 'subscription' ),
820 'value' => 'days',
821 ),
822 array(
823 'label' => __( 'Week', 'subscription' ),
824 'value' => 'weeks',
825 ),
826 array(
827 'label' => __( 'Month', 'subscription' ),
828 'value' => 'months',
829 ),
830 array(
831 'label' => __( 'Year', 'subscription' ),
832 'value' => 'years',
833 ),
834 );
835 }
836 }
837
838 /**
839 * Get WC product in subscription wrapper.
840 *
841 * @deprecated 1.8.17 Use SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product().
842 *
843 * @param \WC_Product|int $product Product object or product id.
844 * @return mixed Subscription product wrapper.
845 */
846 function sdevs_get_subscription_product( $product ) {
847 // Deprecated notice.
848 _deprecated_function( 'sdevs_get_subscription_product', '1.8.17', 'SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product' );
849
850 return Subscription::get_subs_product( $product );
851 }
852
853 /**
854 * Logger
855 *
856 * @param mixed $message Message.
857 * @param bool $should_print Print the output.
858 */
859 function subscrpt_write_log( $message, bool $should_print = false ): void {
860 $logger = wc_get_logger();
861
862 $message = is_array( $message ) || is_object( $message ) ? wp_json_encode( $message ) : $message;
863 $logger->add( 'wp_subscription', $message );
864
865 echo esc_html( $should_print ? $message : '' );
866 }
867
868 /**
869 * Debug Logger
870 *
871 * @param mixed $log logs.
872 */
873 function subscrpt_write_debug_log( $log ): void {
874 if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
875 if ( is_array( $log ) || is_object( $log ) ) {
876 error_log( print_r( $log, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
877 } else {
878 error_log( 'wp_subscription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
879 }
880 }
881 }
882