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

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

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