PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.5.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.5.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / helper.php

helper.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.5.0, at inc/helper.php

1,507 lines 47.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Helper Class - Utility functions for SureDonation
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc;
9
10 use SureDonation\Inc\API\Settings_API;
11 use SureDonation\Inc\Database\Tables\Donations;
12 use SureDonation\Inc\Emails\Email_Handler;
13 use SureDonation\Inc\Payments\Payment_Helper;
14
15 // Exit if accessed directly.
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * Helper class.
22 * Provides utility functions for the plugin.
23 *
24 * @since 0.0.1
25 */
26 class Helper {
27 /**
28 * Option name for all SureDonation settings.
29 *
30 * @since 0.0.1
31 */
32 public const OPTION_NAME = 'suredonation_options';
33
34 /**
35 * Campaign meta key name.
36 *
37 * @since 0.0.1
38 */
39 public const SUREDONATION_CAMPAIGN_META_KEY = '_suredonation_campaign_meta';
40
41 /**
42 * Default campaign meta values.
43 *
44 * @since 0.0.1
45 * @var array<string, mixed>
46 */
47 private static $campaign_meta_defaults = [
48 'goal_type' => 'raised_amount',
49 'goal_amount' => 0,
50 'campaign_status' => 'active',
51 'email_settings' => [],
52 'require_terms' => false,
53 'terms_text' => '',
54 'thank_you_message' => '',
55 ];
56
57 /**
58 * Get a value from the suredonation_options array.
59 *
60 * @param string $key The key to retrieve.
61 * @param mixed $default_value Default value if key doesn't exist.
62 * @return mixed
63 * @since 0.0.1
64 */
65 public static function get_suredonation_option( $key, $default_value = null ) {
66 $options = get_option( self::OPTION_NAME, [] );
67
68 if ( ! is_array( $options ) ) {
69 $options = [];
70 }
71
72 return array_key_exists( $key, $options ) ? $options[ $key ] : $default_value;
73 }
74
75 /**
76 * Update a value in the suredonation_options array.
77 *
78 * @param string $key The key to update.
79 * @param mixed $value The value to set.
80 * @return bool True on success, false on failure.
81 * @since 0.0.1
82 */
83 public static function update_suredonation_option( $key, $value ) {
84 $options = get_option( self::OPTION_NAME, [] );
85
86 if ( ! is_array( $options ) ) {
87 $options = [];
88 }
89
90 $options[ $key ] = $value;
91
92 return update_option( self::OPTION_NAME, $options );
93 }
94
95 /**
96 * Whether honeypot spam protection is enabled in the global settings.
97 *
98 * @return bool True when the honeypot is enabled.
99 * @since 1.1.0
100 */
101 public static function is_honeypot_enabled() {
102 $spam_settings = self::get_suredonation_option( Settings_API::SPAM_OPTION_KEY, [] );
103
104 return is_array( $spam_settings ) && ! empty( $spam_settings['honeypot'] );
105 }
106
107 /**
108 * Output the hidden honeypot field when spam protection is enabled.
109 *
110 * Genuine visitors never see or fill this hidden field, so it is submitted
111 * with an empty value. A filled value (a bot that auto-fills every input) or
112 * a missing field (a bot that strips unknown inputs) is flagged as spam at
113 * submission time.
114 *
115 * @return void
116 * @see Helper::is_honeypot_spam()
117 * @since 1.1.0
118 */
119 public static function render_honeypot_field() {
120 if ( ! self::is_honeypot_enabled() ) {
121 return;
122 }
123
124 echo '<input type="hidden" name="suredonation_honeypot" value="" />';
125 }
126
127 /**
128 * Determine whether the current submission tripped the honeypot.
129 *
130 * Returns false when honeypot protection is disabled. When enabled, a real
131 * submission always carries the hidden field with an empty value; a missing
132 * field or any non-empty value is treated as spam.
133 *
134 * The honeypot field holds no sensitive data and is only inspected for
135 * emptiness. Nonce/referer verification is performed by the calling
136 * submission handler before this method runs.
137 *
138 * @return bool True when the submission should be rejected as spam.
139 * @since 1.1.0
140 */
141 public static function is_honeypot_spam() {
142 if ( ! self::is_honeypot_enabled() ) {
143 return false;
144 }
145
146 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by the calling submission handler; value only checked for emptiness.
147 if ( ! isset( $_POST['suredonation_honeypot'] ) ) {
148 return true;
149 }
150
151 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See note above.
152 $value = sanitize_text_field( wp_unslash( $_POST['suredonation_honeypot'] ) );
153
154 return '' !== $value;
155 }
156
157 /**
158 * Get all campaign meta as an array.
159 *
160 * @param int $campaign_id Campaign post ID.
161 * @return array<string, mixed> Campaign meta values.
162 * @since 0.0.1
163 */
164 public static function get_campaign_meta( $campaign_id ) {
165 $raw = get_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, true );
166
167 $meta = ! empty( $raw ) && is_string( $raw ) ? json_decode( $raw, true ) : [];
168
169 if ( ! is_array( $meta ) ) {
170 $meta = [];
171 }
172
173 return array_merge( self::$campaign_meta_defaults, $meta );
174 }
175
176 /**
177 * Get a single campaign meta value.
178 *
179 * @param int $campaign_id Campaign post ID.
180 * @param string $key Meta key within the campaign meta array.
181 * @param mixed $default_value Default value if not set.
182 * @return mixed
183 * @since 0.0.1
184 */
185 public static function get_campaign_meta_value( $campaign_id, $key, $default_value = null ) {
186 $meta = self::get_campaign_meta( $campaign_id );
187
188 return $meta[ $key ] ?? $default_value;
189 }
190
191 /**
192 * Update campaign meta. Merges provided values with existing meta.
193 *
194 * @param int $campaign_id Campaign post ID.
195 * @param array<string, mixed> $values Key-value pairs to update.
196 * @return bool|int Meta ID on success, false on failure.
197 * @since 0.0.1
198 */
199 public static function update_campaign_meta( $campaign_id, $values ) {
200 $meta = self::get_campaign_meta( $campaign_id );
201 $meta = array_merge( $meta, $values );
202
203 return update_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, wp_json_encode( $meta ) );
204 }
205
206 /**
207 * Whether a (possibly nested) block tree contains a block of the given name.
208 *
209 * Walks parse_blocks() output, descending into innerBlocks so a block nested
210 * inside a layout wrapper (Group/Columns) is still found. Note that a block
211 * inside a synced pattern is not reachable: those parse as `core/block` with
212 * no innerBlocks.
213 *
214 * Lives here rather than on Form_Renderer or Payment_Helper — both need it,
215 * they sit in unrelated namespaces, and this is a generic block utility with
216 * no rendering or payment semantics.
217 *
218 * @param array<int|string, mixed> $blocks Parsed blocks (parse_blocks output).
219 * @param string $target Block name to look for.
220 * @return bool
221 * @since 1.4.0
222 */
223 public static function block_tree_contains( $blocks, $target ) {
224 foreach ( $blocks as $block ) {
225 if ( ! is_array( $block ) ) {
226 continue;
227 }
228 if ( isset( $block['blockName'] ) && $block['blockName'] === $target ) {
229 return true;
230 }
231 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && self::block_tree_contains( $block['innerBlocks'], $target ) ) {
232 return true;
233 }
234 }
235 return false;
236 }
237
238 /**
239 * Checks if current value is string or else returns default value
240 *
241 * @param mixed $data data which need to be checked if is string.
242 * @return string
243 * @since 0.0.1
244 */
245 public static function get_string_value( $data ) {
246 if ( is_scalar( $data ) ) {
247 return (string) $data;
248 }
249 if ( is_object( $data ) && method_exists( $data, '__toString' ) ) {
250 return $data->__toString();
251 }
252 if ( is_null( $data ) ) {
253 return '';
254 }
255 return '';
256 }
257
258 /**
259 * Checks if current value is number or else returns default value
260 *
261 * @param mixed $value data which need to be checked if is string.
262 * @param int $base value can be set is $data is not a string, defaults to empty string.
263 * @return int
264 * @since 0.0.1
265 */
266 public static function get_integer_value( $value, $base = 10 ) {
267 if ( is_numeric( $value ) ) {
268 return (int) $value;
269 }
270 if ( is_string( $value ) ) {
271 $trimmed_value = trim( $value );
272 return intval( $trimmed_value, $base );
273 }
274 return 0;
275 }
276
277 /**
278 * Safely converts a mixed value to float
279 *
280 * @param mixed $value The value to convert.
281 * @param float $default_value Default value if conversion fails.
282 * @return float
283 * @since 0.0.1
284 */
285 public static function get_float_value( $value, $default_value = 0.0 ) {
286 if ( is_numeric( $value ) ) {
287 return (float) $value;
288 }
289 return $default_value;
290 }
291
292 /**
293 * Safely get array value with type checking
294 *
295 * @param mixed $value The value to check.
296 * @param array<string, mixed> $default_value Default value if not an array.
297 * @return array<string, mixed>
298 * @since 0.0.1
299 */
300 public static function get_array_value( $value, $default_value = [] ) {
301 return is_array( $value ) ? $value : $default_value;
302 }
303
304 /**
305 * Check if current user has required capability.
306 *
307 * @param string $capability Capability to check (default: 'manage_options').
308 * @param array<mixed> $args Additional arguments for capability check.
309 * @return bool True if user has capability.
310 * @since 0.0.1
311 */
312 public static function current_user_can( $capability = '', $args = [] ) {
313 if ( ! function_exists( 'current_user_can' ) ) {
314 return false;
315 }
316
317 if ( ! is_string( $capability ) || empty( $capability ) ) {
318 $capability = 'manage_options';
319 }
320
321 return ! empty( $args )
322 ? current_user_can( $capability, ...$args )
323 : current_user_can( $capability );
324 }
325
326 /**
327 * Join an array of strings into a single string, filtering out empty values.
328 *
329 * @param array<string> $strings Array of strings to join.
330 * @param string $glue Separator to use (default: ' ').
331 * @return string Joined string.
332 * @since 0.0.1
333 */
334 public static function join_strings( $strings, $glue = ' ' ) {
335 if ( ! is_array( $strings ) ) {
336 return '';
337 }
338
339 $filtered = array_filter(
340 $strings,
341 static function ( $item ) {
342 return is_string( $item ) && '' !== trim( $item );
343 }
344 );
345
346 return implode( $glue, array_map( 'trim', $filtered ) );
347 }
348
349 /**
350 * Process blocks to generate unique slugs for SureDonation blocks.
351 *
352 * Recursively processes all blocks and generates slugs for those that
353 * don't have one set. Ensures all slugs are unique within the form.
354 *
355 * @param array<mixed> $blocks The blocks to process.
356 * @param array<string> $slugs Array of existing slugs (keyed by block_id).
357 * @param bool $updated Whether any blocks were updated.
358 * @param string $prefix Optional prefix for nested blocks.
359 * @return array{0: array<mixed>, 1: array<string>, 2: bool} Processed blocks, slugs, and updated flag.
360 * @since 0.0.1
361 */
362 public static function process_blocks( $blocks, $slugs = [], $updated = false, $prefix = '' ) {
363 if ( ! is_array( $blocks ) ) {
364 return [ [], $slugs, $updated ];
365 }
366 foreach ( $blocks as $index => $block ) {
367 if ( ! is_array( $block ) ) {
368 continue;
369 }
370 // Skip non-SureDonation blocks.
371 if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'suredonation/' ) !== 0 ) {
372 // Process inner blocks if any.
373 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
374 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
375 }
376 continue;
377 }
378
379 // Skip if no attrs or slug is already set and block_id is in slugs array.
380 if (
381 ! isset( $block['attrs'] ) ||
382 ! is_array( $block['attrs'] ) ||
383 (
384 ! empty( $block['attrs']['slug'] ) &&
385 isset( $block['attrs']['block_id'] ) &&
386 isset( $slugs[ $block['attrs']['block_id'] ] )
387 )
388 ) {
389 // Process inner blocks if any.
390 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
391 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
392 }
393 continue;
394 }
395
396 // Generate slug if empty.
397 if ( empty( $block['attrs']['slug'] ) ) {
398 $blocks[ $index ]['attrs']['slug'] = self::generate_unique_block_slug( $block, $slugs, $prefix );
399 $updated = true;
400 }
401
402 // Track the slug if block_id is set.
403 if ( isset( $block['attrs']['block_id'] ) ) {
404 $slugs[ $block['attrs']['block_id'] ] = $blocks[ $index ]['attrs']['slug'];
405 }
406
407 // Process inner blocks recursively.
408 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
409 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks(
410 $block['innerBlocks'],
411 $slugs,
412 $updated,
413 $blocks[ $index ]['attrs']['slug']
414 );
415 }
416 }
417
418 return [ $blocks, $slugs, $updated ];
419 }
420
421 /**
422 * Generates a unique slug based on the provided block and existing slugs.
423 *
424 * @param array<mixed> $block The block data.
425 * @param array<string> $slugs The array of existing slugs.
426 * @param string $prefix Optional prefix for nested blocks.
427 * @return string The generated unique block slug.
428 * @since 0.0.1
429 */
430 public static function generate_unique_block_slug( $block, $slugs, $prefix = '' ) {
431 $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'suredonation/', '', $block['blockName'] ) : '';
432
433 // Use label if available.
434 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
435 $slug = sanitize_title( $block['attrs']['label'] );
436 }
437
438 // Add prefix for nested blocks.
439 if ( ! empty( $prefix ) ) {
440 $slug = $prefix . '-' . $slug;
441 }
442
443 return self::generate_unique_slug( $slug, $slugs );
444 }
445
446 /**
447 * Ensures that the slug is unique.
448 *
449 * If the slug is already taken, it appends a number to make it unique.
450 *
451 * @param string $slug The slug to make unique.
452 * @param array<string> $slugs Array of existing slugs.
453 * @return string The unique slug.
454 * @since 0.0.1
455 */
456 public static function generate_unique_slug( $slug, $slugs ) {
457 $slug = sanitize_title( $slug );
458
459 // Check if slug exists in the array values.
460 if ( ! in_array( $slug, $slugs, true ) ) {
461 return $slug;
462 }
463
464 // Append a number to make it unique.
465 $index = 1;
466 while ( in_array( $slug . '-' . $index, $slugs, true ) ) {
467 ++$index;
468 }
469
470 return $slug . '-' . $index;
471 }
472
473 /**
474 * Generate a unique block ID for a server-created block.
475 *
476 * Mirrors the client-side generateBlockId() used in each block's edit.js
477 * (a 7-character base36 string). Blocks created programmatically (e.g. the
478 * default form auto-generated when a campaign is published) never run the
479 * editor, so they would otherwise have no block_id. The server-side payment
480 * validation config is keyed on block_id, so without one no config is stored
481 * and donations fail with "Invalid form configuration." until the form is
482 * opened and saved in the editor.
483 *
484 * @return string A 7-character base36 identifier.
485 * @since 1.1.1
486 */
487 public static function generate_block_id() {
488 $chars = '0123456789abcdefghijklmnopqrstuvwxyz';
489 $block_id = '';
490 for ( $i = 0; $i < 7; $i++ ) {
491 $block_id .= $chars[ wp_rand( 0, 35 ) ];
492 }
493 return $block_id;
494 }
495
496 /**
497 * Get client IP address for logging purposes.
498 *
499 * Uses REMOTE_ADDR only — forwarded headers (HTTP_X_FORWARDED_FOR,
500 * HTTP_CLIENT_IP) are deliberately ignored because they are trivially
501 * spoofable. Note: behind a proxy/CDN that does not restore the real client
502 * IP, this returns the proxy's address. Suitable for informational logging
503 * and best-effort geolocation only — do NOT use for security-critical IP
504 * validation.
505 *
506 * @return string Client IP address.
507 * @since 0.0.1
508 */
509 public static function get_client_ip() {
510 // Only trust REMOTE_ADDR — proxy headers (HTTP_X_FORWARDED_FOR, HTTP_CLIENT_IP)
511 // are trivially spoofable and should not be used for logging or security.
512 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
513
514 if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
515 return $ip;
516 }
517
518 return '';
519 }
520
521 /**
522 * Per-IP rate limiter for public (unauthenticated) submission endpoints.
523 *
524 * Uses a short-lived transient bucket keyed by action + client IP to
525 * throttle abuse (card-testing, DB/email flooding) on nopriv AJAX handlers.
526 * When the client IP cannot be determined the request is allowed, so
527 * legitimate donors are never blocked by a missing IP.
528 *
529 * @param string $action Unique action identifier namespacing the bucket.
530 * @param int $max Maximum attempts permitted within the window.
531 * @param int $window Window length in seconds.
532 * @return bool True if the request is within limits; false if the limit is exceeded.
533 * @since 1.1.0
534 */
535 public static function check_rate_limit( $action, $max = 15, $window = MINUTE_IN_SECONDS ) {
536 $ip = self::get_client_ip();
537 if ( '' === $ip ) {
538 return true;
539 }
540
541 $key = 'suredonation_rl_' . md5( (string) $action . '|' . $ip );
542 $count = (int) get_transient( $key );
543
544 if ( $count >= $max ) {
545 return false;
546 }
547
548 set_transient( $key, $count + 1, $window );
549 return true;
550 }
551
552 /**
553 * Get sanitized request metadata (user agent and referer).
554 *
555 * @return array{user_agent: string, referer_url: string} Request metadata.
556 * @since 1.0.0
557 */
558 public static function get_request_meta() {
559 return [
560 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
561 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
562 ];
563 }
564
565 /**
566 * Get allowed HTML tags for form markup.
567 *
568 * The wp_kses_post() doesn't allow form elements, so we need a custom allowed tags array.
569 * This is safe because the markup is generated internally by trusted code that already
570 * escapes user input with esc_attr(), esc_html(), etc.
571 *
572 * @return array<string, array<string, bool>> Allowed HTML tags and attributes.
573 * @since 0.0.1
574 */
575 public static function get_allowed_form_html() {
576 // Note: data-* wildcard doesn't work in wp_kses, so we list each data attribute explicitly.
577 $common_data_attrs = [
578 'data-block-id' => true,
579 'data-form-id' => true,
580 'data-gateway' => true,
581 'data-stripe-key' => true,
582 'data-currency' => true,
583 'data-payment-mode' => true,
584 'data-amount-type' => true,
585 'data-fixed-amount' => true,
586 'data-payment-type' => true,
587 'data-customer-name-field' => true,
588 'data-customer-email-field' => true,
589 'data-nonce' => true,
590 'data-variable-amount-field' => true,
591 'data-minimum-amount' => true,
592 'data-subscription-plan-name' => true,
593 'data-subscription-interval' => true,
594 'data-subscription-billing-cycles' => true,
595 'data-currency-symbol' => true,
596 'data-message-format' => true,
597 'data-payment-methods' => true,
598 'data-payment-available' => true,
599 'data-method' => true,
600 'data-slug' => true,
601 'data-required' => true,
602 'data-fee-percentage' => true,
603 'data-fee-fixed' => true,
604 'data-fee-mode' => true,
605 'data-gateway-fees' => true,
606 'data-invalid-email-msg' => true,
607 'data-invalid-url-msg' => true,
608 'data-sd-mask' => true,
609 'data-custom-sd-mask' => true,
610 // Dropdown (tom-select) field.
611 'data-multiple' => true,
612 'data-searchable' => true,
613 'data-preselected' => true,
614 'data-min-selection' => true,
615 'data-max-selection' => true,
616 'data-placeholder' => true,
617 // Phone (intl-tel-input) field.
618 'data-default-country' => true,
619 'data-auto-country' => true,
620 'data-enable-country-filter' => true,
621 'data-country-filter-type' => true,
622 'data-include-countries' => true,
623 'data-exclude-countries' => true,
624 ];
625
626 $allowed = [
627 'div' => array_merge(
628 [
629 'id' => true,
630 'class' => true,
631 'style' => true,
632 'role' => true,
633 'tabindex' => true,
634 'aria-live' => true,
635 'aria-atomic' => true,
636 'aria-hidden' => true,
637 'aria-labelledby' => true,
638 ],
639 $common_data_attrs
640 ),
641 'form' => array_merge(
642 [
643 'id' => true,
644 'class' => true,
645 'method' => true,
646 'action' => true,
647 ],
648 $common_data_attrs
649 ),
650 'fieldset' => [
651 'id' => true,
652 'class' => true,
653 ],
654 'legend' => [
655 'id' => true,
656 'class' => true,
657 ],
658 'label' => [
659 'id' => true,
660 'class' => true,
661 'for' => true,
662 ],
663 'input' => array_merge(
664 [
665 'id' => true,
666 'class' => true,
667 'type' => true,
668 'name' => true,
669 'value' => true,
670 'placeholder' => true,
671 'min' => true,
672 'max' => true,
673 'step' => true,
674 'maxlength' => true,
675 'checked' => true,
676 'disabled' => true,
677 'readonly' => true,
678 'required' => true,
679 'tabindex' => true,
680 'autocomplete' => true,
681 'inputmode' => true,
682 'aria-describedby' => true,
683 'aria-required' => true,
684 'aria-hidden' => true,
685 ],
686 $common_data_attrs
687 ),
688 'button' => array_merge(
689 [
690 'id' => true,
691 'class' => true,
692 'type' => true,
693 'disabled' => true,
694 ],
695 $common_data_attrs
696 ),
697 'select' => array_merge(
698 [
699 'id' => true,
700 'class' => true,
701 'name' => true,
702 'disabled' => true,
703 'required' => true,
704 'multiple' => true,
705 'tabindex' => true,
706 'autocomplete' => true,
707 'aria-describedby' => true,
708 'aria-required' => true,
709 ],
710 $common_data_attrs
711 ),
712 'option' => [
713 'value' => true,
714 'class' => true,
715 'selected' => true,
716 'disabled' => true,
717 ],
718 'textarea' => array_merge(
719 [
720 'id' => true,
721 'class' => true,
722 'name' => true,
723 'rows' => true,
724 'cols' => true,
725 'placeholder' => true,
726 'maxlength' => true,
727 'disabled' => true,
728 'readonly' => true,
729 'required' => true,
730 'aria-describedby' => true,
731 'aria-required' => true,
732 ],
733 $common_data_attrs
734 ),
735 'span' => array_merge(
736 [
737 'id' => true,
738 'class' => true,
739 'style' => true,
740 'aria-hidden' => true,
741 ],
742 $common_data_attrs
743 ),
744 'p' => [
745 'id' => true,
746 'class' => true,
747 'style' => true,
748 'role' => true,
749 ],
750 'h1' => [
751 'id' => true,
752 'class' => true,
753 ],
754 'h2' => [
755 'id' => true,
756 'class' => true,
757 ],
758 'h3' => [
759 'id' => true,
760 'class' => true,
761 ],
762 'h4' => [
763 'id' => true,
764 'class' => true,
765 ],
766 'h5' => [
767 'id' => true,
768 'class' => true,
769 ],
770 'h6' => [
771 'id' => true,
772 'class' => true,
773 ],
774 'a' => [
775 'id' => true,
776 'class' => true,
777 'href' => true,
778 'target' => true,
779 'rel' => true,
780 'style' => true,
781 ],
782 'strong' => [
783 'class' => true,
784 ],
785 'em' => [
786 'class' => true,
787 ],
788 'ol' => [
789 'class' => true,
790 ],
791 'ul' => [
792 'class' => true,
793 ],
794 'li' => [
795 'class' => true,
796 ],
797 'br' => [],
798 'hr' => [
799 'class' => true,
800 ],
801 // img/figure/figcaption back the Image block (inc/blocks/image) — the
802 // render depends on these entries, so don't drop them in a cleanup.
803 'img' => [
804 'src' => true,
805 'fetchpriority' => true,
806 'srcset' => true,
807 'sizes' => true,
808 'alt' => true,
809 'class' => true,
810 'style' => true,
811 'width' => true,
812 'height' => true,
813 'loading' => true,
814 'decoding' => true,
815 'title' => true,
816 // Lazy-load optimizers (WP Rocket, Perfmatters, Optimole, the
817 // Bricks theme, …) rewrite wp_get_attachment_image() output into
818 // these data-* attributes with a data: placeholder in src; allow
819 // them so kses doesn't strip the real URLs the lazy JS swaps back.
820 'data-src' => true,
821 'data-srcset' => true,
822 'data-sizes' => true,
823 'data-lazy-src' => true,
824 'data-lazy-srcset' => true,
825 'data-lazy-sizes' => true,
826 ],
827 'figure' => [
828 'class' => true,
829 ],
830 'figcaption' => [
831 'class' => true,
832 ],
833 'svg' => [
834 'class' => true,
835 'width' => true,
836 'height' => true,
837 'viewbox' => true,
838 'fill' => true,
839 'xmlns' => true,
840 'aria-hidden' => true,
841 ],
842 'circle' => [
843 'cx' => true,
844 'cy' => true,
845 'r' => true,
846 'stroke' => true,
847 'stroke-width' => true,
848 'fill' => true,
849 ],
850 'rect' => [
851 'x' => true,
852 'y' => true,
853 'width' => true,
854 'height' => true,
855 'rx' => true,
856 'stroke' => true,
857 'stroke-width' => true,
858 ],
859 'path' => [
860 'class' => true,
861 'd' => true,
862 'stroke' => true,
863 'stroke-width' => true,
864 'stroke-linecap' => true,
865 'stroke-linejoin' => true,
866 'fill' => true,
867 ],
868 ];
869
870 /**
871 * Filter the allowed HTML tags/attributes for SureDonation form markup.
872 *
873 * Lets extensions (e.g. the SureDonation Pro date/time pickers) permit the
874 * extra tags or data attributes their fields render.
875 *
876 * @since 1.1.1
877 * @param array<string, array<string, bool>> $allowed Allowed tags/attributes.
878 */
879 return apply_filters( 'suredonation_allowed_form_html', $allowed );
880 }
881
882 /**
883 * Get the nonce action string for a donation form.
884 *
885 * Shared between block render, shortcode render, and donation handler
886 * to ensure the nonce action is always consistent.
887 *
888 * @param int $campaign_id Campaign ID (0 for standalone forms).
889 * @return string Nonce action string.
890 * @since 1.0.0
891 */
892 public static function get_donation_nonce_action( $campaign_id ) {
893 // Note: This nonce is used by the generic donation-handler.php (form POST flow).
894 // Stripe and Offline AJAX handlers use a separate fixed nonce action
895 // 'suredonation_donation_form' generated in payment-markup.php — these are
896 // intentionally different nonce paths (form POST vs payment AJAX).
897 return $campaign_id ? 'suredonation_donation_' . $campaign_id : 'suredonation_donation_standalone';
898 }
899
900 /**
901 * Get form payment settings from post meta.
902 *
903 * Shared between the block and shortcode render paths to build
904 * the `window.suredonationPayment` frontend configuration object.
905 *
906 * @param int $form_id Form post ID.
907 * @return array<string, mixed> Payment settings array.
908 * @since 1.0.0
909 */
910 public static function get_form_payment_settings( $form_id ) {
911 $data = self::get_form_confirmation_settings( $form_id );
912
913 // Map confirmation type to frontend format.
914 $confirmation_type = 'message';
915 $redirect_url = '';
916 if ( 'custom url' === $data['confirmation_type'] ) {
917 $confirmation_type = 'redirect';
918 $redirect_url = $data['custom_url'];
919 } elseif ( 'different page' === $data['confirmation_type'] ) {
920 $confirmation_type = 'redirect';
921 $redirect_url = $data['page_url'];
922 }
923
924 $success_message = ! empty( $data['message'] )
925 ? $data['message']
926 : esc_html__( 'Thank you for your donation!', 'suredonation' );
927
928 return [
929 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
930 'confirmationType' => $confirmation_type,
931 'successTitle' => esc_html__( 'Thank You!', 'suredonation' ),
932 'successMessage' => wp_kses_post( self::get_string_value( $success_message ) ),
933 // Shown when payment succeeded at the gateway but our server-side
934 // finalize did not complete; the webhook will finalize it, so the
935 // donor must not be prompted to pay again.
936 'processingMessage' => esc_html__( 'Payment received. We are finalizing your donation and will email you a confirmation shortly. Please do not pay again.', 'suredonation' ),
937 'redirectUrl' => ! empty( $redirect_url ) ? esc_url( self::get_string_value( $redirect_url ) ) : '',
938 'submissionAction' => $data['submission_action'],
939 // translators: %s: formatted fee amount with currency symbol.
940 'feeIncludesText' => __( '(includes %s processing fee)', 'suredonation' ),
941 'amountPlaceholder' => __( 'Complete the form to view the amount.', 'suredonation' ),
942 // Currency symbol placement for client-side amount/fee formatting.
943 'currencySignPosition' => Payment_Helper::get_currency_sign_position(),
944 ];
945 }
946
947 /**
948 * Get form confirmation settings from post meta.
949 *
950 * Reads from consolidated _suredonation_form_confirmation meta key.
951 *
952 * @param int $form_id Form post ID.
953 * @return array<string, string> Confirmation settings with defaults applied.
954 * @since 1.0.0
955 */
956 public static function get_form_confirmation_settings( $form_id ) {
957 $defaults = [
958 'confirmation_type' => 'same page',
959 'message' => '',
960 'submission_action' => 'hide form',
961 'custom_url' => '',
962 'page_url' => '',
963 ];
964
965 $raw = get_post_meta( $form_id, '_suredonation_form_confirmation', true );
966
967 if ( ! empty( $raw ) && is_string( $raw ) ) {
968 $data = json_decode( $raw, true );
969 if ( is_array( $data ) ) {
970 return wp_parse_args( $data, $defaults );
971 }
972 }
973
974 return $defaults;
975 }
976
977 /**
978 * Get smart tags definitions grouped by context.
979 *
980 * Centralized source of truth for all smart tag lists used across
981 * admin UI, form editor, and email settings.
982 *
983 * @return array<string, array<int, array<string, mixed>>> Smart tags grouped by context.
984 * @since 1.0.0
985 */
986 public static function get_smart_tags() {
987 $confirmation_tags = [
988 [
989 'tag' => '{donor_name}',
990 'title' => __( 'Donor Name', 'suredonation' ),
991 ],
992 [
993 'tag' => '{donor_email}',
994 'title' => __( 'Donor Email', 'suredonation' ),
995 ],
996 [
997 'tag' => '{amount}',
998 'title' => __( 'Donation Amount', 'suredonation' ),
999 ],
1000 [
1001 'tag' => '{campaign_name}',
1002 'title' => __( 'Campaign Name', 'suredonation' ),
1003 ],
1004 [
1005 'tag' => '{donation_date}',
1006 'title' => __( 'Donation Date', 'suredonation' ),
1007 ],
1008 [
1009 'tag' => '{transaction_id}',
1010 'title' => __( 'Transaction ID', 'suredonation' ),
1011 ],
1012 [
1013 'tag' => '{payment_method}',
1014 'title' => __( 'Payment Method', 'suredonation' ),
1015 ],
1016 [
1017 'tag' => '{site_title}',
1018 'title' => __( 'Site Title', 'suredonation' ),
1019 ],
1020 [
1021 'tag' => '{donation_total}',
1022 'title' => __( 'Donation Total', 'suredonation' ),
1023 ],
1024 [
1025 'tag' => '{payment_status}',
1026 'title' => __( 'Payment Status', 'suredonation' ),
1027 ],
1028 [
1029 'tag' => '{donation_receipt}',
1030 'title' => __( 'Donation Receipt', 'suredonation' ),
1031 ],
1032 [
1033 'tag' => '{success_badge}',
1034 'title' => __( 'Success Badge', 'suredonation' ),
1035 ],
1036 ];
1037
1038 $smart_tags = [
1039 'confirmation' => $confirmation_tags,
1040 'email' => array_merge(
1041 $confirmation_tags,
1042 [
1043 [
1044 'tag' => '{admin_email}',
1045 'title' => __( 'Admin Email', 'suredonation' ),
1046 ],
1047 [
1048 'tag' => '{site_url}',
1049 'title' => __( 'Site URL', 'suredonation' ),
1050 ],
1051 [
1052 'tag' => '{admin_url}',
1053 'title' => __( 'Admin URL', 'suredonation' ),
1054 ],
1055 [
1056 'tag' => '{offline_instructions}',
1057 'title' => __( 'Offline Instructions', 'suredonation' ),
1058 ],
1059 ]
1060 ),
1061 'email_grouped' => [
1062 [
1063 'label' => __( 'Donation Tags', 'suredonation' ),
1064 'tags' => [
1065 [
1066 'tag' => '{donor_name}',
1067 'title' => __( 'Donor Name', 'suredonation' ),
1068 ],
1069 [
1070 'tag' => '{donor_email}',
1071 'title' => __( 'Donor Email', 'suredonation' ),
1072 ],
1073 [
1074 'tag' => '{amount}',
1075 'title' => __( 'Donation Amount', 'suredonation' ),
1076 ],
1077 [
1078 'tag' => '{campaign_name}',
1079 'title' => __( 'Campaign Name', 'suredonation' ),
1080 ],
1081 [
1082 'tag' => '{donation_date}',
1083 'title' => __( 'Donation Date', 'suredonation' ),
1084 ],
1085 [
1086 'tag' => '{transaction_id}',
1087 'title' => __( 'Transaction ID', 'suredonation' ),
1088 ],
1089 [
1090 'tag' => '{payment_method}',
1091 'title' => __( 'Payment Method', 'suredonation' ),
1092 ],
1093 [
1094 'tag' => '{refund_amount}',
1095 'title' => __( 'Refund Amount', 'suredonation' ),
1096 ],
1097 ],
1098 ],
1099 [
1100 'label' => __( 'General Tags', 'suredonation' ),
1101 'tags' => [
1102 [
1103 'tag' => '{site_title}',
1104 'title' => __( 'Site Title', 'suredonation' ),
1105 ],
1106 [
1107 'tag' => '{admin_email}',
1108 'title' => __( 'Admin Email', 'suredonation' ),
1109 ],
1110 [
1111 'tag' => '{site_url}',
1112 'title' => __( 'Site URL', 'suredonation' ),
1113 ],
1114 [
1115 'tag' => '{admin_url}',
1116 'title' => __( 'Admin URL', 'suredonation' ),
1117 ],
1118 [
1119 'tag' => '{offline_instructions}',
1120 'title' => __( 'Offline Instructions', 'suredonation' ),
1121 ],
1122 ],
1123 ],
1124 ],
1125 'offline_instructions' => [
1126 [
1127 'tag' => '{campaign_name}',
1128 'title' => __( 'Campaign Name', 'suredonation' ),
1129 ],
1130 [
1131 'tag' => '{site_title}',
1132 'title' => __( 'Site Title', 'suredonation' ),
1133 ],
1134 [
1135 'tag' => '{site_url}',
1136 'title' => __( 'Site URL', 'suredonation' ),
1137 ],
1138 [
1139 'tag' => '{admin_email}',
1140 'title' => __( 'Admin Email', 'suredonation' ),
1141 ],
1142 ],
1143 ];
1144
1145 // Recurring tags resolve to nothing without Pro, so a free-only site was
1146 // being offered two tags it could never use. They stay here rather than
1147 // moving into Pro so that activating Pro does not depend on shipping a
1148 // matching Pro release; anything Pro adds beyond these comes through the
1149 // filter below.
1150 if ( defined( 'SUREDONATION_PRO_VER' ) ) {
1151 $smart_tags['email_grouped'][0]['tags'][] = [
1152 'tag' => '{subscription_id}',
1153 'title' => __( 'Recurring Donation ID', 'suredonation' ),
1154 ];
1155 $smart_tags['email_grouped'][0]['tags'][] = [
1156 'tag' => '{subscription_interval}',
1157 'title' => __( 'Frequency', 'suredonation' ),
1158 ];
1159 }
1160
1161 /**
1162 * Filter the grouped smart tags offered in the email notification editor.
1163 *
1164 * The list is what an admin can insert, so anything registering a tag
1165 * resolver via `suredonation_email_smart_tags` needs to advertise it here
1166 * too. Without this, Pro could resolve recurring tags but had no way to
1167 * surface them, and free listed subscription tags that could never
1168 * resolve for a free-only site.
1169 *
1170 * @param array<int, array<string, mixed>> $groups Grouped tag definitions.
1171 * @since 1.4.0
1172 */
1173 $grouped = apply_filters( 'suredonation_email_smart_tag_groups', $smart_tags['email_grouped'] );
1174
1175 // The filter feeds the editor's tag picker, which iterates groups and
1176 // their tags. A callback returning a non-array — or groups without a
1177 // `tags` array — would fatal there rather than in whatever added it, so
1178 // the shape is re-checked before it is handed on.
1179 if ( is_array( $grouped ) ) {
1180 $smart_tags['email_grouped'] = array_values(
1181 array_filter(
1182 $grouped,
1183 static function ( $group ) {
1184 return is_array( $group ) && isset( $group['tags'] ) && is_array( $group['tags'] );
1185 }
1186 )
1187 );
1188 }
1189
1190 /**
1191 * Filter the smart-tag catalogue grouped by context.
1192 *
1193 * Lets extensions register additional contexts (e.g. a 'pdf' group for
1194 * PDF receipt templates) or extend existing ones. This catalogue only
1195 * drives tag-picker UIs; tag resolution happens in
1196 * Email_Handler::process_smart_tags() and its
1197 * 'suredonation_email_smart_tags' filter, so new tags must be
1198 * registered there as well to take effect.
1199 *
1200 * @param array<string, array<int, array<string, mixed>>> $smart_tags Smart tags grouped by context.
1201 * @since 1.5.0
1202 */
1203 return apply_filters( 'suredonation_smart_tags', $smart_tags );
1204 }
1205
1206 /**
1207 * Map a payment gateway slug to a human-readable label.
1208 *
1209 * @param string $gateway Gateway slug (e.g. stripe, paypal, manual).
1210 * @return string Display label.
1211 * @since 1.0.0
1212 */
1213 public static function get_payment_method_label( $gateway ) {
1214 switch ( $gateway ) {
1215 case 'paypal':
1216 return __( 'PayPal', 'suredonation' );
1217 case 'manual':
1218 case 'offline':
1219 return __( 'Offline Donation', 'suredonation' );
1220 case 'stripe':
1221 return __( 'Stripe', 'suredonation' );
1222 default:
1223 return ucwords( str_replace( [ '_', '-' ], ' ', (string) $gateway ) );
1224 }
1225 }
1226
1227 /**
1228 * Render the static "Success" badge used by the {success_badge} smart tag.
1229 *
1230 * @return string Badge HTML.
1231 * @since 1.0.0
1232 */
1233 public static function render_success_badge() {
1234 return '<span class="sd-success-box__badge">' . esc_html__( 'Success', 'suredonation' ) . '</span>';
1235 }
1236
1237 /**
1238 * Render a styled payment-status badge for the donation confirmation.
1239 *
1240 * @param string $status Payment status (e.g. completed, pending, failed).
1241 * @return array Badge HTML.
1242 * @since 1.0.0
1243 */
1244 public static function get_payment_status_config( $status ) {
1245 $status = strtolower( trim( (string) $status ) );
1246
1247 $map = [
1248 'completed' => [
1249 'label' => __( 'Complete', 'suredonation' ),
1250 'variant' => 'complete',
1251 ],
1252 'complete' => [
1253 'label' => __( 'Complete', 'suredonation' ),
1254 'variant' => 'complete',
1255 ],
1256 'pending' => [
1257 'label' => __( 'Pending', 'suredonation' ),
1258 'variant' => 'pending',
1259 ],
1260 'processing' => [
1261 'label' => __( 'Processing', 'suredonation' ),
1262 'variant' => 'pending',
1263 ],
1264 'failed' => [
1265 'label' => __( 'Failed', 'suredonation' ),
1266 'variant' => 'failed',
1267 ],
1268 'refunded' => [
1269 'label' => __( 'Refunded', 'suredonation' ),
1270 'variant' => 'refunded',
1271 ],
1272 ];
1273
1274 return $map[ $status ] ?? [
1275 'label' => '' !== $status ? ucfirst( $status ) : __( 'Complete', 'suredonation' ),
1276 'variant' => 'pending',
1277 ];
1278 }
1279
1280 /**
1281 * Render a styled payment-status badge for the donation receipt row.
1282 *
1283 * @param string $status Payment status (e.g. completed, pending, failed).
1284 * @return string Badge HTML.
1285 * @since 1.0.0
1286 */
1287 public static function render_payment_status_badge( $status ) {
1288 $config = self::get_payment_status_config( $status );
1289 return sprintf(
1290 '<span class="sd-receipt-badge sd-receipt-badge--%1$s">%2$s</span>',
1291 esc_attr( $config['variant'] ),
1292 esc_html( $config['label'] )
1293 );
1294 }
1295
1296 /**
1297 * Render the donation receipt card used by the {donation_receipt} smart tag.
1298 *
1299 * @param array<string, mixed> $donation_data Donation data.
1300 * @param string $campaign_name Campaign name ('' for standalone forms).
1301 * @return string Receipt card HTML.
1302 * @since 1.0.0
1303 */
1304 public static function render_donation_receipt( $donation_data, $campaign_name = '' ) {
1305 $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
1306 $base_amount = isset( $donation_data['amount'] ) && is_numeric( $donation_data['amount'] ) ? (float) $donation_data['amount'] : 0.0;
1307 $fees_covered = isset( $donation_data['fees_covered'] ) && is_numeric( $donation_data['fees_covered'] ) ? (float) $donation_data['fees_covered'] : 0.0;
1308 $total = $base_amount + $fees_covered;
1309
1310 $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : '';
1311 $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
1312 $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : '';
1313 $status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : '';
1314
1315 $rows = [
1316 [
1317 'label' => __( 'Donor Name', 'suredonation' ),
1318 'value' => esc_html( $donor_name ),
1319 ],
1320 [
1321 'label' => __( 'Donor Email', 'suredonation' ),
1322 'value' => esc_html( $donor_email ),
1323 ],
1324 ];
1325
1326 if ( '' !== $campaign_name ) {
1327 $rows[] = [
1328 'label' => __( 'Campaign Name', 'suredonation' ),
1329 'value' => esc_html( $campaign_name ),
1330 ];
1331 }
1332
1333 $rows[] = [
1334 'label' => __( 'Payment Status', 'suredonation' ),
1335 'value' => self::render_payment_status_badge( $status ),
1336 ];
1337 $rows[] = [
1338 'label' => __( 'Payment Method', 'suredonation' ),
1339 'value' => esc_html( self::get_payment_method_label( $gateway ) ),
1340 ];
1341 $rows[] = [
1342 'label' => __( 'Donation Amount', 'suredonation' ),
1343 'value' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ),
1344 ];
1345
1346 $rows_html = '';
1347 foreach ( $rows as $row ) {
1348 $rows_html .= sprintf(
1349 '<div class="sd-receipt-row"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1350 esc_html( $row['label'] ),
1351 $row['value']
1352 );
1353 }
1354
1355 $rows_html .= sprintf(
1356 '<div class="sd-receipt-row sd-receipt-row--total"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1357 esc_html__( 'Donation Total', 'suredonation' ),
1358 esc_html( Payment_Helper::format_amount( $total, $currency ) )
1359 );
1360
1361 return sprintf(
1362 '<div class="sd-receipt-card"><h3 class="sd-receipt-card__title">%1$s</h3><div class="sd-receipt-rows">%2$s</div></div>',
1363 esc_html__( 'Donation Receipt', 'suredonation' ),
1364 $rows_html
1365 );
1366 }
1367
1368 /**
1369 * Default confirmation message template (receipt layout with smart tags).
1370 *
1371 * @return string Message HTML template.
1372 * @since 1.0.0
1373 */
1374 public static function get_default_confirmation_message() {
1375 return '<p style="text-align: center; margin: 0;">{success_badge}</p>'
1376 . '<h2 class="sd-receipt-title" style="text-align: center;">'
1377 /* translators: {donor_name} is a smart tag replaced with the donor's name. */
1378 . esc_html__( 'Thank you {donor_name} for your Donation', 'suredonation' )
1379 . '</h2>'
1380 . '<p class="sd-receipt-subtitle" style="text-align: center;">'
1381 . esc_html__( 'Your contribution means a lot. We have sent a confirmation email to your registered address with the details of your donation.', 'suredonation' )
1382 . '</p>{donation_receipt}';
1383 }
1384
1385 /**
1386 * Build the rendered confirmation/thank-you HTML for a donation.
1387 *
1388 * Resolves the form's confirmation message template against the donation's
1389 * real data (smart tags) so the frontend can display the receipt. The
1390 * billing interval is lifted out of the nested donation_data column, which
1391 * is the only field of the set that is not stored as a column of its own.
1392 *
1393 * @param int $donation_id Donation ID.
1394 * @param array<string, mixed>|null $donation Donation row to render from.
1395 * Defaults to reading it. Pass one
1396 * when the caller already holds the
1397 * row, or when the row on disk does
1398 * not yet reflect the state being
1399 * reported to the donor.
1400 * @return string Sanitized confirmation HTML, or '' on failure.
1401 * @since 1.0.0
1402 */
1403 public static function render_confirmation_message( $donation_id, $donation = null ) {
1404 if ( ! is_array( $donation ) ) {
1405 $donation = Donations::get( $donation_id );
1406 }
1407
1408 if ( ! is_array( $donation ) ) {
1409 return '';
1410 }
1411
1412 $form_id = isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
1413 $campaign_id = isset( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
1414
1415 $settings = self::get_form_confirmation_settings( $form_id );
1416 $template = ! empty( $settings['message'] ) ? $settings['message'] : self::get_default_confirmation_message();
1417
1418 // The billing interval is the one field the donation row does not carry
1419 // as a column; it is written a level down inside donation_data, so it
1420 // has to be lifted out before the tag map can see it.
1421 $stored = $donation['donation_data'] ?? [];
1422 if ( is_string( $stored ) && '' !== $stored ) {
1423 $stored = json_decode( $stored, true );
1424 }
1425 $stored = is_array( $stored ) ? $stored : [];
1426
1427 $donation_data = [
1428 'id' => $donation_id,
1429 'donor_name' => $donation['donor_name'] ?? '',
1430 'donor_email' => $donation['donor_email'] ?? '',
1431 'amount' => $donation['amount'] ?? 0,
1432 'fees_covered' => $donation['fees_covered'] ?? 0,
1433 'currency' => $donation['currency'] ?? Payment_Helper::get_currency(),
1434 'gateway' => $donation['gateway'] ?? '',
1435 'payment_status' => $donation['payment_status'] ?? '',
1436 'transaction_id' => $donation['transaction_id'] ?? '',
1437 'donation_type' => $donation['donation_type'] ?? 'one-time',
1438 // Recurring donations resolve these two; a one-time donation has
1439 // neither, and the tag map already renders a missing value as empty.
1440 'subscription_id' => $donation['subscription_id'] ?? '',
1441 'subscription_interval' => $stored['subscription_interval'] ?? '',
1442 ];
1443
1444 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
1445
1446 $rendered = Email_Handler::process_smart_tags( $template, $donation_data, $campaign );
1447
1448 return wp_kses_post( $rendered );
1449 }
1450
1451 /**
1452 * Check whether the OttoKit (formerly SureTriggers) plugin is active and
1453 * authenticated with the OttoKit SaaS.
1454 *
1455 * @return bool True when OttoKit is installed, active and connected.
1456 * @since 1.2.0
1457 */
1458 public static function is_suretriggers_ready() {
1459 if ( ! defined( 'SURE_TRIGGERS_FILE' ) ) {
1460 // Plugin is deactivated or not installed at all.
1461 return false;
1462 }
1463
1464 $suretriggers_data = get_option( 'suretrigger_options', [] );
1465 if ( ! is_array( $suretriggers_data ) || empty( $suretriggers_data['secret_key'] ) || ! is_string( $suretriggers_data['secret_key'] ) ) {
1466 // OttoKit is not authenticated yet.
1467 return false;
1468 }
1469
1470 return true;
1471 }
1472
1473 /**
1474 * Get OttoKit (formerly SureTriggers) integration metadata.
1475 *
1476 * Shared by the admin app and the donation form editor so both surface the
1477 * same install/activate/connect state.
1478 *
1479 * @return array<string,mixed> Integration metadata.
1480 * @since 1.2.0
1481 */
1482 public static function get_ottokit_integration() {
1483 $plugin_file = 'suretriggers/suretriggers.php';
1484
1485 if ( ! function_exists( 'is_plugin_active' ) ) {
1486 include_once ABSPATH . 'wp-admin/includes/plugin.php';
1487 }
1488
1489 $status = 'Install';
1490 if ( is_plugin_active( $plugin_file ) ) {
1491 $status = 'Activated';
1492 } elseif ( array_key_exists( $plugin_file, get_plugins() ) ) {
1493 $status = 'Installed';
1494 }
1495
1496 return [
1497 'title' => 'OttoKit',
1498 'slug' => 'suretriggers',
1499 'path' => $plugin_file,
1500 'status' => $status,
1501 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Filter is owned by the OttoKit plugin.
1502 'connected' => apply_filters( 'suretriggers_is_user_connected', '' ),
1503 'connection_url' => admin_url( 'admin.php?page=suretriggers' ),
1504 ];
1505 }
1506 }
1507