| @@ -1,27 +1,21 @@ | ||
| 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; |
| 4 | 14 | use SpringDevs\Subscription\Illuminate\Subscription\Subscription; |
| 5 | 15 | use SpringDevs\Subscription\Utils\Product; |
| 6 | 16 | |
| 7 | 17 | /** |
| 8 | - * Include tailwind CSS file | |
| 9 | - * | |
| 10 | - * * Add this class to the parent element to apply tailwind css styles: | |
| 11 | - * * "`wpsubs-tw-root`" | |
| 12 | - * * | |
| 13 | - * * Use "`yarn build:tailwind`" to build the tailwind CSS file. | |
| 14 | - * * Use "`yarn watch:tailwind`" to continuously build the tailwind CSS file. | |
| 15 | - * | |
| 16 | - * ? This stylesheet is added to the all admin pages of the plugin. | |
| 17 | - * ? You can use this function to add the stylesheet on other pages if necessary. | |
| 18 | - */ | |
| 19 | -function subscrpt_include_tailwind_css() { | |
| 20 | - wp_enqueue_style( 'wpsubs-tailwind', SUBSCRPT_ASSETS . '/css/tailwind/output.css', [], SUBSCRPT_VERSION ); | |
| 21 | -} | |
| 22 | - | |
| 23 | -/** | |
| 24 | 18 | * Generate URL for Subscription Action. |
| 25 | 19 | * |
| 26 | 20 | * @param string $action Action. |
| 27 | 21 | * @param string $nonce nonce. |
| @@ -91,17 +85,263 @@ | ||
| 91 | 85 | return class_exists( 'Sdevs_Wc_Subscription_Pro' ); |
| 92 | 86 | } |
| 93 | 87 | |
| 94 | 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 | +/** | |
| 95 | 290 | * Get renewal process settings. |
| 96 | 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 | + * | |
| 97 | 314 | * @return bool |
| 98 | 315 | */ |
| 99 | 316 | function subscrpt_is_auto_renew_enabled() { |
| 100 | - return 'auto' === get_option( 'subscrpt_renewal_process', 'auto' ); | |
| 317 | + return 'auto' === subscrpt_get_renewal_process(); | |
| 101 | 318 | } |
| 102 | 319 | |
| 103 | 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 | +/** | |
| 104 | 344 | * Get maximum payments for a subscription, checking variation, product, and subscription meta. |
| 105 | 345 | * |
| 106 | 346 | * @param int $subscription_id Subscription ID. |
| 107 | 347 | * @return string|int Maximum payments or empty string if not set. |
| @@ -129,9 +369,9 @@ | ||
| 129 | 369 | if ( ! $max_payments ) { |
| 130 | 370 | $max_payments = get_post_meta( $subscription_id, '_subscrpt_max_no_payment', true ); |
| 131 | 371 | } |
| 132 | 372 | |
| 133 | - return $max_payments ?: ''; | |
| 373 | + return $max_payments ? $max_payments : ''; | |
| 134 | 374 | } |
| 135 | 375 | |
| 136 | 376 | /** |
| 137 | 377 | * Count total payments made. |
| @@ -143,16 +383,14 @@ | ||
| 143 | 383 | global $wpdb; |
| 144 | 384 | |
| 145 | 385 | $table_name = $wpdb->prefix . 'subscrpt_order_relation'; |
| 146 | 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. | |
| 147 | 389 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 148 | 390 | $relations = $wpdb->get_results( |
| 149 | 391 | $wpdb->prepare( |
| 150 | - "SELECT sr.*, p.post_status, p.post_date | |
| 151 | - FROM $table_name sr | |
| 152 | - INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID | |
| 153 | - WHERE sr.subscription_id = %d | |
| 154 | - ORDER BY p.post_date ASC", | |
| 392 | + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC", | |
| 155 | 393 | $subscription_id |
| 156 | 394 | ) |
| 157 | 395 | ); |
| 158 | 396 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| @@ -210,14 +448,11 @@ | ||
| 210 | 448 | $is_reached = subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments ); |
| 211 | 449 | |
| 212 | 450 | // Fire action when split payment plan is completed (first time only) |
| 213 | 451 | if ( $is_reached && ! get_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ) ) { |
| 214 | - // Add completion milestone note | |
| 215 | - subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments ); | |
| 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 ); | |
| 216 | 454 | |
| 217 | - // Allow customization of subscription status after completion | |
| 218 | - $expire_status = apply_filters( 'subscrpt_split_payment_expire_status', 'expired', $subscription_id, $payments_made, $max_payments ); | |
| 219 | - | |
| 220 | 455 | // Update subscription status if different from current |
| 221 | 456 | $current_status = get_post_status( $subscription_id ); |
| 222 | 457 | if ( $current_status !== $expire_status ) { |
| 223 | 458 | wp_update_post( |
| @@ -227,8 +462,11 @@ | ||
| 227 | 462 | ) |
| 228 | 463 | ); |
| 229 | 464 | } |
| 230 | 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 | + | |
| 231 | 469 | do_action( 'subscrpt_split_payment_completed', $subscription_id, $payments_made, $max_payments ); |
| 232 | 470 | update_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ); |
| 233 | 471 | |
| 234 | 472 | // Handle split payment access timing if Pro version is active |
| @@ -305,8 +543,44 @@ | ||
| 305 | 543 | return $payment_type; |
| 306 | 544 | } |
| 307 | 545 | |
| 308 | 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 | +/** | |
| 309 | 583 | * Enhanced completion check considering failed payments and access suspension. |
| 310 | 584 | * |
| 311 | 585 | * @param int $subscription_id Subscription ID. |
| 312 | 586 | * @param int $payments_made Number of successful payments made. |
| @@ -331,9 +605,9 @@ | ||
| 331 | 605 | } |
| 332 | 606 | } |
| 333 | 607 | |
| 334 | 608 | // Check for maximum failure threshold |
| 335 | - $failure_count = get_post_meta( $subscription_id, '_subscrpt_payment_failure_count', true ) ?: 0; | |
| 609 | + $failure_count = (int) get_post_meta( $subscription_id, '_subscrpt_payment_failure_count', true ); | |
| 336 | 610 | $max_failures_before_completion = apply_filters( 'subscrpt_max_failures_before_completion', 0, $subscription_id ); |
| 337 | 611 | |
| 338 | 612 | if ( $max_failures_before_completion > 0 && $failure_count >= $max_failures_before_completion ) { |
| 339 | 613 | // Force completion after too many failures |
| @@ -365,16 +639,14 @@ | ||
| 365 | 639 | global $wpdb; |
| 366 | 640 | |
| 367 | 641 | $table_name = $wpdb->prefix . 'subscrpt_order_relation'; |
| 368 | 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. | |
| 369 | 645 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 370 | 646 | $relations = $wpdb->get_results( |
| 371 | 647 | $wpdb->prepare( |
| 372 | - "SELECT sr.*, p.post_status, p.post_date | |
| 373 | - FROM $table_name sr | |
| 374 | - INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID | |
| 375 | - WHERE sr.subscription_id = %d | |
| 376 | - ORDER BY p.post_date ASC", | |
| 648 | + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC", | |
| 377 | 649 | $subscription_id |
| 378 | 650 | ) |
| 379 | 651 | ); |
| 380 | 652 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| @@ -462,10 +734,20 @@ | ||
| 462 | 734 | } |
| 463 | 735 | |
| 464 | 736 | if ( ! function_exists( 'sdevs_wp_strtotime' ) ) { |
| 465 | 737 | /** |
| 466 | - * Get strtotime with WordPress timezone config. | |
| 738 | + * Resolve a relative date string against a base timestamp, in site timezone. | |
| 467 | 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 | + * | |
| 468 | 750 | * @param string $str string. |
| 469 | 751 | * @param int|null $base_timestamp base timestamp. |
| 470 | 752 | * |
| 471 | 753 | * @return int |
| @@ -470,9 +752,23 @@ | ||
| 470 | 752 | * |
| 471 | 753 | * @return int |
| 472 | 754 | */ |
| 473 | 755 | function sdevs_wp_strtotime( $str, $base_timestamp = null ) { |
| 474 | - 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 ); | |
| 475 | 771 | } |
| 476 | 772 | } |
| 477 | 773 | |
| 478 | 774 | if ( ! function_exists( 'sdevs_order_status_label' ) ) { |
| @@ -540,8 +836,11 @@ | ||
| 540 | 836 | /** |
| 541 | 837 | * Get WC product in subscription wrapper. |
| 542 | 838 | * |
| 543 | 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. | |
| 544 | 843 | */ |
| 545 | 844 | function sdevs_get_subscription_product( $product ) { |
| 546 | 845 | // Deprecated notice. |
| 547 | 846 | _deprecated_function( 'sdevs_get_subscription_product', '1.8.17', 'SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product' ); |
| @@ -558,9 +857,9 @@ | ||
| 558 | 857 | function subscrpt_write_log( $message, bool $should_print = false ): void { |
| 559 | 858 | $logger = wc_get_logger(); |
| 560 | 859 | |
| 561 | 860 | $message = is_array( $message ) || is_object( $message ) ? wp_json_encode( $message ) : $message; |
| 562 | - $logger->add( 'wp_subcription', $message ); | |
| 861 | + $logger->add( 'wp_subscription', $message ); | |
| 563 | 862 | |
| 564 | 863 | echo esc_html( $should_print ? $message : '' ); |
| 565 | 864 | } |
| 566 | 865 | |
| @@ -573,362 +872,8 @@ | ||
| 573 | 872 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) { |
| 574 | 873 | if ( is_array( $log ) || is_object( $log ) ) { |
| 575 | 874 | error_log( print_r( $log, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions |
| 576 | 875 | } else { |
| 577 | - error_log( 'wp_subcription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions | |
| 876 | + error_log( 'wp_subscription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions | |
| 578 | 877 | } |
| 579 | 878 | } |
| 580 | -} | |
| 581 | - | |
| 582 | -/** | |
| 583 | - * Add payment completion note for split payment subscriptions. | |
| 584 | - * | |
| 585 | - * @param int $subscription_id Subscription ID. | |
| 586 | - * @param int $payments_made Number of payments made. | |
| 587 | - * @param int $max_payments Maximum number of payments. | |
| 588 | - */ | |
| 589 | -function subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments ) { | |
| 590 | - // Check if this is a split payment subscription | |
| 591 | - if ( ! function_exists( 'subscrpt_get_payment_type' ) ) { | |
| 592 | - return; | |
| 593 | - } | |
| 594 | - | |
| 595 | - $payment_type = subscrpt_get_payment_type( $subscription_id ); | |
| 596 | - if ( 'split_payment' !== $payment_type ) { | |
| 597 | - return; | |
| 598 | - } | |
| 599 | - | |
| 600 | - // Create completion note | |
| 601 | - $completion_note = sprintf( | |
| 602 | - /* translators: %1$d: payments made, %2$d: total payments */ | |
| 603 | - __( 'Split payment plan completed successfully! %1$d of %2$d payments received.', 'subscription' ), | |
| 604 | - $payments_made, | |
| 605 | - $max_payments | |
| 606 | - ); | |
| 607 | - | |
| 608 | - // Add the completion note | |
| 609 | - $comment_id = wp_insert_comment( | |
| 610 | - array( | |
| 611 | - 'comment_author' => 'Subscription for WooCommerce', | |
| 612 | - 'comment_content' => $completion_note, | |
| 613 | - 'comment_post_ID' => $subscription_id, | |
| 614 | - 'comment_type' => 'order_note', | |
| 615 | - ) | |
| 616 | - ); | |
| 617 | - update_comment_meta( $comment_id, '_subscrpt_activity', __( 'Split Payment - Plan Complete', 'subscription' ) ); | |
| 618 | - update_comment_meta( $comment_id, '_subscrpt_activity_type', 'split_payment' ); | |
| 619 | - | |
| 620 | - // Add payment summary note | |
| 621 | - $payment_summary = sprintf( | |
| 622 | - /* translators: %1$d: payments made, %2$d: total payments, %3$s: completion date */ | |
| 623 | - __( 'Payment Summary: %1$d of %2$d installments completed on %3$s. All payments received successfully.', 'subscription' ), | |
| 624 | - $payments_made, | |
| 625 | - $max_payments, | |
| 626 | - date_i18n( wc_date_format(), current_time( 'timestamp' ) ) | |
| 627 | - ); | |
| 628 | - | |
| 629 | - $summary_comment_id = wp_insert_comment( | |
| 630 | - array( | |
| 631 | - 'comment_author' => 'Subscription for WooCommerce', | |
| 632 | - 'comment_content' => $payment_summary, | |
| 633 | - 'comment_post_ID' => $subscription_id, | |
| 634 | - 'comment_type' => 'order_note', | |
| 635 | - ) | |
| 636 | - ); | |
| 637 | - update_comment_meta( $summary_comment_id, '_subscrpt_activity', __( 'Payment Summary - Complete', 'subscription' ) ); | |
| 638 | - update_comment_meta( $summary_comment_id, '_subscrpt_activity_type', 'split_payment_summary' ); | |
| 639 | -} | |
| 640 | - | |
| 641 | - | |
| 642 | -/** | |
| 643 | - * Render a WooCommerce-style multiselect field. | |
| 644 | - * | |
| 645 | - * @param array $field { | |
| 646 | - * Field arguments. | |
| 647 | - * | |
| 648 | - * @type string $id Required. Meta key / input ID. | |
| 649 | - * @type string $label Field label. | |
| 650 | - * @type array $options Key => Label pairs for options. | |
| 651 | - * @type array|string $selected Optional. Selected value(s). Array, JSON, or CSV. | |
| 652 | - * @type string $desc_tip Optional. Description tooltip text. | |
| 653 | - * @type string $description Optional. Field description text. | |
| 654 | - * @type string $wrapper_class Optional. Extra wrapper classes. | |
| 655 | - * @type string $class Optional. Extra <select> classes. | |
| 656 | - * @type string $name Optional. Input name. Defaults to $id.'[]'. | |
| 657 | - * } | |
| 658 | - */ | |
| 659 | -function subscrpt_multiselect_field( $field ) { | |
| 660 | - $defaults = [ | |
| 661 | - 'id' => '', | |
| 662 | - 'label' => '', | |
| 663 | - 'options' => [], | |
| 664 | - 'selected' => [], | |
| 665 | - 'desc_tip' => false, | |
| 666 | - 'description' => '', | |
| 667 | - 'wrapper_class' => '', | |
| 668 | - 'wrapper_style' => '', | |
| 669 | - 'class' => 'wc-enhanced-select', | |
| 670 | - 'style' => '', | |
| 671 | - 'name' => '', | |
| 672 | - ]; | |
| 673 | - | |
| 674 | - $field = wp_parse_args( $field, $defaults ); | |
| 675 | - | |
| 676 | - if ( empty( $field['id'] ) ) { | |
| 677 | - return; | |
| 678 | - } | |
| 679 | - | |
| 680 | - $id = esc_attr( $field['id'] ); | |
| 681 | - $name = $field['name'] ? $field['name'] : $id . '[]'; | |
| 682 | - $label = esc_html( $field['label'] ); | |
| 683 | - $description = $field['description']; | |
| 684 | - $desc_tip = $field['desc_tip']; | |
| 685 | - | |
| 686 | - // Normalize selected values into array. | |
| 687 | - $selected = []; | |
| 688 | - if ( is_array( $field['selected'] ) ) { | |
| 689 | - $selected = $field['selected']; | |
| 690 | - } elseif ( is_string( $field['selected'] ) && $field['selected'] !== '' ) { | |
| 691 | - if ( false !== strpos( $field['selected'], '[' ) ) { | |
| 692 | - $tmp = json_decode( $field['selected'], true ); | |
| 693 | - $selected = is_array( $tmp ) ? $tmp : []; | |
| 694 | - } else { | |
| 695 | - $selected = array_filter( array_map( 'trim', explode( ',', $field['selected'] ) ) ); | |
| 696 | - } | |
| 697 | - } | |
| 698 | - | |
| 699 | - // Build <option> list. | |
| 700 | - $options_html = ''; | |
| 701 | - foreach ( $field['options'] as $key => $text ) { | |
| 702 | - $is_selected = in_array( (string) $key, (array) $selected, true ) ? ' selected="selected"' : ''; | |
| 703 | - $options_html .= sprintf( | |
| 704 | - '<option value="%s"%s>%s</option>', | |
| 705 | - esc_attr( $key ), | |
| 706 | - $is_selected, | |
| 707 | - esc_html( $text ) | |
| 708 | - ); | |
| 709 | - } | |
| 710 | - | |
| 711 | - $tooltip_html = ''; | |
| 712 | - if ( $desc_tip && $description ) { | |
| 713 | - $tooltip_html = wc_help_tip( $description ); | |
| 714 | - } | |
| 715 | - | |
| 716 | - $description_html = ''; | |
| 717 | - if ( $description && ! $desc_tip ) { | |
| 718 | - $description_html = '<span class="description">' . wp_kses_post( $description ) . '</span>'; | |
| 719 | - } | |
| 720 | - | |
| 721 | - // ? Escaped intentionally. | |
| 722 | - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped | |
| 723 | - ?> | |
| 724 | - <p | |
| 725 | - class="form-field <?php echo esc_attr( $id . '_field ' . ( $field['wrapper_class'] ) ); ?>" | |
| 726 | - style="<?php echo esc_attr( $field['wrapper_style'] ); ?>" | |
| 727 | - > | |
| 728 | - <label for="<?php echo esc_attr( $id ); ?>"> | |
| 729 | - <?php echo esc_html( $label ); ?> | |
| 730 | - </label> | |
| 731 | - | |
| 732 | - <?php echo $tooltip_html; ?> | |
| 733 | - | |
| 734 | - <select | |
| 735 | - multiple="multiple" | |
| 736 | - id="<?php echo esc_attr( $id ); ?>" | |
| 737 | - name="<?php echo esc_attr( $name ); ?>" | |
| 738 | - class="<?php echo esc_attr( $field['class'] ); ?>" | |
| 739 | - style="<?php echo esc_attr( $field['style'] ); ?>" | |
| 740 | - > | |
| 741 | - <?php echo $options_html; ?> | |
| 742 | - </select> | |
| 743 | - | |
| 744 | - <?php echo $description_html; ?> | |
| 745 | - </p> | |
| 746 | - <?php | |
| 747 | - // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped | |
| 748 | -} | |
| 749 | - | |
| 750 | -/** | |
| 751 | - * Render a preview for pages that require WPSubscription Pro, with a blurred background image and a call-to-action overlay. | |
| 752 | - * | |
| 753 | - * @param array $args Preview arguments. | |
| 754 | - */ | |
| 755 | -function subscrpt_render_page_preview( array $args = [] ) { | |
| 756 | - $defaults = [ | |
| 757 | - 'preview_image_url' => SUBSCRPT_ASSETS . '/images/previews/subscrpt-health-preview.png', | |
| 758 | - 'cta_title' => __( 'Upgrade to WPSubscription Pro', 'subscription' ), | |
| 759 | - 'cta_description' => __( 'This page requires WPSubscription Pro. Unlock advanced features, priority support, and more with WPSubscription Pro.', 'subscription' ), | |
| 760 | - 'cta_button_text' => __( 'âš¡ Upgrade to Pro', 'subscription' ), | |
| 761 | - 'cta_button_url' => 'https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro', | |
| 762 | - ]; | |
| 763 | - | |
| 764 | - $args = wp_parse_args( $args, $defaults ); | |
| 765 | - | |
| 766 | - ob_start(); | |
| 767 | - ?> | |
| 768 | - <div style="position: relative;"> | |
| 769 | - <div style="filter:blur(4px);pointer-events:none;"> | |
| 770 | - <div style="max-width:1240px;margin:32px auto 0 auto;"> | |
| 771 | - <img | |
| 772 | - src="<?php echo esc_url( $args['preview_image_url'] ); ?>" | |
| 773 | - alt="<?php esc_attr_e( 'page preview', 'subscription' ); ?>" | |
| 774 | - style="width:100%;display:block;" | |
| 775 | - /> | |
| 776 | - </div> | |
| 777 | - </div> | |
| 778 | - <div style="position:absolute;inset:0;display:flex;align-items:top;justify-content:center;padding:100px 32px 32px;"> | |
| 779 | - <div style="height:fit-content;background:#fff;border-radius:12px;padding:28px 32px;text-align:center;max-width:440px;box-shadow:0 8px 48px rgba(0,0,0,0.22);"> | |
| 780 | - | |
| 781 | - <!-- Lock icon with radial glow --> | |
| 782 | - <div style="position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:20px;"> | |
| 783 | - <div style="position:absolute;width:100px;height:100px;background:radial-gradient(circle,rgba(255,106,52,0.22) 0%,transparent 70%);border-radius:50%;"></div> | |
| 784 | - <div style="position:relative;width:56px;height:56px;border:1.5px solid #ff6a34;border-radius:14px;display:flex;align-items:center;justify-content:center;background:#fff;"> | |
| 785 | - <svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#ff6a34" stroke-width="2" aria-hidden="true"> | |
| 786 | - <rect x="5" y="11" width="14" height="10" rx="2"/> | |
| 787 | - <path stroke-linecap="round" d="M8 11V7a4 4 0 018 0v4"/> | |
| 788 | - </svg> | |
| 789 | - </div> | |
| 790 | - </div> | |
| 791 | - | |
| 792 | - <!-- Title --> | |
| 793 | - <div style="font-size:22px;font-weight:700;color:#111;margin-bottom:10px;line-height:1.3;"> | |
| 794 | - <?php echo esc_html( $args['cta_title'] ); ?> | |
| 795 | - </div> | |
| 796 | - | |
| 797 | - <!-- Subtitle --> | |
| 798 | - <div style="font-size:14px;color:#6b7280;margin-bottom:20px;line-height:1.6;"> | |
| 799 | - <?php echo esc_html( $args['cta_description'] ); ?> | |
| 800 | - </div> | |
| 801 | - | |
| 802 | - <!-- CTA button --> | |
| 803 | - <a href="<?php echo esc_url( $args['cta_button_url'] ); ?>" target="_blank" style="display:flex;align-items:center;justify-content:center;gap:8px;background:#ff6a34;color:#fff;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;text-decoration:none;"> | |
| 804 | - <?php echo esc_html( $args['cta_button_text'] ); ?> | |
| 805 | - </a> | |
| 806 | - </div> | |
| 807 | - </div> | |
| 808 | - </div> | |
| 809 | - <?php | |
| 810 | - return ob_get_clean(); | |
| 811 | -} | |
| 812 | - | |
| 813 | -/** | |
| 814 | - * Render an Advanced Select component. | |
| 815 | - * | |
| 816 | - * Outputs a styled trigger-button + dropdown that replaces a native <select>. | |
| 817 | - * A hidden <input> carries the selected value for form submission. | |
| 818 | - * JS (admin-components.js WPSubsAdvSelect) handles open/close and selection. | |
| 819 | - * | |
| 820 | - * @param array $args { | |
| 821 | - * @type string $name Hidden input name attribute. Required. | |
| 822 | - * @type string $placeholder Trigger label when nothing is selected. | |
| 823 | - * @type string $value Initial hidden-input value (default: ''). | |
| 824 | - * @type array $options Each item: { | |
| 825 | - * string value Value submitted on selection. | |
| 826 | - * string label Display text. | |
| 827 | - * bool danger Red destructive style. | |
| 828 | - * string confirm JS confirm() message before selecting. | |
| 829 | - * bool divider Render a divider BEFORE this item. | |
| 830 | - * bool disabled Non-selectable item. | |
| 831 | - * } | |
| 832 | - * @type string $align Menu alignment: 'left' (default) or 'right'. | |
| 833 | - * @type string $id Optional id on the root element. | |
| 834 | - * @type string $class Extra classes on the root element. | |
| 835 | - * } | |
| 836 | - */ | |
| 837 | -function wpsubs_render_adv_select( array $args ): void { | |
| 838 | - $args = wp_parse_args( | |
| 839 | - $args, | |
| 840 | - array( | |
| 841 | - 'name' => '', | |
| 842 | - 'placeholder' => __( 'Select', 'subscription' ), | |
| 843 | - 'value' => '', | |
| 844 | - 'options' => array(), | |
| 845 | - 'align' => 'left', | |
| 846 | - 'id' => '', | |
| 847 | - 'class' => '', | |
| 848 | - 'attrs' => array(), | |
| 849 | - ) | |
| 850 | - ); | |
| 851 | - | |
| 852 | - $root_classes = 'wpsubs-adv-select wpsubs-adv-select--' . ( 'right' === $args['align'] ? 'right' : 'left' ); | |
| 853 | - if ( $args['class'] ) { | |
| 854 | - $root_classes .= ' ' . $args['class']; | |
| 855 | - } | |
| 856 | - | |
| 857 | - // Resolve trigger label: use matching option's label when a value is already set. | |
| 858 | - $trigger_label = $args['placeholder']; | |
| 859 | - $current_value = (string) $args['value']; | |
| 860 | - if ( '' !== $current_value && '-1' !== $current_value ) { | |
| 861 | - foreach ( $args['options'] as $opt ) { | |
| 862 | - if ( (string) ( $opt['value'] ?? '' ) === $current_value ) { | |
| 863 | - $trigger_label = $opt['label'] ?? $args['placeholder']; | |
| 864 | - break; | |
| 865 | - } | |
| 866 | - } | |
| 867 | - } | |
| 868 | - | |
| 869 | - $chevron_svg = '<svg class="wpsubs-adv-select__chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>'; | |
| 870 | - ?> | |
| 871 | - <div class="<?php echo esc_attr( $root_classes ); ?>" | |
| 872 | - <?php | |
| 873 | - if ( $args['id'] ) : | |
| 874 | - ?> | |
| 875 | - id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?> | |
| 876 | - data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>" | |
| 877 | - data-default-value="<?php echo esc_attr( $args['value'] ); ?>" | |
| 878 | - <?php | |
| 879 | - foreach ( $args['attrs'] as $attr_name => $attr_value ) : | |
| 880 | - echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped. | |
| 881 | - endforeach; | |
| 882 | - ?> | |
| 883 | - > | |
| 884 | - <button type="button" class="wpsubs-adv-select__trigger" aria-haspopup="listbox" aria-expanded="false"> | |
| 885 | - <span class="wpsubs-adv-select__label"><?php echo esc_html( $trigger_label ); ?></span> | |
| 886 | - <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> | |
| 887 | - </button> | |
| 888 | - | |
| 889 | - <div class="wpsubs-adv-select__menu" role="listbox"> | |
| 890 | - <?php | |
| 891 | - foreach ( $args['options'] as $option ) : | |
| 892 | - $option = wp_parse_args( | |
| 893 | - $option, | |
| 894 | - array( | |
| 895 | - 'value' => '', | |
| 896 | - 'label' => '', | |
| 897 | - 'danger' => false, | |
| 898 | - 'confirm' => '', | |
| 899 | - 'divider' => false, | |
| 900 | - 'disabled' => false, | |
| 901 | - ) | |
| 902 | - ); | |
| 903 | - if ( $option['divider'] ) : | |
| 904 | - ?> | |
| 905 | - <div class="wpsubs-adv-select__divider"></div> | |
| 906 | - <?php | |
| 907 | - continue; | |
| 908 | - endif; | |
| 909 | - ?> | |
| 910 | - <button | |
| 911 | - type="button" | |
| 912 | - class="wpsubs-adv-select__item<?php echo $option['danger'] ? ' wpsubs-adv-select__item--danger' : ''; ?>" | |
| 913 | - data-value="<?php echo esc_attr( $option['value'] ); ?>" | |
| 914 | - <?php | |
| 915 | - if ( $option['confirm'] ) : | |
| 916 | - ?> | |
| 917 | - data-confirm="<?php echo esc_attr( $option['confirm'] ); ?>"<?php endif; ?> | |
| 918 | - <?php | |
| 919 | - if ( $option['disabled'] ) : | |
| 920 | - ?> | |
| 921 | - data-disabled<?php endif; ?> | |
| 922 | - role="option" | |
| 923 | - > | |
| 924 | - <span class="wpsubs-adv-select__item-label"><?php echo esc_html( $option['label'] ); ?></span> | |
| 925 | - </button> | |
| 926 | - <?php endforeach; ?> | |
| 927 | - </div> | |
| 928 | - | |
| 929 | - <?php if ( $args['name'] ) : ?> | |
| 930 | - <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $args['value'] ); ?>"> | |
| 931 | - <?php endif; ?> | |
| 932 | - </div> | |
| 933 | - <?php | |
| 934 | 879 | } |