PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
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 trunk, at inc/abilities/runtime.php

2,558 lines 89.4 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_Cpt;
15 use SureDonation\Inc\Campaigns\Campaign_Page;
16 use SureDonation\Inc\Campaigns\Campaign_Stats;
17 use SureDonation\Inc\Database\Tables\Donations;
18 use SureDonation\Inc\Database\Tables\Donors;
19 use SureDonation\Inc\Helper;
20 use SureDonation\Inc\Payments\Payment_Helper;
21 use SureDonation\Inc\Post_Types\Donation_Form;
22 use WP_Error;
23 use WP_Query;
24
25 // Exit if accessed directly.
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Runtime class.
32 *
33 * @since 0.0.1
34 */
35 class Runtime {
36
37 /**
38 * Sentinel value indicating no default was provided to input_get().
39 */
40 private const NO_DEFAULT = '__NO_DEFAULT__';
41
42 /**
43 * Parsed input data.
44 *
45 * @var array<string, mixed>|false
46 */
47 protected $input = false;
48
49 /**
50 * Names of the properties the caller actually sent.
51 *
52 * Parsing materialises every schema property (filling defaults, or a type
53 * zero-value when there is no default), so `$this->input` alone cannot
54 * tell "the caller omitted this" from "the parser supplied it". Partial
55 * updates need that distinction: without it an omitted `goal_amount` arrives
56 * as 0.0 and overwrites the stored goal. Keyed by property name for O(1)
57 * lookups; reset on every parse.
58 *
59 * @var array<string, true>
60 * @since 1.5.0
61 */
62 protected $provided = [];
63
64 // ============================================
65 // Category & Ability Registration
66 // ============================================
67
68 /**
69 * Register ability categories.
70 *
71 * @return void
72 */
73 public function register_categories() {
74 // Another plugin (notably zipwp-mcp) may already have registered this
75 // category; re-registering triggers a _doing_it_wrong notice.
76 if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( 'suredonation' ) ) {
77 return;
78 }
79
80 wp_register_ability_category(
81 'suredonation',
82 [
83 'label' => __( 'SureDonation', 'suredonation' ),
84 'description' => __( 'Abilities for the SureDonation donation management plugin.', 'suredonation' ),
85 ]
86 );
87 }
88
89 /**
90 * Register a dedicated SureDonation MCP server with the MCP adapter.
91 *
92 * Creates endpoint: {site_url}/wp-json/suredonation/v1/mcp
93 *
94 * @param \WP\MCP\Adapter\Adapter $adapter The MCP adapter instance.
95 * @return void
96 * @since 1.0.0
97 */
98 public function register_mcp_server( $adapter ) {
99 $abilities = wp_get_abilities();
100 $tools = [];
101
102 foreach ( $abilities as $ability ) {
103 if ( 0 === strpos( $ability->get_name(), 'suredonation/' ) ) {
104 $tools[] = $ability->get_name();
105 }
106 }
107
108 $transport_class = class_exists( '\WP\MCP\Transport\HttpTransport' )
109 ? \WP\MCP\Transport\HttpTransport::class
110 : \WP\MCP\Transport\Http\RestTransport::class;
111
112 $adapter->create_server(
113 'suredonation',
114 'suredonation/v1',
115 'mcp',
116 __( 'SureDonation MCP Server', 'suredonation' ),
117 __( 'SureDonation MCP Server for donation management.', 'suredonation' ),
118 SUREDONATION_VER,
119 [ $transport_class ],
120 \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
121 \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
122 $tools,
123 [],
124 []
125 );
126 }
127
128 /**
129 * Register all abilities.
130 *
131 * @return void
132 */
133 public function register() {
134 $abilities = Config_Ability::get_abilities();
135
136 foreach ( $abilities as $ability_name => $ability ) {
137 // Skip abilities whose write/delete gate is closed rather than
138 // registering them with a permission callback that always fails.
139 // A registered-but-refusing tool still shows up in MCP/REST
140 // listings, so a client discovers it, calls it, and gets a
141 // permission error for what is really a settings choice.
142 $gate = isset( $ability['gate'] ) ? Helper::get_string_value( $ability['gate'] ) : '';
143 if ( '' !== $gate && ! Config_Ability::is_gate_open( $gate ) ) {
144 continue;
145 }
146
147 // Don't collide with an ability another plugin already registered
148 // under the same name (e.g. zipwp-mcp).
149 if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_name ) ) {
150 continue;
151 }
152
153 wp_register_ability(
154 $ability_name,
155 [
156 'label' => $ability['label'],
157 'description' => $ability['description'],
158 'category' => $ability['category'],
159 'input_schema' => $ability['input_schema'],
160 'output_schema' => $ability['output_schema'],
161 'execute_callback' => $ability['execute_callback'],
162 'permission_callback' => $ability['permission_callback'],
163 'meta' => $ability['meta'],
164 ]
165 );
166 }
167 }
168
169 // ============================================
170 // Campaign Execute Callbacks
171 // ============================================
172
173 /**
174 * List campaigns with pagination, search, status filter, and sorting.
175 *
176 * @param mixed $input Input data.
177 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
178 */
179 public function list_campaigns( $input ) {
180 try {
181 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-campaigns' );
182
183 $page = $this->clamp_page( $this->input_get( 'page' ) );
184 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
185 $search = Helper::get_string_value( $this->input_get( 'search' ) );
186 $status = Helper::get_string_value( $this->input_get( 'status' ) );
187 $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
188 $order = Helper::get_string_value( $this->input_get( 'order' ) );
189
190 $orderby_map = [
191 'date' => 'date',
192 'title' => 'title',
193 'status' => 'post_status',
194 ];
195
196 $args = [
197 'post_type' => SUREDONATION_POST_TYPE,
198 'posts_per_page' => $per_page,
199 'paged' => $page,
200 'orderby' => $orderby_map[ $sort_by ] ?? 'date',
201 'order' => $order,
202 ];
203
204 if ( 'paused' === $status ) {
205 // "paused" is a campaign business status inside the campaign meta
206 // JSON, not a WP post status, so match published campaigns whose
207 // meta marks them paused (mirrors the REST handler).
208 $args['post_status'] = 'publish';
209 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Admin-only campaign list filter.
210 $args['meta_query'] = [
211 [
212 'key' => Helper::SUREDONATION_CAMPAIGN_META_KEY,
213 'value' => '"campaign_status":"paused"',
214 'compare' => 'LIKE',
215 ],
216 ];
217 } elseif ( 'all' !== $status ) {
218 $args['post_status'] = $status;
219 } else {
220 $args['post_status'] = [ 'publish', 'draft' ];
221 }
222
223 if ( ! empty( $search ) ) {
224 $args['s'] = $search;
225 }
226
227 $query = new WP_Query( $args );
228
229 $campaigns = [];
230 if ( $query->have_posts() ) {
231 foreach ( $query->posts as $post ) {
232 $post_obj = $post instanceof \WP_Post ? $post : get_post( $post );
233 if ( $post_obj instanceof \WP_Post ) {
234 $campaigns[] = $this->format_campaign( $post_obj );
235 }
236 }
237 }
238
239 return [
240 'campaigns' => $campaigns,
241 'total' => (int) $query->found_posts,
242 'total_pages' => (int) $query->max_num_pages,
243 ];
244 } catch ( Exception $e ) {
245 return $this->error( $e );
246 }
247 }
248
249 /**
250 * Get a single campaign by ID.
251 *
252 * @param mixed $input Input data.
253 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
254 * @throws Ability_Exception If validation fails or the record is missing.
255 */
256 public function get_campaign( $input ) {
257 try {
258 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-campaign' );
259 $post = $this->require_campaign( $this->input_get( 'id' ) );
260
261 $stats = Campaign_Stats::get_stats( $post->ID );
262 $meta = Helper::get_campaign_meta( $post->ID );
263
264 return [
265 'id' => $post->ID,
266 'title' => $post->post_title,
267 'description' => $post->post_excerpt,
268 'status' => $stats['campaign_status'],
269 'goal_type' => $meta['goal_type'],
270 'goal' => $stats['goal_amount'],
271 'raised' => $stats['total_raised'],
272 'donors' => $stats['donor_count'],
273 'progress' => $stats['progress_percentage'],
274 'donation_count' => $stats['donation_count'],
275 'average_donation' => $stats['average_donation'],
276 'largest_donation' => $stats['largest_donation'],
277 'is_goal_reached' => $stats['is_goal_reached'],
278 'require_terms' => (bool) ( $meta['require_terms'] ?? false ),
279 'post_status' => $post->post_status,
280 'created_at' => $post->post_date,
281 'modified_at' => $post->post_modified,
282 ] + $this->campaign_extras( $post, $meta );
283 } catch ( Exception $e ) {
284 return $this->error( $e );
285 }
286 }
287
288 /**
289 * Create a new campaign.
290 *
291 * @param mixed $input Input data.
292 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
293 * @throws Ability_Exception If validation or creation fails.
294 */
295 public function create_campaign( $input ) {
296 try {
297 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'create-campaign' );
298
299 $title = Helper::get_string_value( $this->input_get( 'title' ) );
300 $description = Helper::get_string_value( $this->input_get( 'description', '' ) );
301
302 // The description is stored as the excerpt so post_content stays
303 // reserved for the campaign page layout (matching the REST handler).
304 $post_id = wp_insert_post(
305 [
306 'post_type' => SUREDONATION_POST_TYPE,
307 'post_title' => sanitize_text_field( $title ),
308 'post_excerpt' => wp_kses_post( $description ),
309 'post_status' => 'publish',
310 'post_author' => get_current_user_id(),
311 ],
312 true
313 );
314
315 if ( is_wp_error( $post_id ) ) {
316 throw new Ability_Exception( 'campaign_create_failed', esc_html__( 'Failed to create campaign.', 'suredonation' ) );
317 }
318
319 $meta_values = [
320 'goal_type' => $this->input_get( 'goal_type' ),
321 'goal_amount' => $this->input_get( 'goal_amount' ),
322 'campaign_status' => $this->input_get( 'campaign_status' ),
323 'require_terms' => $this->input_get( 'require_terms' ),
324 'terms_text' => $this->input_get( 'terms_text', '' ),
325 'thank_you_message' => $this->input_get( 'thank_you_message', '' ),
326 ];
327
328 Helper::update_campaign_meta( $post_id, $meta_values );
329
330 $featured_image = Helper::get_integer_value( $this->input_get( 'featured_image', 0 ) );
331 if ( $featured_image > 0 ) {
332 $this->set_featured_image( $post_id, $featured_image );
333 }
334
335 // Read the status back rather than assuming 'active': the caller can
336 // pass campaign_status, and update_campaign_meta() is the authority.
337 $created_meta = Helper::get_campaign_meta( $post_id );
338
339 return [
340 'id' => $post_id,
341 'title' => $title,
342 'status' => Helper::get_string_value( $created_meta['campaign_status'] ),
343 'message' => esc_html__( 'Campaign created successfully.', 'suredonation' ),
344 ];
345 } catch ( Exception $e ) {
346 return $this->error( $e );
347 }
348 }
349
350 /**
351 * Update an existing campaign.
352 *
353 * @param mixed $input Input data.
354 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
355 * @throws Ability_Exception If validation or update fails.
356 */
357 public function update_campaign( $input ) {
358 try {
359 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-campaign' );
360 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
361 $this->require_campaign( $id );
362
363 // Every field except `id` is optional: only what the caller actually
364 // sent is written. input_provided() is the only reliable test —
365 // $this->input holds every schema property after parsing, so keying
366 // off it would rewrite untouched fields with parser-supplied
367 // zero-values (an omitted goal_amount would reset the goal to 0).
368 $post_data = [ 'ID' => $id ];
369
370 // An explicit empty title is ignored rather than blanking the campaign
371 // (matching the REST handler); an empty description is honoured, since
372 // it clears the paragraph on the seeded campaign page.
373 if ( $this->input_provided( 'title' ) ) {
374 // input_parse() already sanitized this; sanitizing again here is
375 // deliberate belt-and-braces, and keeps this callback readable
376 // standalone and consistent with create_campaign().
377 $title = sanitize_text_field( Helper::get_string_value( $this->input_get( 'title' ) ) );
378 if ( '' !== $title ) {
379 $post_data['post_title'] = $title;
380 }
381 }
382
383 // The description is stored as the excerpt so post_content stays
384 // reserved for the campaign page layout.
385 if ( $this->input_provided( 'description' ) ) {
386 $post_data['post_excerpt'] = wp_kses_post(
387 Helper::get_string_value( $this->input_get( 'description' ) )
388 );
389 }
390
391 if ( count( $post_data ) > 1 ) {
392 $result = wp_update_post( $post_data, true );
393 if ( is_wp_error( $result ) ) {
394 throw new Ability_Exception( 'campaign_update_failed', esc_html__( 'Failed to update campaign.', 'suredonation' ) );
395 }
396 }
397
398 // goal_type and campaign_status are enum-constrained, so a provided
399 // value is always valid — no empty-value guard is needed here.
400 $meta_values = [];
401
402 foreach ( [ 'goal_type', 'goal_amount', 'campaign_status', 'require_terms', 'terms_text', 'thank_you_message' ] as $field ) {
403 if ( $this->input_provided( $field ) ) {
404 $meta_values[ $field ] = $this->input_get( $field );
405 }
406 }
407
408 if ( ! empty( $meta_values ) ) {
409 Helper::update_campaign_meta( $id, $meta_values );
410 }
411
412 // The featured image is a post thumbnail rather than campaign meta.
413 // Only touched when sent; an explicit 0 clears it.
414 if ( $this->input_provided( 'featured_image' ) ) {
415 $featured_image = Helper::get_integer_value( $this->input_get( 'featured_image' ) );
416 if ( $featured_image > 0 ) {
417 $this->set_featured_image( $id, $featured_image );
418 } else {
419 delete_post_thumbnail( $id );
420 }
421 }
422
423 $updated_post = get_post( $id );
424 $meta = Helper::get_campaign_meta( $id );
425
426 return [
427 'id' => $id,
428 'title' => $updated_post ? $updated_post->post_title : '',
429 'status' => $meta['campaign_status'],
430 'message' => esc_html__( 'Campaign updated successfully.', 'suredonation' ),
431 ];
432 } catch ( Exception $e ) {
433 return $this->error( $e );
434 }
435 }
436
437 /**
438 * Delete a campaign permanently.
439 *
440 * @param mixed $input Input data.
441 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
442 * @throws Ability_Exception If validation or deletion fails.
443 */
444 public function delete_campaign( $input ) {
445 try {
446 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'delete-campaign' );
447 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
448 $this->require_campaign( $id );
449
450 // Refuse to delete a campaign that has donations against it. Its rows
451 // are financial history: they must not be deleted, and re-pointing
452 // them at nothing would silently break revenue reporting. Deleting
453 // such a campaign stays a deliberate action in the admin UI.
454 $donation_count = Donations::count_by_campaign( $id );
455 if ( $donation_count > 0 ) {
456 throw new Ability_Exception(
457 'campaign_has_donations',
458 sprintf(
459 /* translators: %d: number of donations recorded against the campaign. */
460 esc_html__( 'This campaign has %d donation(s) recorded against it and cannot be deleted through this ability, because the donation records are financial history. Archive the campaign by setting its status to completed instead.', 'suredonation' ),
461 (int) $donation_count
462 )
463 );
464 }
465
466 // Creating a campaign auto-creates a donation form for it. Deleting
467 // only the campaign left that form behind, pointing at a post that no
468 // longer exists, so clean the children up in the same operation.
469 // WP_Query's 'any' EXCLUDES statuses registered exclude_from_search,
470 // which includes 'trash' — so 'any' would leave a trashed child form
471 // orphaned, the exact case this cleanup exists to prevent. Name them.
472 $child_forms = Donation_Form::get_forms(
473 [
474 'post_status' => [ 'publish', 'draft', 'pending', 'private', 'future', 'trash' ],
475 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-off cleanup on an admin action.
476 [
477 'key' => Donation_Form::META_CAMPAIGN_ID,
478 'value' => $id,
479 'compare' => '=',
480 'type' => 'NUMERIC',
481 ],
482 ],
483 ]
484 );
485
486 $deleted_forms = [];
487 $kept_forms = [];
488
489 foreach ( $child_forms as $form ) {
490 // The campaign-level guard above counts donations recorded against
491 // the CAMPAIGN. A form can have been reassigned to this campaign
492 // while its donations are attributed elsewhere, so check the form
493 // too: deleting it would leave those rows with a dangling form_id.
494 // This mirrors the guard manage-form applies.
495 if ( Donations::count_by_form( (int) $form->ID ) > 0 ) {
496 $kept_forms[] = (int) $form->ID;
497 continue;
498 }
499
500 if ( wp_delete_post( $form->ID, true ) ) {
501 $deleted_forms[] = (int) $form->ID;
502 }
503 }
504
505 $result = wp_delete_post( $id, true );
506 if ( ! $result ) {
507 throw new Ability_Exception( 'campaign_delete_failed', esc_html__( 'Failed to delete campaign.', 'suredonation' ) );
508 }
509
510 return [
511 'id' => $id,
512 'deleted_forms' => $deleted_forms,
513 // Reported rather than silently skipped, so a caller can see that
514 // a form outlived its campaign and why.
515 'kept_forms' => $kept_forms,
516 'message' => esc_html__( 'Campaign permanently deleted.', 'suredonation' ),
517 ];
518 } catch ( Exception $e ) {
519 return $this->error( $e );
520 }
521 }
522
523 /**
524 * Duplicate a campaign.
525 *
526 * @param mixed $input Input data.
527 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
528 * @throws Ability_Exception If validation or duplication fails.
529 */
530 public function duplicate_campaign( $input ) {
531 try {
532 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'duplicate-campaign' );
533 $original = $this->require_campaign( $this->input_get( 'id' ) );
534
535 // The page (post_content) is intentionally NOT copied: its blocks carry
536 // the original campaign's id, so a fresh page is seeded for the duplicate
537 // on first publish (or via the Create Campaign Page CTA). The description
538 // (post_excerpt) is carried over.
539 $duplicate_id = wp_insert_post(
540 [
541 'post_type' => $original->post_type,
542 'post_title' => $original->post_title . ' (Copy)',
543 'post_excerpt' => $original->post_excerpt,
544 'post_status' => 'draft',
545 'post_author' => get_current_user_id(),
546 ],
547 true
548 );
549
550 if ( is_wp_error( $duplicate_id ) ) {
551 throw new Ability_Exception( 'campaign_duplicate_failed', esc_html__( 'Failed to duplicate campaign.', 'suredonation' ) );
552 }
553
554 // Copy campaign meta.
555 $meta = get_post_meta( $original->ID, Helper::SUREDONATION_CAMPAIGN_META_KEY, true );
556 if ( ! empty( $meta ) ) {
557 update_post_meta( $duplicate_id, Helper::SUREDONATION_CAMPAIGN_META_KEY, $meta );
558 }
559
560 return [
561 'id' => $duplicate_id,
562 'title' => $original->post_title . ' (Copy)',
563 'message' => esc_html__( 'Campaign duplicated successfully.', 'suredonation' ),
564 ];
565 } catch ( Exception $e ) {
566 return $this->error( $e );
567 }
568 }
569
570 /**
571 * Get pages/posts where a campaign's form block is embedded.
572 *
573 * @param mixed $input Input data.
574 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
575 */
576 public function get_campaign_form_locations( $input ) {
577 try {
578 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-campaign-form-locations' );
579 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
580 $this->require_campaign( $id );
581
582 global $wpdb;
583
584 // Anchor on the value's terminator so campaign 5 cannot match
585 // "campaignId":51 and a stray digit elsewhere in the content cannot
586 // match at all. Block attributes serialise as JSON, so the id is
587 // followed by a comma or the closing brace.
588 $escaped_id = $wpdb->esc_like( '"campaignId":' . $id );
589 $search_pattern = '%' . $escaped_id . ',%';
590 $alt_pattern = '%' . $escaped_id . '}%';
591
592 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
593 $posts = $wpdb->get_results(
594 $wpdb->prepare(
595 "SELECT ID, post_title, post_type, post_status, post_modified
596 FROM %i
597 WHERE ( post_content LIKE %s OR post_content LIKE %s )
598 AND post_status IN ('publish', 'draft', 'pending', 'private')
599 AND post_type IN ('page', 'post', 'suredonation_cmpgn')
600 ORDER BY post_modified DESC",
601 $wpdb->posts,
602 $search_pattern,
603 $alt_pattern
604 )
605 );
606
607 $locations = [];
608 if ( $posts ) {
609 foreach ( $posts as $found_post ) {
610 $locations[] = [
611 'id' => (int) $found_post->ID,
612 'title' => $found_post->post_title,
613 'type' => $found_post->post_type,
614 'status' => $found_post->post_status,
615 'modified_at' => $found_post->post_modified,
616 'edit_url' => admin_url( 'post.php?post=' . $found_post->ID . '&action=edit' ),
617 'view_url' => 'publish' === $found_post->post_status ? get_permalink( $found_post->ID ) : '',
618 ];
619 }
620 }
621
622 return [
623 'locations' => $locations,
624 ];
625 } catch ( Exception $e ) {
626 return $this->error( $e );
627 }
628 }
629
630 // ============================================
631 // Donation Execute Callbacks
632 // ============================================
633
634 /**
635 * List donations with pagination, search, status/campaign filter, and sorting.
636 *
637 * @param mixed $input Input data.
638 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
639 */
640 public function list_donations( $input ) {
641 try {
642 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-donations' );
643
644 $page = $this->clamp_page( $this->input_get( 'page' ) );
645 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
646 $search = Helper::get_string_value( $this->input_get( 'search' ) );
647 $status = Helper::get_string_value( $this->input_get( 'status' ) );
648 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
649 $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
650 $order = Helper::get_string_value( $this->input_get( 'order' ) );
651
652 $offset = ( $page - 1 ) * $per_page;
653
654 $results = Donations::get_admin_list(
655 $status,
656 $campaign_id,
657 $search,
658 $per_page,
659 $offset,
660 $sort_by,
661 strtoupper( $order )
662 );
663
664 // Must mirror get_admin_list()'s filters, search included, or the
665 // totals describe a different result set than the rows returned.
666 $total = Donations::count_admin_list( $status, $campaign_id, $search );
667
668 $donations = [];
669 foreach ( $results as $donation ) {
670 if ( is_array( $donation ) ) {
671 $donations[] = $this->format_donation_summary( $donation );
672 }
673 }
674
675 return [
676 'donations' => $donations,
677 'total' => (int) $total,
678 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
679 ];
680 } catch ( Exception $e ) {
681 return $this->error( $e );
682 }
683 }
684
685 /**
686 * Get a single donation by ID.
687 *
688 * @param mixed $input Input data.
689 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
690 */
691 public function get_donation( $input ) {
692 try {
693 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation' );
694 $donation = $this->require_donation( $this->input_get( 'id' ) );
695
696 return $this->format_donation( $donation );
697 } catch ( Exception $e ) {
698 return $this->error( $e );
699 }
700 }
701
702 /**
703 * Get paginated notes for a donation.
704 *
705 * @param mixed $input Input data.
706 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
707 * @throws Ability_Exception If validation fails or the record is missing.
708 */
709 public function get_donation_notes( $input ) {
710 try {
711 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation-notes' );
712 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
713 $page = $this->clamp_page( $this->input_get( 'page' ) );
714 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
715 $this->require_donation( $id );
716
717 $notes_data = Donations::get_notes( $id, $page, $per_page );
718
719 return [
720 'notes' => $notes_data['notes'],
721 'total' => (int) $notes_data['total'],
722 'total_pages' => (int) $notes_data['total_pages'],
723 ];
724 } catch ( Exception $e ) {
725 return $this->error( $e );
726 }
727 }
728
729 /**
730 * Add a note to a donation.
731 *
732 * @param mixed $input Input data.
733 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
734 * @throws Ability_Exception If validation fails.
735 */
736 public function add_donation_note( $input ) {
737 try {
738 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'add-donation-note' );
739 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
740 $note = Helper::get_string_value( $this->input_get( 'note' ) );
741 $this->require_donation( $id );
742
743 $result = Donations::add_note( $id, $note, get_current_user_id() );
744
745 if ( ! $result['success'] ) {
746 throw new Ability_Exception( 'donation_note_add_failed', esc_html__( 'Failed to add note.', 'suredonation' ) );
747 }
748
749 return [
750 'note_id' => $result['note_id'],
751 'message' => esc_html__( 'Note added successfully.', 'suredonation' ),
752 ];
753 } catch ( Exception $e ) {
754 return $this->error( $e );
755 }
756 }
757
758 // ============================================
759 // Donor Execute Callbacks
760 // ============================================
761
762 /**
763 * List donors with pagination, status filter, and sorting.
764 *
765 * @param mixed $input Input data.
766 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
767 */
768 public function list_donors( $input ) {
769 try {
770 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-donors' );
771
772 $page = $this->clamp_page( $this->input_get( 'page' ) );
773 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
774 $status = Helper::get_string_value( $this->input_get( 'status' ) );
775 $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
776 $order = Helper::get_string_value( $this->input_get( 'order' ) );
777 $search = Helper::get_string_value( $this->input_get( 'search' ) );
778 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
779 $after = $this->valid_date( $this->input_get( 'after' ) );
780 $before = $this->valid_date( $this->input_get( 'before' ) );
781 $offset = ( $page - 1 ) * $per_page;
782
783 // get_all()/get_by_status() cannot search, filter by campaign, or
784 // filter by date. The admin listing uses these instead, so the
785 // ability now shares one filter contract with list-donations.
786 $results = Donors::get_admin_list( $search, $campaign_id, $status, $per_page, $offset, $sort_by, $order, $after, $before );
787 $total = Donors::get_total_donors_filtered( $search, $campaign_id, $status, $after, $before );
788
789 $donors = [];
790 foreach ( $results as $donor ) {
791 if ( is_array( $donor ) ) {
792 $donors[] = $this->format_donor( $donor );
793 }
794 }
795
796 return [
797 'donors' => $donors,
798 'total' => (int) $total,
799 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
800 ];
801 } catch ( Exception $e ) {
802 return $this->error( $e );
803 }
804 }
805
806 /**
807 * Get a single donor by ID.
808 *
809 * @param mixed $input Input data.
810 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
811 * @throws Ability_Exception If validation fails or the record is missing.
812 */
813 public function get_donor( $input ) {
814 try {
815 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor' );
816 $donor = $this->require_donor( $this->input_get( 'id' ) );
817
818 return $this->format_donor( $donor );
819 } catch ( Exception $e ) {
820 return $this->error( $e );
821 }
822 }
823
824 /**
825 * Get a donor by email address.
826 *
827 * @param mixed $input Input data.
828 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
829 * @throws Ability_Exception If validation fails.
830 */
831 public function get_donor_by_email( $input ) {
832 try {
833 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor-by-email' );
834 $email = sanitize_email( Helper::get_string_value( $this->input_get( 'email' ) ) );
835
836 if ( empty( $email ) || ! is_email( $email ) ) {
837 throw new Ability_Exception( 'invalid_donor_email', esc_html__( 'A valid email address is required.', 'suredonation' ) );
838 }
839
840 $donor = Donors::get_by_email( $email );
841 if ( ! $donor ) {
842 throw new Ability_Exception( 'donor_not_found', esc_html__( 'Donor not found.', 'suredonation' ) );
843 }
844
845 return $this->format_donor( $donor );
846 } catch ( Exception $e ) {
847 return $this->error( $e );
848 }
849 }
850
851 /**
852 * Get top donors ranked by total donated.
853 *
854 * @param mixed $input Input data.
855 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
856 */
857 public function get_top_donors( $input ) {
858 try {
859 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-top-donors' );
860 $limit = $this->clamp_per_page( $this->input_get( 'limit' ) );
861
862 $results = Donors::get_top_donors( $limit );
863
864 $donors = [];
865 foreach ( $results as $donor ) {
866 if ( is_array( $donor ) ) {
867 $id_val = $donor['id'] ?? 0;
868 $donated_val = $donor['total_donated'] ?? 0;
869 $count_val = $donor['donation_count'] ?? 0;
870
871 $donors[] = [
872 'id' => is_numeric( $id_val ) ? (int) $id_val : 0,
873 'name' => $donor['name'] ?? '',
874 'email' => $donor['email'] ?? '',
875 'total_donated' => is_numeric( $donated_val ) ? (float) $donated_val : 0.0,
876 'donation_count' => is_numeric( $count_val ) ? (int) $count_val : 0,
877 ];
878 }
879 }
880
881 return [
882 'donors' => $donors,
883 ];
884 } catch ( Exception $e ) {
885 return $this->error( $e );
886 }
887 }
888
889 // ============================================
890 // Form Execute Callbacks
891 // ============================================
892
893 /**
894 * List donation forms with optional campaign and status filter.
895 *
896 * @param mixed $input Input data.
897 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
898 * @throws Ability_Exception If validation fails or the record is missing.
899 */
900 public function list_forms( $input ) {
901 try {
902 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-forms' );
903
904 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
905 $status = Helper::get_string_value( $this->input_get( 'status' ) );
906 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
907 $page = $this->clamp_page( $this->input_get( 'page' ) );
908
909 $post_status = 'any' === $status ? [ 'publish', 'draft', 'trash' ] : $status;
910
911 $args = [
912 'posts_per_page' => $per_page,
913 'paged' => $page,
914 'post_status' => $post_status,
915 ];
916
917 if ( $campaign_id > 0 ) {
918 $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
919 [
920 'key' => Donation_Form::META_CAMPAIGN_ID,
921 'value' => $campaign_id,
922 'compare' => '=',
923 'type' => 'NUMERIC',
924 ],
925 ];
926 }
927
928 $forms = Donation_Form::get_forms( $args );
929
930 $formatted = [];
931 // One GROUP BY for the page instead of a COUNT/SUM per form.
932 $stats = Donations::get_form_stats_bulk( wp_list_pluck( $forms, 'ID' ) );
933
934 foreach ( $forms as $form ) {
935 $formatted[] = $this->format_form( $form, $stats[ (int) $form->ID ] ?? null );
936 }
937
938 // get_posts() returns no count, so total comes from a matching
939 // count-only query. Without it list-forms was the one list ability
940 // with no pagination contract.
941 // count_forms() reads WP_Query's found_posts rather than loading
942 // every form ID into memory to call count() on it, which grew
943 // linearly with the number of forms on the site.
944 $total = Donation_Form::count_forms( $args );
945
946 return [
947 'forms' => $formatted,
948 'total' => $total,
949 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
950 ];
951 } catch ( Exception $e ) {
952 return $this->error( $e );
953 }
954 }
955
956 /**
957 * Get a single donation form by ID.
958 *
959 * @param mixed $input Input data.
960 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
961 * @throws Ability_Exception If validation fails.
962 */
963 public function get_form( $input ) {
964 try {
965 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-form' );
966 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
967
968 if ( 0 === $id ) {
969 throw new Ability_Exception( 'invalid_form_id', esc_html__( 'Invalid form ID.', 'suredonation' ) );
970 }
971
972 $form = get_post( $id );
973 if ( ! $form || Donation_Form::POST_TYPE !== $form->post_type ) {
974 throw new Ability_Exception( 'form_not_found', esc_html__( 'Form not found.', 'suredonation' ) );
975 }
976
977 return $this->format_form( $form );
978 } catch ( Exception $e ) {
979 return $this->error( $e );
980 }
981 }
982
983 /**
984 * Delete a note from a donation.
985 *
986 * @param mixed $input Input data.
987 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
988 * @throws Ability_Exception If validation fails or the record is missing.
989 */
990 public function delete_donation_note( $input ) {
991 try {
992 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'delete-donation-note' );
993 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
994 $note_id = Helper::get_string_value( $this->input_get( 'note_id' ) );
995 $this->require_donation( $id );
996
997 $this->rest_call( 'DELETE', '/donations/' . $id . '/notes/' . rawurlencode( $note_id ) );
998
999 return [
1000 'id' => $id,
1001 'note_id' => $note_id,
1002 'message' => esc_html__( 'Note deleted successfully.', 'suredonation' ),
1003 ];
1004 } catch ( Exception $e ) {
1005 return $this->error( $e );
1006 }
1007 }
1008
1009 /**
1010 * Change a donation's payment status.
1011 *
1012 * @param mixed $input Input data.
1013 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1014 * @throws Ability_Exception If validation fails or the record is missing.
1015 */
1016 public function update_donation_status( $input ) {
1017 try {
1018 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-donation-status' );
1019 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
1020 $status = Helper::get_string_value( $this->input_get( 'status' ) );
1021 $donation = $this->require_donation( $id );
1022
1023 $previous = Helper::get_string_value( $donation['payment_status'] ?? '' );
1024 if ( $previous === $status ) {
1025 return [
1026 'id' => $id,
1027 'payment_status' => $status,
1028 'previous_status' => $previous,
1029 'changed' => false,
1030 'message' => esc_html__( 'The donation already has that status.', 'suredonation' ),
1031 ];
1032 }
1033
1034 $this->rest_call( 'POST', '/donations/' . $id . '/status', [ 'status' => $status ] );
1035
1036 return [
1037 'id' => $id,
1038 'payment_status' => $status,
1039 'previous_status' => $previous,
1040 'changed' => true,
1041 'message' => esc_html__( 'Donation status updated.', 'suredonation' ),
1042 ];
1043 } catch ( Exception $e ) {
1044 return $this->error( $e );
1045 }
1046 }
1047
1048 /**
1049 * Refund a donation through its original gateway.
1050 *
1051 * @param mixed $input Input data.
1052 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1053 * @throws Ability_Exception If validation fails or the refund is rejected.
1054 */
1055 public function refund_donation( $input ) {
1056 try {
1057 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'refund-donation' );
1058 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
1059 $donation = $this->require_donation( $id );
1060
1061 $currency = Helper::get_string_value( $donation['currency'] ?? 'USD' );
1062 $total = Helper::get_float_value( $donation['amount'] ?? 0 );
1063 $already = Helper::get_float_value( $donation['refunded_amount'] ?? 0 );
1064 $transaction_id = Helper::get_string_value( $donation['transaction_id'] ?? '' );
1065
1066 if ( '' === $transaction_id ) {
1067 throw new Ability_Exception(
1068 'no_transaction_id',
1069 esc_html__( 'This donation has no gateway transaction to refund.', 'suredonation' )
1070 );
1071 }
1072
1073 // An explicit transaction_id is optional. When supplied it is checked,
1074 // so a caller can guard against refunding the wrong record; when
1075 // omitted the donation's own id is used, because making a model echo
1076 // back a value it just read adds a failure mode rather than safety.
1077 $claimed = Helper::get_string_value( $this->input_get( 'transaction_id', '' ) );
1078 if ( '' !== $claimed && $claimed !== $transaction_id ) {
1079 throw new Ability_Exception(
1080 'transaction_mismatch',
1081 esc_html__( 'The supplied transaction ID does not match this donation.', 'suredonation' )
1082 );
1083 }
1084
1085 // Callers work in major units (25.50), which is what they read back
1086 // from every other ability. The REST endpoint expects the gateway's
1087 // minor unit, so convert here rather than pushing cents onto callers.
1088 $requested = Helper::get_float_value( $this->input_get( 'amount', 0 ) );
1089 $remaining = round( $total - $already, 2 );
1090 if ( $requested <= 0 ) {
1091 $requested = $remaining;
1092 }
1093
1094 if ( $requested > $remaining ) {
1095 throw new Ability_Exception(
1096 'exceeds_refundable',
1097 sprintf(
1098 /* translators: 1: requested amount, 2: refundable amount, 3: currency code. */
1099 esc_html__( 'Requested refund of %1$s exceeds the %2$s %3$s still refundable on this donation.', 'suredonation' ),
1100 esc_html( (string) $requested ),
1101 esc_html( (string) $remaining ),
1102 esc_html( strtoupper( $currency ) )
1103 )
1104 );
1105 }
1106
1107 $minor = Payment_Helper::amount_to_stripe_format( $requested, $currency );
1108
1109 $this->rest_call(
1110 'POST',
1111 '/donations/' . $id . '/refund',
1112 [
1113 'transaction_id' => $transaction_id,
1114 'refund_amount' => $minor,
1115 'refund_type' => $requested >= $remaining ? 'full' : 'partial',
1116 'refund_notes' => Helper::get_string_value( $this->input_get( 'notes', '' ) ),
1117 ]
1118 );
1119
1120 $refreshed = Donations::get( $id );
1121
1122 return [
1123 'id' => $id,
1124 'refunded' => $requested,
1125 'currency' => strtoupper( $currency ),
1126 'refunded_total' => is_array( $refreshed ) ? Helper::get_float_value( $refreshed['refunded_amount'] ?? 0 ) : $already + $requested,
1127 'payment_status' => is_array( $refreshed ) ? Helper::get_string_value( $refreshed['payment_status'] ?? '' ) : '',
1128 'message' => esc_html__( 'Refund processed.', 'suredonation' ),
1129 ];
1130 } catch ( Exception $e ) {
1131 return $this->error( $e );
1132 }
1133 }
1134
1135 /**
1136 * Record a donation that was taken outside the payment flow.
1137 *
1138 * @param mixed $input Input data.
1139 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1140 * @throws Ability_Exception If validation or creation fails.
1141 */
1142 public function create_donation( $input ) {
1143 try {
1144 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'create-donation' );
1145
1146 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
1147 $this->require_campaign( $campaign_id );
1148
1149 $amount = Helper::get_float_value( $this->input_get( 'amount' ) );
1150 if ( $amount <= 0 ) {
1151 throw new Ability_Exception(
1152 'invalid_amount',
1153 esc_html__( 'The donation amount must be greater than zero.', 'suredonation' )
1154 );
1155 }
1156
1157 // Validate before sanitising: sanitize_email() strips a malformed
1158 // address to '', which would silently pass an "is it empty?" check
1159 // and record the donation with no donor email at all.
1160 $raw_email = trim( Helper::get_string_value( $this->input_get( 'donor_email', '' ) ) );
1161 if ( '' !== $raw_email && ! is_email( $raw_email ) ) {
1162 throw new Ability_Exception(
1163 'invalid_donor_email',
1164 esc_html__( 'A valid donor email address is required.', 'suredonation' )
1165 );
1166 }
1167 $email = '' !== $raw_email ? sanitize_email( $raw_email ) : '';
1168
1169 $response = $this->rest_call(
1170 'POST',
1171 '/donations',
1172 [
1173 'campaign_id' => $campaign_id,
1174 'amount' => $amount,
1175 'donor_name' => Helper::get_string_value( $this->input_get( 'donor_name', '' ) ),
1176 'donor_email' => $email,
1177 'donor_phone' => Helper::get_string_value( $this->input_get( 'donor_phone', '' ) ),
1178 'donor_comment' => Helper::get_string_value( $this->input_get( 'donor_comment', '' ) ),
1179 'payment_status' => Helper::get_string_value( $this->input_get( 'payment_status' ) ),
1180 'donation_type' => Helper::get_string_value( $this->input_get( 'donation_type' ) ),
1181 'gateway' => Helper::get_string_value( $this->input_get( 'gateway' ) ),
1182 'transaction_id' => Helper::get_string_value( $this->input_get( 'transaction_id', '' ) ),
1183 'fees_covered' => Helper::get_float_value( $this->input_get( 'fees_covered', 0 ) ),
1184 'is_anonymous' => (bool) $this->input_get( 'is_anonymous', false ),
1185 ]
1186 );
1187
1188 $created = isset( $response['donation'] ) && is_array( $response['donation'] ) ? $response['donation'] : [];
1189
1190 return [
1191 'id' => isset( $created['id'] ) ? Helper::get_integer_value( $created['id'] ) : 0,
1192 'campaign_id' => $campaign_id,
1193 'amount' => $amount,
1194 'payment_status' => Helper::get_string_value( $created['payment_status'] ?? '' ),
1195 'message' => esc_html__( 'Donation recorded successfully.', 'suredonation' ),
1196 ];
1197 } catch ( Exception $e ) {
1198 return $this->error( $e );
1199 }
1200 }
1201
1202 /**
1203 * Change a campaign's WordPress post status.
1204 *
1205 * Distinct from the campaign's business status (active/paused/completed),
1206 * which update-campaign handles via campaign_status.
1207 *
1208 * @param mixed $input Input data.
1209 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1210 * @throws Ability_Exception If validation fails or the record is missing.
1211 */
1212 public function update_campaign_status( $input ) {
1213 try {
1214 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-campaign-status' );
1215 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
1216 $status = Helper::get_string_value( $this->input_get( 'status' ) );
1217 $post = $this->require_campaign( $id );
1218
1219 $previous = $post->post_status;
1220 if ( $previous === $status ) {
1221 return [
1222 'id' => $id,
1223 'post_status' => $status,
1224 'previous_status' => $previous,
1225 'changed' => false,
1226 'message' => esc_html__( 'The campaign already has that status.', 'suredonation' ),
1227 ];
1228 }
1229
1230 $this->rest_call( 'POST', '/campaigns/' . $id . '/status', [ 'status' => $status ] );
1231
1232 return [
1233 'id' => $id,
1234 'post_status' => $status,
1235 'previous_status' => $previous,
1236 'changed' => true,
1237 'message' => esc_html__( 'Campaign status updated.', 'suredonation' ),
1238 ];
1239 } catch ( Exception $e ) {
1240 return $this->error( $e );
1241 }
1242 }
1243
1244 /**
1245 * Update a donor's contact details, status or tags.
1246 *
1247 * @param mixed $input Input data.
1248 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1249 * @throws Ability_Exception If validation fails or the record is missing.
1250 */
1251 public function update_donor( $input ) {
1252 try {
1253 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-donor' );
1254 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
1255 $this->require_donor( $id );
1256
1257 // Unlike the campaign, donation and form endpoints, the donors REST
1258 // controller additionally requires an X-WP-Nonce on writes. An
1259 // internal dispatch has no nonce to present, and minting a fake one
1260 // to satisfy a browser-oriented check would be worse than writing
1261 // through the table directly — which is all that endpoint does here
1262 // anyway, with no gateway, email or webhook side effects.
1263 //
1264 // Only fields the caller actually sent are written, so a partial
1265 // update cannot blank the rest of the record (the update-campaign
1266 // lesson from #326).
1267 $update_data = [];
1268
1269 if ( $this->input_provided( 'name' ) ) {
1270 $update_data['name'] = sanitize_text_field( Helper::get_string_value( $this->input_get( 'name' ) ) );
1271 }
1272
1273 if ( $this->input_provided( 'phone' ) ) {
1274 $update_data['phone'] = sanitize_text_field( Helper::get_string_value( $this->input_get( 'phone' ) ) );
1275 }
1276
1277 if ( $this->input_provided( 'company' ) ) {
1278 $update_data['company'] = sanitize_text_field( Helper::get_string_value( $this->input_get( 'company' ) ) );
1279 }
1280
1281 if ( $this->input_provided( 'address' ) ) {
1282 $update_data['address'] = sanitize_textarea_field( Helper::get_string_value( $this->input_get( 'address' ) ) );
1283 }
1284
1285 // Validate before sanitising: sanitize_email() reduces a malformed
1286 // address to '', which would otherwise overwrite a good one.
1287 if ( $this->input_provided( 'email' ) ) {
1288 $email = trim( Helper::get_string_value( $this->input_get( 'email' ) ) );
1289 if ( '' === $email || ! is_email( $email ) ) {
1290 throw new Ability_Exception(
1291 'invalid_donor_email',
1292 esc_html__( 'A valid donor email address is required.', 'suredonation' )
1293 );
1294 }
1295 $update_data['email'] = sanitize_email( $email );
1296 }
1297
1298 if ( $this->input_provided( 'donor_status' ) ) {
1299 $donor_status = Helper::get_string_value( $this->input_get( 'donor_status' ) );
1300 if ( ! in_array( $donor_status, Donors::get_valid_statuses(), true ) ) {
1301 throw new Ability_Exception(
1302 'invalid_donor_status',
1303 esc_html__( 'Invalid donor status.', 'suredonation' )
1304 );
1305 }
1306 $update_data['donor_status'] = $donor_status;
1307 }
1308
1309 if ( $this->input_provided( 'donor_tags' ) ) {
1310 $tags = $this->input_get( 'donor_tags' );
1311 $update_data['donor_tags'] = is_array( $tags ) ? array_map( 'sanitize_text_field', array_values( $tags ) ) : [];
1312 }
1313
1314 if ( empty( $update_data ) ) {
1315 throw new Ability_Exception(
1316 'nothing_to_update',
1317 esc_html__( 'Provide at least one donor field to update.', 'suredonation' )
1318 );
1319 }
1320
1321 // email is UNIQUE, so a duplicate makes $wpdb->update() return false and
1322 // the entire write is lost. Reporting success then tells the agent the
1323 // record holds values it does not.
1324 if ( false === Donors::update( $id, $update_data ) ) {
1325 throw new Ability_Exception(
1326 'donor_update_failed',
1327 esc_html__( 'The donor could not be updated. If you changed the email address, another donor may already use it.', 'suredonation' )
1328 );
1329 }
1330
1331 $donor = Donors::get( $id );
1332
1333 return [
1334 'id' => $id,
1335 'updated' => array_keys( $update_data ),
1336 'donor' => is_array( $donor ) ? $this->format_donor( $donor ) : [],
1337 'message' => esc_html__( 'Donor updated successfully.', 'suredonation' ),
1338 ];
1339 } catch ( Exception $e ) {
1340 return $this->error( $e );
1341 }
1342 }
1343
1344 /**
1345 * Reassign a donation form to a different campaign.
1346 *
1347 * @param mixed $input Input data.
1348 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1349 * @throws Ability_Exception If validation fails or a record is missing.
1350 */
1351 public function update_form( $input ) {
1352 try {
1353 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-form' );
1354 $form_id = Helper::get_integer_value( $this->input_get( 'id' ) );
1355 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
1356
1357 $form = get_post( $form_id );
1358 if ( ! $form instanceof \WP_Post || Donation_Form::POST_TYPE !== $form->post_type ) {
1359 throw new Ability_Exception( 'form_not_found', esc_html__( 'Form not found.', 'suredonation' ) );
1360 }
1361
1362 $this->require_campaign( $campaign_id );
1363
1364 $this->rest_call( 'POST', '/forms/' . $form_id, [ 'campaign_id' => $campaign_id ] );
1365
1366 $refreshed = get_post( $form_id );
1367
1368 return array_merge(
1369 $refreshed instanceof \WP_Post ? $this->format_form( $refreshed ) : [],
1370 [ 'message' => esc_html__( 'Form updated successfully.', 'suredonation' ) ]
1371 );
1372 } catch ( Exception $e ) {
1373 return $this->error( $e );
1374 }
1375 }
1376
1377 /**
1378 * Duplicate a donation form.
1379 *
1380 * @param mixed $input Input data.
1381 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1382 * @throws Ability_Exception If validation fails or the record is missing.
1383 */
1384 public function duplicate_form( $input ) {
1385 try {
1386 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'duplicate-form' );
1387 $form_id = Helper::get_integer_value( $this->input_get( 'id' ) );
1388
1389 $form = get_post( $form_id );
1390 if ( ! $form instanceof \WP_Post || Donation_Form::POST_TYPE !== $form->post_type ) {
1391 throw new Ability_Exception( 'form_not_found', esc_html__( 'Form not found.', 'suredonation' ) );
1392 }
1393
1394 $response = $this->rest_call( 'POST', '/forms/duplicate', [ 'form_id' => $form_id ] );
1395
1396 $new_id = 0;
1397 foreach ( [ 'form_id', 'id', 'new_form_id' ] as $key ) {
1398 if ( isset( $response[ $key ] ) && is_numeric( $response[ $key ] ) ) {
1399 $new_id = (int) $response[ $key ];
1400 break;
1401 }
1402 }
1403 if ( 0 === $new_id && isset( $response['form'] ) && is_array( $response['form'] ) && isset( $response['form']['id'] ) && is_numeric( $response['form']['id'] ) ) {
1404 $new_id = (int) $response['form']['id'];
1405 }
1406
1407 return [
1408 'id' => $new_id,
1409 'source_id' => $form_id,
1410 'title' => $new_id ? Helper::get_string_value( get_the_title( $new_id ) ) : '',
1411 'message' => esc_html__( 'Form duplicated successfully.', 'suredonation' ),
1412 ];
1413 } catch ( Exception $e ) {
1414 return $this->error( $e );
1415 }
1416 }
1417
1418 /**
1419 * Set which form a campaign renders by default.
1420 *
1421 * @param mixed $input Input data.
1422 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1423 * @throws Ability_Exception If validation fails or a record is missing.
1424 */
1425 public function set_default_form( $input ) {
1426 try {
1427 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'set-default-form' );
1428 $form_id = Helper::get_integer_value( $this->input_get( 'form_id' ) );
1429 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
1430
1431 $form = get_post( $form_id );
1432 if ( ! $form instanceof \WP_Post || Donation_Form::POST_TYPE !== $form->post_type ) {
1433 throw new Ability_Exception( 'form_not_found', esc_html__( 'Form not found.', 'suredonation' ) );
1434 }
1435 $this->require_campaign( $campaign_id );
1436
1437 $this->rest_call(
1438 'POST',
1439 '/forms/set-default',
1440 [
1441 'form_id' => $form_id,
1442 'campaign_id' => $campaign_id,
1443 ]
1444 );
1445
1446 return [
1447 'campaign_id' => $campaign_id,
1448 'default_form_id' => Campaign_Cpt::get_default_form_id( $campaign_id ),
1449 'message' => esc_html__( 'Default form updated.', 'suredonation' ),
1450 ];
1451 } catch ( Exception $e ) {
1452 return $this->error( $e );
1453 }
1454 }
1455
1456 /**
1457 * Trash, restore or permanently delete donation forms.
1458 *
1459 * @param mixed $input Input data.
1460 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1461 * @throws Ability_Exception If validation fails.
1462 */
1463 public function manage_form( $input ) {
1464 try {
1465 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'manage-form' );
1466 $form_id = Helper::get_integer_value( $this->input_get( 'id' ) );
1467 $action = Helper::get_string_value( $this->input_get( 'action' ) );
1468
1469 $form = get_post( $form_id );
1470 if ( ! $form instanceof \WP_Post || Donation_Form::POST_TYPE !== $form->post_type ) {
1471 throw new Ability_Exception( 'form_not_found', esc_html__( 'Form not found.', 'suredonation' ) );
1472 }
1473
1474 // delete-campaign refuses when donations exist because those rows are
1475 // financial history. Permanently deleting a form its donations still
1476 // reference would be the same loss by a shorter route, so apply the
1477 // same guard here. Trash and restore stay available - they're reversible.
1478 if ( 'delete' === $action ) {
1479 $donation_count = Donations::count_by_form( $form_id );
1480 if ( $donation_count > 0 ) {
1481 throw new Ability_Exception(
1482 'form_has_donations',
1483 sprintf(
1484 /* translators: %d: number of donations recorded through the form. */
1485 esc_html__( 'This form has %d donation(s) recorded through it and cannot be permanently deleted through this ability, because those records are financial history. Use the "trash" action instead, which is reversible.', 'suredonation' ),
1486 (int) $donation_count
1487 )
1488 );
1489 }
1490 }
1491
1492 $response = $this->rest_call(
1493 'POST',
1494 '/forms/manage',
1495 [
1496 'form_ids' => [ $form_id ],
1497 'action' => $action,
1498 ]
1499 );
1500
1501 // This endpoint returns HTTP 200 even when the operation failed, with
1502 // the reason in errors[] — so rest_call()'s status check cannot see it
1503 // and the ability would report a success that never happened.
1504 if ( ! empty( $response['errors'] ) && is_array( $response['errors'] ) ) {
1505 $first = is_array( $response['errors'][0] ?? null ) ? $response['errors'][0] : [];
1506 $reason = Helper::get_string_value( $first['error'] ?? '' );
1507
1508 throw new Ability_Exception(
1509 'form_action_failed',
1510 '' !== $reason
1511 ? esc_html( $reason )
1512 : esc_html__( 'The form action could not be completed.', 'suredonation' )
1513 );
1514 }
1515
1516 $refreshed = get_post( $form_id );
1517
1518 return [
1519 'id' => $form_id,
1520 'action' => $action,
1521 'post_status' => $refreshed instanceof \WP_Post ? $refreshed->post_status : 'deleted',
1522 'message' => esc_html__( 'Form updated successfully.', 'suredonation' ),
1523 ];
1524 } catch ( Exception $e ) {
1525 return $this->error( $e );
1526 }
1527 }
1528
1529 // ============================================
1530 // Dashboard & Analytics Execute Callbacks
1531 // ============================================
1532
1533 /**
1534 * Attach an attachment as a post's featured image, rejecting non-images.
1535 *
1536 * Core's set_post_thumbnail() DELETES the existing thumbnail when the given
1537 * id is not a renderable image, so passing the id of a PDF — or of an
1538 * ordinary post — would silently clear a campaign's hero image and still
1539 * report success.
1540 *
1541 * @param int $post_id Post to attach to.
1542 * @param int $attachment_id Attachment to use.
1543 * @return void
1544 * @throws Ability_Exception If the attachment is missing or is not an image.
1545 * @since 1.5.0
1546 */
1547 protected function set_featured_image( $post_id, $attachment_id ) {
1548 if ( 'attachment' !== get_post_type( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) {
1549 throw new Ability_Exception(
1550 'invalid_featured_image',
1551 esc_html__( 'The featured image must be the ID of an image in the media library.', 'suredonation' )
1552 );
1553 }
1554
1555 set_post_thumbnail( $post_id, $attachment_id );
1556 }
1557
1558 /**
1559 * Resolve the currency and payment mode a reporting ability should report on.
1560 *
1561 * Every monetary aggregate must be scoped to one currency and one payment
1562 * mode, or the figure is a meaningless mixed sum. Defaults are the store
1563 * currency and the store's current mode, so a caller that supplies neither
1564 * still gets a coherent number, and both are echoed back in the payload.
1565 *
1566 * @return array{currency: string, payment_mode: string}
1567 * @since 1.5.0
1568 */
1569 protected function resolve_report_scope() {
1570 $currency = strtoupper( Helper::get_string_value( $this->input_get( 'currency', '' ) ) );
1571 if ( '' === $currency ) {
1572 $currency = Payment_Helper::get_currency();
1573 }
1574
1575 $payment_mode = strtolower( Helper::get_string_value( $this->input_get( 'payment_mode', '' ) ) );
1576 if ( ! in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1577 $payment_mode = Payment_Helper::get_payment_mode();
1578 }
1579
1580 return [
1581 'currency' => $currency,
1582 'payment_mode' => $payment_mode,
1583 ];
1584 }
1585 /**
1586 * Get the site-wide donation dashboard figures.
1587 *
1588 * @param mixed $input Input data.
1589 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1590 */
1591 public function get_dashboard_stats( $input ) {
1592 try {
1593 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-dashboard-stats' );
1594
1595 $scope = $this->resolve_report_scope();
1596 $stats = Donations::get_dashboard_stats( $scope['currency'], $scope['payment_mode'] );
1597
1598 // wp_count_posts() is filterable and a filter may return a non-object,
1599 // where a property read would be a fatal rather than a caught Exception.
1600 $counts = wp_count_posts( SUREDONATION_POST_TYPE );
1601 $published_count = is_object( $counts ) && isset( $counts->publish ) ? $counts->publish : 0;
1602
1603 return [
1604 'total_donations' => Helper::get_integer_value( $stats['total_donations'] ?? 0 ),
1605 'total_raised' => Helper::get_float_value( $stats['total_raised'] ?? 0 ),
1606 'unique_donors' => Helper::get_integer_value( $stats['unique_donors'] ?? 0 ),
1607 'average_donation' => Helper::get_float_value( $stats['average_donation'] ?? 0 ),
1608 'largest_donation' => Helper::get_float_value( $stats['largest_donation'] ?? 0 ),
1609 'published_campaigns' => Helper::get_integer_value( $published_count ),
1610 'currency' => $scope['currency'],
1611 'payment_mode' => $scope['payment_mode'],
1612 ];
1613 } catch ( Exception $e ) {
1614 return $this->error( $e );
1615 }
1616 }
1617
1618 /**
1619 * Get the most recent donations across all campaigns.
1620 *
1621 * @param mixed $input Input data.
1622 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1623 */
1624 public function get_recent_donations( $input ) {
1625 try {
1626 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-recent-donations' );
1627 $limit = $this->clamp_per_page( $this->input_get( 'limit' ) );
1628 $scope = $this->resolve_report_scope();
1629
1630 $donations = [];
1631 foreach ( Donations::get_recent_donations_global( $limit, $scope['currency'], $scope['payment_mode'] ) as $donation ) {
1632 if ( is_array( $donation ) ) {
1633 $donations[] = $this->format_donation_summary( $donation );
1634 }
1635 }
1636
1637 return [
1638 'donations' => $donations,
1639 'currency' => $scope['currency'],
1640 'payment_mode' => $scope['payment_mode'],
1641 ];
1642 } catch ( Exception $e ) {
1643 return $this->error( $e );
1644 }
1645 }
1646
1647 /**
1648 * Get the campaigns that have raised the most.
1649 *
1650 * @param mixed $input Input data.
1651 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1652 */
1653 public function get_top_campaigns( $input ) {
1654 try {
1655 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-top-campaigns' );
1656 $limit = $this->clamp_per_page( $this->input_get( 'limit' ) );
1657 $scope = $this->resolve_report_scope();
1658
1659 $campaigns = [];
1660 foreach ( Donations::get_top_campaigns( $limit, $scope['currency'], $scope['payment_mode'] ) as $row ) {
1661 if ( ! is_array( $row ) ) {
1662 continue;
1663 }
1664 $campaign_id = Helper::get_integer_value( $row['campaign_id'] ?? 0 );
1665 if ( $campaign_id <= 0 ) {
1666 continue;
1667 }
1668
1669 // The title comes from the query's join, so a page of results is
1670 // one query rather than one get_post() per row. The join is also
1671 // what guarantees the campaign post still exists.
1672 $campaigns[] = [
1673 'id' => $campaign_id,
1674 'title' => wp_kses_post( Helper::get_string_value( $row['campaign_title'] ?? '' ) ),
1675 'total_raised' => Helper::get_float_value( $row['total_raised'] ?? 0 ),
1676 'donation_count' => Helper::get_integer_value( $row['donation_count'] ?? 0 ),
1677 ];
1678 }
1679
1680 return [
1681 'campaigns' => $campaigns,
1682 'currency' => $scope['currency'],
1683 'payment_mode' => $scope['payment_mode'],
1684 ];
1685 } catch ( Exception $e ) {
1686 return $this->error( $e );
1687 }
1688 }
1689
1690 /**
1691 * Get the non-sensitive store settings.
1692 *
1693 * Deliberately curated rather than dumping the options: payment credentials
1694 * and the `ai_settings` block that gates these abilities are never exposed.
1695 * Returning them would put live secrets into an assistant's context, and
1696 * `ai_settings` in particular would let a caller reason about disabling its
1697 * own guard rails.
1698 *
1699 * @param mixed $input Input data.
1700 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1701 */
1702 public function get_settings( $input ) {
1703 try {
1704 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-settings' );
1705
1706 $donor_option = Helper::get_suredonation_option( 'donor_settings', [] );
1707 $donor = is_array( $donor_option ) ? $donor_option : [];
1708
1709 return [
1710 'currency' => Payment_Helper::get_currency(),
1711 'currency_symbol' => Payment_Helper::get_currency_symbol(),
1712 'currency_sign_position' => Payment_Helper::get_currency_sign_position(),
1713 'payment_mode' => Payment_Helper::get_payment_mode(),
1714 'honeypot_enabled' => Helper::is_honeypot_enabled(),
1715 'create_wp_user' => ! empty( $donor['create_wp_user'] ),
1716 ];
1717 } catch ( Exception $e ) {
1718 return $this->error( $e );
1719 }
1720 }
1721
1722 /**
1723 * Get which payment gateways are available and connected.
1724 *
1725 * Connection state only — never credentials.
1726 *
1727 * @param mixed $input Input data.
1728 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1729 */
1730 public function get_payment_gateways( $input ) {
1731 try {
1732 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-payment-gateways' );
1733
1734 $mode = Payment_Helper::get_payment_mode();
1735
1736 $stripe_connected = class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' )
1737 && \SureDonation\Inc\Payments\Stripe\Stripe_Helper::is_stripe_connected();
1738
1739 $paypal_connected = class_exists( '\SureDonation\Inc\Payments\PayPal\PayPal_Helper' )
1740 && \SureDonation\Inc\Payments\PayPal\PayPal_Helper::is_paypal_connected();
1741
1742 $offline_enabled = class_exists( '\SureDonation\Inc\Payments\Offline\Offline_Helper' )
1743 && \SureDonation\Inc\Payments\Offline\Offline_Helper::is_offline_enabled();
1744
1745 return [
1746 // payment_mode is global, not per gateway: switching it changes
1747 // which credentials every gateway uses.
1748 'payment_mode' => $mode,
1749 'gateways' => [
1750 [
1751 'id' => 'stripe',
1752 'connected' => $stripe_connected,
1753 ],
1754 [
1755 'id' => 'paypal',
1756 'connected' => $paypal_connected,
1757 ],
1758 [
1759 'id' => 'offline',
1760 'connected' => $offline_enabled,
1761 ],
1762 ],
1763 ];
1764 } catch ( Exception $e ) {
1765 return $this->error( $e );
1766 }
1767 }
1768
1769 /**
1770 * Get a donor's donation history.
1771 *
1772 * @param mixed $input Input data.
1773 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1774 * @throws Ability_Exception If validation fails or the record is missing.
1775 */
1776 public function get_donor_donations( $input ) {
1777 try {
1778 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor-donations' );
1779 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
1780 $page = $this->clamp_page( $this->input_get( 'page' ) );
1781 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
1782 $this->require_donor( $id );
1783
1784 $offset = ( $page - 1 ) * $per_page;
1785 $result = Donations::get_by_donor_id( $id, $per_page, $offset );
1786 $total = Helper::get_integer_value( $result['total'] ?? 0 );
1787
1788 $donations = [];
1789 foreach ( ( $result['donations'] ?? [] ) as $donation ) {
1790 if ( is_array( $donation ) ) {
1791 $donations[] = $this->format_donation_summary( $donation );
1792 }
1793 }
1794
1795 return [
1796 'donor_id' => $id,
1797 'donations' => $donations,
1798 'total' => $total,
1799 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
1800 ];
1801 } catch ( Exception $e ) {
1802 return $this->error( $e );
1803 }
1804 }
1805
1806 /**
1807 * Get donation trends for time-series analysis.
1808 *
1809 * @param mixed $input Input data.
1810 * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
1811 */
1812 public function get_donation_trends( $input ) {
1813 try {
1814 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation-trends' );
1815
1816 $after = $this->valid_date( $this->input_get( 'after' ) );
1817 $before = $this->valid_date( $this->input_get( 'before' ) );
1818 $group = Helper::get_string_value( $this->input_get( 'group' ) );
1819 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
1820
1821 // Amounts in different currencies, or across test and live mode, cannot
1822 // be summed into one figure. Scope to one of each so `total_amount` and
1823 // the reported currency/mode actually describe the same rows.
1824 $scope = $this->resolve_report_scope();
1825 $currency = $scope['currency'];
1826
1827 $trends = Donations::get_donation_trends( $after, $before, $group, $currency, $campaign_id, $scope['payment_mode'] );
1828
1829 $formatted = [];
1830 foreach ( $trends as $trend ) {
1831 $formatted[] = [
1832 'period' => $trend['period'] ?? '',
1833 'donation_count' => isset( $trend['donation_count'] ) ? (int) $trend['donation_count'] : 0,
1834 'total_amount' => isset( $trend['total_amount'] ) ? (float) $trend['total_amount'] : 0.0,
1835 ];
1836 }
1837
1838 return [
1839 'trends' => $formatted,
1840 'currency' => $currency,
1841 'payment_mode' => $scope['payment_mode'],
1842 // The query defaults to the last 30 days when a bound is empty,
1843 // so echo the window back rather than leaving the caller to guess.
1844 'after' => '' !== $after ? $after : gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
1845 'before' => '' !== $before ? $before : gmdate( 'Y-m-d' ),
1846 ];
1847 } catch ( Exception $e ) {
1848 return $this->error( $e );
1849 }
1850 }
1851
1852 /**
1853 * Check user capabilities.
1854 *
1855 * @param string|array<string> $caps Single capability or array of capabilities (AND logic).
1856 * @return bool True if user has required capabilities.
1857 */
1858 public function permission_callback( $caps ) {
1859 if ( empty( $caps ) ) {
1860 return false;
1861 }
1862
1863 $user = wp_get_current_user();
1864 if ( ! $user || 0 === $user->ID ) {
1865 return false;
1866 }
1867
1868 if ( is_string( $caps ) ) {
1869 return $user->has_cap( $caps );
1870 }
1871
1872 if ( is_array( $caps ) ) {
1873 foreach ( $caps as $cap ) {
1874 if ( ! $user->has_cap( $cap ) ) {
1875 return false;
1876 }
1877 }
1878 return true;
1879 }
1880
1881 return false;
1882 }
1883
1884 /**
1885 * Get a parsed input value.
1886 *
1887 * @param string $name Property name.
1888 * @param mixed $fallback Fallback value if property not found.
1889 * @return mixed
1890 * @throws Ability_Exception If inputs not parsed or property not found and no fallback.
1891 */
1892 public function input_get( $name, $fallback = self::NO_DEFAULT ) {
1893 if ( false === $this->input ) {
1894 throw new Ability_Exception( 'inputs_not_parsed', esc_html__( 'Inputs not parsed.', 'suredonation' ) );
1895 }
1896
1897 if ( ! array_key_exists( $name, $this->input ) ) {
1898 if ( self::NO_DEFAULT !== $fallback ) {
1899 return $fallback;
1900 }
1901 throw new Ability_Exception(
1902 'property_not_found',
1903 sprintf(
1904 /* translators: %s: property name */
1905 esc_html__( 'Property %s not found.', 'suredonation' ),
1906 esc_html( $name )
1907 )
1908 );
1909 }
1910
1911 return $this->input[ $name ];
1912 }
1913
1914 /**
1915 * Whether the caller explicitly sent a property.
1916 *
1917 * Use this — not `isset( $this->input[ $name ] )` — to decide whether a
1918 * partial update should touch a field. Every schema property is present in
1919 * `$this->input` after parsing, so only this tells you the caller meant it.
1920 *
1921 * @param string $name Property name.
1922 * @return bool True when the property was present in the raw input.
1923 * @since 1.5.0
1924 */
1925 public function input_provided( $name ) {
1926 return isset( $this->provided[ $name ] );
1927 }
1928
1929 // ============================================
1930 // Helper Methods
1931 // ============================================
1932
1933 /**
1934 * Initialize input parsing.
1935 *
1936 * @param mixed $input Raw input.
1937 * @param string $ability_name Ability identifier.
1938 * @return void
1939 * @throws Ability_Exception If validation fails or the record is missing.
1940 */
1941 protected function init( $input, $ability_name ) {
1942 $this->input_parse( $input, $ability_name );
1943 }
1944
1945 /**
1946 * Parse and validate input against schema.
1947 *
1948 * @param mixed $input Raw input.
1949 * @param string $ability_name Ability identifier.
1950 * @return array<string, mixed> Parsed input.
1951 * @throws Ability_Exception If required field is missing or invalid value.
1952 */
1953 protected function input_parse( $input, $ability_name ) {
1954 $this->input = [];
1955 $this->provided = [];
1956
1957 if ( is_object( $input ) && is_a( $input, 'WP_REST_Request' ) ) {
1958 $input = $input->get_json_params();
1959 if ( ! is_array( $input ) ) {
1960 $input = [];
1961 }
1962 }
1963
1964 if ( ! is_array( $input ) ) {
1965 $input = [];
1966 }
1967
1968 $input_schema = Config_Ability::get_ability_input_schema( $ability_name );
1969 if ( ! is_array( $input_schema ) || empty( $input_schema ) ) {
1970 return [];
1971 }
1972
1973 if ( ! isset( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) {
1974 return [];
1975 }
1976
1977 $required_fields = isset( $input_schema['required'] ) && is_array( $input_schema['required'] )
1978 ? $input_schema['required']
1979 : [];
1980
1981 foreach ( $input_schema['properties'] as $name => $prop ) {
1982 $type = isset( $prop['type'] ) ? strtolower( $prop['type'] ) : 'string';
1983 $was_provided = array_key_exists( $name, $input );
1984 $raw_value = $was_provided ? $input[ $name ] : null;
1985
1986 if ( $was_provided ) {
1987 $this->provided[ $name ] = true;
1988 }
1989
1990 $is_required = in_array( $name, $required_fields, true );
1991 if ( $is_required && ( null === $raw_value || '' === $raw_value ) ) {
1992 throw new Ability_Exception(
1993 'missing_required_field',
1994 sprintf(
1995 /* translators: %s: field name */
1996 esc_html__( 'Required field %s is missing.', 'suredonation' ),
1997 esc_html( $name )
1998 )
1999 );
2000 }
2001
2002 if ( null === $raw_value && isset( $prop['default'] ) ) {
2003 $raw_value = $prop['default'];
2004 }
2005
2006 if ( null === $raw_value ) {
2007 switch ( $type ) {
2008 case 'integer':
2009 $raw_value = 0;
2010 break;
2011 case 'number':
2012 $raw_value = 0.0;
2013 break;
2014 case 'boolean':
2015 $raw_value = false;
2016 break;
2017 case 'array':
2018 case 'object':
2019 $raw_value = [];
2020 break;
2021 default:
2022 $raw_value = '';
2023 break;
2024 }
2025 }
2026
2027 $value = $raw_value;
2028
2029 switch ( $type ) {
2030 case 'integer':
2031 $value = intval( $value );
2032 break;
2033 case 'number':
2034 $value = floatval( $value );
2035 break;
2036 case 'boolean':
2037 $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
2038 break;
2039 case 'string':
2040 $str_value = is_string( $value ) ? $value : strval( $value );
2041 // Use wp_kses_post for fields with format 'html', sanitize_text_field for all others.
2042 $value = isset( $prop['format'] ) && 'html' === $prop['format']
2043 ? wp_kses_post( $str_value )
2044 : sanitize_text_field( $str_value );
2045 break;
2046 case 'array':
2047 case 'object':
2048 if ( ! is_array( $value ) ) {
2049 $value = [];
2050 }
2051 $value = $this->sanitize_recursive( $value );
2052 break;
2053 }
2054
2055 // Only validate an enum the caller actually sent. An omitted optional
2056 // enum with no schema default is coerced to '' above, which is never a
2057 // member of the enum — validating it would reject every partial update
2058 // that leaves the field alone (e.g. update-campaign without goal_type).
2059 if ( $was_provided && isset( $prop['enum'] ) && is_array( $prop['enum'] ) ) {
2060 if ( ! in_array( $value, $prop['enum'], true ) ) {
2061 throw new Ability_Exception(
2062 'invalid_field_value',
2063 sprintf(
2064 /* translators: %s: field name */
2065 esc_html__( 'Invalid value for %s.', 'suredonation' ),
2066 esc_html( $name )
2067 )
2068 );
2069 }
2070 }
2071
2072 $this->input[ $name ] = $value;
2073 }
2074
2075 return $this->input;
2076 }
2077
2078 /**
2079 * Recursively sanitize array/object values.
2080 *
2081 * @param array<mixed> $data Data to sanitize.
2082 * @return array<mixed> Sanitized data.
2083 */
2084 protected function sanitize_recursive( $data ) {
2085 if ( ! is_array( $data ) ) {
2086 return $data;
2087 }
2088
2089 $sanitized = [];
2090 foreach ( $data as $key => $value ) {
2091 $key = sanitize_text_field( strval( $key ) );
2092 if ( is_array( $value ) ) {
2093 $sanitized[ $key ] = $this->sanitize_recursive( $value );
2094 } elseif ( is_string( $value ) ) {
2095 $sanitized[ $key ] = sanitize_text_field( $value );
2096 } elseif ( is_int( $value ) ) {
2097 $sanitized[ $key ] = intval( $value );
2098 } elseif ( is_float( $value ) ) {
2099 $sanitized[ $key ] = floatval( $value );
2100 } elseif ( is_bool( $value ) ) {
2101 $sanitized[ $key ] = (bool) $value;
2102 } else {
2103 $sanitized[ $key ] = sanitize_text_field( strval( $value ) );
2104 }
2105 }
2106 return $sanitized;
2107 }
2108
2109 /**
2110 * Convert a caught exception into a WP_Error.
2111 *
2112 * Returning a WP_Error is the Abilities API's failure channel: WP_Ability
2113 * passes it straight back to the caller. The previous shape — a normal
2114 * return value carrying an `error` key — was indistinguishable from success,
2115 * so a client acted on records that did not exist, and the key was not in
2116 * any declared output_schema.
2117 *
2118 * @param Exception $e The exception.
2119 * @return WP_Error Error response.
2120 */
2121 protected function error( $e ) {
2122 if ( $e instanceof Ability_Exception ) {
2123 return new WP_Error( $e->get_error_code(), $e->getMessage() );
2124 }
2125
2126 // Anything else is an internal failure whose message was never written
2127 // for an audience — a DB-layer throw can carry a query or a path. The
2128 // caller here is an MCP client or a REST consumer, so return a fixed
2129 // string and keep the real one in the log for the site owner.
2130 $this->log_internal_failure( $e );
2131
2132 return new WP_Error(
2133 Ability_Exception::DEFAULT_CODE,
2134 __( 'The request could not be completed because of an internal error.', 'suredonation' )
2135 );
2136 }
2137
2138 /**
2139 * Log an unexpected exception without surfacing it to the caller.
2140 *
2141 * @param \Throwable $e The exception to record.
2142 * @return void
2143 * @since 1.5.0
2144 */
2145 protected function log_internal_failure( $e ) {
2146 if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
2147 return;
2148 }
2149
2150 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only diagnostic for an unexpected internal failure.
2151 error_log(
2152 sprintf(
2153 'SureDonation ability failure: %s in %s:%d',
2154 $e->getMessage(),
2155 $e->getFile(),
2156 $e->getLine()
2157 )
2158 );
2159 }
2160
2161 /**
2162 * Require a valid campaign post by ID.
2163 *
2164 * @param mixed $id Campaign ID.
2165 * @return \WP_Post The campaign post.
2166 * @throws Ability_Exception If ID is zero, post not found, or wrong post type.
2167 */
2168 protected function require_campaign( $id ) {
2169 $id = Helper::get_integer_value( $id );
2170 if ( 0 === $id ) {
2171 throw new Ability_Exception( 'invalid_campaign_id', esc_html__( 'Invalid campaign ID.', 'suredonation' ) );
2172 }
2173
2174 $post = get_post( $id );
2175 if ( ! $post || SUREDONATION_POST_TYPE !== $post->post_type ) {
2176 throw new Ability_Exception( 'campaign_not_found', esc_html__( 'Campaign not found.', 'suredonation' ) );
2177 }
2178
2179 return $post;
2180 }
2181
2182 /**
2183 * Require a valid donation by ID.
2184 *
2185 * @param mixed $id Donation ID.
2186 * @return array<string, mixed> The donation record.
2187 * @throws Ability_Exception If ID is zero or donation not found.
2188 */
2189 protected function require_donation( $id ) {
2190 $id = Helper::get_integer_value( $id );
2191 if ( 0 === $id ) {
2192 throw new Ability_Exception( 'invalid_donation_id', esc_html__( 'Invalid donation ID.', 'suredonation' ) );
2193 }
2194
2195 $donation = Donations::get( $id );
2196 if ( ! $donation ) {
2197 throw new Ability_Exception( 'donation_not_found', esc_html__( 'Donation not found.', 'suredonation' ) );
2198 }
2199
2200 return $donation;
2201 }
2202
2203 /**
2204 * Require a valid donor by ID.
2205 *
2206 * @param mixed $id Donor ID.
2207 * @return array<string, mixed> The donor record.
2208 * @throws Ability_Exception If ID is zero or donor not found.
2209 */
2210 protected function require_donor( $id ) {
2211 $id = Helper::get_integer_value( $id );
2212 if ( 0 === $id ) {
2213 throw new Ability_Exception( 'invalid_donor_id', esc_html__( 'Invalid donor ID.', 'suredonation' ) );
2214 }
2215
2216 $donor = Donors::get( $id );
2217 if ( ! $donor ) {
2218 throw new Ability_Exception( 'donor_not_found', esc_html__( 'Donor not found.', 'suredonation' ) );
2219 }
2220
2221 return $donor;
2222 }
2223
2224 /**
2225 * Clamp per_page to safe bounds.
2226 *
2227 * @param mixed $per_page Raw per_page value.
2228 * @param int $max Maximum allowed.
2229 * @return int Clamped value (minimum 1).
2230 */
2231 protected function clamp_per_page( $per_page, $max = 100 ) {
2232 return max( 1, min( Helper::get_integer_value( $per_page ), $max ) );
2233 }
2234
2235 /**
2236 * Clamp page to minimum 1.
2237 *
2238 * @param mixed $page Raw page value.
2239 * @return int Clamped value (minimum 1).
2240 */
2241 protected function clamp_page( $page ) {
2242 return max( 1, Helper::get_integer_value( $page ) );
2243 }
2244
2245 /**
2246 * Dispatch an internal REST request to one of the plugin's own endpoints.
2247 *
2248 * The donation lifecycle operations (refund, status change, manual entry)
2249 * are substantial: the refund handler alone orchestrates two gateways,
2250 * recomputes the status in minor units, stores the refund for webhook
2251 * de-duplication, writes the audit log and sends notifications.
2252 * Re-implementing that here would guarantee the two copies drift, and Pro
2253 * extends these same endpoints through `suredonation_rest_api_endpoints`,
2254 * so delegating keeps Pro's behaviour intact for free.
2255 *
2256 * @param string $method HTTP method.
2257 * @param string $route Route relative to the plugin namespace.
2258 * @param array<string, mixed> $params Request body parameters.
2259 * @return array<string, mixed> The decoded successful response.
2260 * @throws Ability_Exception If the endpoint returns an error.
2261 * @since 1.5.0
2262 */
2263 protected function rest_call( $method, $route, $params = [] ) {
2264 $request = new \WP_REST_Request( $method, '/suredonation/v1' . $route );
2265
2266 foreach ( $params as $key => $value ) {
2267 $request->set_param( $key, $value );
2268 }
2269
2270 $response = rest_do_request( $request );
2271
2272 if ( $response->is_error() ) {
2273 $error = $response->as_error();
2274 $code = Ability_Exception::DEFAULT_CODE;
2275 $message = __( 'The request could not be completed.', 'suredonation' );
2276
2277 if ( $error instanceof WP_Error ) {
2278 $code = Helper::get_string_value( $error->get_error_code() );
2279 $message = $error->get_error_message();
2280 }
2281
2282 // The code is a machine-readable key the caller matches on, not
2283 // output — escaping belongs at the output boundary, and escaping it
2284 // here would corrupt any code containing an escapable character.
2285 throw new Ability_Exception( $code, esc_html( $message ) );
2286 }
2287
2288 $data = $response->get_data();
2289
2290 return is_array( $data ) ? $data : [];
2291 }
2292
2293 /**
2294 * Normalise a YYYY-MM-DD date bound, discarding anything malformed.
2295 *
2296 * @param mixed $value Raw date value.
2297 * @return string The date, or '' when absent or malformed.
2298 * @since 1.5.0
2299 */
2300 protected function valid_date( $value ) {
2301 $date = Helper::get_string_value( $value );
2302
2303 return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : '';
2304 }
2305
2306 /**
2307 * Format a donation for a list context.
2308 *
2309 * The detail formatter is deliberately not reused here. It runs a per-row
2310 * Donations::get_log() query — which re-SELECTs a row the list query has
2311 * already fetched — and returns the donor's submitted field values, phone,
2312 * comment, Stripe customer/account ids and receipt URL. On a page of up to
2313 * 100 rows that is 100 redundant queries plus bulk donor PII, for fields the
2314 * list output_schema does not declare and the caller never asked for.
2315 *
2316 * The keys returned here are exactly the ones the list schemas declare.
2317 * Callers that need the full record fetch it with get-donation.
2318 *
2319 * @param array<string, mixed> $donation Raw donation row.
2320 * @return array<string, mixed> Formatted donation summary.
2321 * @since 1.5.0
2322 */
2323 protected function format_donation_summary( $donation ) {
2324 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
2325 $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
2326
2327 return [
2328 'id' => isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0,
2329 'campaign_id' => $campaign_id,
2330 'campaign_title' => $campaign_id ? wp_kses_post( get_the_title( $campaign_id ) ) : '',
2331 'form_id' => $form_id,
2332 'form_title' => $form_id ? wp_kses_post( get_the_title( $form_id ) ) : '',
2333 'donor_name' => $donation['donor_name'] ?? '',
2334 'donor_email' => $donation['donor_email'] ?? '',
2335 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
2336 'currency' => $donation['currency'] ?? 'USD',
2337 'payment_status' => $donation['payment_status'] ?? '',
2338 'donation_type' => $donation['donation_type'] ?? 'one-time',
2339 'gateway' => $donation['gateway'] ?? '',
2340 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
2341 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
2342 'created_at' => $donation['created_at'] ?? '',
2343 ];
2344 }
2345
2346 /**
2347 * Format a donation record for ability output.
2348 *
2349 * Protected rather than private: SureDonation Pro extends this class to
2350 * register its own abilities, and subscriptions and renewals ARE donation
2351 * rows. Pro must return the same donation shape as free or the two payloads
2352 * diverge on the first change to either. Same reasoning for the other
2353 * format_* methods below.
2354 *
2355 * @param array<string, mixed> $donation Raw donation data from database.
2356 * @return array<string, mixed> Formatted donation data.
2357 */
2358 protected function format_donation( $donation ) {
2359 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
2360 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
2361
2362 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
2363 $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
2364
2365 // donation_data holds the submitted field list and the subscription
2366 // metadata. decode_by_datatype() usually decodes it already; tolerate a
2367 // raw JSON string for rows fetched outside that path.
2368 $donation_data = $donation['donation_data'] ?? [];
2369 if ( is_string( $donation_data ) && '' !== $donation_data ) {
2370 $donation_data = json_decode( $donation_data, true );
2371 }
2372 if ( ! is_array( $donation_data ) ) {
2373 $donation_data = [];
2374 }
2375
2376 // The label/value/group triples captured at submission time. This is the
2377 // only way a caller can answer "what did this donor actually fill in?".
2378 $submitted_fields = [];
2379 if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) {
2380 foreach ( $donation_data['fields'] as $field ) {
2381 if ( ! is_array( $field ) ) {
2382 continue;
2383 }
2384 $submitted_fields[] = [
2385 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ),
2386 'value' => sanitize_text_field( Helper::get_string_value( $field['value'] ?? '' ) ),
2387 'group' => sanitize_text_field( Helper::get_string_value( $field['group'] ?? '' ) ),
2388 ];
2389 }
2390 }
2391
2392 return [
2393 'id' => $donation_id,
2394 'campaign_id' => $campaign_id,
2395 'campaign_title' => $campaign_id ? wp_kses_post( get_the_title( $campaign_id ) ) : '',
2396 'form_id' => $form_id,
2397 'form_title' => $form_id ? wp_kses_post( get_the_title( $form_id ) ) : '',
2398 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
2399 'donor_name' => $donation['donor_name'] ?? '',
2400 'donor_email' => $donation['donor_email'] ?? '',
2401 'donor_phone' => $donation['donor_phone'] ?? '',
2402 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
2403 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
2404 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
2405 'currency' => $donation['currency'] ?? 'USD',
2406 'donation_type' => $donation['donation_type'] ?? 'one-time',
2407 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
2408 'donor_comment' => $donation['donor_comment'] ?? '',
2409 'payment_status' => $donation['payment_status'] ?? 'pending',
2410 'payment_mode' => $donation['payment_mode'] ?? 'test',
2411 'gateway' => $donation['gateway'] ?? '',
2412 'transaction_id' => $donation['transaction_id'] ?? '',
2413 'stripe_customer_id' => $donation['customer_id'] ?? '',
2414 'stripe_account_id' => $donation['stripe_account_id'] ?? '',
2415 'subscription_id' => $donation['subscription_id'] ?? '',
2416 'subscription_status' => $donation['subscription_status'] ?? '',
2417 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
2418 'subscription_interval' => Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ),
2419 'billing_cycles' => Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ),
2420 'receipt_sent' => ! empty( $donation['receipt_sent'] ),
2421 'receipt_pdf_url' => $donation['receipt_pdf_url'] ?? '',
2422 'import_source' => $donation['import_source'] ?? '',
2423 'fields' => $submitted_fields,
2424 'created_at' => $donation['created_at'] ?? '',
2425 'updated_at' => $donation['updated_at'] ?? '',
2426 'logs' => $logs,
2427 ];
2428 }
2429
2430 /**
2431 * Format a donation form for ability output.
2432 *
2433 * @param \WP_Post $form Form post object.
2434 * @param array{entries: int, revenue: float}|null $stats Pre-computed totals for this form, or null to query them.
2435 * @return array<string, mixed> Formatted form data.
2436 */
2437 protected function format_form( $form, $stats = null ) {
2438 $campaign_id = Donation_Form::get_form_campaign_id( $form->ID );
2439 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
2440
2441 // A list passes stats it has already batched for the whole page; a
2442 // single-form read falls back to the one-form query.
2443 if ( ! is_array( $stats ) ) {
2444 $stats = Donations::get_form_stats( $form->ID );
2445 }
2446
2447 return [
2448 'id' => $form->ID,
2449 'title' => $form->post_title,
2450 'status' => $form->post_status,
2451 'campaign_id' => $campaign_id,
2452 'campaign_name' => $campaign ? $campaign->post_title : '',
2453 'entries' => $stats['entries'],
2454 'revenue' => $stats['revenue'],
2455 // Which form a campaign actually renders, so a caller can tell the
2456 // live form apart from the others attached to the same campaign.
2457 'is_default' => $campaign_id > 0 && Campaign_Cpt::get_default_form_id( $campaign_id ) === (int) $form->ID,
2458 'created_at' => $form->post_date,
2459 'modified_at' => $form->post_modified,
2460 'edit_url' => admin_url( 'post.php?post=' . $form->ID . '&action=edit' ),
2461 ];
2462 }
2463
2464 /**
2465 * Format a donor record for ability output.
2466 *
2467 * @param array<string, mixed> $donor Raw donor data from database.
2468 * @return array<string, mixed> Formatted donor data.
2469 */
2470 protected function format_donor( $donor ) {
2471 $id_val = $donor['id'] ?? 0;
2472 $user_val = $donor['user_id'] ?? 0;
2473 $donated_val = $donor['total_donated'] ?? 0;
2474 $count_val = $donor['donation_count'] ?? 0;
2475 $largest_val = $donor['largest_donation'] ?? 0;
2476
2477 return [
2478 'id' => is_numeric( $id_val ) ? (int) $id_val : 0,
2479 'name' => $donor['name'] ?? '',
2480 'email' => $donor['email'] ?? '',
2481 'phone' => $donor['phone'] ?? '',
2482 'company' => $donor['company'] ?? '',
2483 'address' => $donor['address'] ?? '',
2484 'stripe_customer_id' => $donor['stripe_customer_id'] ?? '',
2485 'user_id' => is_numeric( $user_val ) ? (int) $user_val : 0,
2486 'donor_status' => $donor['donor_status'] ?? 'active',
2487 'total_donated' => is_numeric( $donated_val ) ? (float) $donated_val : 0.0,
2488 'donation_count' => is_numeric( $count_val ) ? (int) $count_val : 0,
2489 'largest_donation' => is_numeric( $largest_val ) ? (float) $largest_val : 0.0,
2490 'first_donation_date' => $donor['first_donation_date'] ?? '',
2491 'last_donation_date' => $donor['last_donation_date'] ?? '',
2492 'donor_tags' => is_array( $donor['donor_tags'] ?? null ) ? $donor['donor_tags'] : [],
2493 'created_at' => $donor['created_at'] ?? '',
2494 'updated_at' => $donor['updated_at'] ?? '',
2495 ];
2496 }
2497
2498 /**
2499 * Format a campaign post for ability output.
2500 *
2501 * @param \WP_Post $post Campaign post.
2502 * @return array<string, mixed> Formatted campaign data.
2503 */
2504 protected function format_campaign( $post ) {
2505 $stats = Campaign_Stats::get_stats( $post->ID );
2506 $meta = Helper::get_campaign_meta( $post->ID );
2507
2508 return array_merge(
2509 [
2510 'id' => $post->ID,
2511 'title' => $post->post_title,
2512 // `status` is the campaign business status (active/paused/
2513 // completed). `post_status` is the WordPress one — without it a
2514 // caller cannot tell a draft from a published campaign.
2515 'status' => $stats['campaign_status'],
2516 'post_status' => $post->post_status,
2517 'goal_type' => $meta['goal_type'],
2518 'goal' => $stats['goal_amount'],
2519 'raised' => $stats['total_raised'],
2520 'donors' => $stats['donor_count'],
2521 'progress' => $stats['progress_percentage'],
2522 'created_at' => $post->post_date,
2523 'modified_at' => $post->post_modified,
2524 ],
2525 $this->campaign_extras( $post, $meta )
2526 );
2527 }
2528
2529 /**
2530 * Fields shared by the campaign list and detail payloads.
2531 *
2532 * Kept separate so list-campaigns and get-campaign cannot drift apart, and
2533 * so the currency travels with every monetary figure.
2534 *
2535 * @param \WP_Post $post Campaign post.
2536 * @param array<string, mixed> $meta Decoded campaign meta.
2537 * @return array<string, mixed> Additional campaign fields.
2538 * @since 1.5.0
2539 */
2540 private function campaign_extras( $post, $meta ) {
2541 $thumbnail_url = get_the_post_thumbnail_url( $post->ID, 'medium' );
2542
2543 return [
2544 // goal/raised are amounts; without a currency code they are ambiguous.
2545 'currency' => Payment_Helper::get_currency(),
2546 'terms_text' => Helper::get_string_value( $meta['terms_text'] ?? '' ),
2547 'thank_you_message' => Helper::get_string_value( $meta['thank_you_message'] ?? '' ),
2548 'featured_image' => (int) get_post_thumbnail_id( $post->ID ),
2549 'featured_image_url' => is_string( $thumbnail_url ) ? $thumbnail_url : '',
2550 'has_page' => Campaign_Page::has_page( $post->ID ),
2551 'permalink' => 'publish' === $post->post_status ? (string) get_permalink( $post->ID ) : '',
2552 'author' => (string) get_the_author_meta( 'display_name', (int) $post->post_author ),
2553 'edit_url' => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ),
2554 'default_form_id' => Campaign_Cpt::get_default_form_id( $post->ID ),
2555 ];
2556 }
2557 }
2558