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