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

donations-api.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/api/donations-api.php

1,368 lines 44.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Donations REST API endpoints.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\API;
9
10 use SureDonation\Inc\Database\Tables\Donations;
11 use SureDonation\Inc\Database\Tables\Donors;
12 use SureDonation\Inc\Emails\Email_Handler;
13 use SureDonation\Inc\Helper;
14 use SureDonation\Inc\Payments\Payment_Helper;
15 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
16 use WP_Error;
17 use WP_REST_Request;
18 use WP_REST_Response;
19 use WP_REST_Server;
20
21 // Exit if accessed directly.
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 /**
27 * Donations API class.
28 *
29 * @since 0.0.1
30 */
31 class Donations_API {
32 /**
33 * Get donation endpoints.
34 *
35 * @return array<string, mixed>
36 * @since 0.0.1
37 */
38 public function get_endpoints() {
39 return [
40 // Get donations list & create donation.
41 '/donations' => [
42 [
43 'methods' => WP_REST_Server::READABLE,
44 'callback' => [ $this, 'get_donations' ],
45 'permission_callback' => [ $this, 'check_permissions' ],
46 'args' => [
47 'after' => [
48 'sanitize_callback' => 'sanitize_text_field',
49 'validate_callback' => [ $this, 'validate_date_param' ],
50 ],
51 'before' => [
52 'sanitize_callback' => 'sanitize_text_field',
53 'validate_callback' => [ $this, 'validate_date_param' ],
54 ],
55 ],
56 ],
57 [
58 'methods' => WP_REST_Server::CREATABLE,
59 'callback' => [ $this, 'create_donation' ],
60 'permission_callback' => [ $this, 'check_permissions' ],
61 'args' => $this->get_donation_args(),
62 ],
63 ],
64
65 // Get, update, delete single donation.
66 '/donations/(?P<id>\d+)' => [
67 [
68 'methods' => WP_REST_Server::READABLE,
69 'callback' => [ $this, 'get_donation' ],
70 'permission_callback' => [ $this, 'check_permissions' ],
71 'args' => [
72 'id' => [
73 'required' => true,
74 'validate_callback' => static function ( $param ) {
75 return is_numeric( $param );
76 },
77 ],
78 ],
79 ],
80 [
81 'methods' => WP_REST_Server::EDITABLE,
82 'callback' => [ $this, 'update_donation' ],
83 'permission_callback' => [ $this, 'check_permissions' ],
84 'args' => array_merge(
85 [
86 'id' => [
87 'required' => true,
88 'validate_callback' => static function ( $param ) {
89 return is_numeric( $param );
90 },
91 ],
92 ],
93 $this->get_donation_args( false )
94 ),
95 ],
96 [
97 'methods' => WP_REST_Server::DELETABLE,
98 'callback' => [ $this, 'delete_donation' ],
99 'permission_callback' => [ $this, 'check_permissions' ],
100 'args' => [
101 'id' => [
102 'required' => true,
103 'validate_callback' => static function ( $param ) {
104 return is_numeric( $param );
105 },
106 ],
107 ],
108 ],
109 ],
110
111 // Update donation status.
112 '/donations/(?P<id>\d+)/status' => [
113 'methods' => WP_REST_Server::EDITABLE,
114 'callback' => [ $this, 'update_donation_status' ],
115 'permission_callback' => [ $this, 'check_permissions' ],
116 'args' => [
117 'id' => [
118 'required' => true,
119 'validate_callback' => static function ( $param ) {
120 return is_numeric( $param );
121 },
122 ],
123 'status' => [
124 'required' => true,
125 'type' => 'string',
126 // Sourced from the table's own whitelist rather than
127 // restated: the two lists had already drifted — suspicious
128 // is written on an amount mismatch and was missing here.
129 'enum' => Donations::get_valid_statuses(),
130 'sanitize_callback' => 'sanitize_text_field',
131 // Required for the enum to be enforced at all. An arg with
132 // a sanitize_callback and no validate_callback has its enum
133 // silently skipped (see #340), so this endpoint answered
134 // "updated successfully" to a status it had refused to
135 // write.
136 'validate_callback' => 'rest_validate_request_arg',
137 ],
138 ],
139 ],
140
141 // Get donations by campaign.
142 '/donations/campaign/(?P<id>\d+)' => [
143 'methods' => WP_REST_Server::READABLE,
144 'callback' => [ $this, 'get_campaign_donations' ],
145 'permission_callback' => [ $this, 'check_permissions' ],
146 'args' => [
147 'id' => [
148 'required' => true,
149 'validate_callback' => static function ( $param ) {
150 return is_numeric( $param );
151 },
152 ],
153 ],
154 ],
155
156 // Bulk actions.
157 '/donations/bulk' => [
158 'methods' => WP_REST_Server::EDITABLE,
159 'callback' => [ $this, 'bulk_action' ],
160 'permission_callback' => [ $this, 'check_permissions' ],
161 'args' => [
162 'action' => [
163 'required' => true,
164 'type' => 'string',
165 'enum' => [ 'delete', 'update_status' ],
166 'sanitize_callback' => 'sanitize_text_field',
167 'validate_callback' => 'rest_validate_request_arg',
168 ],
169 'ids' => [
170 'required' => true,
171 'validate_callback' => static function ( $param ) {
172 return is_array( $param ) && ! empty( $param );
173 },
174 ],
175 'status' => [
176 'type' => 'string',
177 'enum' => Donations::get_valid_statuses(),
178 'sanitize_callback' => 'sanitize_text_field',
179 'validate_callback' => 'rest_validate_request_arg',
180 ],
181 ],
182 ],
183
184 // Refund donation payment.
185 '/donations/(?P<id>\d+)/refund' => [
186 'methods' => WP_REST_Server::CREATABLE,
187 'callback' => [ $this, 'refund_donation' ],
188 'permission_callback' => [ $this, 'check_permissions' ],
189 'args' => [
190 'id' => [
191 'required' => true,
192 'validate_callback' => static function ( $param ) {
193 return is_numeric( $param );
194 },
195 ],
196 'transaction_id' => [
197 'required' => true,
198 'sanitize_callback' => 'sanitize_text_field',
199 ],
200 'refund_amount' => [
201 'required' => true,
202 'sanitize_callback' => 'absint',
203 ],
204 'refund_type' => [
205 'required' => true,
206 'type' => 'string',
207 'enum' => [ 'full', 'partial' ],
208 'sanitize_callback' => 'sanitize_text_field',
209 // The last arg in this file carrying the #340 shape: an
210 // enum that reads as enforced and is not. rest_validate_
211 // request_arg() reads the schema's type, so the type above
212 // is not decoration.
213 'validate_callback' => 'rest_validate_request_arg',
214 ],
215 'refund_notes' => [
216 'sanitize_callback' => 'sanitize_textarea_field',
217 ],
218 ],
219 ],
220
221 // Delete donation log entry.
222 '/donations/(?P<id>\d+)/log/(?P<log_index>\d+)' => [
223 'methods' => WP_REST_Server::DELETABLE,
224 'callback' => [ $this, 'delete_donation_log' ],
225 'permission_callback' => [ $this, 'check_permissions' ],
226 'args' => [
227 'id' => [
228 'required' => true,
229 'validate_callback' => static function ( $param ) {
230 return is_numeric( $param );
231 },
232 ],
233 'log_index' => [
234 'required' => true,
235 'validate_callback' => static function ( $param ) {
236 return is_numeric( $param ) && $param >= 0;
237 },
238 ],
239 ],
240 ],
241
242 // Get and add donation notes.
243 '/donations/(?P<id>\d+)/notes' => [
244 [
245 'methods' => WP_REST_Server::READABLE,
246 'callback' => [ $this, 'get_donation_notes' ],
247 'permission_callback' => [ $this, 'check_permissions' ],
248 'args' => [
249 'id' => [
250 'required' => true,
251 'validate_callback' => static function ( $param ) {
252 return is_numeric( $param );
253 },
254 ],
255 'page' => [
256 'default' => 1,
257 'sanitize_callback' => 'absint',
258 ],
259 'per_page' => [
260 'default' => 3,
261 'sanitize_callback' => 'absint',
262 ],
263 ],
264 ],
265 [
266 'methods' => WP_REST_Server::CREATABLE,
267 'callback' => [ $this, 'add_donation_note' ],
268 'permission_callback' => [ $this, 'check_permissions' ],
269 'args' => [
270 'id' => [
271 'required' => true,
272 'validate_callback' => static function ( $param ) {
273 return is_numeric( $param );
274 },
275 ],
276 'note' => [
277 'required' => true,
278 'sanitize_callback' => 'sanitize_textarea_field',
279 ],
280 ],
281 ],
282 ],
283
284 // Delete donation note.
285 '/donations/(?P<id>\d+)/notes/(?P<note_id>[\w.]+)' => [
286 'methods' => WP_REST_Server::DELETABLE,
287 'callback' => [ $this, 'delete_donation_note' ],
288 'permission_callback' => [ $this, 'check_permissions' ],
289 'args' => [
290 'id' => [
291 'required' => true,
292 'validate_callback' => static function ( $param ) {
293 return is_numeric( $param );
294 },
295 ],
296 'note_id' => [
297 'required' => true,
298 'sanitize_callback' => 'sanitize_text_field',
299 ],
300 ],
301 ],
302 ];
303 }
304
305 /**
306 * Get a single donation by ID.
307 *
308 * @param WP_REST_Request $request Request object.
309 * @return WP_REST_Response|WP_Error Response object.
310 * @since 0.0.1
311 */
312 public function get_donation( $request ) {
313 $donation_id = absint( $request->get_param( 'id' ) );
314
315 // Get the donation from database.
316 $donation = Donations::get( $donation_id );
317
318 if ( ! $donation ) {
319 return new WP_Error(
320 'donation_not_found',
321 __( 'Donation not found.', 'suredonation' ),
322 [ 'status' => 404 ]
323 );
324 }
325
326 // Format and return the donation data.
327 $formatted = $this->format_donation( $donation );
328
329 return new WP_REST_Response(
330 [
331 'success' => true,
332 'donation' => $formatted,
333 ],
334 200
335 );
336 }
337
338 /**
339 * Get donations list with filters, sorting, and pagination.
340 *
341 * @param WP_REST_Request $request Request object.
342 * @return WP_REST_Response|WP_Error Response object.
343 * @since 0.0.1
344 */
345 public function get_donations( $request ) {
346 $page = $request->get_param( 'page' ) ?? 1;
347 // Clamp to a minimum of 1 so the total_pages calculation below can never
348 // divide by zero (per_page=0 would otherwise trigger a DivisionByZeroError).
349 $per_page = max( 1, absint( $request->get_param( 'per_page' ) ?? 20 ) );
350 $search = $request->get_param( 'search' ) ?? '';
351 $status = $request->get_param( 'status' ) ?? 'all';
352 $campaign = $request->get_param( 'campaign' ) ?? '';
353 $donor = $request->get_param( 'donor' ) ?? '';
354 $sort_by = $request->get_param( 'sort_by' ) ?? 'created_at';
355 $order = $request->get_param( 'order' ) ?? 'desc';
356
357 // Calculate pagination.
358 $limit = absint( $per_page );
359 $offset = ( absint( $page ) - 1 ) * $limit;
360
361 // If filtering by donor, use the donor-specific query.
362 if ( ! empty( $donor ) ) {
363 $donor_data = Donations::get_by_donor_id( absint( $donor ), $limit, $offset );
364 $results = $donor_data['donations'];
365 $total = $donor_data['total'];
366 } else {
367 // Get donations from database using admin list method with filters.
368 $results = Donations::get_admin_list(
369 $status,
370 ! empty( $campaign ) ? absint( $campaign ) : 0,
371 sanitize_text_field( $search ),
372 $limit,
373 $offset,
374 $sort_by, // using whitelist validation in the method.
375 strtoupper( $order ) // using whitelist validation in the method.
376 );
377
378 // Get total count.
379 $total = Donations::count_admin_list( $status, ! empty( $campaign ) ? absint( $campaign ) : 0, sanitize_text_field( $search ) );
380 }
381
382 // Format donations data.
383 $donations = [];
384 foreach ( $results as $donation ) {
385 if ( is_array( $donation ) ) {
386 $donations[] = $this->format_donation( $donation );
387 }
388 }
389
390 // Prepare response.
391 return new WP_REST_Response(
392 [
393 'donations' => $donations,
394 'pagination' => [
395 'total' => (int) $total,
396 'total_pages' => (int) ceil( $total / $per_page ),
397 'per_page' => (int) $per_page,
398 'current' => (int) $page,
399 ],
400 ]
401 );
402 }
403
404 /**
405 * Get donations for a specific campaign.
406 *
407 * @param WP_REST_Request $request Request object.
408 * @return WP_REST_Response|WP_Error Response object.
409 * @since 0.0.1
410 */
411 public function get_campaign_donations( $request ) {
412 $campaign_id = absint( $request->get_param( 'id' ) );
413 $limit = absint( $request->get_param( 'limit' ) ?? 5 );
414
415 $results = Donations::get_recent_donations( $campaign_id, $limit );
416
417 $donations = [];
418 foreach ( $results as $donation ) {
419 if ( is_array( $donation ) ) {
420 $donations[] = $this->format_donation( $donation );
421 }
422 }
423
424 return new WP_REST_Response(
425 [
426 'success' => true,
427 'donations' => $donations,
428 ],
429 200
430 );
431 }
432
433 /**
434 * Create a new donation.
435 *
436 * @param WP_REST_Request $request Request object.
437 * @return WP_REST_Response|WP_Error Response object.
438 * @since 0.0.1
439 */
440 public function create_donation( $request ) {
441 $campaign_id = $request->get_param( 'campaign_id' );
442 $donor_name = $request->get_param( 'donor_name' ) ?? '';
443 $donor_email = $request->get_param( 'donor_email' ) ?? '';
444 $donor_phone = $request->get_param( 'donor_phone' ) ?? '';
445 $amount = $request->get_param( 'amount' );
446 $fees_covered = $request->get_param( 'fees_covered' ) ?? 0;
447 $payment_status = $request->get_param( 'payment_status' ) ?? 'pending';
448 $donation_type = $request->get_param( 'donation_type' ) ?? 'one-time';
449 $is_anonymous = $request->get_param( 'is_anonymous' ) ?? false;
450 $donor_comment = $request->get_param( 'donor_comment' ) ?? '';
451 $gateway = $request->get_param( 'gateway' ) ?? 'manual';
452 $transaction_id = $request->get_param( 'transaction_id' ) ?? '';
453
454 // Get or create donor.
455 $donor_id = 0;
456 if ( ! empty( $donor_email ) ) {
457 $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
458 }
459
460 // Build donation data — pro can add subscription fields via filter.
461 $donation_data = [
462 'campaign_id' => $campaign_id,
463 'donor_id' => $donor_id ? $donor_id : 0,
464 'amount' => $amount,
465 'fees_covered' => $fees_covered,
466 'currency' => Payment_Helper::get_currency(),
467 'gateway' => $gateway,
468 'payment_status' => $payment_status,
469 'payment_mode' => Payment_Helper::get_payment_mode(),
470 'donor_name' => $donor_name,
471 'donor_email' => $donor_email,
472 'donor_phone' => $donor_phone,
473 'is_anonymous' => $is_anonymous ? 1 : 0,
474 'donation_type' => $donation_type,
475 'donor_comment' => $donor_comment,
476 'transaction_id' => $transaction_id,
477 ];
478
479 /**
480 * Filter donation data before insertion.
481 *
482 * Pro uses this to add subscription_id, subscription_status, parent_subscription_id.
483 *
484 * @param array<string, mixed> $donation_data Donation data to insert.
485 * @param \WP_REST_Request $request The original REST request.
486 * @since 1.0.0
487 */
488 $donation_data = apply_filters( 'suredonation_create_donation_data', $donation_data, $request );
489
490 // Create the donation in database.
491 $donation_id = Donations::add( $donation_data );
492
493 if ( ! $donation_id ) {
494 return new WP_Error(
495 'create_failed',
496 __( 'Failed to create donation.', 'suredonation' ),
497 [ 'status' => 500 ]
498 );
499 }
500
501 $donation = Donations::get( $donation_id );
502
503 return new WP_REST_Response(
504 [
505 'success' => true,
506 'message' => __( 'Donation created successfully.', 'suredonation' ),
507 'donation' => is_array( $donation ) ? $this->format_donation( $donation ) : [],
508 ],
509 201
510 );
511 }
512
513 /**
514 * Update an existing donation.
515 *
516 * @param WP_REST_Request $request Request object.
517 * @return WP_REST_Response|WP_Error Response object.
518 * @since 0.0.1
519 */
520 public function update_donation( $request ) {
521 $donation_id = absint( $request->get_param( 'id' ) );
522
523 // Check if donation exists.
524 $donation = Donations::get( $donation_id );
525 if ( ! $donation ) {
526 return new WP_Error(
527 'donation_not_found',
528 __( 'Donation not found.', 'suredonation' ),
529 [ 'status' => 404 ]
530 );
531 }
532
533 // Build update data.
534 $update_data = [];
535 $fields = [
536 'campaign_id',
537 'donor_name',
538 'donor_email',
539 'donor_phone',
540 'amount',
541 'fees_covered',
542 'donation_type',
543 'is_anonymous',
544 'donor_comment',
545 'donor_comment_status',
546 'payment_status',
547 'gateway',
548 'transaction_id',
549 ];
550
551 foreach ( $fields as $field ) {
552 $value = $request->get_param( $field );
553 if ( ! is_null( $value ) ) {
554 if ( 'is_anonymous' === $field ) {
555 $update_data[ $field ] = $value ? 1 : 0;
556 } else {
557 $update_data[ $field ] = $value;
558 }
559 }
560 }
561
562 /**
563 * Filter donation update data before saving.
564 *
565 * Pro uses this to add subscription fields to the update.
566 *
567 * @param array<string, mixed> $update_data Data to update.
568 * @param \WP_REST_Request $request The REST request.
569 * @param int $donation_id Donation ID.
570 * @since 1.0.0
571 */
572 $update_data = apply_filters( 'suredonation_update_donation_data', $update_data, $request, $donation_id );
573
574 if ( ! empty( $update_data ) ) {
575 Donations::update( $donation_id, $update_data );
576 }
577
578 $updated_donation = Donations::get( $donation_id );
579
580 return new WP_REST_Response(
581 [
582 'success' => true,
583 'message' => __( 'Donation updated successfully.', 'suredonation' ),
584 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
585 ],
586 200
587 );
588 }
589
590 /**
591 * Update donation payment status.
592 *
593 * @param WP_REST_Request $request Request object.
594 * @return WP_REST_Response|WP_Error Response object.
595 * @since 0.0.1
596 */
597 public function update_donation_status( $request ) {
598 $donation_id = absint( $request->get_param( 'id' ) );
599 $status = $request->get_param( 'status' );
600
601 $donation = Donations::get( $donation_id );
602 if ( ! $donation ) {
603 return new WP_Error(
604 'donation_not_found',
605 __( 'Donation not found.', 'suredonation' ),
606 [ 'status' => 404 ]
607 );
608 }
609
610 $old_status = $donation['payment_status'] ?? 'pending';
611 $updated = Donations::update_status( $donation_id, $status );
612
613 // Strictly false, which is update_status() refusing the value. A 0 is
614 // $wpdb->update() reporting that no row changed, which cannot mean "no
615 // such row" here because the 404 above already proved it exists, and
616 // cannot mean "same status" either because update() always writes
617 // updated_at. Treating both as success is how a refused write looked
618 // like a successful one to every client.
619 //
620 // Note for anyone comparing this with bulk_action(): that path has no
621 // existence check, so a 0 there does mean "no such row" and is
622 // correctly counted as a failure. The two are not in conflict.
623 if ( false === $updated ) {
624 return new WP_Error(
625 'donation_status_not_updated',
626 __( 'The donation status could not be updated.', 'suredonation' ),
627 [ 'status' => 500 ]
628 );
629 }
630
631 // If status changed to completed, update donor stats.
632 //
633 // Guarded, not plain: an admin completing a still-pending donation here
634 // does not stop the gateway webhook arriving for the same row later
635 // (Stripe retries for days), and the webhook's donor block has no
636 // "still pending" check of its own. Without a marker written here, that
637 // webhook would record the same donation a second time and double the
638 // donor's total, count and largest gift.
639 if ( 'completed' !== $old_status && 'completed' === $status ) {
640 if ( ! empty( $donation['donor_id'] ) ) {
641 Donors::record_donation_once( $donation['donor_id'], floatval( $donation['amount'] ), $donation_id );
642 }
643 }
644
645 return new WP_REST_Response(
646 [
647 'success' => true,
648 'message' => __( 'Donation status updated successfully.', 'suredonation' ),
649 ],
650 200
651 );
652 }
653
654 /**
655 * Delete donation.
656 *
657 * @param WP_REST_Request $request Request object.
658 * @return WP_REST_Response|WP_Error Response object.
659 * @since 0.0.1
660 */
661 public function delete_donation( $request ) {
662 $donation_id = absint( $request->get_param( 'id' ) );
663
664 $result = Donations::delete( $donation_id );
665
666 if ( ! $result ) {
667 return new WP_Error(
668 'delete_failed',
669 __( 'Failed to delete donation.', 'suredonation' ),
670 [ 'status' => 500 ]
671 );
672 }
673
674 return new WP_REST_Response(
675 [
676 'success' => true,
677 'message' => __( 'Donation deleted successfully.', 'suredonation' ),
678 ],
679 200
680 );
681 }
682
683 /**
684 * Bulk action on donations.
685 *
686 * @param WP_REST_Request $request Request object.
687 * @return WP_REST_Response|WP_Error Response object.
688 * @since 0.0.1
689 */
690 public function bulk_action( $request ) {
691 $action = $request->get_param( 'action' );
692 $ids = $request->get_param( 'ids' );
693
694 if ( ! is_array( $ids ) ) {
695 $ids = [];
696 }
697
698 // Cap bulk operations at 200 IDs per request. Each ID triggers a
699 // per-row SELECT + DELETE / UPDATE — an arbitrarily large array in one
700 // request would chew through the database serially and time out the
701 // response. 200 is enough headroom for any realistic admin UI
702 // selection; larger jobs should be split client-side (parity with the
703 // donors bulk-action endpoint).
704 if ( count( $ids ) > 200 ) {
705 return new WP_Error(
706 'too_many_items',
707 __( 'Bulk actions are limited to 200 donations per request.', 'suredonation' ),
708 [ 'status' => 400 ]
709 );
710 }
711
712 $success_count = 0;
713 $error_count = 0;
714
715 foreach ( $ids as $id ) {
716 $result = false;
717
718 if ( 'delete' === $action ) {
719 $result = Donations::delete( absint( $id ) );
720 } elseif ( 'update_status' === $action ) {
721 $status = $request->get_param( 'status' );
722 if ( $status ) {
723 $result = Donations::update_status( absint( $id ), $status );
724 }
725 }
726
727 if ( $result ) {
728 ++$success_count;
729 } else {
730 ++$error_count;
731 }
732 }
733
734 return new WP_REST_Response(
735 [
736 'success' => true,
737 'message' => sprintf(
738 // translators: %1$d: success count, %2$d: error count.
739 __( 'Bulk action completed. Success: %1$d, Failed: %2$d', 'suredonation' ),
740 $success_count,
741 $error_count
742 ),
743 'success_count' => $success_count,
744 'error_count' => $error_count,
745 ],
746 200
747 );
748 }
749
750 /**
751 * Refund a donation payment.
752 *
753 * @param WP_REST_Request $request Request object.
754 * @return WP_REST_Response|WP_Error Response object.
755 * @since 0.0.1
756 */
757 public function refund_donation( $request ) {
758 $donation_id = absint( $request->get_param( 'id' ) );
759 $transaction_id = $request->get_param( 'transaction_id' );
760 $refund_amount = absint( $request->get_param( 'refund_amount' ) );
761
762 // Get the donation.
763 $donation = Donations::get( $donation_id );
764 if ( ! $donation ) {
765 return new WP_Error(
766 'donation_not_found',
767 __( 'Donation not found.', 'suredonation' ),
768 [ 'status' => 404 ]
769 );
770 }
771
772 // Verify the donation is in a refundable state.
773 $refundable_statuses = [ 'completed', 'partially_refunded' ];
774 if ( ! in_array( $donation['payment_status'], $refundable_statuses, true ) ) {
775 return new WP_Error(
776 'not_refundable',
777 __( 'Only completed or partially refunded donations can be refunded.', 'suredonation' ),
778 [ 'status' => 400 ]
779 );
780 }
781
782 // Verify transaction ID matches.
783 if ( $transaction_id !== $donation['transaction_id'] ) {
784 return new WP_Error(
785 'transaction_mismatch',
786 __( 'Transaction ID mismatch.', 'suredonation' ),
787 [ 'status' => 400 ]
788 );
789 }
790
791 // Validate refund amount.
792 $gateway = $donation['gateway'] ?? 'stripe';
793 $currency = $donation['currency'] ?? 'USD';
794 $total_amount = $this->amount_to_stripe_format( floatval( $donation['amount'] ), $currency );
795 $refunded_amount = $this->amount_to_stripe_format( floatval( $donation['refunded_amount'] ?? 0 ), $currency );
796 $refundable = $total_amount - $refunded_amount;
797
798 if ( $refund_amount > $refundable ) {
799 return new WP_Error(
800 'exceeds_refundable',
801 sprintf(
802 /* translators: %s: maximum refundable amount */
803 __( 'Refund amount exceeds maximum refundable amount of %s.', 'suredonation' ),
804 $this->amount_from_stripe_format( $refundable, $currency )
805 ),
806 [ 'status' => 400 ]
807 );
808 }
809
810 // Process refund through the appropriate gateway.
811 if ( 'paypal' === $gateway ) {
812 $refund_amount_major = $this->amount_from_stripe_format( $refund_amount, $currency );
813 $refund_result = \SureDonation\Inc\Payments\PayPal\PayPal_Api_Payments::refund_capture(
814 $transaction_id,
815 $refund_amount_major,
816 $currency
817 );
818 } else {
819 // Check if Stripe is connected.
820 if ( ! Stripe_Helper::is_stripe_connected() ) {
821 return new WP_Error(
822 'stripe_not_connected',
823 __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
824 [ 'status' => 400 ]
825 );
826 }
827 $refund_account_id = isset( $donation['stripe_account_id'] ) && is_string( $donation['stripe_account_id'] ) ? $donation['stripe_account_id'] : '';
828 $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer', $refund_account_id );
829 }
830
831 if ( is_wp_error( $refund_result ) ) {
832 return new WP_Error(
833 'refund_failed',
834 $refund_result->get_error_message(),
835 [ 'status' => 500 ]
836 );
837 }
838
839 // Calculate new refunded amount in cents for comparison.
840 $new_refunded_in_cents = $refunded_amount + $refund_amount;
841
842 // Determine new status by comparing in cents to avoid floating point precision issues.
843 $new_status = $new_refunded_in_cents >= $total_amount ? 'refunded' : 'partially_refunded';
844
845 // Convert back to major currency unit for storage.
846 $new_refunded_amount = $this->amount_from_stripe_format( $new_refunded_in_cents, $currency );
847
848 // Store refund in donation_data FIRST (prevents webhook duplicate processing).
849 $refund_id = $refund_result['id'] ?? '';
850 if ( ! empty( $refund_id ) ) {
851 $refund_data = [
852 'refund_id' => $refund_id,
853 'amount' => absint( $refund_amount ),
854 'currency' => strtoupper( $currency ),
855 'status' => $refund_result['status'] ?? 'succeeded',
856 'created' => time(),
857 'reason' => 'requested_by_customer',
858 'refunded_by' => 'admin',
859 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
860 ];
861 Donations::add_refund_to_donation_data( $donation_id, $refund_data );
862 }
863
864 // Update donation record with new status and refunded amount.
865 Donations::update(
866 $donation_id,
867 [
868 'payment_status' => $new_status,
869 'refunded_amount' => $new_refunded_amount,
870 ]
871 );
872
873 // Determine refund type for log message.
874 $refund_type = $new_refunded_in_cents >= $total_amount
875 ? __( 'Full', 'suredonation' )
876 : __( 'Partial', 'suredonation' );
877
878 // Add log entry.
879 Donations::add_log(
880 $donation_id,
881 'refund',
882 sprintf(
883 /* translators: %s: Refund type (Full/Partial) */
884 __( '%s refund processed via admin', 'suredonation' ),
885 $refund_type
886 ),
887 [
888 'refund_id' => $refund_id,
889 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
890 'total_refunded' => $new_refunded_amount,
891 'original_amount' => floatval( $donation['amount'] ),
892 'payment_status' => $new_status,
893 'currency' => strtoupper( $currency ),
894 ]
895 );
896
897 // Send refund email notifications.
898 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
899 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
900 $donation_data = [
901 'id' => $donation_id,
902 'donor_name' => $donation['donor_name'] ?? '',
903 'donor_email' => $donation['donor_email'] ?? '',
904 'amount' => $donation['amount'] ?? 0,
905 'currency' => strtoupper( $currency ),
906 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
907 'donation_type' => $donation['donation_type'] ?? 'one-time',
908 'gateway' => 'stripe',
909 ];
910
911 Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
912
913 // Get updated donation.
914 $updated_donation = Donations::get( $donation_id );
915
916 return new WP_REST_Response(
917 [
918 'success' => true,
919 'message' => __( 'Refund processed successfully.', 'suredonation' ),
920 'refund_id' => $refund_id,
921 'status' => $refund_result['status'] ?? 'succeeded',
922 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
923 ],
924 200
925 );
926 }
927 /**
928 * Check if user has permission to manage donations.
929 *
930 * @return bool True if user has permission.
931 * @since 0.0.1
932 */
933 public function check_permissions() {
934 return current_user_can( 'manage_options' );
935 }
936
937 /**
938 * Delete a log entry from a donation.
939 *
940 * @param WP_REST_Request $request Request object.
941 * @return WP_REST_Response|WP_Error Response object.
942 * @since 0.0.1
943 */
944 public function delete_donation_log( $request ) {
945 $donation_id = absint( $request->get_param( 'id' ) );
946 $log_index = absint( $request->get_param( 'log_index' ) );
947
948 // Get the donation from database.
949 $donation = Donations::get( $donation_id );
950
951 if ( ! $donation ) {
952 return new WP_Error(
953 'donation_not_found',
954 __( 'Donation not found.', 'suredonation' ),
955 [ 'status' => 404 ]
956 );
957 }
958
959 // Get current logs.
960 $logs = Donations::get_log( $donation_id );
961
962 if ( ! is_array( $logs ) || empty( $logs ) ) {
963 return new WP_Error(
964 'no_logs',
965 __( 'No logs found for this donation.', 'suredonation' ),
966 [ 'status' => 404 ]
967 );
968 }
969
970 // Check if log index exists.
971 if ( ! isset( $logs[ $log_index ] ) ) {
972 return new WP_Error(
973 'log_not_found',
974 __( 'Log entry not found.', 'suredonation' ),
975 [ 'status' => 404 ]
976 );
977 }
978
979 // Remove log at specified index.
980 array_splice( $logs, $log_index, 1 );
981
982 // Re-index array to prevent gaps.
983 $logs = array_values( $logs );
984
985 // Update log column with modified logs array.
986 $result = Donations::update( $donation_id, [ 'log' => $logs ] );
987
988 if ( false === $result ) {
989 return new WP_Error(
990 'update_failed',
991 __( 'Failed to delete log entry.', 'suredonation' ),
992 [ 'status' => 500 ]
993 );
994 }
995
996 return new WP_REST_Response(
997 [
998 'success' => true,
999 'message' => __( 'Log entry deleted successfully.', 'suredonation' ),
1000 'logs' => $logs,
1001 ],
1002 200
1003 );
1004 }
1005
1006 /**
1007 * Get notes for a donation.
1008 *
1009 * @param WP_REST_Request $request Request object.
1010 * @return WP_REST_Response|WP_Error Response object.
1011 * @since 0.0.1
1012 */
1013 public function get_donation_notes( $request ) {
1014 $donation_id = absint( $request->get_param( 'id' ) );
1015 $page = absint( $request->get_param( 'page' ) ) ?? 1;
1016 $per_page = absint( $request->get_param( 'per_page' ) ) ?? 3;
1017
1018 // Get the donation from database.
1019 $donation = Donations::get( $donation_id );
1020
1021 if ( ! $donation ) {
1022 return new WP_Error(
1023 'donation_not_found',
1024 __( 'Donation not found.', 'suredonation' ),
1025 [ 'status' => 404 ]
1026 );
1027 }
1028
1029 // Get paginated notes.
1030 $notes_data = Donations::get_notes( $donation_id, $page, $per_page );
1031
1032 return new WP_REST_Response(
1033 [
1034 'success' => true,
1035 'notes' => $notes_data['notes'],
1036 'total' => $notes_data['total'],
1037 'total_pages' => $notes_data['total_pages'],
1038 ],
1039 200
1040 );
1041 }
1042
1043 /**
1044 * Add a note to a donation.
1045 *
1046 * @param WP_REST_Request $request Request object.
1047 * @return WP_REST_Response|WP_Error Response object.
1048 * @since 0.0.1
1049 */
1050 public function add_donation_note( $request ) {
1051 $donation_id = absint( $request->get_param( 'id' ) );
1052 $note = $request->get_param( 'note' );
1053
1054 // Get the donation from database.
1055 $donation = Donations::get( $donation_id );
1056
1057 if ( ! $donation ) {
1058 return new WP_Error(
1059 'donation_not_found',
1060 __( 'Donation not found.', 'suredonation' ),
1061 [ 'status' => 404 ]
1062 );
1063 }
1064
1065 // Add the note.
1066 $result = Donations::add_note( $donation_id, $note, get_current_user_id() );
1067
1068 if ( ! $result['success'] ) {
1069 return new WP_Error(
1070 'note_failed',
1071 __( 'Failed to add note.', 'suredonation' ),
1072 [ 'status' => 500 ]
1073 );
1074 }
1075
1076 return new WP_REST_Response(
1077 [
1078 'success' => true,
1079 'message' => __( 'Note added successfully.', 'suredonation' ),
1080 'note_id' => $result['note_id'],
1081 ],
1082 201
1083 );
1084 }
1085
1086 /**
1087 * Delete a note from a donation.
1088 *
1089 * @param WP_REST_Request $request Request object.
1090 * @return WP_REST_Response|WP_Error Response object.
1091 * @since 0.0.1
1092 */
1093 public function delete_donation_note( $request ) {
1094 $donation_id = absint( $request->get_param( 'id' ) );
1095 $note_id = $request->get_param( 'note_id' );
1096
1097 // Get the donation from database.
1098 $donation = Donations::get( $donation_id );
1099
1100 if ( ! $donation ) {
1101 return new WP_Error(
1102 'donation_not_found',
1103 __( 'Donation not found.', 'suredonation' ),
1104 [ 'status' => 404 ]
1105 );
1106 }
1107
1108 // Delete the note.
1109 $result = Donations::delete_note( $donation_id, $note_id );
1110
1111 if ( ! $result ) {
1112 return new WP_Error(
1113 'note_not_found',
1114 __( 'Note not found or could not be deleted.', 'suredonation' ),
1115 [ 'status' => 404 ]
1116 );
1117 }
1118
1119 return new WP_REST_Response(
1120 [
1121 'success' => true,
1122 'message' => __( 'Note deleted successfully.', 'suredonation' ),
1123 ],
1124 200
1125 );
1126 }
1127
1128 /**
1129 * Get donation arguments schema.
1130 *
1131 * @param bool $required Whether fields are required.
1132 * @return array<string, array<string, mixed>>
1133 * @since 0.0.1
1134 */
1135 private function get_donation_args( $required = true ) {
1136 return [
1137 'campaign_id' => [
1138 'required' => $required,
1139 'sanitize_callback' => 'absint',
1140 ],
1141 'donor_name' => [
1142 'sanitize_callback' => 'sanitize_text_field',
1143 ],
1144 'donor_email' => [
1145 'sanitize_callback' => 'sanitize_email',
1146 ],
1147 'donor_phone' => [
1148 'sanitize_callback' => 'sanitize_text_field',
1149 ],
1150 'amount' => [
1151 'required' => $required,
1152 'sanitize_callback' => static function ( $value ) {
1153 return floatval( $value );
1154 },
1155 ],
1156 'fees_covered' => [
1157 'sanitize_callback' => static function ( $value ) {
1158 return floatval( $value );
1159 },
1160 ],
1161 // No 'default' on this or 'payment_status' below, deliberately. These args
1162 // are shared with the update route, where WordPress fills an absent param
1163 // with its declared default before the callback runs — so update_donation()'
1164 // s `! is_null()` test passes and the field is written even though the
1165 // client never sent it. A partial update (e.g. the Donor Comment panel
1166 // sending only donor_comment_status) therefore reset payment_status to
1167 // 'pending' and donation_type to 'one-time', un-completing the donation and
1168 // downgrading a subscription. create_donation() supplies its own fallbacks
1169 // (`?? 'pending'`, `?? 'one-time'`), so nothing depends on the defaults here.
1170 'donation_type' => [
1171 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1172 'sanitize_callback' => 'sanitize_text_field',
1173 'validate_callback' => static function ( $param ) {
1174 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1175 },
1176 ],
1177 'is_anonymous' => [
1178 'sanitize_callback' => 'rest_sanitize_boolean',
1179 ],
1180 'donor_comment' => [
1181 // sanitize_textarea_field, matching the capture path in
1182 // Payment_Helper::get_mapped_donor_comment(). wp_kses_post() was
1183 // actively destructive here: it parses anything tag-shaped, so a
1184 // moderator saving the comment "a < b and 3 > 2" stored "a <b> 2"
1185 // — losing " and 3 " — and any surviving markup then rendered as
1186 // literal angle brackets, because the campaign page esc_html()s.
1187 // Both sanitizers preserve the donor's newlines.
1188 'sanitize_callback' => 'sanitize_textarea_field',
1189 ],
1190 'donor_comment_status' => [
1191 'enum' => [ 'approved', 'pending', 'rejected' ],
1192 'sanitize_callback' => 'sanitize_text_field',
1193 // A sanitize_callback silently disables `enum` enforcement, so the
1194 // allowed set is checked here too — otherwise any string would reach
1195 // the column and every comment would read as un-approved.
1196 'validate_callback' => static function ( $param ) {
1197 return in_array( $param, Donations::get_valid_comment_statuses(), true );
1198 },
1199 ],
1200 'payment_status' => [
1201 'type' => 'string',
1202 // Deliberately no 'default'. These args are shared with the
1203 // update route, and WordPress fills an absent param with its
1204 // default before the callback runs — so update_donation()'s
1205 // `! is_null()` test passes and the status is overwritten on a
1206 // partial update the client never sent it in. dev carries the
1207 // default; keeping it here would reinstate that bug. See
1208 // Test_Donations_API::test_update_donation_ignores_unsent_fields().
1209 'enum' => Donations::get_valid_statuses(),
1210 'sanitize_callback' => 'sanitize_text_field',
1211 'validate_callback' => 'rest_validate_request_arg',
1212 ],
1213 'gateway' => [
1214 'sanitize_callback' => 'sanitize_text_field',
1215 ],
1216 'transaction_id' => [
1217 'sanitize_callback' => 'sanitize_text_field',
1218 ],
1219 ];
1220 }
1221
1222 /**
1223 * Validate REST date filter parameters.
1224 *
1225 * @param mixed $param Date parameter.
1226 * @return bool Whether the date is valid.
1227 * @since 0.0.1
1228 */
1229 public function validate_date_param( $param ) {
1230 if ( '' === $param || null === $param ) {
1231 return true;
1232 }
1233
1234 return is_string( $param ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $param );
1235 }
1236
1237 /**
1238 * Convert amount to Stripe's smallest currency unit.
1239 *
1240 * @param float $amount Amount in major currency unit.
1241 * @param string $currency Currency code.
1242 * @return int Amount in smallest currency unit.
1243 * @since 0.0.1
1244 */
1245 private function amount_to_stripe_format( $amount, $currency ) {
1246 // Delegates rather than repeating the zero-decimal list: the abilities
1247 // layer guards refunds with Payment_Helper, so a second hardcoded list
1248 // here could disagree with the guard about what a currency's minor unit
1249 // is. Payment_Helper derives it from the currency data table.
1250 return Payment_Helper::amount_to_stripe_format( $amount, $currency );
1251 }
1252
1253 /**
1254 * Convert amount from Stripe's smallest currency unit.
1255 *
1256 * @param int $amount Amount in smallest currency unit.
1257 * @param string $currency Currency code.
1258 * @return float Amount in major currency unit.
1259 * @since 0.0.1
1260 */
1261 private function amount_from_stripe_format( $amount, $currency ) {
1262 return Payment_Helper::amount_from_stripe_format( $amount, $currency );
1263 }
1264
1265 /**
1266 * Format donation data for API response.
1267 *
1268 * @param array<string, mixed> $donation Donation data from database.
1269 * @return array<string, mixed> Formatted donation data.
1270 * @since 0.0.1
1271 */
1272 private function format_donation( $donation ) {
1273 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
1274 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
1275 $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
1276
1277 // Get payment logs for this donation.
1278 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
1279
1280 // Get payment mode for Stripe dashboard URL.
1281 $payment_mode = $donation['payment_mode'] ?? 'test';
1282
1283 $form_edit_url = '';
1284 if ( $form_id && current_user_can( 'edit_post', $form_id ) ) {
1285 $form_edit_url = esc_url_raw( get_edit_post_link( $form_id, 'raw' ) );
1286 }
1287
1288 // Parse donation_data for subscription metadata.
1289 $donation_data = $donation['donation_data'] ?? [];
1290 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1291 $donation_data = json_decode( $donation_data, true );
1292 }
1293 if ( ! is_array( $donation_data ) ) {
1294 $donation_data = [];
1295 }
1296
1297 // Build the persisted submitted fields list (label/value/group). The
1298 // group is the parent block label (e.g. "Address") used to nest
1299 // sub-fields on the entry screen; '' for standalone fields.
1300 $submitted_fields = [];
1301 if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) {
1302 foreach ( $donation_data['fields'] as $field ) {
1303 if ( ! is_array( $field ) ) {
1304 continue;
1305 }
1306 // sanitize_text_field (not esc_html) for REST data: the values are
1307 // already sanitized at write time and React escapes on render, so
1308 // esc_html here would double-encode (e.g. "Cats & Dogs" -> "Cats &amp; Dogs").
1309 $submitted_fields[] = [
1310 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ),
1311 // Checkbox fields store a canonical untranslated token so the
1312 // stored column stays locale-stable; it is translated here, on
1313 // read, for the entry screen. Non-checkbox values pass through.
1314 'value' => sanitize_text_field( Helper::format_checkbox_field_value( $field['value'] ?? '' ) ),
1315 'group' => sanitize_text_field( Helper::get_string_value( $field['group'] ?? '' ) ),
1316 ];
1317 }
1318 }
1319
1320 return [
1321 'id' => $donation_id,
1322 'campaign_id' => $campaign_id,
1323 // Plain-text titles rendered by React (which escapes text nodes and does
1324 // not decode HTML entities). get_the_title() runs wptexturize, whose
1325 // default replacements are entities (e.g. " - " -> "&#8211;"), so decode
1326 // them here; wp_kses_post would leave the entity and it would show raw.
1327 'campaign_title' => $campaign_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $campaign_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1328 'form_id' => $form_id,
1329 'form_title' => $form_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $form_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1330 'form_edit_url' => $form_edit_url,
1331 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1332 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1333 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
1334 'donor_phone' => esc_html( Helper::get_string_value( $donation['donor_phone'] ?? '' ) ),
1335 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1336 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1337 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1338 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1339 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1340 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1341 // Returned raw, unlike its neighbours. The only consumer is the React
1342 // moderation panel, which renders it as a text child and so escapes it
1343 // itself; and DonorCommentSection writes this value straight back on
1344 // Save. Running it through wp_kses_post() here therefore did not
1345 // protect anything — it parsed anything tag-shaped and the moderator
1346 // persisted the parsed result, so "a < b and 3 > 2" was shown as
1347 // "a <b> 2" and saved as "a 2". esc_html() would be just as wrong:
1348 // the panel would display the entities rather than the donor's text.
1349 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
1350 'donor_comment_status' => esc_html( Helper::get_string_value( $donation['donor_comment_status'] ?? 'approved' ) ),
1351 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1352 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1353 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1354 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
1355 'stripe_customer_id' => esc_html( Helper::get_string_value( $donation['customer_id'] ?? '' ) ),
1356 'subscription_id' => esc_html( Helper::get_string_value( $donation['subscription_id'] ?? '' ) ),
1357 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1358 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1359 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1360 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1361 'fields' => $submitted_fields,
1362 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1363 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1364 'logs' => $logs,
1365 ];
1366 }
1367 }
1368