PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
← All changes | includes/functions.php +339 -550 1.10.2 → 2.4.0 View file →
@@ -1,27 +1,23 @@
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 /**
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 20 * Generate URL for Subscription Action.
25 21 *
26 22 * @param string $action Action.
27 23 * @param string $nonce nonce.
@@ -91,17 +87,263 @@
91 87 return class_exists( 'Sdevs_Wc_Subscription_Pro' );
92 88 }
93 89
94 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 +/**
95 292 * Get renewal process settings.
96 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 + *
97 316 * @return bool
98 317 */
99 318 function subscrpt_is_auto_renew_enabled() {
100 - return 'auto' === get_option( 'subscrpt_renewal_process', 'auto' );
319 + return 'auto' === subscrpt_get_renewal_process();
101 320 }
102 321
103 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 +/**
104 346 * Get maximum payments for a subscription, checking variation, product, and subscription meta.
105 347 *
106 348 * @param int $subscription_id Subscription ID.
107 349 * @return string|int Maximum payments or empty string if not set.
@@ -129,9 +371,9 @@
129 371 if ( ! $max_payments ) {
130 372 $max_payments = get_post_meta( $subscription_id, '_subscrpt_max_no_payment', true );
131 373 }
132 374
133 - return $max_payments ?: '';
375 + return $max_payments ? $max_payments : '';
134 376 }
135 377
136 378 /**
137 379 * Count total payments made.
@@ -143,16 +385,14 @@
143 385 global $wpdb;
144 386
145 387 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
146 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.
147 391 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
148 392 $relations = $wpdb->get_results(
149 393 $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",
394 + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC",
155 395 $subscription_id
156 396 )
157 397 );
158 398 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
@@ -210,14 +450,11 @@
210 450 $is_reached = subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments );
211 451
212 452 // Fire action when split payment plan is completed (first time only)
213 453 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 );
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 );
216 456
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 457 // Update subscription status if different from current
221 458 $current_status = get_post_status( $subscription_id );
222 459 if ( $current_status !== $expire_status ) {
223 460 wp_update_post(
@@ -227,8 +464,11 @@
227 464 )
228 465 );
229 466 }
230 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 +
231 471 do_action( 'subscrpt_split_payment_completed', $subscription_id, $payments_made, $max_payments );
232 472 update_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true );
233 473
234 474 // Handle split payment access timing if Pro version is active
@@ -305,8 +545,44 @@
305 545 return $payment_type;
306 546 }
307 547
308 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 +/**
309 585 * Enhanced completion check considering failed payments and access suspension.
310 586 *
311 587 * @param int $subscription_id Subscription ID.
312 588 * @param int $payments_made Number of successful payments made.
@@ -331,9 +607,9 @@
331 607 }
332 608 }
333 609
334 610 // Check for maximum failure threshold
335 - $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 );
336 612 $max_failures_before_completion = apply_filters( 'subscrpt_max_failures_before_completion', 0, $subscription_id );
337 613
338 614 if ( $max_failures_before_completion > 0 && $failure_count >= $max_failures_before_completion ) {
339 615 // Force completion after too many failures
@@ -365,16 +641,14 @@
365 641 global $wpdb;
366 642
367 643 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
368 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.
369 647 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
370 648 $relations = $wpdb->get_results(
371 649 $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",
650 + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC",
377 651 $subscription_id
378 652 )
379 653 );
380 654 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
@@ -462,10 +736,20 @@
462 736 }
463 737
464 738 if ( ! function_exists( 'sdevs_wp_strtotime' ) ) {
465 739 /**
466 - * Get strtotime with WordPress timezone config.
740 + * Resolve a relative date string against a base timestamp, in site timezone.
467 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 + *
468 752 * @param string $str string.
469 753 * @param int|null $base_timestamp base timestamp.
470 754 *
471 755 * @return int
@@ -470,9 +754,23 @@
470 754 *
471 755 * @return int
472 756 */
473 757 function sdevs_wp_strtotime( $str, $base_timestamp = null ) {
474 - 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 );
475 773 }
476 774 }
477 775
478 776 if ( ! function_exists( 'sdevs_order_status_label' ) ) {
@@ -540,8 +838,11 @@
540 838 /**
541 839 * Get WC product in subscription wrapper.
542 840 *
543 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.
544 845 */
545 846 function sdevs_get_subscription_product( $product ) {
546 847 // Deprecated notice.
547 848 _deprecated_function( 'sdevs_get_subscription_product', '1.8.17', 'SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product' );
@@ -558,9 +859,9 @@
558 859 function subscrpt_write_log( $message, bool $should_print = false ): void {
559 860 $logger = wc_get_logger();
560 861
561 862 $message = is_array( $message ) || is_object( $message ) ? wp_json_encode( $message ) : $message;
562 - $logger->add( 'wp_subcription', $message );
863 + $logger->add( 'wp_subscription', $message );
563 864
564 865 echo esc_html( $should_print ? $message : '' );
565 866 }
566 867
@@ -573,520 +874,8 @@
573 874 if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
574 875 if ( is_array( $log ) || is_object( $log ) ) {
575 876 error_log( print_r( $log, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
576 877 } else {
577 - error_log( 'wp_subcription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
878 + error_log( 'wp_subscription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
578 879 }
579 880 }
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,var(--wpsubs-brand-ring) 0%,transparent 70%);border-radius:50%;"></div>
784 - <div style="position:relative;width:56px;height:56px;border:1.5px solid var(--wpsubs-brand);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" style="stroke:var(--wpsubs-brand);" 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:var(--wpsubs-brand);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 -}
935 -
936 -/**
937 - * Render a tag/pill select input with an inline filter and filterable dropdown.
938 - * Supports single and multiple selection. No external dependencies.
939 - *
940 - * JS: WPSubsTagSelect (admin-components.js) auto-inits elements.
941 - * Event fired on root: `wpsubs:select` — detail: { value, label, selected }
942 - *
943 - * @param array $args {
944 - * string $name Form field name (base name, without [] suffix).
945 - * string $placeholder Input placeholder shown when nothing is selected.
946 - * string|array $value Current value(s). Array for multiple, string for single.
947 - * array $options Options: array of { value, label, disabled? }.
948 - * bool $multiple Enable multi-select mode.
949 - * string $id Optional root element id.
950 - * string $class Extra CSS classes for the root element.
951 - * array $attrs Extra HTML attributes for the root element.
952 - * }
953 - */
954 -function wpsubs_render_tag_select( array $args ): void {
955 - $args = wp_parse_args(
956 - $args,
957 - array(
958 - 'name' => '',
959 - 'placeholder' => __( 'Select...', 'subscription' ),
960 - 'value' => '',
961 - 'options' => array(),
962 - 'multiple' => false,
963 - 'id' => '',
964 - 'class' => '',
965 - 'attrs' => array(),
966 - )
967 - );
968 -
969 - $multiple = (bool) $args['multiple'];
970 - $current_value = $multiple ? (array) $args['value'] : (string) $args['value'];
971 -
972 - if ( $multiple ) {
973 - $selected_values = array_filter( array_map( 'strval', $current_value ), fn( $v ) => '' !== $v );
974 - } else {
975 - $selected_values = ( '' !== $current_value ) ? array( $current_value ) : array();
976 - }
977 -
978 - // Map selected values to their labels for pill rendering.
979 - $selected_labels = array();
980 - foreach ( $args['options'] as $opt ) {
981 - $opt_val = (string) ( $opt['value'] ?? '' );
982 - if ( in_array( $opt_val, $selected_values, true ) ) {
983 - $selected_labels[ $opt_val ] = $opt['label'] ?? $opt_val;
984 - }
985 - }
986 -
987 - $root_classes = 'wpsubs-tag-select';
988 - if ( $multiple ) {
989 - $root_classes .= ' wpsubs-tag-select--multi';
990 - }
991 - if ( $args['class'] ) {
992 - $root_classes .= ' ' . $args['class'];
993 - }
994 -
995 - $has_pills = ! empty( $selected_values );
996 - $placeholder = $has_pills ? '' : esc_attr( $args['placeholder'] );
997 -
998 - $chevron_svg = '<svg 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>';
999 - ?>
1000 - <div
1001 - class="<?php echo esc_attr( $root_classes ); ?>"
1002 - <?php
1003 - if ( $args['id'] ) :
1004 - ?>
1005 - id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
1006 - data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
1007 - data-name="<?php echo esc_attr( $args['name'] ); ?>"
1008 - <?php
1009 - if ( $multiple ) :
1010 - ?>
1011 - data-multiple="1"<?php endif; ?>
1012 - <?php
1013 - foreach ( $args['attrs'] as $attr_name => $attr_value ) :
1014 - echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
1015 - endforeach;
1016 - ?>
1017 - >
1018 - <div class="wpsubs-tag-select__field">
1019 - <?php foreach ( $selected_labels as $val => $lbl ) : ?>
1020 - <span class="wpsubs-tag-select__pill" data-value="<?php echo esc_attr( $val ); ?>">
1021 - <span class="wpsubs-tag-select__pill-label"><?php echo esc_html( $lbl ); ?></span>
1022 - <button type="button" class="wpsubs-tag-select__pill-remove" aria-label="<?php esc_attr_e( 'Remove', 'subscription' ); ?>">&#x2715;</button>
1023 - </span>
1024 - <?php endforeach; ?>
1025 - <input
1026 - type="text"
1027 - class="wpsubs-tag-select__input"
1028 - placeholder="<?php echo $placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above. ?>"
1029 - autocomplete="off"
1030 - aria-label="<?php esc_attr_e( 'Filter options', 'subscription' ); ?>"
1031 - />
1032 - <span class="wpsubs-tag-select__chevron" aria-hidden="true">
1033 - <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1034 - </span>
1035 - </div>
1036 -
1037 - <div class="wpsubs-tag-select__dropdown">
1038 - <div class="wpsubs-tag-select__list" role="listbox"
1039 - <?php
1040 - if ( $multiple ) :
1041 - ?>
1042 - aria-multiselectable="true"<?php endif; ?>>
1043 - <?php
1044 - foreach ( $args['options'] as $option ) :
1045 - $option = wp_parse_args(
1046 - $option,
1047 - array(
1048 - 'value' => '',
1049 - 'label' => '',
1050 - 'disabled' => false,
1051 - )
1052 - );
1053 - $opt_value = (string) $option['value'];
1054 - $is_selected = in_array( $opt_value, $selected_values, true );
1055 - ?>
1056 - <button
1057 - type="button"
1058 - class="wpsubs-tag-select__item"
1059 - data-value="<?php echo esc_attr( $opt_value ); ?>"
1060 - role="option"
1061 - aria-selected="<?php echo $is_selected ? 'true' : 'false'; ?>"
1062 - <?php
1063 - if ( $is_selected ) :
1064 - ?>
1065 - data-selected<?php endif; ?>
1066 - <?php
1067 - if ( $option['disabled'] ) :
1068 - ?>
1069 - data-disabled<?php endif; ?>
1070 - style="<?php echo $is_selected ? 'display:none;' : ''; ?>"
1071 - ><?php echo esc_html( $option['label'] ); ?></button>
1072 - <?php endforeach; ?>
1073 - </div>
1074 - <div class="wpsubs-tag-select__empty"><?php esc_html_e( 'No results found.', 'subscription' ); ?></div>
1075 - </div>
1076 -
1077 - <?php if ( $args['name'] ) : ?>
1078 - <?php if ( $multiple ) : ?>
1079 - <?php if ( empty( $selected_values ) ) : ?>
1080 - <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="" data-ts-val />
1081 - <?php else : ?>
1082 - <?php foreach ( $selected_values as $val ) : ?>
1083 - <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="<?php echo esc_attr( $val ); ?>" data-ts-val />
1084 - <?php endforeach; ?>
1085 - <?php endif; ?>
1086 - <?php else : ?>
1087 - <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $current_value ); ?>" data-ts-val />
1088 - <?php endif; ?>
1089 - <?php endif; ?>
1090 - </div>
1091 - <?php
1092 881 }