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
← All changes | includes/functions.php +731 -33 1.4.02.0.0 View file →
@@ -1,13 +1,22 @@
1 1 <?php
2 +/**
3 + * Global helper functions.
4 + *
5 + * @package SpringDevs\Subscription
6 + */
2 7
8 +// Exit if accessed directly.
9 +if ( ! defined( 'ABSPATH' ) ) {
10 + exit;
11 +}
12 +
3 13 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
14 +use SpringDevs\Subscription\Illuminate\Subscription\Subscription;
4 15 use SpringDevs\Subscription\Utils\Product;
5 -use SpringDevs\Subscription\Utils\ProductFactory;
6 -use SpringDevs\Subscription\Utils\SubscriptionProduct;
7 16
8 17 /**
9 - * Generate Url for Subscription Action.
18 + * Generate URL for Subscription Action.
10 19 *
11 20 * @param string $action Action.
12 21 * @param string $nonce nonce.
13 22 * @param int $subscription_id Subscription ID.
@@ -14,8 +23,9 @@
14 23 *
15 24 * @return string
16 25 */
17 26 function subscrpt_get_action_url( $action, $nonce, $subscription_id ) {
27 + $view_subscription_endpoint = Subscription::get_user_endpoint( 'view_subs' );
18 28 return add_query_arg(
19 29 array(
20 30 'subscrpt_id' => $subscription_id,
21 31 'action' => $action,
@@ -20,24 +30,32 @@
20 30 'subscrpt_id' => $subscription_id,
21 31 'action' => $action,
22 32 'wpnonce' => $nonce,
23 33 ),
24 - wc_get_endpoint_url( 'view-subscription', $subscription_id, wc_get_page_permalink( 'myaccount' ) )
34 + wc_get_endpoint_url( $view_subscription_endpoint, $subscription_id, wc_get_page_permalink( 'myaccount' ) )
25 35 );
26 36 }
27 37
28 38
39 +/**
40 + * Get typos.
41 + *
42 + * @param int $number Number.
43 + * @param string $typo Typo.
44 + *
45 + * @return string
46 + */
29 47 function subscrpt_get_typos( $number, $typo ) {
30 48 if ( $number == 1 && $typo == 'days' ) {
31 - return __( 'day', 'sdevs_subscrpt' );
49 + return ucfirst( __( 'day', 'subscription' ) );
32 50 } elseif ( $number == 1 && $typo == 'weeks' ) {
33 - return __( 'week', 'sdevs_subscrpt' );
51 + return ucfirst( __( 'week', 'subscription' ) );
34 52 } elseif ( $number == 1 && $typo == 'months' ) {
35 - return __( 'month', 'sdevs_subscrpt' );
53 + return ucfirst( __( 'month', 'subscription' ) );
36 54 } elseif ( $number == 1 && $typo == 'years' ) {
37 - return __( 'year', 'sdevs_subscrpt' );
55 + return ucfirst( __( 'year', 'subscription' ) );
38 56 } else {
39 - return $typo;
57 + return ucfirst( $typo );
40 58 }
41 59 }
42 60
43 61 /**
@@ -67,40 +85,647 @@
67 85 return class_exists( 'Sdevs_Wc_Subscription_Pro' );
68 86 }
69 87
70 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 +/**
71 290 * Get renewal process settings.
72 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 + *
73 314 * @return bool
74 315 */
75 316 function subscrpt_is_auto_renew_enabled() {
76 - return 'auto' === get_option( 'subscrpt_renewal_process', 'auto' );
317 + return 'auto' === subscrpt_get_renewal_process();
77 318 }
78 319
79 320 /**
80 - * Return Label against key.
321 + * Split-payment amounts for a given total and installment count.
81 322 *
82 - * @param string $key Key to return cast Value.
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)
83 327 *
84 - * @return string
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}
85 331 */
86 -function order_relation_type_cast( string $key ) {
87 - $relational_type_keys = apply_filters(
88 - 'subscrpt_order_relational_types',
89 - array(
90 - 'new' => __( 'New Subscription Order', 'sdevs_subscrpt' ),
91 - 'renew' => __( 'Renewal Order', 'sdevs_subscrpt' ),
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
92 394 )
93 395 );
396 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
94 397
95 - return isset( $relational_type_keys[ $key ] ) ? $relational_type_keys[ $key ] : '-';
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;
96 418 }
97 419
98 -if ( ! function_exists( 'is_wc_order_hpos_enabled' ) ) {
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' ) ) {
99 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 + /**
100 715 * Check if HPOS enabled.
101 716 */
102 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() {
103 728 return function_exists( 'wc_get_container' ) ?
104 729 wc_get_container()
105 730 ->get( CustomOrdersTableController::class )
106 731 ->custom_orders_table_usage_is_enabled()
@@ -109,10 +734,20 @@
109 734 }
110 735
111 736 if ( ! function_exists( 'sdevs_wp_strtotime' ) ) {
112 737 /**
113 - * Get strtotime with WordPress timezone config.
738 + * Resolve a relative date string against a base timestamp, in site timezone.
114 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 + *
115 750 * @param string $str string.
116 751 * @param int|null $base_timestamp base timestamp.
117 752 *
118 753 * @return int
@@ -117,9 +752,23 @@
117 752 *
118 753 * @return int
119 754 */
120 755 function sdevs_wp_strtotime( $str, $base_timestamp = null ) {
121 - return strtotime( wp_date( 'Y-m-d H:i:s', strtotime( $str, $base_timestamp ) ) );
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 );
122 771 }
123 772 }
124 773
125 774 if ( ! function_exists( 'sdevs_order_status_label' ) ) {
@@ -136,9 +785,9 @@
136 785 return ( isset( $order_statuses[ "wc-{$status}" ] ) ? $order_statuses[ "wc-{$status}" ] : $status );
137 786 }
138 787 }
139 788
140 -if ( ! function_exists( 'get_timing_types' ) ) {
789 +if ( ! function_exists( 'wps_subscription_get_timing_types' ) ) {
141 790 /**
142 791 * Get labels.
143 792 *
144 793 * @param bool $key_value key_value.
@@ -145,8 +794,20 @@
145 794 *
146 795 * @return array
147 796 */
148 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 {
149 810 return $key_value ? array(
150 811 'days' => 'Daily',
151 812 'weeks' => 'Weekly',
152 813 'months' => 'Monthly',
@@ -152,21 +813,21 @@
152 813 'months' => 'Monthly',
153 814 'years' => 'Yearly',
154 815 ) : array(
155 816 array(
156 - 'label' => __( 'day(s)', 'sdevs_subscrpt' ),
817 + 'label' => __( 'Day', 'subscription' ),
157 818 'value' => 'days',
158 819 ),
159 820 array(
160 - 'label' => __( 'week(s)', 'sdevs_subscrpt' ),
821 + 'label' => __( 'Week', 'subscription' ),
161 822 'value' => 'weeks',
162 823 ),
163 824 array(
164 - 'label' => __( 'month(s)', 'sdevs_subscrpt' ),
825 + 'label' => __( 'Month', 'subscription' ),
165 826 'value' => 'months',
166 827 ),
167 828 array(
168 - 'label' => __( 'year(s)', 'sdevs_subscrpt' ),
829 + 'label' => __( 'Year', 'subscription' ),
169 830 'value' => 'years',
170 831 ),
171 832 );
172 833 }
@@ -171,11 +832,48 @@
171 832 );
172 833 }
173 834 }
174 835
175 -function sdevs_get_subscription_product( $product ): Product {
176 - if ( is_int( $product ) ) {
177 - $product = wc_get_product( $product );
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 + }
178 878 }
179 -
180 - return ProductFactory::load( $product );
181 879 }