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