| @@ -1,7 +1,19 @@ | ||
| 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\Multilingual; | |
| 15 | +use SpringDevs\Subscription\Illuminate\Plans\PlanRepository; | |
| 4 | 16 | use SpringDevs\Subscription\Illuminate\Subscription\Subscription; |
| 5 | 17 | use SpringDevs\Subscription\Utils\Product; |
| 6 | 18 | |
| 7 | 19 | /** |
| @@ -75,17 +87,263 @@ | ||
| 75 | 87 | return class_exists( 'Sdevs_Wc_Subscription_Pro' ); |
| 76 | 88 | } |
| 77 | 89 | |
| 78 | 90 | /** |
| 91 | + * Whether a product is tied to at least one active subscription plan. | |
| 92 | + * | |
| 93 | + * The single fallback guard every surface (storefront, checkout, admin) branches | |
| 94 | + * on: when this returns false, code must fall back to the classic `_subscrpt_*` | |
| 95 | + * per-product meta and must not read or write any plan table. Keeps plan | |
| 96 | + * detection consistent so no surface invents its own. | |
| 97 | + * | |
| 98 | + * @param int $product_id Product (parent) id. | |
| 99 | + * @param int $variation_id Variation id, or 0 for simple products. | |
| 100 | + * | |
| 101 | + * @return bool | |
| 102 | + */ | |
| 103 | +function subscrpt_product_has_plan( $product_id, $variation_id = 0 ): bool { | |
| 104 | + return ! empty( | |
| 105 | + PlanRepository::resolve_for_product( $product_id, $variation_id ) | |
| 106 | + ); | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** | |
| 110 | + * Whether a product / variation is actually offered as a subscription on the | |
| 111 | + * storefront: it must be tied to a plan AND be subscription-enabled | |
| 112 | + * (`_subscrpt_enabled`) on the exact entity — the variation when a variation id | |
| 113 | + * is given, otherwise the product. Storefront surfaces (plan selector, plan | |
| 114 | + * price, variation visibility) branch on this so a plan-tied but disabled | |
| 115 | + * product / variation shows no subscription UI at all. | |
| 116 | + * | |
| 117 | + * @param int $product_id Product (parent) id. | |
| 118 | + * @param int $variation_id Variation id, or 0 for simple products. | |
| 119 | + * | |
| 120 | + * @return bool | |
| 121 | + */ | |
| 122 | +function subscrpt_plan_offered( $product_id, $variation_id = 0 ): bool { | |
| 123 | + if ( ! subscrpt_product_has_plan( $product_id, $variation_id ) ) { | |
| 124 | + return false; | |
| 125 | + } | |
| 126 | + | |
| 127 | + return subscrpt_is_subscription_enabled( $product_id, $variation_id ); | |
| 128 | +} | |
| 129 | + | |
| 130 | +/** | |
| 131 | + * Whether a product / variation is subscription-enabled (`_subscrpt_enabled`). | |
| 132 | + * | |
| 133 | + * When the enable meta was never explicitly saved, a connected plan turns the | |
| 134 | + * subscription on by default — so attaching a plan enables it automatically, and | |
| 135 | + * it stays on until a save explicitly clears the toggle (an empty saved value). | |
| 136 | + * With no plan and no saved meta it is off (a fresh product defaults to off). | |
| 137 | + * | |
| 138 | + * @param int $product_id Product (parent) id. | |
| 139 | + * @param int $variation_id Variation id, or 0 for simple products. | |
| 140 | + * | |
| 141 | + * @return bool | |
| 142 | + */ | |
| 143 | +function subscrpt_is_subscription_enabled( $product_id, $variation_id = 0 ): bool { | |
| 144 | + $entity_id = $variation_id ? (int) $variation_id : (int) $product_id; | |
| 145 | + | |
| 146 | + if ( metadata_exists( 'post', $entity_id, '_subscrpt_enabled' ) ) { | |
| 147 | + return ! empty( get_post_meta( $entity_id, '_subscrpt_enabled', true ) ); | |
| 148 | + } | |
| 149 | + | |
| 150 | + // Never explicitly set: a connected plan enables the subscription by default. | |
| 151 | + return subscrpt_product_has_plan( $product_id, $variation_id ); | |
| 152 | +} | |
| 153 | + | |
| 154 | +/** | |
| 155 | + * Discount badge text for a storefront plan selector card. | |
| 156 | + * | |
| 157 | + * The single source both selectors share, so free and Pro word a discount | |
| 158 | + * identically. Returning an empty string from the filter hides the badge. | |
| 159 | + * | |
| 160 | + * @param array $group Plan group (id, type, label, terms, discount_percent, …). | |
| 161 | + * @param \WC_Product $product Product or variation being rendered. | |
| 162 | + * @param int $percent The group's best discount percentage. | |
| 163 | + * @param bool $varying Whether the group's terms discount by differing | |
| 164 | + * amounts, in which case the badge reads "up to". | |
| 165 | + * | |
| 166 | + * @return string | |
| 167 | + */ | |
| 168 | +function subscrpt_card_badge_text( $group, $product, $percent = 0, $varying = false ) { | |
| 169 | + if ( $percent > 0 ) { | |
| 170 | + $default = $varying | |
| 171 | + /* translators: %d: discount percentage. */ | |
| 172 | + ? sprintf( __( 'Save up to %d%%', 'subscription' ), $percent ) | |
| 173 | + /* translators: %d: discount percentage. */ | |
| 174 | + : sprintf( __( 'Save %d%%', 'subscription' ), $percent ); | |
| 175 | + } else { | |
| 176 | + $default = __( 'Sale', 'subscription' ); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * Filters the discount badge text on a storefront plan selector card. | |
| 181 | + * | |
| 182 | + * @param string $text Badge text (empty string hides the badge). | |
| 183 | + * @param array $group The plan group (id, type, label, terms, discount_percent, …). | |
| 184 | + * @param \WC_Product $product Product or variation being rendered. | |
| 185 | + * @param int $percent Computed discount percentage for the group. | |
| 186 | + */ | |
| 187 | + return (string) apply_filters( 'subscrpt_plan_card_badge', $default, $group, $product, $percent ); | |
| 188 | +} | |
| 189 | + | |
| 190 | +/** | |
| 191 | + * Build the storefront One-Time Purchase card for a product or variation. | |
| 192 | + * | |
| 193 | + * Offered only when the merchant opted in on this exact product or variation: | |
| 194 | + * `_subscrpt_one_time_enabled` is stored per variation, so pass the variation | |
| 195 | + * itself, never its parent, whose flag only means "any variation enabled". | |
| 196 | + * | |
| 197 | + * The single source of the one-time price maths. Both selectors call it so the | |
| 198 | + * free and Pro storefronts can never disagree on a price; Pro layers its | |
| 199 | + * discount badge onto the returned group rather than recomputing anything. | |
| 200 | + * | |
| 201 | + * @param \WC_Product $product Product or variation. | |
| 202 | + * | |
| 203 | + * @return array|null Selector group in plan-selector.php shape, or null when | |
| 204 | + * one-time purchase is not offered for this product. | |
| 205 | + */ | |
| 206 | +function subscrpt_one_time_group( $product ) { | |
| 207 | + if ( ! $product instanceof \WC_Product || ! function_exists( 'wc_price' ) ) { | |
| 208 | + return null; | |
| 209 | + } | |
| 210 | + | |
| 211 | + if ( 'yes' !== get_post_meta( $product->get_id(), '_subscrpt_one_time_enabled', true ) ) { | |
| 212 | + return null; | |
| 213 | + } | |
| 214 | + | |
| 215 | + $regular = (float) $product->get_regular_price(); | |
| 216 | + $sale = $product->get_sale_price(); | |
| 217 | + $price = '' !== $sale ? (float) $sale : $regular; | |
| 218 | + | |
| 219 | + // Strike the regular price through only when one-time is genuinely on sale. | |
| 220 | + $old_price = ( '' !== $sale && (float) $sale < $regular ) ? wc_price( $regular ) : ''; | |
| 221 | + $percent = ( '' !== $old_price && $regular > 0 ) | |
| 222 | + ? (int) round( ( $regular - $price ) / $regular * 100 ) | |
| 223 | + : 0; | |
| 224 | + | |
| 225 | + $group = array( | |
| 226 | + 'id' => 'one_time', | |
| 227 | + 'type' => 'one_time', | |
| 228 | + 'label' => __( 'One Time Purchase', 'subscription' ), | |
| 229 | + 'price' => wc_price( $price ), | |
| 230 | + 'old_price' => $old_price, | |
| 231 | + 'terms' => array(), | |
| 232 | + 'note' => '', | |
| 233 | + 'badge' => '', | |
| 234 | + 'discount_percent' => $percent, | |
| 235 | + ); | |
| 236 | + | |
| 237 | + if ( $percent > 0 ) { | |
| 238 | + $group['badge'] = subscrpt_card_badge_text( $group, $product, $percent, false ); | |
| 239 | + } | |
| 240 | + | |
| 241 | + return $group; | |
| 242 | +} | |
| 243 | + | |
| 244 | +/** | |
| 245 | + * Truncate a string to a max length, appending an ellipsis when shortened. | |
| 246 | + * | |
| 247 | + * Multibyte-safe. Returns the text unchanged when it is within the limit, so | |
| 248 | + * callers can compare the result to the original to detect truncation (e.g. to | |
| 249 | + * add a title attribute with the full text). | |
| 250 | + * | |
| 251 | + * @param string $text Text to truncate. | |
| 252 | + * @param int $length Maximum length before truncation. Default 30. | |
| 253 | + * | |
| 254 | + * @return string | |
| 255 | + */ | |
| 256 | +function subscrpt_truncate_text( $text, $length = 30 ) { | |
| 257 | + $text = (string) $text; | |
| 258 | + return mb_strlen( $text ) > $length ? mb_substr( $text, 0, $length ) . '…' : $text; | |
| 259 | +} | |
| 260 | + | |
| 261 | +/** | |
| 262 | + * Resolve a setting that was renamed without its readers being updated. | |
| 263 | + * | |
| 264 | + * Commit d4719e1 ("changed SUBSCRPT to WP_SUBSCRIPTION") renamed six option ids | |
| 265 | + * inside Admin/Settings.php and touched no reader. Four were caught later; two | |
| 266 | + * were not, so since 2025-05-08 the settings screen has been writing | |
| 267 | + * `wp_subscription_*` while the code kept reading `subscrpt_*` — the saved value | |
| 268 | + * never reached the feature, and the feature's default never reached the screen. | |
| 269 | + * | |
| 270 | + * Reading both names is what makes the two agree again. It is deliberately a | |
| 271 | + * read and not a migration: `subscrpt_is_auto_renew_enabled()` is called from | |
| 272 | + * the Stripe gateway and the renewal actions, and an option write on that path | |
| 273 | + * to fix a display problem is a bad trade. A site that saves its settings once | |
| 274 | + * writes the current name and never consults the legacy one again. | |
| 275 | + * | |
| 276 | + * @param string $option Current option name. | |
| 277 | + * @param string $legacy_option Name used before the rename. | |
| 278 | + * @param mixed $default_value Value when neither is set. | |
| 279 | + * @return mixed | |
| 280 | + */ | |
| 281 | +function subscrpt_get_renamed_option( $option, $legacy_option, $default_value = '' ) { | |
| 282 | + $value = get_option( $option, '' ); | |
| 283 | + | |
| 284 | + if ( '' !== $value && false !== $value && null !== $value ) { | |
| 285 | + return $value; | |
| 286 | + } | |
| 287 | + | |
| 288 | + return get_option( $legacy_option, $default_value ); | |
| 289 | +} | |
| 290 | + | |
| 291 | +/** | |
| 79 | 292 | * Get renewal process settings. |
| 80 | 293 | * |
| 294 | + * Must be used everywhere the renewal process is read, including the settings | |
| 295 | + * field itself — if the screen resolved the value differently from the code it | |
| 296 | + * would show "Automatic" to a site that is in fact set to manual. | |
| 297 | + * | |
| 298 | + * @return string 'auto' or 'manual'. | |
| 299 | + */ | |
| 300 | +function subscrpt_get_renewal_process() { | |
| 301 | + return (string) subscrpt_get_renamed_option( 'wp_subscription_renewal_process', 'subscrpt_renewal_process', 'auto' ); | |
| 302 | +} | |
| 303 | + | |
| 304 | +/** | |
| 305 | + * Notice shown when a manual renewal puts the product in the cart. | |
| 306 | + * | |
| 307 | + * @return string | |
| 308 | + */ | |
| 309 | +function subscrpt_get_manual_renew_cart_notice() { | |
| 310 | + return (string) subscrpt_get_renamed_option( 'wp_subscription_manual_renew_cart_notice', 'subscrpt_manual_renew_cart_notice', '' ); | |
| 311 | +} | |
| 312 | + | |
| 313 | +/** | |
| 314 | + * Get renewal process settings. | |
| 315 | + * | |
| 81 | 316 | * @return bool |
| 82 | 317 | */ |
| 83 | 318 | function subscrpt_is_auto_renew_enabled() { |
| 84 | - return 'auto' === get_option( 'subscrpt_renewal_process', 'auto' ); | |
| 319 | + return 'auto' === subscrpt_get_renewal_process(); | |
| 85 | 320 | } |
| 86 | 321 | |
| 87 | 322 | /** |
| 323 | + * Split-payment amounts for a given total and installment count. | |
| 324 | + * | |
| 325 | + * Single source of truth for split math so the product page, cart, checkout and | |
| 326 | + * subscription always agree: | |
| 327 | + * - per_installment = total / count, rounded UP to 2 decimals (ceil) | |
| 328 | + * - total = the price exactly as entered (never per × count) | |
| 329 | + * | |
| 330 | + * @param float|string $total Total price as entered on the plan/product. | |
| 331 | + * @param int $count Number of installments (minimum 1). | |
| 332 | + * @return array{total:float,count:int,per_installment:float} | |
| 333 | + */ | |
| 334 | +function subscrpt_split_amounts( $total, $count ) { | |
| 335 | + $total = (float) $total; | |
| 336 | + $count = max( 1, (int) $count ); | |
| 337 | + | |
| 338 | + return array( | |
| 339 | + 'total' => $total, | |
| 340 | + 'count' => $count, | |
| 341 | + 'per_installment' => ceil( $total / $count * 100 ) / 100, | |
| 342 | + ); | |
| 343 | +} | |
| 344 | + | |
| 345 | +/** | |
| 88 | 346 | * Get maximum payments for a subscription, checking variation, product, and subscription meta. |
| 89 | 347 | * |
| 90 | 348 | * @param int $subscription_id Subscription ID. |
| 91 | 349 | * @return string|int Maximum payments or empty string if not set. |
| @@ -113,9 +371,9 @@ | ||
| 113 | 371 | if ( ! $max_payments ) { |
| 114 | 372 | $max_payments = get_post_meta( $subscription_id, '_subscrpt_max_no_payment', true ); |
| 115 | 373 | } |
| 116 | 374 | |
| 117 | - return $max_payments ?: ''; | |
| 375 | + return $max_payments ? $max_payments : ''; | |
| 118 | 376 | } |
| 119 | 377 | |
| 120 | 378 | /** |
| 121 | 379 | * Count total payments made. |
| @@ -127,16 +385,14 @@ | ||
| 127 | 385 | global $wpdb; |
| 128 | 386 | |
| 129 | 387 | $table_name = $wpdb->prefix . 'subscrpt_order_relation'; |
| 130 | 388 | |
| 389 | + // Query the relation table only. Joining wp_posts would drop every row under | |
| 390 | + // HPOS (orders are not stored there); wc_get_order() below is HPOS-safe. | |
| 131 | 391 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 132 | 392 | $relations = $wpdb->get_results( |
| 133 | 393 | $wpdb->prepare( |
| 134 | - "SELECT sr.*, p.post_status, p.post_date | |
| 135 | - FROM $table_name sr | |
| 136 | - INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID | |
| 137 | - WHERE sr.subscription_id = %d | |
| 138 | - ORDER BY p.post_date ASC", | |
| 394 | + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC", | |
| 139 | 395 | $subscription_id |
| 140 | 396 | ) |
| 141 | 397 | ); |
| 142 | 398 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| @@ -194,14 +450,11 @@ | ||
| 194 | 450 | $is_reached = subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments ); |
| 195 | 451 | |
| 196 | 452 | // Fire action when split payment plan is completed (first time only) |
| 197 | 453 | if ( $is_reached && ! get_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ) ) { |
| 198 | - // Add completion milestone note | |
| 199 | - subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments ); | |
| 454 | + // All installments paid: complete the subscription (no renewal / expiry / grace). | |
| 455 | + $expire_status = apply_filters( 'subscrpt_split_payment_expire_status', 'completed', $subscription_id, $payments_made, $max_payments ); | |
| 200 | 456 | |
| 201 | - // Allow customization of subscription status after completion | |
| 202 | - $expire_status = apply_filters( 'subscrpt_split_payment_expire_status', 'expired', $subscription_id, $payments_made, $max_payments ); | |
| 203 | - | |
| 204 | 457 | // Update subscription status if different from current |
| 205 | 458 | $current_status = get_post_status( $subscription_id ); |
| 206 | 459 | if ( $current_status !== $expire_status ) { |
| 207 | 460 | wp_update_post( |
| @@ -211,8 +464,11 @@ | ||
| 211 | 464 | ) |
| 212 | 465 | ); |
| 213 | 466 | } |
| 214 | 467 | |
| 468 | + // Clear the next date so cron never expires it into a grace period. | |
| 469 | + delete_post_meta( $subscription_id, '_subscrpt_next_date' ); | |
| 470 | + | |
| 215 | 471 | do_action( 'subscrpt_split_payment_completed', $subscription_id, $payments_made, $max_payments ); |
| 216 | 472 | update_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ); |
| 217 | 473 | |
| 218 | 474 | // Handle split payment access timing if Pro version is active |
| @@ -289,8 +545,44 @@ | ||
| 289 | 545 | return $payment_type; |
| 290 | 546 | } |
| 291 | 547 | |
| 292 | 548 | /** |
| 549 | + * Human-readable label of the plan a subscription was purchased on. | |
| 550 | + * | |
| 551 | + * Combines the plan group name and the plan-term title (e.g. "Split Pay – Every | |
| 552 | + * Day"). Returns an empty string for legacy per-product subscriptions that were | |
| 553 | + * not bought through a plan. | |
| 554 | + * | |
| 555 | + * @param int $subscription_id Subscription ID. | |
| 556 | + * @return string Plan label, or '' when the subscription has no plan. | |
| 557 | + */ | |
| 558 | +function subscrpt_get_subscription_plan_label( $subscription_id ) { | |
| 559 | + $plan_id = (int) get_post_meta( $subscription_id, '_subscrpt_plan_id', true ); | |
| 560 | + if ( ! $plan_id || ! class_exists( PlanRepository::class ) ) { | |
| 561 | + return ''; | |
| 562 | + } | |
| 563 | + | |
| 564 | + $plan = PlanRepository::get_plan( $plan_id ); | |
| 565 | + if ( ! $plan ) { | |
| 566 | + return ''; | |
| 567 | + } | |
| 568 | + | |
| 569 | + $term_title = isset( $plan['title'] ) ? trim( Multilingual::translate_string( $plan['title'], Multilingual::plan_string_name( $plan_id ) ) ) : ''; | |
| 570 | + $group_title = ''; | |
| 571 | + $group_id = (int) ( $plan['plan_group_id'] ?? 0 ); | |
| 572 | + if ( $group_id ) { | |
| 573 | + $group = PlanRepository::get_group( $group_id ); | |
| 574 | + if ( $group && isset( $group['title'] ) ) { | |
| 575 | + $group_title = trim( Multilingual::translate_string( $group['title'], Multilingual::group_string_name( $group_id ) ) ); | |
| 576 | + } | |
| 577 | + } | |
| 578 | + | |
| 579 | + $parts = array_filter( array( $group_title, $term_title ) ); | |
| 580 | + | |
| 581 | + return implode( ' – ', $parts ); | |
| 582 | +} | |
| 583 | + | |
| 584 | +/** | |
| 293 | 585 | * Enhanced completion check considering failed payments and access suspension. |
| 294 | 586 | * |
| 295 | 587 | * @param int $subscription_id Subscription ID. |
| 296 | 588 | * @param int $payments_made Number of successful payments made. |
| @@ -315,9 +607,9 @@ | ||
| 315 | 607 | } |
| 316 | 608 | } |
| 317 | 609 | |
| 318 | 610 | // Check for maximum failure threshold |
| 319 | - $failure_count = get_post_meta( $subscription_id, '_subscrpt_payment_failure_count', true ) ?: 0; | |
| 611 | + $failure_count = (int) get_post_meta( $subscription_id, '_subscrpt_payment_failure_count', true ); | |
| 320 | 612 | $max_failures_before_completion = apply_filters( 'subscrpt_max_failures_before_completion', 0, $subscription_id ); |
| 321 | 613 | |
| 322 | 614 | if ( $max_failures_before_completion > 0 && $failure_count >= $max_failures_before_completion ) { |
| 323 | 615 | // Force completion after too many failures |
| @@ -349,16 +641,14 @@ | ||
| 349 | 641 | global $wpdb; |
| 350 | 642 | |
| 351 | 643 | $table_name = $wpdb->prefix . 'subscrpt_order_relation'; |
| 352 | 644 | |
| 645 | + // Query the relation table only. Joining wp_posts would drop every row under | |
| 646 | + // HPOS (orders are not stored there); wc_get_order() below is HPOS-safe. | |
| 353 | 647 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 354 | 648 | $relations = $wpdb->get_results( |
| 355 | 649 | $wpdb->prepare( |
| 356 | - "SELECT sr.*, p.post_status, p.post_date | |
| 357 | - FROM $table_name sr | |
| 358 | - INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID | |
| 359 | - WHERE sr.subscription_id = %d | |
| 360 | - ORDER BY p.post_date ASC", | |
| 650 | + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC", | |
| 361 | 651 | $subscription_id |
| 362 | 652 | ) |
| 363 | 653 | ); |
| 364 | 654 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| @@ -446,10 +736,20 @@ | ||
| 446 | 736 | } |
| 447 | 737 | |
| 448 | 738 | if ( ! function_exists( 'sdevs_wp_strtotime' ) ) { |
| 449 | 739 | /** |
| 450 | - * Get strtotime with WordPress timezone config. | |
| 740 | + * Resolve a relative date string against a base timestamp, in site timezone. | |
| 451 | 741 | * |
| 742 | + * The relative interval is applied to the site-local wall clock (so "+1 month" | |
| 743 | + * keeps the same local time across DST changes), and a real UTC timestamp is | |
| 744 | + * returned. | |
| 745 | + * | |
| 746 | + * Do not reimplement this as strtotime( wp_date( ... ) ): wp_date() renders the | |
| 747 | + * site-local wall clock while strtotime() parses it as UTC (WP sets PHP's default | |
| 748 | + * timezone to UTC), so the site's UTC offset gets added on every call. For | |
| 749 | + * recurring dates that compounds — a daily subscription on a UTC+7 site renews | |
| 750 | + * every 31 hours and skips a calendar day every few renewals. | |
| 751 | + * | |
| 452 | 752 | * @param string $str string. |
| 453 | 753 | * @param int|null $base_timestamp base timestamp. |
| 454 | 754 | * |
| 455 | 755 | * @return int |
| @@ -454,9 +754,23 @@ | ||
| 454 | 754 | * |
| 455 | 755 | * @return int |
| 456 | 756 | */ |
| 457 | 757 | function sdevs_wp_strtotime( $str, $base_timestamp = null ) { |
| 458 | - return strtotime( wp_date( 'Y-m-d H:i:s', strtotime( $str, $base_timestamp ) ) ); | |
| 758 | + $base = null === $base_timestamp ? time() : (int) $base_timestamp; | |
| 759 | + | |
| 760 | + try { | |
| 761 | + $date = new DateTime( '@' . $base ); | |
| 762 | + $modified = $date->setTimezone( wp_timezone() )->modify( $str ); | |
| 763 | + | |
| 764 | + if ( $modified instanceof DateTime ) { | |
| 765 | + return $modified->getTimestamp(); | |
| 766 | + } | |
| 767 | + } catch ( Exception $e ) { | |
| 768 | + // Unparsable string — fall through to strtotime(). | |
| 769 | + return strtotime( $str, $base ); | |
| 770 | + } | |
| 771 | + | |
| 772 | + return strtotime( $str, $base ); | |
| 459 | 773 | } |
| 460 | 774 | } |
| 461 | 775 | |
| 462 | 776 | if ( ! function_exists( 'sdevs_order_status_label' ) ) { |
| @@ -524,8 +838,11 @@ | ||
| 524 | 838 | /** |
| 525 | 839 | * Get WC product in subscription wrapper. |
| 526 | 840 | * |
| 527 | 841 | * @deprecated 1.8.17 Use SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product(). |
| 842 | + * | |
| 843 | + * @param \WC_Product|int $product Product object or product id. | |
| 844 | + * @return mixed Subscription product wrapper. | |
| 528 | 845 | */ |
| 529 | 846 | function sdevs_get_subscription_product( $product ) { |
| 530 | 847 | // Deprecated notice. |
| 531 | 848 | _deprecated_function( 'sdevs_get_subscription_product', '1.8.17', 'SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product' ); |
| @@ -560,64 +877,5 @@ | ||
| 560 | 877 | } else { |
| 561 | 878 | error_log( 'wp_subscription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions |
| 562 | 879 | } |
| 563 | 880 | } |
| 564 | -} | |
| 565 | - | |
| 566 | -/** | |
| 567 | - * Add payment completion note for split payment subscriptions. | |
| 568 | - * | |
| 569 | - * @param int $subscription_id Subscription ID. | |
| 570 | - * @param int $payments_made Number of payments made. | |
| 571 | - * @param int $max_payments Maximum number of payments. | |
| 572 | - */ | |
| 573 | -function subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments ) { | |
| 574 | - // Check if this is a split payment subscription | |
| 575 | - if ( ! function_exists( 'subscrpt_get_payment_type' ) ) { | |
| 576 | - return; | |
| 577 | - } | |
| 578 | - | |
| 579 | - $payment_type = subscrpt_get_payment_type( $subscription_id ); | |
| 580 | - if ( 'split_payment' !== $payment_type ) { | |
| 581 | - return; | |
| 582 | - } | |
| 583 | - | |
| 584 | - // Create completion note | |
| 585 | - $completion_note = sprintf( | |
| 586 | - /* translators: %1$d: payments made, %2$d: total payments */ | |
| 587 | - __( 'Split payment plan completed successfully! %1$d of %2$d payments received.', 'subscription' ), | |
| 588 | - $payments_made, | |
| 589 | - $max_payments | |
| 590 | - ); | |
| 591 | - | |
| 592 | - // Add the completion note | |
| 593 | - $comment_id = wp_insert_comment( | |
| 594 | - array( | |
| 595 | - 'comment_author' => 'Subscription for WooCommerce', | |
| 596 | - 'comment_content' => $completion_note, | |
| 597 | - 'comment_post_ID' => $subscription_id, | |
| 598 | - 'comment_type' => 'order_note', | |
| 599 | - ) | |
| 600 | - ); | |
| 601 | - update_comment_meta( $comment_id, '_subscrpt_activity', __( 'Split Payment - Plan Complete', 'subscription' ) ); | |
| 602 | - update_comment_meta( $comment_id, '_subscrpt_activity_type', 'split_payment' ); | |
| 603 | - | |
| 604 | - // Add payment summary note | |
| 605 | - $payment_summary = sprintf( | |
| 606 | - /* translators: %1$d: payments made, %2$d: total payments, %3$s: completion date */ | |
| 607 | - __( 'Payment Summary: %1$d of %2$d installments completed on %3$s. All payments received successfully.', 'subscription' ), | |
| 608 | - $payments_made, | |
| 609 | - $max_payments, | |
| 610 | - date_i18n( wc_date_format(), current_time( 'timestamp' ) ) | |
| 611 | - ); | |
| 612 | - | |
| 613 | - $summary_comment_id = wp_insert_comment( | |
| 614 | - array( | |
| 615 | - 'comment_author' => 'Subscription for WooCommerce', | |
| 616 | - 'comment_content' => $payment_summary, | |
| 617 | - 'comment_post_ID' => $subscription_id, | |
| 618 | - 'comment_type' => 'order_note', | |
| 619 | - ) | |
| 620 | - ); | |
| 621 | - update_comment_meta( $summary_comment_id, '_subscrpt_activity', __( 'Payment Summary - Complete', 'subscription' ) ); | |
| 622 | - update_comment_meta( $summary_comment_id, '_subscrpt_activity_type', 'split_payment_summary' ); | |
| 623 | 881 | } |