| 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 |
|