PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/abilities/runtime.php +165 -1432 trunk1.1.0 View file →
@@ -10,10 +10,8 @@
10 10
11 11 namespace SureDonation\Inc\Abilities;
12 12
13 13 use Exception;
14 -use SureDonation\Inc\Campaigns\Campaign_Cpt;
15 -use SureDonation\Inc\Campaigns\Campaign_Page;
16 14 use SureDonation\Inc\Campaigns\Campaign_Stats;
17 15 use SureDonation\Inc\Database\Tables\Donations;
18 16 use SureDonation\Inc\Database\Tables\Donors;
19 17 use SureDonation\Inc\Helper;
@@ -18,9 +16,8 @@
18 16 use SureDonation\Inc\Database\Tables\Donors;
19 17 use SureDonation\Inc\Helper;
20 18 use SureDonation\Inc\Payments\Payment_Helper;
21 19 use SureDonation\Inc\Post_Types\Donation_Form;
22 -use WP_Error;
23 20 use WP_Query;
24 21
25 22 // Exit if accessed directly.
26 23 if ( ! defined( 'ABSPATH' ) ) {
@@ -32,9 +29,8 @@
32 29 *
33 30 * @since 0.0.1
34 31 */
35 32 class Runtime {
36 -
37 33 /**
38 34 * Sentinel value indicating no default was provided to input_get().
39 35 */
40 36 private const NO_DEFAULT = '__NO_DEFAULT__';
@@ -45,23 +41,8 @@
45 41 * @var array<string, mixed>|false
46 42 */
47 43 protected $input = false;
48 44
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 45 // ============================================
65 46 // Category & Ability Registration
66 47 // ============================================
67 48
@@ -70,14 +51,8 @@
70 51 *
71 52 * @return void
72 53 */
73 54 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 55 wp_register_ability_category(
81 56 'suredonation',
82 57 [
83 58 'label' => __( 'SureDonation', 'suredonation' ),
@@ -133,24 +108,8 @@
133 108 public function register() {
134 109 $abilities = Config_Ability::get_abilities();
135 110
136 111 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 112 wp_register_ability(
154 113 $ability_name,
155 114 [
156 115 'label' => $ability['label'],
@@ -173,9 +132,9 @@
173 132 /**
174 133 * List campaigns with pagination, search, status filter, and sorting.
175 134 *
176 135 * @param mixed $input Input data.
177 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
136 + * @return array<string, mixed> Response.
178 137 */
179 138 public function list_campaigns( $input ) {
180 139 try {
181 140 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-campaigns' );
@@ -200,22 +159,9 @@
200 159 'orderby' => $orderby_map[ $sort_by ] ?? 'date',
201 160 'order' => $order,
202 161 ];
203 162
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 ) {
163 + if ( 'all' !== $status ) {
218 164 $args['post_status'] = $status;
219 165 } else {
220 166 $args['post_status'] = [ 'publish', 'draft' ];
221 167 }
@@ -249,10 +195,9 @@
249 195 /**
250 196 * Get a single campaign by ID.
251 197 *
252 198 * @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.
199 + * @return array<string, mixed> Response.
255 200 */
256 201 public function get_campaign( $input ) {
257 202 try {
258 203 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-campaign' );
@@ -275,12 +220,11 @@
275 220 'average_donation' => $stats['average_donation'],
276 221 'largest_donation' => $stats['largest_donation'],
277 222 'is_goal_reached' => $stats['is_goal_reached'],
278 223 'require_terms' => (bool) ( $meta['require_terms'] ?? false ),
279 - 'post_status' => $post->post_status,
280 224 'created_at' => $post->post_date,
281 225 'modified_at' => $post->post_modified,
282 - ] + $this->campaign_extras( $post, $meta );
226 + ];
283 227 } catch ( Exception $e ) {
284 228 return $this->error( $e );
285 229 }
286 230 }
@@ -288,10 +232,10 @@
288 232 /**
289 233 * Create a new campaign.
290 234 *
291 235 * @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.
236 + * @return array<string, mixed> Response.
237 + * @throws Exception If validation or creation fails.
294 238 */
295 239 public function create_campaign( $input ) {
296 240 try {
297 241 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'create-campaign' );
@@ -312,35 +256,25 @@
312 256 true
313 257 );
314 258
315 259 if ( is_wp_error( $post_id ) ) {
316 - throw new Ability_Exception( 'campaign_create_failed', esc_html__( 'Failed to create campaign.', 'suredonation' ) );
260 + throw new Exception( esc_html__( 'Failed to create campaign.', 'suredonation' ) );
317 261 }
318 262
319 263 $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', '' ),
264 + 'goal_type' => $this->input_get( 'goal_type' ),
265 + 'goal_amount' => $this->input_get( 'goal_amount' ),
266 + 'campaign_status' => $this->input_get( 'campaign_status' ),
267 + 'require_terms' => $this->input_get( 'require_terms' ),
268 + 'terms_text' => $this->input_get( 'terms_text', '' ),
326 269 ];
327 270
328 271 Helper::update_campaign_meta( $post_id, $meta_values );
329 272
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 273 return [
340 274 'id' => $post_id,
341 275 'title' => $title,
342 - 'status' => Helper::get_string_value( $created_meta['campaign_status'] ),
276 + 'status' => 'active',
343 277 'message' => esc_html__( 'Campaign created successfully.', 'suredonation' ),
344 278 ];
345 279 } catch ( Exception $e ) {
346 280 return $this->error( $e );
@@ -350,10 +284,10 @@
350 284 /**
351 285 * Update an existing campaign.
352 286 *
353 287 * @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.
288 + * @return array<string, mixed> Response.
289 + * @throws Exception If validation or update fails.
356 290 */
357 291 public function update_campaign( $input ) {
358 292 try {
359 293 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-campaign' );
@@ -359,33 +293,22 @@
359 293 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'update-campaign' );
360 294 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
361 295 $this->require_campaign( $id );
362 296
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).
297 + $parsed_inputs = is_array( $this->input ) ? $this->input : [];
298 +
299 + // Update post fields if provided.
368 300 $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 - }
301 + $title = Helper::get_string_value( $this->input_get( 'title', '' ) );
302 + if ( ! empty( $title ) ) {
303 + $post_data['post_title'] = sanitize_text_field( $title );
381 304 }
382 305
383 306 // The description is stored as the excerpt so post_content stays
384 - // reserved for the campaign page layout.
385 - if ( $this->input_provided( 'description' ) ) {
307 + // reserved for the campaign page layout. An empty string clears it.
308 + if ( array_key_exists( 'description', $parsed_inputs ) ) {
386 309 $post_data['post_excerpt'] = wp_kses_post(
387 - Helper::get_string_value( $this->input_get( 'description' ) )
310 + Helper::get_string_value( $this->input_get( 'description', '' ) )
388 311 );
389 312 }
390 313
391 314 if ( count( $post_data ) > 1 ) {
@@ -390,19 +313,23 @@
390 313
391 314 if ( count( $post_data ) > 1 ) {
392 315 $result = wp_update_post( $post_data, true );
393 316 if ( is_wp_error( $result ) ) {
394 - throw new Ability_Exception( 'campaign_update_failed', esc_html__( 'Failed to update campaign.', 'suredonation' ) );
317 + throw new Exception( esc_html__( 'Failed to update campaign.', 'suredonation' ) );
395 318 }
396 319 }
397 320
398 - // goal_type and campaign_status are enum-constrained, so a provided
399 - // value is always valid — no empty-value guard is needed here.
321 + // Update meta fields if provided.
322 + $meta_fields = [ 'goal_type', 'goal_amount', 'campaign_status', 'require_terms', 'terms_text' ];
400 323 $meta_values = [];
401 324
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 );
325 + foreach ( $meta_fields as $field ) {
326 + if ( array_key_exists( $field, $parsed_inputs ) ) {
327 + $value = $parsed_inputs[ $field ];
328 + // Only include non-default/non-empty values for optional fields.
329 + if ( '' !== $value && null !== $value ) {
330 + $meta_values[ $field ] = $value;
331 + }
405 332 }
406 333 }
407 334
408 335 if ( ! empty( $meta_values ) ) {
@@ -408,19 +335,8 @@
408 335 if ( ! empty( $meta_values ) ) {
409 336 Helper::update_campaign_meta( $id, $meta_values );
410 337 }
411 338
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 339 $updated_post = get_post( $id );
424 340 $meta = Helper::get_campaign_meta( $id );
425 341
426 342 return [
@@ -437,10 +353,10 @@
437 353 /**
438 354 * Delete a campaign permanently.
439 355 *
440 356 * @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.
357 + * @return array<string, mixed> Response.
358 + * @throws Exception If validation or deletion fails.
443 359 */
444 360 public function delete_campaign( $input ) {
445 361 try {
446 362 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'delete-campaign' );
@@ -446,75 +362,16 @@
446 362 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'delete-campaign' );
447 363 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
448 364 $this->require_campaign( $id );
449 365
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 366 $result = wp_delete_post( $id, true );
506 367 if ( ! $result ) {
507 - throw new Ability_Exception( 'campaign_delete_failed', esc_html__( 'Failed to delete campaign.', 'suredonation' ) );
368 + throw new Exception( esc_html__( 'Failed to delete campaign.', 'suredonation' ) );
508 369 }
509 370
510 371 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' ),
372 + 'id' => $id,
373 + 'message' => esc_html__( 'Campaign permanently deleted.', 'suredonation' ),
517 374 ];
518 375 } catch ( Exception $e ) {
519 376 return $this->error( $e );
520 377 }
@@ -523,10 +380,10 @@
523 380 /**
524 381 * Duplicate a campaign.
525 382 *
526 383 * @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.
384 + * @return array<string, mixed> Response.
385 + * @throws Exception If validation or duplication fails.
529 386 */
530 387 public function duplicate_campaign( $input ) {
531 388 try {
532 389 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'duplicate-campaign' );
@@ -547,9 +404,9 @@
547 404 true
548 405 );
549 406
550 407 if ( is_wp_error( $duplicate_id ) ) {
551 - throw new Ability_Exception( 'campaign_duplicate_failed', esc_html__( 'Failed to duplicate campaign.', 'suredonation' ) );
408 + throw new Exception( esc_html__( 'Failed to duplicate campaign.', 'suredonation' ) );
552 409 }
553 410
554 411 // Copy campaign meta.
555 412 $meta = get_post_meta( $original->ID, Helper::SUREDONATION_CAMPAIGN_META_KEY, true );
@@ -570,9 +427,9 @@
570 427 /**
571 428 * Get pages/posts where a campaign's form block is embedded.
572 429 *
573 430 * @param mixed $input Input data.
574 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
431 + * @return array<string, mixed> Response.
575 432 */
576 433 public function get_campaign_form_locations( $input ) {
577 434 try {
578 435 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-campaign-form-locations' );
@@ -580,15 +437,9 @@
580 437 $this->require_campaign( $id );
581 438
582 439 global $wpdb;
583 440
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 . '}%';
441 + $search_pattern = '%"campaignId":%' . $id . '%';
591 442
592 443 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
593 444 $posts = $wpdb->get_results(
594 445 $wpdb->prepare(
@@ -593,15 +444,14 @@
593 444 $posts = $wpdb->get_results(
594 445 $wpdb->prepare(
595 446 "SELECT ID, post_title, post_type, post_status, post_modified
596 447 FROM %i
597 - WHERE ( post_content LIKE %s OR post_content LIKE %s )
448 + WHERE post_content LIKE %s
598 449 AND post_status IN ('publish', 'draft', 'pending', 'private')
599 450 AND post_type IN ('page', 'post', 'suredonation_cmpgn')
600 451 ORDER BY post_modified DESC",
601 452 $wpdb->posts,
602 - $search_pattern,
603 - $alt_pattern
453 + $search_pattern
604 454 )
605 455 );
606 456
607 457 $locations = [];
@@ -607,15 +457,14 @@
607 457 $locations = [];
608 458 if ( $posts ) {
609 459 foreach ( $posts as $found_post ) {
610 460 $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 ) : '',
461 + 'id' => (int) $found_post->ID,
462 + 'title' => $found_post->post_title,
463 + 'type' => $found_post->post_type,
464 + 'status' => $found_post->post_status,
465 + 'edit_url' => admin_url( 'post.php?post=' . $found_post->ID . '&action=edit' ),
466 + 'view_url' => 'publish' === $found_post->post_status ? get_permalink( $found_post->ID ) : '',
618 467 ];
619 468 }
620 469 }
621 470
@@ -634,9 +483,9 @@
634 483 /**
635 484 * List donations with pagination, search, status/campaign filter, and sorting.
636 485 *
637 486 * @param mixed $input Input data.
638 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
487 + * @return array<string, mixed> Response.
639 488 */
640 489 public function list_donations( $input ) {
641 490 try {
642 491 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-donations' );
@@ -660,16 +509,14 @@
660 509 $sort_by,
661 510 strtoupper( $order )
662 511 );
663 512
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 );
513 + $total = Donations::get_total_donations_by_status( $status, $campaign_id );
667 514
668 515 $donations = [];
669 516 foreach ( $results as $donation ) {
670 517 if ( is_array( $donation ) ) {
671 - $donations[] = $this->format_donation_summary( $donation );
518 + $donations[] = $this->format_donation( $donation );
672 519 }
673 520 }
674 521
675 522 return [
@@ -685,9 +532,9 @@
685 532 /**
686 533 * Get a single donation by ID.
687 534 *
688 535 * @param mixed $input Input data.
689 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
536 + * @return array<string, mixed> Response.
690 537 */
691 538 public function get_donation( $input ) {
692 539 try {
693 540 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation' );
@@ -702,10 +549,9 @@
702 549 /**
703 550 * Get paginated notes for a donation.
704 551 *
705 552 * @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.
553 + * @return array<string, mixed> Response.
708 554 */
709 555 public function get_donation_notes( $input ) {
710 556 try {
711 557 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation-notes' );
@@ -729,10 +575,10 @@
729 575 /**
730 576 * Add a note to a donation.
731 577 *
732 578 * @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.
579 + * @return array<string, mixed> Response.
580 + * @throws Exception If validation fails.
735 581 */
736 582 public function add_donation_note( $input ) {
737 583 try {
738 584 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'add-donation-note' );
@@ -742,9 +588,9 @@
742 588
743 589 $result = Donations::add_note( $id, $note, get_current_user_id() );
744 590
745 591 if ( ! $result['success'] ) {
746 - throw new Ability_Exception( 'donation_note_add_failed', esc_html__( 'Failed to add note.', 'suredonation' ) );
592 + throw new Exception( esc_html__( 'Failed to add note.', 'suredonation' ) );
747 593 }
748 594
749 595 return [
750 596 'note_id' => $result['note_id'],
@@ -762,31 +608,29 @@
762 608 /**
763 609 * List donors with pagination, status filter, and sorting.
764 610 *
765 611 * @param mixed $input Input data.
766 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
612 + * @return array<string, mixed> Response.
767 613 */
768 614 public function list_donors( $input ) {
769 615 try {
770 616 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-donors' );
771 617
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;
618 + $page = $this->clamp_page( $this->input_get( 'page' ) );
619 + $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
620 + $status = Helper::get_string_value( $this->input_get( 'status' ) );
621 + $sort_by = Helper::get_string_value( $this->input_get( 'sort_by' ) );
622 + $order = Helper::get_string_value( $this->input_get( 'order' ) );
623 + $offset = ( $page - 1 ) * $per_page;
782 624
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 );
625 + if ( 'all' === $status ) {
626 + $results = Donors::get_all( $per_page, $offset, $sort_by, $order );
627 + } else {
628 + $results = Donors::get_by_status( $status, $per_page, $offset, $sort_by, $order );
629 + }
788 630
631 + $total = Donors::get_total_donors( $status );
632 +
789 633 $donors = [];
790 634 foreach ( $results as $donor ) {
791 635 if ( is_array( $donor ) ) {
792 636 $donors[] = $this->format_donor( $donor );
@@ -806,10 +650,9 @@
806 650 /**
807 651 * Get a single donor by ID.
808 652 *
809 653 * @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.
654 + * @return array<string, mixed> Response.
812 655 */
813 656 public function get_donor( $input ) {
814 657 try {
815 658 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor' );
@@ -824,10 +667,10 @@
824 667 /**
825 668 * Get a donor by email address.
826 669 *
827 670 * @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.
671 + * @return array<string, mixed> Response.
672 + * @throws Exception If validation fails.
830 673 */
831 674 public function get_donor_by_email( $input ) {
832 675 try {
833 676 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor-by-email' );
@@ -833,14 +676,14 @@
833 676 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donor-by-email' );
834 677 $email = sanitize_email( Helper::get_string_value( $this->input_get( 'email' ) ) );
835 678
836 679 if ( empty( $email ) || ! is_email( $email ) ) {
837 - throw new Ability_Exception( 'invalid_donor_email', esc_html__( 'A valid email address is required.', 'suredonation' ) );
680 + throw new Exception( esc_html__( 'A valid email address is required.', 'suredonation' ) );
838 681 }
839 682
840 683 $donor = Donors::get_by_email( $email );
841 684 if ( ! $donor ) {
842 - throw new Ability_Exception( 'donor_not_found', esc_html__( 'Donor not found.', 'suredonation' ) );
685 + throw new Exception( esc_html__( 'Donor not found.', 'suredonation' ) );
843 686 }
844 687
845 688 return $this->format_donor( $donor );
846 689 } catch ( Exception $e ) {
@@ -851,9 +694,9 @@
851 694 /**
852 695 * Get top donors ranked by total donated.
853 696 *
854 697 * @param mixed $input Input data.
855 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
698 + * @return array<string, mixed> Response.
856 699 */
857 700 public function get_top_donors( $input ) {
858 701 try {
859 702 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-top-donors' );
@@ -893,10 +736,9 @@
893 736 /**
894 737 * List donation forms with optional campaign and status filter.
895 738 *
896 739 * @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.
740 + * @return array<string, mixed> Response.
899 741 */
900 742 public function list_forms( $input ) {
901 743 try {
902 744 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'list-forms' );
@@ -903,15 +745,13 @@
903 745
904 746 $campaign_id = Helper::get_integer_value( $this->input_get( 'campaign_id' ) );
905 747 $status = Helper::get_string_value( $this->input_get( 'status' ) );
906 748 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
907 - $page = $this->clamp_page( $this->input_get( 'page' ) );
908 749
909 750 $post_status = 'any' === $status ? [ 'publish', 'draft', 'trash' ] : $status;
910 751
911 752 $args = [
912 753 'posts_per_page' => $per_page,
913 - 'paged' => $page,
914 754 'post_status' => $post_status,
915 755 ];
916 756
917 757 if ( $campaign_id > 0 ) {
@@ -927,27 +767,14 @@
927 767
928 768 $forms = Donation_Form::get_forms( $args );
929 769
930 770 $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 771 foreach ( $forms as $form ) {
935 - $formatted[] = $this->format_form( $form, $stats[ (int) $form->ID ] ?? null );
772 + $formatted[] = $this->format_form( $form );
936 773 }
937 774
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 775 return [
947 - 'forms' => $formatted,
948 - 'total' => $total,
949 - 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
776 + 'forms' => $formatted,
950 777 ];
951 778 } catch ( Exception $e ) {
952 779 return $this->error( $e );
953 780 }
@@ -956,10 +783,10 @@
956 783 /**
957 784 * Get a single donation form by ID.
958 785 *
959 786 * @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.
787 + * @return array<string, mixed> Response.
788 + * @throws Exception If validation fails.
962 789 */
963 790 public function get_form( $input ) {
964 791 try {
965 792 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-form' );
@@ -965,14 +792,14 @@
965 792 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-form' );
966 793 $id = Helper::get_integer_value( $this->input_get( 'id' ) );
967 794
968 795 if ( 0 === $id ) {
969 - throw new Ability_Exception( 'invalid_form_id', esc_html__( 'Invalid form ID.', 'suredonation' ) );
796 + throw new Exception( esc_html__( 'Invalid form ID.', 'suredonation' ) );
970 797 }
971 798
972 799 $form = get_post( $id );
973 800 if ( ! $form || Donation_Form::POST_TYPE !== $form->post_type ) {
974 - throw new Ability_Exception( 'form_not_found', esc_html__( 'Form not found.', 'suredonation' ) );
801 + throw new Exception( esc_html__( 'Form not found.', 'suredonation' ) );
975 802 }
976 803
977 804 return $this->format_form( $form );
978 805 } catch ( Exception $e ) {
@@ -979,853 +806,36 @@
979 806 return $this->error( $e );
980 807 }
981 808 }
982 809
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 810 // ============================================
1530 811 // Dashboard & Analytics Execute Callbacks
1531 812 // ============================================
1532 813
1533 814 /**
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 815 * Get donation trends for time-series analysis.
1808 816 *
1809 817 * @param mixed $input Input data.
1810 - * @return array<string, mixed>|WP_Error Response on success, WP_Error on failure.
818 + * @return array<string, mixed> Response.
1811 819 */
1812 820 public function get_donation_trends( $input ) {
1813 821 try {
1814 822 $this->init( $input, SUREDONATION_ABILITY_API_NAMESPACE . 'get-donation-trends' );
1815 823
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' ) );
824 + $after = Helper::get_string_value( $this->input_get( 'after' ) );
825 + $before = Helper::get_string_value( $this->input_get( 'before' ) );
826 + $group = Helper::get_string_value( $this->input_get( 'group' ) );
1820 827
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'];
828 + $date_pattern = '/^\d{4}-\d{2}-\d{2}$/';
829 + if ( ! empty( $after ) && ! preg_match( $date_pattern, $after ) ) {
830 + $after = '';
831 + }
832 + if ( ! empty( $before ) && ! preg_match( $date_pattern, $before ) ) {
833 + $before = '';
834 + }
1826 835
1827 - $trends = Donations::get_donation_trends( $after, $before, $group, $currency, $campaign_id, $scope['payment_mode'] );
836 + $trends = Donations::get_donation_trends( $after, $before, $group );
837 + $currency = Payment_Helper::get_currency();
1828 838
1829 839 $formatted = [];
1830 840 foreach ( $trends as $trend ) {
1831 841 $formatted[] = [
@@ -1835,15 +845,10 @@
1835 845 ];
1836 846 }
1837 847
1838 848 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' ),
849 + 'trends' => $formatted,
850 + 'currency' => $currency,
1846 851 ];
1847 852 } catch ( Exception $e ) {
1848 853 return $this->error( $e );
1849 854 }
@@ -1886,13 +891,13 @@
1886 891 *
1887 892 * @param string $name Property name.
1888 893 * @param mixed $fallback Fallback value if property not found.
1889 894 * @return mixed
1890 - * @throws Ability_Exception If inputs not parsed or property not found and no fallback.
895 + * @throws Exception If inputs not parsed or property not found and no fallback.
1891 896 */
1892 897 public function input_get( $name, $fallback = self::NO_DEFAULT ) {
1893 898 if ( false === $this->input ) {
1894 - throw new Ability_Exception( 'inputs_not_parsed', esc_html__( 'Inputs not parsed.', 'suredonation' ) );
899 + throw new Exception( esc_html__( 'Inputs not parsed.', 'suredonation' ) );
1895 900 }
1896 901
1897 902 if ( ! array_key_exists( $name, $this->input ) ) {
1898 903 if ( self::NO_DEFAULT !== $fallback ) {
@@ -1897,10 +902,9 @@
1897 902 if ( ! array_key_exists( $name, $this->input ) ) {
1898 903 if ( self::NO_DEFAULT !== $fallback ) {
1899 904 return $fallback;
1900 905 }
1901 - throw new Ability_Exception(
1902 - 'property_not_found',
906 + throw new Exception(
1903 907 sprintf(
1904 908 /* translators: %s: property name */
1905 909 esc_html__( 'Property %s not found.', 'suredonation' ),
1906 910 esc_html( $name )
@@ -1910,23 +914,8 @@
1910 914
1911 915 return $this->input[ $name ];
1912 916 }
1913 917
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 918 // ============================================
1930 919 // Helper Methods
1931 920 // ============================================
1932 921
@@ -1935,9 +924,8 @@
1935 924 *
1936 925 * @param mixed $input Raw input.
1937 926 * @param string $ability_name Ability identifier.
1938 927 * @return void
1939 - * @throws Ability_Exception If validation fails or the record is missing.
1940 928 */
1941 929 protected function init( $input, $ability_name ) {
1942 930 $this->input_parse( $input, $ability_name );
1943 931 }
@@ -1947,13 +935,12 @@
1947 935 *
1948 936 * @param mixed $input Raw input.
1949 937 * @param string $ability_name Ability identifier.
1950 938 * @return array<string, mixed> Parsed input.
1951 - * @throws Ability_Exception If required field is missing or invalid value.
939 + * @throws Exception If required field is missing or invalid value.
1952 940 */
1953 941 protected function input_parse( $input, $ability_name ) {
1954 - $this->input = [];
1955 - $this->provided = [];
942 + $this->input = [];
1956 943
1957 944 if ( is_object( $input ) && is_a( $input, 'WP_REST_Request' ) ) {
1958 945 $input = $input->get_json_params();
1959 946 if ( ! is_array( $input ) ) {
@@ -1978,20 +965,14 @@
1978 965 ? $input_schema['required']
1979 966 : [];
1980 967
1981 968 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;
969 + $type = isset( $prop['type'] ) ? strtolower( $prop['type'] ) : 'string';
970 + $raw_value = array_key_exists( $name, $input ) ? $input[ $name ] : null;
1985 971
1986 - if ( $was_provided ) {
1987 - $this->provided[ $name ] = true;
1988 - }
1989 -
1990 972 $is_required = in_array( $name, $required_fields, true );
1991 973 if ( $is_required && ( null === $raw_value || '' === $raw_value ) ) {
1992 - throw new Ability_Exception(
1993 - 'missing_required_field',
974 + throw new Exception(
1994 975 sprintf(
1995 976 /* translators: %s: field name */
1996 977 esc_html__( 'Required field %s is missing.', 'suredonation' ),
1997 978 esc_html( $name )
@@ -2051,16 +1032,11 @@
2051 1032 $value = $this->sanitize_recursive( $value );
2052 1033 break;
2053 1034 }
2054 1035
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'] ) ) {
1036 + if ( isset( $prop['enum'] ) && is_array( $prop['enum'] ) ) {
2060 1037 if ( ! in_array( $value, $prop['enum'], true ) ) {
2061 - throw new Ability_Exception(
2062 - 'invalid_field_value',
1038 + throw new Exception(
2063 1039 sprintf(
2064 1040 /* translators: %s: field name */
2065 1041 esc_html__( 'Invalid value for %s.', 'suredonation' ),
2066 1042 esc_html( $name )
@@ -2106,75 +1082,38 @@
2106 1082 return $sanitized;
2107 1083 }
2108 1084
2109 1085 /**
2110 - * Convert a caught exception into a WP_Error.
1086 + * Format error response.
2111 1087 *
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 1088 * @param Exception $e The exception.
2119 - * @return WP_Error Error response.
1089 + * @return array<string, mixed> Error response.
2120 1090 */
2121 1091 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 - );
1092 + return [
1093 + 'error' => [
1094 + 'code' => 'suredonation_error',
1095 + 'message' => $e->getMessage(),
1096 + ],
1097 + ];
2136 1098 }
2137 1099
2138 1100 /**
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 1101 * Require a valid campaign post by ID.
2163 1102 *
2164 1103 * @param mixed $id Campaign ID.
2165 1104 * @return \WP_Post The campaign post.
2166 - * @throws Ability_Exception If ID is zero, post not found, or wrong post type.
1105 + * @throws Exception If ID is zero, post not found, or wrong post type.
2167 1106 */
2168 1107 protected function require_campaign( $id ) {
2169 1108 $id = Helper::get_integer_value( $id );
2170 1109 if ( 0 === $id ) {
2171 - throw new Ability_Exception( 'invalid_campaign_id', esc_html__( 'Invalid campaign ID.', 'suredonation' ) );
1110 + throw new Exception( esc_html__( 'Invalid campaign ID.', 'suredonation' ) );
2172 1111 }
2173 1112
2174 1113 $post = get_post( $id );
2175 1114 if ( ! $post || SUREDONATION_POST_TYPE !== $post->post_type ) {
2176 - throw new Ability_Exception( 'campaign_not_found', esc_html__( 'Campaign not found.', 'suredonation' ) );
1115 + throw new Exception( esc_html__( 'Campaign not found.', 'suredonation' ) );
2177 1116 }
2178 1117
2179 1118 return $post;
2180 1119 }
@@ -2183,19 +1122,19 @@
2183 1122 * Require a valid donation by ID.
2184 1123 *
2185 1124 * @param mixed $id Donation ID.
2186 1125 * @return array<string, mixed> The donation record.
2187 - * @throws Ability_Exception If ID is zero or donation not found.
1126 + * @throws Exception If ID is zero or donation not found.
2188 1127 */
2189 1128 protected function require_donation( $id ) {
2190 1129 $id = Helper::get_integer_value( $id );
2191 1130 if ( 0 === $id ) {
2192 - throw new Ability_Exception( 'invalid_donation_id', esc_html__( 'Invalid donation ID.', 'suredonation' ) );
1131 + throw new Exception( esc_html__( 'Invalid donation ID.', 'suredonation' ) );
2193 1132 }
2194 1133
2195 1134 $donation = Donations::get( $id );
2196 1135 if ( ! $donation ) {
2197 - throw new Ability_Exception( 'donation_not_found', esc_html__( 'Donation not found.', 'suredonation' ) );
1136 + throw new Exception( esc_html__( 'Donation not found.', 'suredonation' ) );
2198 1137 }
2199 1138
2200 1139 return $donation;
2201 1140 }
@@ -2204,19 +1143,19 @@
2204 1143 * Require a valid donor by ID.
2205 1144 *
2206 1145 * @param mixed $id Donor ID.
2207 1146 * @return array<string, mixed> The donor record.
2208 - * @throws Ability_Exception If ID is zero or donor not found.
1147 + * @throws Exception If ID is zero or donor not found.
2209 1148 */
2210 1149 protected function require_donor( $id ) {
2211 1150 $id = Helper::get_integer_value( $id );
2212 1151 if ( 0 === $id ) {
2213 - throw new Ability_Exception( 'invalid_donor_id', esc_html__( 'Invalid donor ID.', 'suredonation' ) );
1152 + throw new Exception( esc_html__( 'Invalid donor ID.', 'suredonation' ) );
2214 1153 }
2215 1154
2216 1155 $donor = Donors::get( $id );
2217 1156 if ( ! $donor ) {
2218 - throw new Ability_Exception( 'donor_not_found', esc_html__( 'Donor not found.', 'suredonation' ) );
1157 + throw new Exception( esc_html__( 'Donor not found.', 'suredonation' ) );
2219 1158 }
2220 1159
2221 1160 return $donor;
2222 1161 }
@@ -2242,196 +1181,41 @@
2242 1181 return max( 1, Helper::get_integer_value( $page ) );
2243 1182 }
2244 1183
2245 1184 /**
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 1185 * Format a donation record for ability output.
2348 1186 *
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 1187 * @param array<string, mixed> $donation Raw donation data from database.
2356 1188 * @return array<string, mixed> Formatted donation data.
2357 1189 */
2358 - protected function format_donation( $donation ) {
1190 + private function format_donation( $donation ) {
2359 1191 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
2360 1192 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
2361 1193
2362 - $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
2363 - $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
1194 + $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
2364 1195
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 1196 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 - // Alongside the comment, because the comment alone does not say
2410 - // whether it is public: with "Hold donor comments for review" on it
2411 - // is 'pending' and hidden, and a rejected one is kept but never
2412 - // shown. The REST detail response and the CSV export both carry
2413 - // this, so an agent reading a donation should not be the only
2414 - // consumer that cannot tell.
2415 - 'donor_comment_status' => $donation['donor_comment_status'] ?? 'approved',
2416 - 'payment_status' => $donation['payment_status'] ?? 'pending',
2417 - 'payment_mode' => $donation['payment_mode'] ?? 'test',
2418 - 'gateway' => $donation['gateway'] ?? '',
2419 - 'transaction_id' => $donation['transaction_id'] ?? '',
2420 - 'stripe_customer_id' => $donation['customer_id'] ?? '',
2421 - 'stripe_account_id' => $donation['stripe_account_id'] ?? '',
2422 - 'subscription_id' => $donation['subscription_id'] ?? '',
2423 - 'subscription_status' => $donation['subscription_status'] ?? '',
2424 - 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
2425 - 'subscription_interval' => Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ),
2426 - 'billing_cycles' => Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ),
2427 - 'receipt_sent' => ! empty( $donation['receipt_sent'] ),
2428 - 'receipt_pdf_url' => $donation['receipt_pdf_url'] ?? '',
2429 - 'import_source' => $donation['import_source'] ?? '',
2430 - 'fields' => $submitted_fields,
2431 - 'created_at' => $donation['created_at'] ?? '',
2432 - 'updated_at' => $donation['updated_at'] ?? '',
2433 - 'logs' => $logs,
1197 + 'id' => $donation_id,
1198 + 'campaign_id' => $campaign_id,
1199 + 'campaign_title' => $campaign_id ? wp_kses_post( get_the_title( $campaign_id ) ) : '',
1200 + 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1201 + 'donor_name' => $donation['donor_name'] ?? '',
1202 + 'donor_email' => $donation['donor_email'] ?? '',
1203 + 'donor_phone' => $donation['donor_phone'] ?? '',
1204 + 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1205 + 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1206 + 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1207 + 'currency' => $donation['currency'] ?? 'USD',
1208 + 'donation_type' => $donation['donation_type'] ?? 'one-time',
1209 + 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1210 + 'donor_comment' => $donation['donor_comment'] ?? '',
1211 + 'payment_status' => $donation['payment_status'] ?? 'pending',
1212 + 'payment_mode' => $donation['payment_mode'] ?? 'test',
1213 + 'gateway' => $donation['gateway'] ?? '',
1214 + 'transaction_id' => $donation['transaction_id'] ?? '',
1215 + 'created_at' => $donation['created_at'] ?? '',
1216 + 'updated_at' => $donation['updated_at'] ?? '',
1217 + 'logs' => $logs,
2434 1218 ];
2435 1219 }
2436 1220
2437 1221 /**
@@ -2437,21 +1221,14 @@
2437 1221 /**
2438 1222 * Format a donation form for ability output.
2439 1223 *
2440 1224 * @param \WP_Post $form Form post object.
2441 - * @param array{entries: int, revenue: float}|null $stats Pre-computed totals for this form, or null to query them.
2442 1225 * @return array<string, mixed> Formatted form data.
2443 1226 */
2444 - protected function format_form( $form, $stats = null ) {
1227 + private function format_form( $form ) {
2445 1228 $campaign_id = Donation_Form::get_form_campaign_id( $form->ID );
2446 1229 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
2447 1230
2448 - // A list passes stats it has already batched for the whole page; a
2449 - // single-form read falls back to the one-form query.
2450 - if ( ! is_array( $stats ) ) {
2451 - $stats = Donations::get_form_stats( $form->ID );
2452 - }
2453 -
2454 1231 return [
2455 1232 'id' => $form->ID,
2456 1233 'title' => $form->post_title,
2457 1234 'status' => $form->post_status,
@@ -2456,13 +1233,8 @@
2456 1233 'title' => $form->post_title,
2457 1234 'status' => $form->post_status,
2458 1235 'campaign_id' => $campaign_id,
2459 1236 'campaign_name' => $campaign ? $campaign->post_title : '',
2460 - 'entries' => $stats['entries'],
2461 - 'revenue' => $stats['revenue'],
2462 - // Which form a campaign actually renders, so a caller can tell the
2463 - // live form apart from the others attached to the same campaign.
2464 - 'is_default' => $campaign_id > 0 && Campaign_Cpt::get_default_form_id( $campaign_id ) === (int) $form->ID,
2465 1237 'created_at' => $form->post_date,
2466 1238 'modified_at' => $form->post_modified,
2467 1239 'edit_url' => admin_url( 'post.php?post=' . $form->ID . '&action=edit' ),
2468 1240 ];
@@ -2473,9 +1245,9 @@
2473 1245 *
2474 1246 * @param array<string, mixed> $donor Raw donor data from database.
2475 1247 * @return array<string, mixed> Formatted donor data.
2476 1248 */
2477 - protected function format_donor( $donor ) {
1249 + private function format_donor( $donor ) {
2478 1250 $id_val = $donor['id'] ?? 0;
2479 1251 $user_val = $donor['user_id'] ?? 0;
2480 1252 $donated_val = $donor['total_donated'] ?? 0;
2481 1253 $count_val = $donor['donation_count'] ?? 0;
@@ -2485,11 +1257,8 @@
2485 1257 'id' => is_numeric( $id_val ) ? (int) $id_val : 0,
2486 1258 'name' => $donor['name'] ?? '',
2487 1259 'email' => $donor['email'] ?? '',
2488 1260 'phone' => $donor['phone'] ?? '',
2489 - 'company' => $donor['company'] ?? '',
2490 - 'address' => $donor['address'] ?? '',
2491 - 'stripe_customer_id' => $donor['stripe_customer_id'] ?? '',
2492 1261 'user_id' => is_numeric( $user_val ) ? (int) $user_val : 0,
2493 1262 'donor_status' => $donor['donor_status'] ?? 'active',
2494 1263 'total_donated' => is_numeric( $donated_val ) ? (float) $donated_val : 0.0,
2495 1264 'donation_count' => is_numeric( $count_val ) ? (int) $count_val : 0,
@@ -2507,58 +1276,22 @@
2507 1276 *
2508 1277 * @param \WP_Post $post Campaign post.
2509 1278 * @return array<string, mixed> Formatted campaign data.
2510 1279 */
2511 - protected function format_campaign( $post ) {
1280 + private function format_campaign( $post ) {
2512 1281 $stats = Campaign_Stats::get_stats( $post->ID );
2513 1282 $meta = Helper::get_campaign_meta( $post->ID );
2514 1283
2515 - return array_merge(
2516 - [
2517 - 'id' => $post->ID,
2518 - 'title' => $post->post_title,
2519 - // `status` is the campaign business status (active/paused/
2520 - // completed). `post_status` is the WordPress one — without it a
2521 - // caller cannot tell a draft from a published campaign.
2522 - 'status' => $stats['campaign_status'],
2523 - 'post_status' => $post->post_status,
2524 - 'goal_type' => $meta['goal_type'],
2525 - 'goal' => $stats['goal_amount'],
2526 - 'raised' => $stats['total_raised'],
2527 - 'donors' => $stats['donor_count'],
2528 - 'progress' => $stats['progress_percentage'],
2529 - 'created_at' => $post->post_date,
2530 - 'modified_at' => $post->post_modified,
2531 - ],
2532 - $this->campaign_extras( $post, $meta )
2533 - );
2534 - }
2535 -
2536 - /**
2537 - * Fields shared by the campaign list and detail payloads.
2538 - *
2539 - * Kept separate so list-campaigns and get-campaign cannot drift apart, and
2540 - * so the currency travels with every monetary figure.
2541 - *
2542 - * @param \WP_Post $post Campaign post.
2543 - * @param array<string, mixed> $meta Decoded campaign meta.
2544 - * @return array<string, mixed> Additional campaign fields.
2545 - * @since 1.5.0
2546 - */
2547 - private function campaign_extras( $post, $meta ) {
2548 - $thumbnail_url = get_the_post_thumbnail_url( $post->ID, 'medium' );
2549 -
2550 1284 return [
2551 - // goal/raised are amounts; without a currency code they are ambiguous.
2552 - 'currency' => Payment_Helper::get_currency(),
2553 - 'terms_text' => Helper::get_string_value( $meta['terms_text'] ?? '' ),
2554 - 'thank_you_message' => Helper::get_string_value( $meta['thank_you_message'] ?? '' ),
2555 - 'featured_image' => (int) get_post_thumbnail_id( $post->ID ),
2556 - 'featured_image_url' => is_string( $thumbnail_url ) ? $thumbnail_url : '',
2557 - 'has_page' => Campaign_Page::has_page( $post->ID ),
2558 - 'permalink' => 'publish' === $post->post_status ? (string) get_permalink( $post->ID ) : '',
2559 - 'author' => (string) get_the_author_meta( 'display_name', (int) $post->post_author ),
2560 - 'edit_url' => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ),
2561 - 'default_form_id' => Campaign_Cpt::get_default_form_id( $post->ID ),
1285 + 'id' => $post->ID,
1286 + 'title' => $post->post_title,
1287 + 'status' => $stats['campaign_status'],
1288 + 'goal_type' => $meta['goal_type'],
1289 + 'goal' => $stats['goal_amount'],
1290 + 'raised' => $stats['total_raised'],
1291 + 'donors' => $stats['donor_count'],
1292 + 'progress' => $stats['progress_percentage'],
1293 + 'created_at' => $post->post_date,
1294 + 'modified_at' => $post->post_modified,
2562 1295 ];
2563 1296 }
2564 1297 }