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
suredonation / inc / api / donations-api.php

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

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