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

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

906 lines 26.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Donors 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\Helper;
13 use WP_Error;
14 use WP_REST_Request;
15 use WP_REST_Response;
16 use WP_REST_Server;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Donors API class.
25 *
26 * @since 1.0.0
27 */
28 class Donors_API {
29 /**
30 * Get donor endpoints.
31 *
32 * @return array<string, mixed>
33 * @since 1.0.0
34 */
35 public function get_endpoints() {
36 return [
37 // Get donors list.
38 '/donors' => [
39 'methods' => WP_REST_Server::READABLE,
40 'callback' => [ $this, 'get_donors' ],
41 'permission_callback' => [ $this, 'check_permissions' ],
42 'args' => [
43 'page' => [
44 'default' => 1,
45 'sanitize_callback' => 'absint',
46 ],
47 'per_page' => [
48 'default' => 20,
49 'sanitize_callback' => 'absint',
50 ],
51 'search' => [
52 'default' => '',
53 'sanitize_callback' => 'sanitize_text_field',
54 ],
55 'campaign' => [
56 'sanitize_callback' => 'absint',
57 ],
58 'after' => [
59 'sanitize_callback' => 'sanitize_text_field',
60 ],
61 'before' => [
62 'sanitize_callback' => 'sanitize_text_field',
63 ],
64 'sort_by' => [
65 'default' => 'created_at',
66 'sanitize_callback' => 'sanitize_text_field',
67 ],
68 'order' => [
69 'default' => 'desc',
70 'sanitize_callback' => 'sanitize_text_field',
71 ],
72 ],
73 ],
74
75 // Get donor aggregate stats.
76 '/donors/stats' => [
77 'methods' => WP_REST_Server::READABLE,
78 'callback' => [ $this, 'get_donor_stats' ],
79 'permission_callback' => [ $this, 'check_permissions' ],
80 ],
81
82 // Export donors as CSV.
83 '/donors/export' => [
84 'methods' => WP_REST_Server::READABLE,
85 'callback' => [ $this, 'export_donors_csv' ],
86 'permission_callback' => [ $this, 'check_permissions' ],
87 'args' => [
88 'search' => [
89 'default' => '',
90 'sanitize_callback' => 'sanitize_text_field',
91 ],
92 'campaign' => [
93 'sanitize_callback' => 'absint',
94 ],
95 ],
96 ],
97
98 // Bulk actions.
99 '/donors/bulk' => [
100 'methods' => WP_REST_Server::EDITABLE,
101 'callback' => [ $this, 'bulk_action' ],
102 'permission_callback' => [ $this, 'check_permissions' ],
103 'args' => [
104 'action' => [
105 'required' => true,
106 'sanitize_callback' => 'sanitize_text_field',
107 'enum' => [ 'delete', 'update_status' ],
108 ],
109 'ids' => [
110 'required' => true,
111 'validate_callback' => static function ( $param ) {
112 return is_array( $param ) && ! empty( $param );
113 },
114 ],
115 'status' => [
116 'sanitize_callback' => 'sanitize_text_field',
117 ],
118 ],
119 ],
120
121 // Get, update, delete single donor.
122 '/donors/(?P<id>\d+)' => [
123 [
124 'methods' => WP_REST_Server::READABLE,
125 'callback' => [ $this, 'get_donor' ],
126 'permission_callback' => [ $this, 'check_permissions' ],
127 'args' => [
128 'id' => [
129 'required' => true,
130 'validate_callback' => static function ( $param ) {
131 return is_numeric( $param );
132 },
133 ],
134 ],
135 ],
136 [
137 'methods' => WP_REST_Server::EDITABLE,
138 'callback' => [ $this, 'update_donor' ],
139 'permission_callback' => [ $this, 'check_permissions' ],
140 'args' => [
141 'id' => [
142 'required' => true,
143 'validate_callback' => static function ( $param ) {
144 return is_numeric( $param );
145 },
146 ],
147 ],
148 ],
149 [
150 'methods' => WP_REST_Server::DELETABLE,
151 'callback' => [ $this, 'delete_donor' ],
152 'permission_callback' => [ $this, 'check_permissions' ],
153 'args' => [
154 'id' => [
155 'required' => true,
156 'validate_callback' => static function ( $param ) {
157 return is_numeric( $param );
158 },
159 ],
160 ],
161 ],
162 ],
163
164 // Get donor's donation history.
165 '/donors/(?P<id>\d+)/donations' => [
166 'methods' => WP_REST_Server::READABLE,
167 'callback' => [ $this, 'get_donor_donations' ],
168 'permission_callback' => [ $this, 'check_permissions' ],
169 'args' => [
170 'id' => [
171 'required' => true,
172 'validate_callback' => static function ( $param ) {
173 return is_numeric( $param );
174 },
175 ],
176 'page' => [
177 'default' => 1,
178 'sanitize_callback' => 'absint',
179 ],
180 'per_page' => [
181 'default' => 10,
182 'sanitize_callback' => 'absint',
183 ],
184 ],
185 ],
186
187 // Get donor activity (chart data + stats).
188 '/donors/(?P<id>\d+)/activity' => [
189 'methods' => WP_REST_Server::READABLE,
190 'callback' => [ $this, 'get_donor_activity' ],
191 'permission_callback' => [ $this, 'check_permissions' ],
192 'args' => [
193 'id' => [
194 'required' => true,
195 'validate_callback' => static function ( $param ) {
196 return is_numeric( $param );
197 },
198 ],
199 'after' => [
200 'sanitize_callback' => 'sanitize_text_field',
201 ],
202 'before' => [
203 'sanitize_callback' => 'sanitize_text_field',
204 ],
205 ],
206 ],
207
208 // Export single donor as CSV.
209 '/donors/(?P<id>\d+)/export' => [
210 'methods' => WP_REST_Server::READABLE,
211 'callback' => [ $this, 'export_single_donor_csv' ],
212 'permission_callback' => [ $this, 'check_permissions' ],
213 'args' => [
214 'id' => [
215 'required' => true,
216 'validate_callback' => static function ( $param ) {
217 return is_numeric( $param );
218 },
219 ],
220 ],
221 ],
222 ];
223 }
224
225 /**
226 * Get donors list with filters, sorting, and pagination.
227 *
228 * @param WP_REST_Request $request Request object.
229 * @return WP_REST_Response Response object.
230 * @since 1.0.0
231 */
232 public function get_donors( $request ) {
233 $page = $request->get_param( 'page' ) ?? 1;
234 $per_page = $request->get_param( 'per_page' ) ?? 20;
235 $search = $request->get_param( 'search' ) ?? '';
236 $campaign_id = $request->get_param( 'campaign' ) ?? 0;
237 $after = $request->get_param( 'after' ) ?? '';
238 $before = $request->get_param( 'before' ) ?? '';
239 $sort_by = $request->get_param( 'sort_by' ) ?? 'created_at';
240 $order = $request->get_param( 'order' ) ?? 'desc';
241
242 $limit = absint( $per_page );
243 $offset = ( absint( $page ) - 1 ) * $limit;
244
245 $results = Donors::get_admin_list(
246 sanitize_text_field( $search ),
247 ! empty( $campaign_id ) ? absint( $campaign_id ) : 0,
248 'all',
249 $limit,
250 $offset,
251 $sort_by,
252 strtoupper( $order ),
253 sanitize_text_field( $after ),
254 sanitize_text_field( $before )
255 );
256
257 $total = Donors::get_total_donors_filtered(
258 sanitize_text_field( $search ),
259 ! empty( $campaign_id ) ? absint( $campaign_id ) : 0,
260 'all',
261 sanitize_text_field( $after ),
262 sanitize_text_field( $before )
263 );
264
265 $donors = [];
266 foreach ( $results as $donor ) {
267 if ( is_array( $donor ) ) {
268 $donors[] = $this->format_donor( $donor );
269 }
270 }
271
272 return new WP_REST_Response(
273 [
274 'donors' => $donors,
275 'pagination' => [
276 'total' => (int) $total,
277 'total_pages' => (int) ceil( $total / $per_page ),
278 'per_page' => (int) $per_page,
279 'current' => (int) $page,
280 ],
281 ]
282 );
283 }
284
285 /**
286 * Get aggregate donor stats.
287 *
288 * @return WP_REST_Response Response object.
289 * @since 1.0.0
290 */
291 public function get_donor_stats() {
292 $stats = Donors::get_aggregate_stats();
293
294 return new WP_REST_Response(
295 [
296 'success' => true,
297 'stats' => $stats,
298 ],
299 200
300 );
301 }
302
303 /**
304 * Get a single donor by ID.
305 *
306 * @param WP_REST_Request $request Request object.
307 * @return WP_REST_Response|WP_Error Response object.
308 * @since 1.0.0
309 */
310 public function get_donor( $request ) {
311 $donor_id = absint( $request->get_param( 'id' ) );
312 $donor = Donors::get( $donor_id );
313
314 if ( ! $donor ) {
315 return new WP_Error(
316 'donor_not_found',
317 __( 'Donor not found.', 'suredonation' ),
318 [ 'status' => 404 ]
319 );
320 }
321
322 return new WP_REST_Response(
323 [
324 'success' => true,
325 'donor' => $this->format_donor( $donor ),
326 ],
327 200
328 );
329 }
330
331 /**
332 * Update a donor.
333 *
334 * @param WP_REST_Request $request Request object.
335 * @return WP_REST_Response|WP_Error Response object.
336 * @since 1.0.0
337 */
338 public function update_donor( $request ) {
339 $donor_id = absint( $request->get_param( 'id' ) );
340 $donor = Donors::get( $donor_id );
341
342 if ( ! $donor ) {
343 return new WP_Error(
344 'donor_not_found',
345 __( 'Donor not found.', 'suredonation' ),
346 [ 'status' => 404 ]
347 );
348 }
349
350 $update_data = [];
351
352 $name = $request->get_param( 'name' );
353 if ( ! is_null( $name ) ) {
354 $update_data['name'] = sanitize_text_field( $name );
355 }
356
357 $email = $request->get_param( 'email' );
358 if ( ! is_null( $email ) ) {
359 $sanitized_email = sanitize_email( $email );
360 if ( empty( $sanitized_email ) ) {
361 return new WP_Error(
362 'invalid_email',
363 __( 'Please provide a valid email address.', 'suredonation' ),
364 [ 'status' => 400 ]
365 );
366 }
367 $update_data['email'] = $sanitized_email;
368 }
369
370 $phone = $request->get_param( 'phone' );
371 if ( ! is_null( $phone ) ) {
372 $update_data['phone'] = sanitize_text_field( $phone );
373 }
374
375 $company = $request->get_param( 'company' );
376 if ( ! is_null( $company ) ) {
377 $update_data['company'] = sanitize_text_field( $company );
378 }
379
380 $address = $request->get_param( 'address' );
381 if ( ! is_null( $address ) ) {
382 $update_data['address'] = sanitize_textarea_field( $address );
383 }
384
385 $donor_status = $request->get_param( 'donor_status' );
386 if ( ! is_null( $donor_status ) ) {
387 if ( ! in_array( $donor_status, Donors::get_valid_statuses(), true ) ) {
388 return new WP_Error(
389 'invalid_status',
390 __( 'Invalid donor status.', 'suredonation' ),
391 [ 'status' => 400 ]
392 );
393 }
394 $update_data['donor_status'] = $donor_status;
395 }
396
397 $donor_tags = $request->get_param( 'donor_tags' );
398 if ( ! is_null( $donor_tags ) ) {
399 $update_data['donor_tags'] = is_array( $donor_tags )
400 ? array_map( 'sanitize_text_field', $donor_tags )
401 : [];
402 }
403
404 if ( ! empty( $update_data ) ) {
405 Donors::update( $donor_id, $update_data );
406 }
407
408 $updated_donor = Donors::get( $donor_id );
409
410 return new WP_REST_Response(
411 [
412 'success' => true,
413 'message' => __( 'Donor updated successfully.', 'suredonation' ),
414 'donor' => is_array( $updated_donor ) ? $this->format_donor( $updated_donor ) : [],
415 ],
416 200
417 );
418 }
419
420 /**
421 * Delete a donor.
422 *
423 * @param WP_REST_Request $request Request object.
424 * @return WP_REST_Response|WP_Error Response object.
425 * @since 1.0.0
426 */
427 public function delete_donor( $request ) {
428 $donor_id = absint( $request->get_param( 'id' ) );
429 $result = Donors::delete( $donor_id );
430
431 if ( ! $result ) {
432 return new WP_Error(
433 'delete_failed',
434 __( 'Failed to delete donor.', 'suredonation' ),
435 [ 'status' => 500 ]
436 );
437 }
438
439 return new WP_REST_Response(
440 [
441 'success' => true,
442 'message' => __( 'Donor deleted successfully.', 'suredonation' ),
443 ],
444 200
445 );
446 }
447
448 /**
449 * Bulk action on donors.
450 *
451 * @param WP_REST_Request $request Request object.
452 * @return WP_REST_Response|WP_Error Response object.
453 * @since 1.0.0
454 */
455 public function bulk_action( $request ) {
456 $action = $request->get_param( 'action' );
457 $ids = $request->get_param( 'ids' );
458
459 if ( ! is_array( $ids ) ) {
460 $ids = [];
461 }
462
463 // Cap bulk operations at 200 IDs per request. Each ID triggers a
464 // per-row SELECT + DELETE / UPDATE — 100k IDs in one request would
465 // chew through the database serially and time out the response.
466 // 200 is enough headroom for any realistic admin UI selection;
467 // larger jobs should be split client-side.
468 if ( count( $ids ) > 200 ) {
469 return new WP_Error(
470 'too_many_items',
471 __( 'Bulk actions are limited to 200 donors per request.', 'suredonation' ),
472 [ 'status' => 400 ]
473 );
474 }
475
476 $success_count = 0;
477 $error_count = 0;
478
479 foreach ( $ids as $id ) {
480 $id = absint( $id );
481 if ( ! $id ) {
482 ++$error_count;
483 continue;
484 }
485
486 switch ( $action ) {
487 case 'delete':
488 $result = Donors::delete( $id );
489 break;
490 case 'update_status':
491 $status = $request->get_param( 'status' );
492 if ( ! $status || ! in_array( $status, Donors::get_valid_statuses(), true ) ) {
493 ++$error_count;
494 continue 2;
495 }
496 $result = Donors::update( $id, [ 'donor_status' => $status ] );
497 break;
498 default:
499 $result = false;
500 break;
501 }
502
503 if ( false !== $result ) {
504 ++$success_count;
505 } else {
506 ++$error_count;
507 }
508 }
509
510 return new WP_REST_Response(
511 [
512 'success' => true,
513 'message' => sprintf(
514 // translators: %1$d is the success count, %2$d is the error count.
515 __( '%1$d donor(s) processed, %2$d error(s).', 'suredonation' ),
516 $success_count,
517 $error_count
518 ),
519 ],
520 200
521 );
522 }
523
524 /**
525 * Get paginated donation history for a donor.
526 *
527 * @param WP_REST_Request $request Request object.
528 * @return WP_REST_Response|WP_Error Response object.
529 * @since 1.0.0
530 */
531 public function get_donor_donations( $request ) {
532 $donor_id = absint( $request->get_param( 'id' ) );
533 $page = absint( $request->get_param( 'page' ) );
534 $page = $page > 0 ? $page : 1;
535 $per_page = absint( $request->get_param( 'per_page' ) );
536 $per_page = $per_page > 0 ? $per_page : 10;
537
538 $donor = Donors::get( $donor_id );
539 if ( ! $donor ) {
540 return new WP_Error(
541 'donor_not_found',
542 __( 'Donor not found.', 'suredonation' ),
543 [ 'status' => 404 ]
544 );
545 }
546
547 $offset = ( $page - 1 ) * $per_page;
548 $data = Donations::get_by_donor_id( $donor_id, $per_page, $offset );
549
550 $donations = [];
551 foreach ( $data['donations'] as $donation ) {
552 if ( is_array( $donation ) ) {
553 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
554 $donations[] = [
555 'id' => isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0,
556 'campaign_id' => $campaign_id,
557 'campaign_title' => $campaign_id ? wp_kses_post( (string) get_the_title( $campaign_id ) ) : '',
558 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
559 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
560 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
561 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
562 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
563 ];
564 }
565 }
566
567 return new WP_REST_Response(
568 [
569 'success' => true,
570 'donations' => $donations,
571 'pagination' => [
572 'total' => $data['total'],
573 'total_pages' => (int) ceil( $data['total'] / $per_page ),
574 'per_page' => $per_page,
575 'current' => $page,
576 ],
577 ],
578 200
579 );
580 }
581
582 /**
583 * Get donor activity data (chart + stats).
584 *
585 * @param WP_REST_Request $request Request object.
586 * @return WP_REST_Response|WP_Error Response object.
587 * @since 1.0.0
588 */
589 public function get_donor_activity( $request ) {
590 $donor_id = absint( $request->get_param( 'id' ) );
591 $after = $request->get_param( 'after' ) ?? '';
592 $before = $request->get_param( 'before' ) ?? '';
593
594 $donor = Donors::get( $donor_id );
595 if ( ! $donor ) {
596 return new WP_Error(
597 'donor_not_found',
598 __( 'Donor not found.', 'suredonation' ),
599 [ 'status' => 404 ]
600 );
601 }
602
603 $activity = Donations::get_donor_activity( $donor_id, sanitize_text_field( $after ), sanitize_text_field( $before ) );
604
605 return new WP_REST_Response(
606 [
607 'success' => true,
608 'chart_data' => $activity['chart_data'],
609 'stats' => $activity['stats'],
610 ],
611 200
612 );
613 }
614
615 /**
616 * Export donors as CSV.
617 *
618 * @param WP_REST_Request $request Request object.
619 * @return WP_REST_Response|WP_Error Response object.
620 * @since 1.0.0
621 */
622 public function export_donors_csv( $request ) {
623 $search = $request->get_param( 'search' ) ?? '';
624 $campaign_id = $request->get_param( 'campaign' ) ?? 0;
625
626 // Hard cap at 10k rows per export. Total matching count is computed
627 // up-front so the response can signal truncation — admins acting on
628 // the exported file shouldn't have to guess whether it was complete.
629 $export_cap = 10000;
630 $total_count = Donors::get_total_donors_filtered(
631 sanitize_text_field( $search ),
632 ! empty( $campaign_id ) ? absint( $campaign_id ) : 0,
633 'all'
634 );
635 $truncated = $total_count > $export_cap;
636
637 // Get all matching donors (no pagination limit for export).
638 $donors = Donors::get_admin_list(
639 sanitize_text_field( $search ),
640 ! empty( $campaign_id ) ? absint( $campaign_id ) : 0,
641 'all',
642 $export_cap,
643 0,
644 'created_at',
645 'DESC'
646 );
647
648 $csv_lines = [];
649 $csv_lines[] = [
650 __( 'ID', 'suredonation' ),
651 __( 'Name', 'suredonation' ),
652 __( 'Email', 'suredonation' ),
653 __( 'Phone', 'suredonation' ),
654 __( 'Company', 'suredonation' ),
655 __( 'Address', 'suredonation' ),
656 __( 'Status', 'suredonation' ),
657 __( 'Total Donated', 'suredonation' ),
658 __( 'Donation Count', 'suredonation' ),
659 __( 'Largest Donation', 'suredonation' ),
660 __( 'First Donation', 'suredonation' ),
661 __( 'Last Donation', 'suredonation' ),
662 __( 'Created At', 'suredonation' ),
663 ];
664
665 foreach ( $donors as $donor ) {
666 if ( ! is_array( $donor ) ) {
667 continue;
668 }
669 $csv_lines[] = [
670 $donor['id'] ?? '',
671 $this->sanitize_csv_value( $donor['name'] ?? '' ),
672 $this->sanitize_csv_value( $donor['email'] ?? '' ),
673 $this->sanitize_csv_value( $donor['phone'] ?? '' ),
674 $this->sanitize_csv_value( $donor['company'] ?? '' ),
675 $this->sanitize_csv_value( $donor['address'] ?? '' ),
676 $this->sanitize_csv_value( $donor['donor_status'] ?? '' ),
677 $donor['total_donated'] ?? 0,
678 $donor['donation_count'] ?? 0,
679 $donor['largest_donation'] ?? 0,
680 $donor['first_donation_date'] ?? '',
681 $donor['last_donation_date'] ?? '',
682 $donor['created_at'] ?? '',
683 ];
684 }
685
686 // Build CSV string using php://temp (in-memory stream, not filesystem).
687 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Writing to in-memory stream, not filesystem.
688 $output = fopen( 'php://temp', 'r+' );
689 if ( false === $output ) {
690 return new WP_Error(
691 'export_failed',
692 __( 'Failed to generate CSV.', 'suredonation' ),
693 [ 'status' => 500 ]
694 );
695 }
696
697 foreach ( $csv_lines as $line ) {
698 fputcsv( $output, $line );
699 }
700
701 rewind( $output );
702 $csv_content = stream_get_contents( $output );
703 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing in-memory stream.
704 fclose( $output );
705
706 return new WP_REST_Response(
707 [
708 'success' => true,
709 'csv' => $csv_content,
710 'filename' => 'suredonation-donors-export-' . gmdate( 'Y-m-d' ) . '.csv',
711 'truncated' => $truncated,
712 'total_count' => $total_count,
713 'exported' => count( $donors ),
714 ],
715 200
716 );
717 }
718
719 /**
720 * Export a single donor's donation history as CSV.
721 *
722 * @param WP_REST_Request $request Request object.
723 * @return WP_REST_Response|WP_Error Response object.
724 * @since 1.0.0
725 */
726 public function export_single_donor_csv( $request ) {
727 $donor_id = absint( $request->get_param( 'id' ) );
728 $donor = Donors::get( $donor_id );
729
730 if ( ! $donor ) {
731 return new WP_Error(
732 'donor_not_found',
733 __( 'Donor not found.', 'suredonation' ),
734 [ 'status' => 404 ]
735 );
736 }
737
738 $formatted = $this->format_donor( $donor );
739
740 // Get all donations for this donor.
741 $donations_data = Donations::get_by_donor_id( $donor_id, 10000, 0 );
742
743 // Build CSV using php://temp (in-memory stream, not filesystem).
744 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Writing to in-memory stream, not filesystem.
745 $output = fopen( 'php://temp', 'r+' );
746 if ( false === $output ) {
747 return new WP_Error(
748 'export_failed',
749 __( 'Failed to generate CSV.', 'suredonation' ),
750 [ 'status' => 500 ]
751 );
752 }
753
754 // Header row.
755 fputcsv(
756 $output,
757 [
758 __( 'ID', 'suredonation' ),
759 __( 'Campaign', 'suredonation' ),
760 __( 'Amount', 'suredonation' ),
761 __( 'Currency', 'suredonation' ),
762 __( 'Status', 'suredonation' ),
763 __( 'Type', 'suredonation' ),
764 __( 'Payment Method', 'suredonation' ),
765 __( 'Date', 'suredonation' ),
766 ]
767 );
768
769 foreach ( $donations_data['donations'] as $donation ) {
770 if ( ! is_array( $donation ) ) {
771 continue;
772 }
773 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
774 $gateway = Helper::get_string_value( $donation['gateway'] ?? '' );
775 $campaign_title = $campaign_id ? wp_strip_all_tags( (string) get_the_title( $campaign_id ) ) : '';
776
777 // @phpstan-var array<int, string|int|float> $row
778 $row = [
779 Helper::get_string_value( $donation['id'] ?? '' ),
780 $this->sanitize_csv_value( $campaign_title ),
781 Helper::get_float_value( $donation['amount'] ?? 0 ),
782 Helper::get_string_value( $donation['currency'] ?? 'USD' ),
783 Helper::get_string_value( $donation['payment_status'] ?? '' ),
784 Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ),
785 $this->sanitize_csv_value( ucfirst( $gateway ) ),
786 Helper::get_string_value( $donation['created_at'] ?? '' ),
787 ];
788 fputcsv( $output, $row );
789 }
790
791 rewind( $output );
792 $csv_content = stream_get_contents( $output );
793 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing in-memory stream.
794 fclose( $output );
795
796 $donor_name = Helper::get_string_value( $formatted['name'] ?? '' );
797 $slug = sanitize_title( ! empty( $donor_name ) ? $donor_name : 'donor-' . $donor_id );
798
799 return new WP_REST_Response(
800 [
801 'success' => true,
802 'csv' => $csv_content,
803 'filename' => 'donor-' . $slug . '-' . gmdate( 'Y-m-d' ) . '.csv',
804 ],
805 200
806 );
807 }
808
809 /**
810 * Check if user has permission to manage donors.
811 *
812 * For state-changing methods (POST/PUT/PATCH/DELETE), also verifies the
813 * WP REST nonce so a logged-in admin's session can't be CSRF'd by a
814 * cross-site fetch into bulk-deleting donors or editing PII. Default WP
815 * REST cookie-auth doesn't enforce nonces — it only uses them to
816 * authenticate, not to gate writes — so the explicit check belongs here.
817 *
818 * @param \WP_REST_Request|null $request REST request (passed by
819 * permission_callback).
820 * @return bool|\WP_Error True if user has permission.
821 * @since 1.0.0
822 */
823 public function check_permissions( $request = null ) {
824 if ( ! current_user_can( 'manage_options' ) ) {
825 return false;
826 }
827
828 if ( $request instanceof \WP_REST_Request ) {
829 $method = strtoupper( $request->get_method() );
830 if ( in_array( $method, [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
831 $nonce = $request->get_header( 'X-WP-Nonce' );
832 if ( empty( $nonce ) ) {
833 $nonce_param = $request->get_param( '_wpnonce' );
834 $nonce = is_string( $nonce_param ) ? $nonce_param : '';
835 }
836 if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
837 return new \WP_Error(
838 'rest_forbidden',
839 __( 'Invalid or missing nonce.', 'suredonation' ),
840 [ 'status' => 403 ]
841 );
842 }
843 }
844 }
845
846 return true;
847 }
848
849 /**
850 * Sanitize a value for safe CSV output.
851 *
852 * Prevents CSV injection by prefixing formula-triggering characters
853 * with a single quote, which neutralizes them in spreadsheet applications.
854 *
855 * @param string $value The value to sanitize.
856 * @return string Sanitized value.
857 * @since 1.0.0
858 */
859 private function sanitize_csv_value( string $value ) {
860 // Strip leading whitespace before inspecting the first character —
861 // Excel still interprets " =cmd|..." as a formula even with leading
862 // spaces. Also catches `|` and `%` which trigger DDE in some Excel
863 // locales beyond the standard formula-prefix set.
864 $stripped = ltrim( $value );
865 if ( '' !== $stripped && in_array( $stripped[0], [ '=', '+', '-', '@', '|', '%', "\t", "\r" ], true ) ) {
866 $value = "'" . $value;
867 }
868 return $value;
869 }
870
871 /**
872 * Format donor data for API response.
873 *
874 * @param array<string, mixed> $donor Donor data from database.
875 * @return array<string, mixed> Formatted donor data.
876 * @since 1.0.0
877 */
878 private function format_donor( $donor ) {
879 // Return raw (validated/sanitized at storage) strings in the JSON
880 // payload. React JSX renders text nodes with built-in escaping, so
881 // server-side esc_html() would double-escape — a donor named
882 // "Smith & Co" would display as "Smith &amp; Co". esc_html is still
883 // the right tool for HTML-only output paths (admin notices,
884 // server-rendered templates), just not JSON-bound REST responses.
885 return [
886 'id' => isset( $donor['id'] ) ? Helper::get_integer_value( $donor['id'] ) : 0,
887 'email' => sanitize_email( Helper::get_string_value( $donor['email'] ?? '' ) ),
888 'name' => Helper::get_string_value( $donor['name'] ?? '' ),
889 'phone' => Helper::get_string_value( $donor['phone'] ?? '' ),
890 'company' => Helper::get_string_value( $donor['company'] ?? '' ),
891 'address' => Helper::get_string_value( $donor['address'] ?? '' ),
892 'user_id' => isset( $donor['user_id'] ) ? Helper::get_integer_value( $donor['user_id'] ) : 0,
893 'total_donated' => Helper::get_float_value( $donor['total_donated'] ?? 0 ),
894 'donation_count' => isset( $donor['donation_count'] ) ? Helper::get_integer_value( $donor['donation_count'] ) : 0,
895 'largest_donation' => Helper::get_float_value( $donor['largest_donation'] ?? 0 ),
896 'first_donation_date' => Helper::get_string_value( $donor['first_donation_date'] ?? '' ),
897 'last_donation_date' => Helper::get_string_value( $donor['last_donation_date'] ?? '' ),
898 'donor_tags' => is_array( $donor['donor_tags'] ?? null ) ? array_values( array_filter( array_map( [ Helper::class, 'get_string_value' ], $donor['donor_tags'] ) ) ) : [],
899 'donor_status' => Helper::get_string_value( $donor['donor_status'] ?? 'active' ),
900 'stripe_customer_id' => Helper::get_string_value( $donor['stripe_customer_id'] ?? '' ),
901 'created_at' => Helper::get_string_value( $donor['created_at'] ?? '' ),
902 'updated_at' => Helper::get_string_value( $donor['updated_at'] ?? '' ),
903 ];
904 }
905 }
906