PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.2
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.2
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 / abilities / runtime.php

runtime.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.2, at inc/abilities/runtime.php

1,298 lines 37.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abilities API Runtime
4 *
5 * Contains execute callbacks and helpers for SureDonation abilities.
6 *
7 * @package SureDonation
8 * @since 0.0.1
9 */
10
11 namespace SureDonation\Inc\Abilities;
12
13 use Exception;
14 use SureDonation\Inc\Campaigns\Campaign_Stats;
15 use SureDonation\Inc\Database\Tables\Donations;
16 use SureDonation\Inc\Database\Tables\Donors;
17 use SureDonation\Inc\Helper;
18 use SureDonation\Inc\Payments\Payment_Helper;
19 use SureDonation\Inc\Post_Types\Donation_Form;
20 use WP_Query;
21
22 // Exit if accessed directly.
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 /**
28 * Runtime class.
29 *
30 * @since 0.0.1
31 */
32 class Runtime {
33 /**
34 * Sentinel value indicating no default was provided to input_get().
35 */
36 private const NO_DEFAULT = '__NO_DEFAULT__';
37
38 /**
39 * Parsed input data.
40 *
41 * @var array<string, mixed>|false
42 */
43 protected $input = false;
44
45 // ============================================
46 // Category & Ability Registration
47 // ============================================
48
49 /**
50 * Register ability categories.
51 *
52 * @return void
53 */
54 public function register_categories() {
55 wp_register_ability_category(
56 'suredonation',
57 [
58 'label' => __( 'SureDonation', 'suredonation' ),
59 'description' => __( 'Abilities for the SureDonation donation management plugin.', 'suredonation' ),
60 ]
61 );
62 }
63
64 /**
65 * Register a dedicated SureDonation MCP server with the MCP adapter.
66 *
67 * Creates endpoint: {site_url}/wp-json/suredonation/v1/mcp
68 *
69 * @param \WP\MCP\Adapter\Adapter $adapter The MCP adapter instance.
70 * @return void
71 * @since 1.0.0
72 */
73 public function register_mcp_server( $adapter ) {
74 $abilities = wp_get_abilities();
75 $tools = [];
76
77 foreach ( $abilities as $ability ) {
78 if ( 0 === strpos( $ability->get_name(), 'suredonation/' ) ) {
79 $tools[] = $ability->get_name();
80 }
81 }
82
83 $transport_class = class_exists( '\WP\MCP\Transport\HttpTransport' )
84 ? \WP\MCP\Transport\HttpTransport::class
85 : \WP\MCP\Transport\Http\RestTransport::class;
86
87 $adapter->create_server(
88 'suredonation',
89 'suredonation/v1',
90 'mcp',
91 __( 'SureDonation MCP Server', 'suredonation' ),
92 __( 'SureDonation MCP Server for donation management.', 'suredonation' ),
93 SUREDONATION_VER,
94 [ $transport_class ],
95 \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
96 \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
97 $tools,
98 [],
99 []
100 );
101 }
102
103 /**
104 * Register all abilities.
105 *
106 * @return void
107 */
108 public function register() {
109 $abilities = Config_Ability::get_abilities();
110
111 foreach ( $abilities as $ability_name => $ability ) {
112 wp_register_ability(
113 $ability_name,
114 [
115 'label' => $ability['label'],
116 'description' => $ability['description'],
117 'category' => $ability['category'],
118 'input_schema' => $ability['input_schema'],
119 'output_schema' => $ability['output_schema'],
120 'execute_callback' => $ability['execute_callback'],
121 'permission_callback' => $ability['permission_callback'],
122 'meta' => $ability['meta'],
123 ]
124 );
125 }
126 }
127
128 // ============================================
129 // Campaign Execute Callbacks
130 // ============================================
131
132 /**
133 * List campaigns with pagination, search, status filter, and sorting.
134 *
135 * @param mixed $input Input data.
136 * @return array<string, mixed> Response.
137 */
138 public function list_campaigns( $input ) {
139 try {
140 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-campaigns' );
141
142 $page = $this->clamp_page( $this->input_get( 'page' ) );
143 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
144 $search = Helper::get_string_value( $this->input_get( 'search' ) );
145 $status = Helper::get_string_value( $this->input_get( 'status' ) );
146 $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
147 $order = Helper::get_string_value( $this->input_get( 'order' ) );
148
149 $orderby_map = [
150 'date' => 'date',
151 'title' => 'title',
152 'status' => 'post_status',
153 ];
154
155 $args = [
156 'post_type' => SUREDONATION_POST_TYPE,
157 'posts_per_page' => $per_page,
158 'paged' => $page,
159 'orderby' => $orderby_map[ $sort_by ] ?? 'date',
160 'order' => $order,
161 ];
162
163 if ( 'all' !== $status ) {
164 $args['post_status'] = $status;
165 } else {
166 $args['post_status'] = [ 'publish', 'draft' ];
167 }
168
169 if ( ! empty( $search ) ) {
170 $args['s'] = $search;
171 }
172
173 $query = new WP_Query( $args );
174
175 $campaigns = [];
176 if ( $query->have_posts() ) {
177 foreach ( $query->posts as $post ) {
178 $post_obj = $post instanceof \WP_Post ? $post : get_post( $post );
179 if ( $post_obj instanceof \WP_Post ) {
180 $campaigns[] = $this->format_campaign( $post_obj );
181 }
182 }
183 }
184
185 return [
186 'campaigns' => $campaigns,
187 'total' => (int) $query->found_posts,
188 'total_pages' => (int) $query->max_num_pages,
189 ];
190 } catch ( Exception $e ) {
191 return $this->error( $e );
192 }
193 }
194
195 /**
196 * Get a single campaign by ID.
197 *
198 * @param mixed $input Input data.
199 * @return array<string, mixed> Response.
200 */
201 public function get_campaign( $input ) {
202 try {
203 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-campaign' );
204 $post = $this->require_campaign( $this->input_get( 'id' ) );
205
206 $stats = Campaign_Stats::get_stats( $post->ID );
207 $meta = Helper::get_campaign_meta( $post->ID );
208
209 return [
210 'id' => $post->ID,
211 'title' => $post->post_title,
212 'description' => $post->post_excerpt,
213 'status' => $stats['campaign_status'],
214 'goal_type' => $meta['goal_type'],
215 'goal' => $stats['goal_amount'],
216 'raised' => $stats['total_raised'],
217 'donors' => $stats['donor_count'],
218 'progress' => $stats['progress_percentage'],
219 'donation_count' => $stats['donation_count'],
220 'average_donation' => $stats['average_donation'],
221 'largest_donation' => $stats['largest_donation'],
222 'is_goal_reached' => $stats['is_goal_reached'],
223 'require_terms' => (bool) ( $meta['require_terms'] ?? false ),
224 'created_at' => $post->post_date,
225 'modified_at' => $post->post_modified,
226 ];
227 } catch ( Exception $e ) {
228 return $this->error( $e );
229 }
230 }
231
232 /**
233 * Create a new campaign.
234 *
235 * @param mixed $input Input data.
236 * @return array<string, mixed> Response.
237 * @throws Exception If validation or creation fails.
238 */
239 public function create_campaign( $input ) {
240 try {
241 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'create-campaign' );
242
243 $title = Helper::get_string_value( $this->input_get( 'title' ) );
244 $description = Helper::get_string_value( $this->input_get( 'description', '' ) );
245
246 // The description is stored as the excerpt so post_content stays
247 // reserved for the campaign page layout (matching the REST handler).
248 $post_id = wp_insert_post(
249 [
250 'post_type' => SUREDONATION_POST_TYPE,
251 'post_title' => sanitize_text_field( $title ),
252 'post_excerpt' => wp_kses_post( $description ),
253 'post_status' => 'publish',
254 'post_author' => get_current_user_id(),
255 ],
256 true
257 );
258
259 if ( is_wp_error( $post_id ) ) {
260 throw new Exception( esc_html__( 'Failed to create campaign.', 'suredonation' ) );
261 }
262
263 $meta_values = [
264 'goal_type' => $this->input_get( 'goal_type' ),
265 'goal_amount' => $this->input_get( 'goal_amount' ),
266 'campaign_status' => $this->input_get( 'campaign_status' ),
267 'require_terms' => $this->input_get( 'require_terms' ),
268 'terms_text' => $this->input_get( 'terms_text', '' ),
269 ];
270
271 Helper::update_campaign_meta( $post_id, $meta_values );
272
273 return [
274 'id' => $post_id,
275 'title' => $title,
276 'status' => 'active',
277 'message' => esc_html__( 'Campaign created successfully.', 'suredonation' ),
278 ];
279 } catch ( Exception $e ) {
280 return $this->error( $e );
281 }
282 }
283
284 /**
285 * Update an existing campaign.
286 *
287 * @param mixed $input Input data.
288 * @return array<string, mixed> Response.
289 * @throws Exception If validation or update fails.
290 */
291 public function update_campaign( $input ) {
292 try {
293 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-campaign' );
294 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
295 $this->require_campaign( $id );
296
297 $parsed_inputs = is_array( $this->input ) ? $this->input : [];
298
299 // Update post fields if provided.
300 $post_data = [ 'ID' => $id ];
301 $title = Helper::get_string_value( $this->input_get( 'title', '' ) );
302 if ( ! empty( $title ) ) {
303 $post_data['post_title'] = sanitize_text_field( $title );
304 }
305
306 // The description is stored as the excerpt so post_content stays
307 // reserved for the campaign page layout. An empty string clears it.
308 if ( array_key_exists( 'description', $parsed_inputs ) ) {
309 $post_data['post_excerpt'] = wp_kses_post(
310 Helper::get_string_value( $this->input_get( 'description', '' ) )
311 );
312 }
313
314 if ( count( $post_data ) > 1 ) {
315 $result = wp_update_post( $post_data, true );
316 if ( is_wp_error( $result ) ) {
317 throw new Exception( esc_html__( 'Failed to update campaign.', 'suredonation' ) );
318 }
319 }
320
321 // Update meta fields if provided.
322 $meta_fields = [ 'goal_type', 'goal_amount', 'campaign_status', 'require_terms', 'terms_text' ];
323 $meta_values = [];
324
325 foreach ( $meta_fields as $field ) {
326 if ( array_key_exists( $field, $parsed_inputs ) ) {
327 $value = $parsed_inputs[ $field ];
328 // Only include non-default/non-empty values for optional fields.
329 if ( '' !== $value && null !== $value ) {
330 $meta_values[ $field ] = $value;
331 }
332 }
333 }
334
335 if ( ! empty( $meta_values ) ) {
336 Helper::update_campaign_meta( $id, $meta_values );
337 }
338
339 $updated_post = get_post( $id );
340 $meta = Helper::get_campaign_meta( $id );
341
342 return [
343 'id' => $id,
344 'title' => $updated_post ? $updated_post->post_title : '',
345 'status' => $meta['campaign_status'],
346 'message' => esc_html__( 'Campaign updated successfully.', 'suredonation' ),
347 ];
348 } catch ( Exception $e ) {
349 return $this->error( $e );
350 }
351 }
352
353 /**
354 * Delete a campaign permanently.
355 *
356 * @param mixed $input Input data.
357 * @return array<string, mixed> Response.
358 * @throws Exception If validation or deletion fails.
359 */
360 public function delete_campaign( $input ) {
361 try {
362 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'delete-campaign' );
363 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
364 $this->require_campaign( $id );
365
366 $result = wp_delete_post( $id, true );
367 if ( ! $result ) {
368 throw new Exception( esc_html__( 'Failed to delete campaign.', 'suredonation' ) );
369 }
370
371 return [
372 'id' => $id,
373 'message' => esc_html__( 'Campaign permanently deleted.', 'suredonation' ),
374 ];
375 } catch ( Exception $e ) {
376 return $this->error( $e );
377 }
378 }
379
380 /**
381 * Duplicate a campaign.
382 *
383 * @param mixed $input Input data.
384 * @return array<string, mixed> Response.
385 * @throws Exception If validation or duplication fails.
386 */
387 public function duplicate_campaign( $input ) {
388 try {
389 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'duplicate-campaign' );
390 $original = $this->require_campaign( $this->input_get( 'id' ) );
391
392 // The page (post_content) is intentionally NOT copied: its blocks carry
393 // the original campaign's id, so a fresh page is seeded for the duplicate
394 // on first publish (or via the Create Campaign Page CTA). The description
395 // (post_excerpt) is carried over.
396 $duplicate_id = wp_insert_post(
397 [
398 'post_type' => $original->post_type,
399 'post_title' => $original->post_title . ' (Copy)',
400 'post_excerpt' => $original->post_excerpt,
401 'post_status' => 'draft',
402 'post_author' => get_current_user_id(),
403 ],
404 true
405 );
406
407 if ( is_wp_error( $duplicate_id ) ) {
408 throw new Exception( esc_html__( 'Failed to duplicate campaign.', 'suredonation' ) );
409 }
410
411 // Copy campaign meta.
412 $meta = get_post_meta( $original->ID, Helper::SUREDONATION_CAMPAIGN_META_KEY, true );
413 if ( ! empty( $meta ) ) {
414 update_post_meta( $duplicate_id, Helper::SUREDONATION_CAMPAIGN_META_KEY, $meta );
415 }
416
417 return [
418 'id' => $duplicate_id,
419 'title' => $original->post_title . ' (Copy)',
420 'message' => esc_html__( 'Campaign duplicated successfully.', 'suredonation' ),
421 ];
422 } catch ( Exception $e ) {
423 return $this->error( $e );
424 }
425 }
426
427 /**
428 * Get pages/posts where a campaign's form block is embedded.
429 *
430 * @param mixed $input Input data.
431 * @return array<string, mixed> Response.
432 */
433 public function get_campaign_form_locations( $input ) {
434 try {
435 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-campaign-form-locations' );
436 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
437 $this->require_campaign( $id );
438
439 global $wpdb;
440
441 $search_pattern = '%"campaignId":%' . $id . '%';
442
443 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
444 $posts = $wpdb->get_results(
445 $wpdb->prepare(
446 "SELECT ID, post_title, post_type, post_status, post_modified
447 FROM %i
448 WHERE post_content LIKE %s
449 AND post_status IN ('publish', 'draft', 'pending', 'private')
450 AND post_type IN ('page', 'post', 'suredonation_cmpgn')
451 ORDER BY post_modified DESC",
452 $wpdb->posts,
453 $search_pattern
454 )
455 );
456
457 $locations = [];
458 if ( $posts ) {
459 foreach ( $posts as $found_post ) {
460 $locations[] = [
461 'id' => (int) $found_post->ID,
462 'title' => $found_post->post_title,
463 'type' => $found_post->post_type,
464 'status' => $found_post->post_status,
465 'edit_url' => admin_url( 'post.php?post=' . $found_post->ID . '&action=edit' ),
466 'view_url' => 'publish' === $found_post->post_status ? get_permalink( $found_post->ID ) : '',
467 ];
468 }
469 }
470
471 return [
472 'locations' => $locations,
473 ];
474 } catch ( Exception $e ) {
475 return $this->error( $e );
476 }
477 }
478
479 // ============================================
480 // Donation Execute Callbacks
481 // ============================================
482
483 /**
484 * List donations with pagination, search, status/campaign filter, and sorting.
485 *
486 * @param mixed $input Input data.
487 * @return array<string, mixed> Response.
488 */
489 public function list_donations( $input ) {
490 try {
491 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-donations' );
492
493 $page = $this->clamp_page( $this->input_get( 'page' ) );
494 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
495 $search = Helper::get_string_value( $this->input_get( 'search' ) );
496 $status = Helper::get_string_value( $this->input_get( 'status' ) );
497 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
498 $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
499 $order = Helper::get_string_value( $this->input_get( 'order' ) );
500
501 $offset = ( $page - 1 ) * $per_page;
502
503 $results = Donations::get_admin_list(
504 $status,
505 $campaign_id,
506 $search,
507 $per_page,
508 $offset,
509 $sort_by,
510 strtoupper( $order )
511 );
512
513 $total = Donations::get_total_donations_by_status( $status, $campaign_id );
514
515 $donations = [];
516 foreach ( $results as $donation ) {
517 if ( is_array( $donation ) ) {
518 $donations[] = $this->format_donation( $donation );
519 }
520 }
521
522 return [
523 'donations' => $donations,
524 'total' => (int) $total,
525 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
526 ];
527 } catch ( Exception $e ) {
528 return $this->error( $e );
529 }
530 }
531
532 /**
533 * Get a single donation by ID.
534 *
535 * @param mixed $input Input data.
536 * @return array<string, mixed> Response.
537 */
538 public function get_donation( $input ) {
539 try {
540 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation' );
541 $donation = $this->require_donation( $this->input_get( 'id' ) );
542
543 return $this->format_donation( $donation );
544 } catch ( Exception $e ) {
545 return $this->error( $e );
546 }
547 }
548
549 /**
550 * Get paginated notes for a donation.
551 *
552 * @param mixed $input Input data.
553 * @return array<string, mixed> Response.
554 */
555 public function get_donation_notes( $input ) {
556 try {
557 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation-notes' );
558 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
559 $page = $this->clamp_page( $this->input_get( 'page' ) );
560 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
561 $this->require_donation( $id );
562
563 $notes_data = Donations::get_notes( $id, $page, $per_page );
564
565 return [
566 'notes' => $notes_data['notes'],
567 'total' => (int) $notes_data['total'],
568 'total_pages' => (int) $notes_data['total_pages'],
569 ];
570 } catch ( Exception $e ) {
571 return $this->error( $e );
572 }
573 }
574
575 /**
576 * Add a note to a donation.
577 *
578 * @param mixed $input Input data.
579 * @return array<string, mixed> Response.
580 * @throws Exception If validation fails.
581 */
582 public function add_donation_note( $input ) {
583 try {
584 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'add-donation-note' );
585 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
586 $note = Helper::get_string_value( $this->input_get( 'note' ) );
587 $this->require_donation( $id );
588
589 $result = Donations::add_note( $id, $note, get_current_user_id() );
590
591 if ( ! $result['success'] ) {
592 throw new Exception( esc_html__( 'Failed to add note.', 'suredonation' ) );
593 }
594
595 return [
596 'note_id' => $result['note_id'],
597 'message' => esc_html__( 'Note added successfully.', 'suredonation' ),
598 ];
599 } catch ( Exception $e ) {
600 return $this->error( $e );
601 }
602 }
603
604 // ============================================
605 // Donor Execute Callbacks
606 // ============================================
607
608 /**
609 * List donors with pagination, status filter, and sorting.
610 *
611 * @param mixed $input Input data.
612 * @return array<string, mixed> Response.
613 */
614 public function list_donors( $input ) {
615 try {
616 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-donors' );
617
618 $page = $this->clamp_page( $this->input_get( 'page' ) );
619 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
620 $status = Helper::get_string_value( $this->input_get( 'status' ) );
621 $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
622 $order = Helper::get_string_value( $this->input_get( 'order' ) );
623 $offset = ( $page - 1 ) * $per_page;
624
625 if ( 'all' === $status ) {
626 $results = Donors::get_all( $per_page, $offset, $sort_by, $order );
627 } else {
628 $results = Donors::get_by_status( $status, $per_page, $offset, $sort_by, $order );
629 }
630
631 $total = Donors::get_total_donors( $status );
632
633 $donors = [];
634 foreach ( $results as $donor ) {
635 if ( is_array( $donor ) ) {
636 $donors[] = $this->format_donor( $donor );
637 }
638 }
639
640 return [
641 'donors' => $donors,
642 'total' => (int) $total,
643 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
644 ];
645 } catch ( Exception $e ) {
646 return $this->error( $e );
647 }
648 }
649
650 /**
651 * Get a single donor by ID.
652 *
653 * @param mixed $input Input data.
654 * @return array<string, mixed> Response.
655 */
656 public function get_donor( $input ) {
657 try {
658 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor' );
659 $donor = $this->require_donor( $this->input_get( 'id' ) );
660
661 return $this->format_donor( $donor );
662 } catch ( Exception $e ) {
663 return $this->error( $e );
664 }
665 }
666
667 /**
668 * Get a donor by email address.
669 *
670 * @param mixed $input Input data.
671 * @return array<string, mixed> Response.
672 * @throws Exception If validation fails.
673 */
674 public function get_donor_by_email( $input ) {
675 try {
676 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor-by-email' );
677 $email = sanitize_email( Helper::get_string_value( $this->input_get( 'email' ) ) );
678
679 if ( empty( $email ) || ! is_email( $email ) ) {
680 throw new Exception( esc_html__( 'A valid email address is required.', 'suredonation' ) );
681 }
682
683 $donor = Donors::get_by_email( $email );
684 if ( ! $donor ) {
685 throw new Exception( esc_html__( 'Donor not found.', 'suredonation' ) );
686 }
687
688 return $this->format_donor( $donor );
689 } catch ( Exception $e ) {
690 return $this->error( $e );
691 }
692 }
693
694 /**
695 * Get top donors ranked by total donated.
696 *
697 * @param mixed $input Input data.
698 * @return array<string, mixed> Response.
699 */
700 public function get_top_donors( $input ) {
701 try {
702 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-top-donors' );
703 $limit = $this->clamp_per_page( $this->input_get( 'limit' ) );
704
705 $results = Donors::get_top_donors( $limit );
706
707 $donors = [];
708 foreach ( $results as $donor ) {
709 if ( is_array( $donor ) ) {
710 $id_val = $donor['id'] ?? 0;
711 $donated_val = $donor['total_donated'] ?? 0;
712 $count_val = $donor['donation_count'] ?? 0;
713
714 $donors[] = [
715 'id' => is_numeric( $id_val ) ? (int) $id_val : 0,
716 'name' => $donor['name'] ?? '',
717 'email' => $donor['email'] ?? '',
718 'total_donated' => is_numeric( $donated_val ) ? (float) $donated_val : 0.0,
719 'donation_count' => is_numeric( $count_val ) ? (int) $count_val : 0,
720 ];
721 }
722 }
723
724 return [
725 'donors' => $donors,
726 ];
727 } catch ( Exception $e ) {
728 return $this->error( $e );
729 }
730 }
731
732 // ============================================
733 // Form Execute Callbacks
734 // ============================================
735
736 /**
737 * List donation forms with optional campaign and status filter.
738 *
739 * @param mixed $input Input data.
740 * @return array<string, mixed> Response.
741 */
742 public function list_forms( $input ) {
743 try {
744 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-forms' );
745
746 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
747 $status = Helper::get_string_value( $this->input_get( 'status' ) );
748 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
749
750 $post_status = 'any' === $status ? [ 'publish', 'draft', 'trash' ] : $status;
751
752 $args = [
753 'posts_per_page' => $per_page,
754 'post_status' => $post_status,
755 ];
756
757 if ( $campaign_id > 0 ) {
758 $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
759 [
760 'key' => Donation_Form::META_CAMPAIGN_ID,
761 'value' => $campaign_id,
762 'compare' => '=',
763 'type' => 'NUMERIC',
764 ],
765 ];
766 }
767
768 $forms = Donation_Form::get_forms( $args );
769
770 $formatted = [];
771 foreach ( $forms as $form ) {
772 $formatted[] = $this->format_form( $form );
773 }
774
775 return [
776 'forms' => $formatted,
777 ];
778 } catch ( Exception $e ) {
779 return $this->error( $e );
780 }
781 }
782
783 /**
784 * Get a single donation form by ID.
785 *
786 * @param mixed $input Input data.
787 * @return array<string, mixed> Response.
788 * @throws Exception If validation fails.
789 */
790 public function get_form( $input ) {
791 try {
792 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-form' );
793 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
794
795 if ( 0 === $id ) {
796 throw new Exception( esc_html__( 'Invalid form ID.', 'suredonation' ) );
797 }
798
799 $form = get_post( $id );
800 if ( ! $form || Donation_Form::POST_TYPE !== $form->post_type ) {
801 throw new Exception( esc_html__( 'Form not found.', 'suredonation' ) );
802 }
803
804 return $this->format_form( $form );
805 } catch ( Exception $e ) {
806 return $this->error( $e );
807 }
808 }
809
810 // ============================================
811 // Dashboard & Analytics Execute Callbacks
812 // ============================================
813
814 /**
815 * Get donation trends for time-series analysis.
816 *
817 * @param mixed $input Input data.
818 * @return array<string, mixed> Response.
819 */
820 public function get_donation_trends( $input ) {
821 try {
822 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation-trends' );
823
824 $after = Helper::get_string_value( $this->input_get( 'after' ) );
825 $before = Helper::get_string_value( $this->input_get( 'before' ) );
826 $group = Helper::get_string_value( $this->input_get( 'group' ) );
827
828 $date_pattern = '/^\d{4}-\d{2}-\d{2}$/';
829 if ( ! empty( $after ) && ! preg_match( $date_pattern, $after ) ) {
830 $after = '';
831 }
832 if ( ! empty( $before ) && ! preg_match( $date_pattern, $before ) ) {
833 $before = '';
834 }
835
836 $trends = Donations::get_donation_trends( $after, $before, $group );
837 $currency = Payment_Helper::get_currency();
838
839 $formatted = [];
840 foreach ( $trends as $trend ) {
841 $formatted[] = [
842 'period' => $trend['period'] ?? '',
843 'donation_count' => isset( $trend['donation_count'] ) ? (int) $trend['donation_count'] : 0,
844 'total_amount' => isset( $trend['total_amount'] ) ? (float) $trend['total_amount'] : 0.0,
845 ];
846 }
847
848 return [
849 'trends' => $formatted,
850 'currency' => $currency,
851 ];
852 } catch ( Exception $e ) {
853 return $this->error( $e );
854 }
855 }
856
857 /**
858 * Check user capabilities.
859 *
860 * @param string|array<string> $caps Single capability or array of capabilities (AND logic).
861 * @return bool True if user has required capabilities.
862 */
863 public function permission_callback( $caps ) {
864 if ( empty( $caps ) ) {
865 return false;
866 }
867
868 $user = wp_get_current_user();
869 if ( ! $user || 0 === $user->ID ) {
870 return false;
871 }
872
873 if ( is_string( $caps ) ) {
874 return $user->has_cap( $caps );
875 }
876
877 if ( is_array( $caps ) ) {
878 foreach ( $caps as $cap ) {
879 if ( ! $user->has_cap( $cap ) ) {
880 return false;
881 }
882 }
883 return true;
884 }
885
886 return false;
887 }
888
889 /**
890 * Get a parsed input value.
891 *
892 * @param string $name Property name.
893 * @param mixed $fallback Fallback value if property not found.
894 * @return mixed
895 * @throws Exception If inputs not parsed or property not found and no fallback.
896 */
897 public function input_get( $name, $fallback = self::NO_DEFAULT ) {
898 if ( false === $this->input ) {
899 throw new Exception( esc_html__( 'Inputs not parsed.', 'suredonation' ) );
900 }
901
902 if ( ! array_key_exists( $name, $this->input ) ) {
903 if ( self::NO_DEFAULT !== $fallback ) {
904 return $fallback;
905 }
906 throw new Exception(
907 sprintf(
908 /* translators: %s: property name */
909 esc_html__( 'Property %s not found.', 'suredonation' ),
910 esc_html( $name )
911 )
912 );
913 }
914
915 return $this->input[ $name ];
916 }
917
918 // ============================================
919 // Helper Methods
920 // ============================================
921
922 /**
923 * Initialize input parsing.
924 *
925 * @param mixed $input Raw input.
926 * @param string $ability_name Ability identifier.
927 * @return void
928 */
929 protected function init( $input, $ability_name ) {
930 $this->input_parse( $input, $ability_name );
931 }
932
933 /**
934 * Parse and validate input against schema.
935 *
936 * @param mixed $input Raw input.
937 * @param string $ability_name Ability identifier.
938 * @return array<string, mixed> Parsed input.
939 * @throws Exception If required field is missing or invalid value.
940 */
941 protected function input_parse( $input, $ability_name ) {
942 $this->input = [];
943
944 if ( is_object( $input ) && is_a( $input, 'WP_REST_Request' ) ) {
945 $input = $input->get_json_params();
946 if ( ! is_array( $input ) ) {
947 $input = [];
948 }
949 }
950
951 if ( ! is_array( $input ) ) {
952 $input = [];
953 }
954
955 $input_schema = Config_Ability::get_ability_input_schema( $ability_name );
956 if ( ! is_array( $input_schema ) || empty( $input_schema ) ) {
957 return [];
958 }
959
960 if ( ! isset( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) {
961 return [];
962 }
963
964 $required_fields = isset( $input_schema['required'] ) && is_array( $input_schema['required'] )
965 ? $input_schema['required']
966 : [];
967
968 foreach ( $input_schema['properties'] as $name => $prop ) {
969 $type = isset( $prop['type'] ) ? strtolower( $prop['type'] ) : 'string';
970 $raw_value = array_key_exists( $name, $input ) ? $input[ $name ] : null;
971
972 $is_required = in_array( $name, $required_fields, true );
973 if ( $is_required && ( null === $raw_value || '' === $raw_value ) ) {
974 throw new Exception(
975 sprintf(
976 /* translators: %s: field name */
977 esc_html__( 'Required field %s is missing.', 'suredonation' ),
978 esc_html( $name )
979 )
980 );
981 }
982
983 if ( null === $raw_value && isset( $prop['default'] ) ) {
984 $raw_value = $prop['default'];
985 }
986
987 if ( null === $raw_value ) {
988 switch ( $type ) {
989 case 'integer':
990 $raw_value = 0;
991 break;
992 case 'number':
993 $raw_value = 0.0;
994 break;
995 case 'boolean':
996 $raw_value = false;
997 break;
998 case 'array':
999 case 'object':
1000 $raw_value = [];
1001 break;
1002 default:
1003 $raw_value = '';
1004 break;
1005 }
1006 }
1007
1008 $value = $raw_value;
1009
1010 switch ( $type ) {
1011 case 'integer':
1012 $value = intval( $value );
1013 break;
1014 case 'number':
1015 $value = floatval( $value );
1016 break;
1017 case 'boolean':
1018 $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
1019 break;
1020 case 'string':
1021 $str_value = is_string( $value ) ? $value : strval( $value );
1022 // Use wp_kses_post for fields with format 'html', sanitize_text_field for all others.
1023 $value = isset( $prop['format'] ) && 'html' === $prop['format']
1024 ? wp_kses_post( $str_value )
1025 : sanitize_text_field( $str_value );
1026 break;
1027 case 'array':
1028 case 'object':
1029 if ( ! is_array( $value ) ) {
1030 $value = [];
1031 }
1032 $value = $this->sanitize_recursive( $value );
1033 break;
1034 }
1035
1036 if ( isset( $prop['enum'] ) && is_array( $prop['enum'] ) ) {
1037 if ( ! in_array( $value, $prop['enum'], true ) ) {
1038 throw new Exception(
1039 sprintf(
1040 /* translators: %s: field name */
1041 esc_html__( 'Invalid value for %s.', 'suredonation' ),
1042 esc_html( $name )
1043 )
1044 );
1045 }
1046 }
1047
1048 $this->input[ $name ] = $value;
1049 }
1050
1051 return $this->input;
1052 }
1053
1054 /**
1055 * Recursively sanitize array/object values.
1056 *
1057 * @param array<mixed> $data Data to sanitize.
1058 * @return array<mixed> Sanitized data.
1059 */
1060 protected function sanitize_recursive( $data ) {
1061 if ( ! is_array( $data ) ) {
1062 return $data;
1063 }
1064
1065 $sanitized = [];
1066 foreach ( $data as $key => $value ) {
1067 $key = sanitize_text_field( strval( $key ) );
1068 if ( is_array( $value ) ) {
1069 $sanitized[ $key ] = $this->sanitize_recursive( $value );
1070 } elseif ( is_string( $value ) ) {
1071 $sanitized[ $key ] = sanitize_text_field( $value );
1072 } elseif ( is_int( $value ) ) {
1073 $sanitized[ $key ] = intval( $value );
1074 } elseif ( is_float( $value ) ) {
1075 $sanitized[ $key ] = floatval( $value );
1076 } elseif ( is_bool( $value ) ) {
1077 $sanitized[ $key ] = (bool) $value;
1078 } else {
1079 $sanitized[ $key ] = sanitize_text_field( strval( $value ) );
1080 }
1081 }
1082 return $sanitized;
1083 }
1084
1085 /**
1086 * Format error response.
1087 *
1088 * @param Exception $e The exception.
1089 * @return array<string, mixed> Error response.
1090 */
1091 protected function error( $e ) {
1092 return [
1093 'error' => [
1094 'code' => 'suredonation_error',
1095 'message' => $e->getMessage(),
1096 ],
1097 ];
1098 }
1099
1100 /**
1101 * Require a valid campaign post by ID.
1102 *
1103 * @param mixed $id Campaign ID.
1104 * @return \WP_Post The campaign post.
1105 * @throws Exception If ID is zero, post not found, or wrong post type.
1106 */
1107 protected function require_campaign( $id ) {
1108 $id = Helper::get_integer_value( $id );
1109 if ( 0 === $id ) {
1110 throw new Exception( esc_html__( 'Invalid campaign ID.', 'suredonation' ) );
1111 }
1112
1113 $post = get_post( $id );
1114 if ( ! $post || SUREDONATION_POST_TYPE !== $post->post_type ) {
1115 throw new Exception( esc_html__( 'Campaign not found.', 'suredonation' ) );
1116 }
1117
1118 return $post;
1119 }
1120
1121 /**
1122 * Require a valid donation by ID.
1123 *
1124 * @param mixed $id Donation ID.
1125 * @return array<string, mixed> The donation record.
1126 * @throws Exception If ID is zero or donation not found.
1127 */
1128 protected function require_donation( $id ) {
1129 $id = Helper::get_integer_value( $id );
1130 if ( 0 === $id ) {
1131 throw new Exception( esc_html__( 'Invalid donation ID.', 'suredonation' ) );
1132 }
1133
1134 $donation = Donations::get( $id );
1135 if ( ! $donation ) {
1136 throw new Exception( esc_html__( 'Donation not found.', 'suredonation' ) );
1137 }
1138
1139 return $donation;
1140 }
1141
1142 /**
1143 * Require a valid donor by ID.
1144 *
1145 * @param mixed $id Donor ID.
1146 * @return array<string, mixed> The donor record.
1147 * @throws Exception If ID is zero or donor not found.
1148 */
1149 protected function require_donor( $id ) {
1150 $id = Helper::get_integer_value( $id );
1151 if ( 0 === $id ) {
1152 throw new Exception( esc_html__( 'Invalid donor ID.', 'suredonation' ) );
1153 }
1154
1155 $donor = Donors::get( $id );
1156 if ( ! $donor ) {
1157 throw new Exception( esc_html__( 'Donor not found.', 'suredonation' ) );
1158 }
1159
1160 return $donor;
1161 }
1162
1163 /**
1164 * Clamp per_page to safe bounds.
1165 *
1166 * @param mixed $per_page Raw per_page value.
1167 * @param int $max Maximum allowed.
1168 * @return int Clamped value (minimum 1).
1169 */
1170 protected function clamp_per_page( $per_page, $max = 100 ) {
1171 return max( 1, min( Helper::get_integer_value( $per_page ), $max ) );
1172 }
1173
1174 /**
1175 * Clamp page to minimum 1.
1176 *
1177 * @param mixed $page Raw page value.
1178 * @return int Clamped value (minimum 1).
1179 */
1180 protected function clamp_page( $page ) {
1181 return max( 1, Helper::get_integer_value( $page ) );
1182 }
1183
1184 /**
1185 * Format a donation record for ability output.
1186 *
1187 * @param array<string, mixed> $donation Raw donation data from database.
1188 * @return array<string, mixed> Formatted donation data.
1189 */
1190 private function format_donation( $donation ) {
1191 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
1192 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
1193
1194 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
1195
1196 return [
1197 'id' => $donation_id,
1198 'campaign_id' => $campaign_id,
1199 'campaign_title' => $campaign_id ? wp_kses_post( get_the_title( $campaign_id ) ) : '',
1200 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1201 'donor_name' => $donation['donor_name'] ?? '',
1202 'donor_email' => $donation['donor_email'] ?? '',
1203 'donor_phone' => $donation['donor_phone'] ?? '',
1204 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1205 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1206 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1207 'currency' => $donation['currency'] ?? 'USD',
1208 'donation_type' => $donation['donation_type'] ?? 'one-time',
1209 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1210 'donor_comment' => $donation['donor_comment'] ?? '',
1211 'payment_status' => $donation['payment_status'] ?? 'pending',
1212 'payment_mode' => $donation['payment_mode'] ?? 'test',
1213 'gateway' => $donation['gateway'] ?? '',
1214 'transaction_id' => $donation['transaction_id'] ?? '',
1215 'created_at' => $donation['created_at'] ?? '',
1216 'updated_at' => $donation['updated_at'] ?? '',
1217 'logs' => $logs,
1218 ];
1219 }
1220
1221 /**
1222 * Format a donation form for ability output.
1223 *
1224 * @param \WP_Post $form Form post object.
1225 * @return array<string, mixed> Formatted form data.
1226 */
1227 private function format_form( $form ) {
1228 $campaign_id = Donation_Form::get_form_campaign_id( $form->ID );
1229 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
1230
1231 return [
1232 'id' => $form->ID,
1233 'title' => $form->post_title,
1234 'status' => $form->post_status,
1235 'campaign_id' => $campaign_id,
1236 'campaign_name' => $campaign ? $campaign->post_title : '',
1237 'created_at' => $form->post_date,
1238 'modified_at' => $form->post_modified,
1239 'edit_url' => admin_url( 'post.php?post=' . $form->ID . '&action=edit' ),
1240 ];
1241 }
1242
1243 /**
1244 * Format a donor record for ability output.
1245 *
1246 * @param array<string, mixed> $donor Raw donor data from database.
1247 * @return array<string, mixed> Formatted donor data.
1248 */
1249 private function format_donor( $donor ) {
1250 $id_val = $donor['id'] ?? 0;
1251 $user_val = $donor['user_id'] ?? 0;
1252 $donated_val = $donor['total_donated'] ?? 0;
1253 $count_val = $donor['donation_count'] ?? 0;
1254 $largest_val = $donor['largest_donation'] ?? 0;
1255
1256 return [
1257 'id' => is_numeric( $id_val ) ? (int) $id_val : 0,
1258 'name' => $donor['name'] ?? '',
1259 'email' => $donor['email'] ?? '',
1260 'phone' => $donor['phone'] ?? '',
1261 'user_id' => is_numeric( $user_val ) ? (int) $user_val : 0,
1262 'donor_status' => $donor['donor_status'] ?? 'active',
1263 'total_donated' => is_numeric( $donated_val ) ? (float) $donated_val : 0.0,
1264 'donation_count' => is_numeric( $count_val ) ? (int) $count_val : 0,
1265 'largest_donation' => is_numeric( $largest_val ) ? (float) $largest_val : 0.0,
1266 'first_donation_date' => $donor['first_donation_date'] ?? '',
1267 'last_donation_date' => $donor['last_donation_date'] ?? '',
1268 'donor_tags' => is_array( $donor['donor_tags'] ?? null ) ? $donor['donor_tags'] : [],
1269 'created_at' => $donor['created_at'] ?? '',
1270 'updated_at' => $donor['updated_at'] ?? '',
1271 ];
1272 }
1273
1274 /**
1275 * Format a campaign post for ability output.
1276 *
1277 * @param \WP_Post $post Campaign post.
1278 * @return array<string, mixed> Formatted campaign data.
1279 */
1280 private function format_campaign( $post ) {
1281 $stats = Campaign_Stats::get_stats( $post->ID );
1282 $meta = Helper::get_campaign_meta( $post->ID );
1283
1284 return [
1285 'id' => $post->ID,
1286 'title' => $post->post_title,
1287 'status' => $stats['campaign_status'],
1288 'goal_type' => $meta['goal_type'],
1289 'goal' => $stats['goal_amount'],
1290 'raised' => $stats['total_raised'],
1291 'donors' => $stats['donor_count'],
1292 'progress' => $stats['progress_percentage'],
1293 'created_at' => $post->post_date,
1294 'modified_at' => $post->post_modified,
1295 ];
1296 }
1297 }
1298