PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
← All changes | includes/functions.php +337 -203 1.9.62.0.0 View file →
@@ -1,24 +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 -function subscrpt_include_tailwind_css() {
17 - wp_enqueue_style( 'wpsubs-tailwind', SUBSCRPT_ASSETS . '/css/tailwind/output.css', [], SUBSCRPT_VERSION );
18 -}
19 -
20 -/**
21 18 * Generate URL for Subscription Action.
22 19 *
23 20 * @param string $action Action.
24 21 * @param string $nonce nonce.
@@ -88,17 +85,263 @@
88 85 return class_exists( 'Sdevs_Wc_Subscription_Pro' );
89 86 }
90 87
91 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 +/**
92 290 * Get renewal process settings.
93 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 + *
94 314 * @return bool
95 315 */
96 316 function subscrpt_is_auto_renew_enabled() {
97 - return 'auto' === get_option( 'subscrpt_renewal_process', 'auto' );
317 + return 'auto' === subscrpt_get_renewal_process();
98 318 }
99 319
100 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 +/**
101 344 * Get maximum payments for a subscription, checking variation, product, and subscription meta.
102 345 *
103 346 * @param int $subscription_id Subscription ID.
104 347 * @return string|int Maximum payments or empty string if not set.
@@ -126,9 +369,9 @@
126 369 if ( ! $max_payments ) {
127 370 $max_payments = get_post_meta( $subscription_id, '_subscrpt_max_no_payment', true );
128 371 }
129 372
130 - return $max_payments ?: '';
373 + return $max_payments ? $max_payments : '';
131 374 }
132 375
133 376 /**
134 377 * Count total payments made.
@@ -140,16 +383,14 @@
140 383 global $wpdb;
141 384
142 385 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
143 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.
144 389 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
145 390 $relations = $wpdb->get_results(
146 391 $wpdb->prepare(
147 - "SELECT sr.*, p.post_status, p.post_date
148 - FROM $table_name sr
149 - INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID
150 - WHERE sr.subscription_id = %d
151 - ORDER BY p.post_date ASC",
392 + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC",
152 393 $subscription_id
153 394 )
154 395 );
155 396 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
@@ -207,14 +448,11 @@
207 448 $is_reached = subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments );
208 449
209 450 // Fire action when split payment plan is completed (first time only)
210 451 if ( $is_reached && ! get_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ) ) {
211 - // Add completion milestone note
212 - 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 );
213 454
214 - // Allow customization of subscription status after completion
215 - $expire_status = apply_filters( 'subscrpt_split_payment_expire_status', 'expired', $subscription_id, $payments_made, $max_payments );
216 -
217 455 // Update subscription status if different from current
218 456 $current_status = get_post_status( $subscription_id );
219 457 if ( $current_status !== $expire_status ) {
220 458 wp_update_post(
@@ -224,8 +462,11 @@
224 462 )
225 463 );
226 464 }
227 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 +
228 469 do_action( 'subscrpt_split_payment_completed', $subscription_id, $payments_made, $max_payments );
229 470 update_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true );
230 471
231 472 // Handle split payment access timing if Pro version is active
@@ -302,8 +543,44 @@
302 543 return $payment_type;
303 544 }
304 545
305 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 +/**
306 583 * Enhanced completion check considering failed payments and access suspension.
307 584 *
308 585 * @param int $subscription_id Subscription ID.
309 586 * @param int $payments_made Number of successful payments made.
@@ -328,9 +605,9 @@
328 605 }
329 606 }
330 607
331 608 // Check for maximum failure threshold
332 - $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 );
333 610 $max_failures_before_completion = apply_filters( 'subscrpt_max_failures_before_completion', 0, $subscription_id );
334 611
335 612 if ( $max_failures_before_completion > 0 && $failure_count >= $max_failures_before_completion ) {
336 613 // Force completion after too many failures
@@ -362,16 +639,14 @@
362 639 global $wpdb;
363 640
364 641 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
365 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.
366 645 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
367 646 $relations = $wpdb->get_results(
368 647 $wpdb->prepare(
369 - "SELECT sr.*, p.post_status, p.post_date
370 - FROM $table_name sr
371 - INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID
372 - WHERE sr.subscription_id = %d
373 - ORDER BY p.post_date ASC",
648 + "SELECT * FROM $table_name WHERE subscription_id = %d ORDER BY id ASC",
374 649 $subscription_id
375 650 )
376 651 );
377 652 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
@@ -459,10 +734,20 @@
459 734 }
460 735
461 736 if ( ! function_exists( 'sdevs_wp_strtotime' ) ) {
462 737 /**
463 - * Get strtotime with WordPress timezone config.
738 + * Resolve a relative date string against a base timestamp, in site timezone.
464 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 + *
465 750 * @param string $str string.
466 751 * @param int|null $base_timestamp base timestamp.
467 752 *
468 753 * @return int
@@ -467,9 +752,23 @@
467 752 *
468 753 * @return int
469 754 */
470 755 function sdevs_wp_strtotime( $str, $base_timestamp = null ) {
471 - 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 );
472 771 }
473 772 }
474 773
475 774 if ( ! function_exists( 'sdevs_order_status_label' ) ) {
@@ -537,8 +836,11 @@
537 836 /**
538 837 * Get WC product in subscription wrapper.
539 838 *
540 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.
541 843 */
542 844 function sdevs_get_subscription_product( $product ) {
543 845 // Deprecated notice.
544 846 _deprecated_function( 'sdevs_get_subscription_product', '1.8.17', 'SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product' );
@@ -555,9 +857,9 @@
555 857 function subscrpt_write_log( $message, bool $should_print = false ): void {
556 858 $logger = wc_get_logger();
557 859
558 860 $message = is_array( $message ) || is_object( $message ) ? wp_json_encode( $message ) : $message;
559 - $logger->add( 'wp_subcription', $message );
861 + $logger->add( 'wp_subscription', $message );
560 862
561 863 echo esc_html( $should_print ? $message : '' );
562 864 }
563 865
@@ -570,176 +872,8 @@
570 872 if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
571 873 if ( is_array( $log ) || is_object( $log ) ) {
572 874 error_log( print_r( $log, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
573 875 } else {
574 - error_log( 'wp_subcription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
876 + error_log( 'wp_subscription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
575 877 }
576 878 }
577 -}
578 -
579 -/**
580 - * Add payment completion note for split payment subscriptions.
581 - *
582 - * @param int $subscription_id Subscription ID.
583 - * @param int $payments_made Number of payments made.
584 - * @param int $max_payments Maximum number of payments.
585 - */
586 -function subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments ) {
587 - // Check if this is a split payment subscription
588 - if ( ! function_exists( 'subscrpt_get_payment_type' ) ) {
589 - return;
590 - }
591 -
592 - $payment_type = subscrpt_get_payment_type( $subscription_id );
593 - if ( 'split_payment' !== $payment_type ) {
594 - return;
595 - }
596 -
597 - // Create completion note
598 - $completion_note = sprintf(
599 - /* translators: %1$d: payments made, %2$d: total payments */
600 - __( 'Split payment plan completed successfully! %1$d of %2$d payments received.', 'subscription' ),
601 - $payments_made,
602 - $max_payments
603 - );
604 -
605 - // Add the completion note
606 - $comment_id = wp_insert_comment(
607 - array(
608 - 'comment_author' => 'Subscription for WooCommerce',
609 - 'comment_content' => $completion_note,
610 - 'comment_post_ID' => $subscription_id,
611 - 'comment_type' => 'order_note',
612 - )
613 - );
614 - update_comment_meta( $comment_id, '_subscrpt_activity', __( 'Split Payment - Plan Complete', 'subscription' ) );
615 - update_comment_meta( $comment_id, '_subscrpt_activity_type', 'split_payment' );
616 -
617 - // Add payment summary note
618 - $payment_summary = sprintf(
619 - /* translators: %1$d: payments made, %2$d: total payments, %3$s: completion date */
620 - __( 'Payment Summary: %1$d of %2$d installments completed on %3$s. All payments received successfully.', 'subscription' ),
621 - $payments_made,
622 - $max_payments,
623 - date_i18n( wc_date_format(), current_time( 'timestamp' ) )
624 - );
625 -
626 - $summary_comment_id = wp_insert_comment(
627 - array(
628 - 'comment_author' => 'Subscription for WooCommerce',
629 - 'comment_content' => $payment_summary,
630 - 'comment_post_ID' => $subscription_id,
631 - 'comment_type' => 'order_note',
632 - )
633 - );
634 - update_comment_meta( $summary_comment_id, '_subscrpt_activity', __( 'Payment Summary - Complete', 'subscription' ) );
635 - update_comment_meta( $summary_comment_id, '_subscrpt_activity_type', 'split_payment_summary' );
636 -}
637 -
638 -
639 -/**
640 - * Render a WooCommerce-style multiselect field.
641 - *
642 - * @param array $field {
643 - * Field arguments.
644 - *
645 - * @type string $id Required. Meta key / input ID.
646 - * @type string $label Field label.
647 - * @type array $options Key => Label pairs for options.
648 - * @type array|string $selected Optional. Selected value(s). Array, JSON, or CSV.
649 - * @type string $desc_tip Optional. Description tooltip text.
650 - * @type string $description Optional. Field description text.
651 - * @type string $wrapper_class Optional. Extra wrapper classes.
652 - * @type string $class Optional. Extra <select> classes.
653 - * @type string $name Optional. Input name. Defaults to $id.'[]'.
654 - * }
655 - */
656 -function subscrpt_multiselect_field( $field ) {
657 - $defaults = [
658 - 'id' => '',
659 - 'label' => '',
660 - 'options' => [],
661 - 'selected' => [],
662 - 'desc_tip' => false,
663 - 'description' => '',
664 - 'wrapper_class' => '',
665 - 'wrapper_style' => '',
666 - 'class' => 'wc-enhanced-select',
667 - 'style' => '',
668 - 'name' => '',
669 - ];
670 -
671 - $field = wp_parse_args( $field, $defaults );
672 -
673 - if ( empty( $field['id'] ) ) {
674 - return;
675 - }
676 -
677 - $id = esc_attr( $field['id'] );
678 - $name = $field['name'] ? $field['name'] : $id . '[]';
679 - $label = esc_html( $field['label'] );
680 - $description = $field['description'];
681 - $desc_tip = $field['desc_tip'];
682 -
683 - // Normalize selected values into array.
684 - $selected = [];
685 - if ( is_array( $field['selected'] ) ) {
686 - $selected = $field['selected'];
687 - } elseif ( is_string( $field['selected'] ) && $field['selected'] !== '' ) {
688 - if ( false !== strpos( $field['selected'], '[' ) ) {
689 - $tmp = json_decode( $field['selected'], true );
690 - $selected = is_array( $tmp ) ? $tmp : [];
691 - } else {
692 - $selected = array_filter( array_map( 'trim', explode( ',', $field['selected'] ) ) );
693 - }
694 - }
695 -
696 - // Build <option> list.
697 - $options_html = '';
698 - foreach ( $field['options'] as $key => $text ) {
699 - $is_selected = in_array( (string) $key, (array) $selected, true ) ? ' selected="selected"' : '';
700 - $options_html .= sprintf(
701 - '<option value="%s"%s>%s</option>',
702 - esc_attr( $key ),
703 - $is_selected,
704 - esc_html( $text )
705 - );
706 - }
707 -
708 - $tooltip_html = '';
709 - if ( $desc_tip && $description ) {
710 - $tooltip_html = wc_help_tip( $description );
711 - }
712 -
713 - $description_html = '';
714 - if ( $description && ! $desc_tip ) {
715 - $description_html = '<span class="description">' . wp_kses_post( $description ) . '</span>';
716 - }
717 -
718 - // ? Escaped intentionally.
719 - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
720 - ?>
721 - <p
722 - class="form-field <?php echo esc_attr( $id . '_field ' . ( $field['wrapper_class'] ) ); ?>"
723 - style="<?php echo esc_attr( $field['wrapper_style'] ); ?>"
724 - >
725 - <label for="<?php echo esc_attr( $id ); ?>">
726 - <?php echo esc_html( $label ); ?>
727 - </label>
728 -
729 - <?php echo $tooltip_html; ?>
730 -
731 - <select
732 - multiple="multiple"
733 - id="<?php echo esc_attr( $id ); ?>"
734 - name="<?php echo esc_attr( $name ); ?>"
735 - class="<?php echo esc_attr( $field['class'] ); ?>"
736 - style="<?php echo esc_attr( $field['style'] ); ?>"
737 - >
738 - <?php echo $options_html; ?>
739 - </select>
740 -
741 - <?php echo $description_html; ?>
742 - </p>
743 - <?php
744 - // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
745 879 }