PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.3.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.3.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.3.0, at inc/api/donations-api.php

1,282 lines 39.5 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 'sanitize_callback' => 'sanitize_text_field',
126 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
127 ],
128 ],
129 ],
130
131 // Get donations by campaign.
132 '/donations/campaign/(?P<id>\d+)' => [
133 'methods' => WP_REST_Server::READABLE,
134 'callback' => [ $this, 'get_campaign_donations' ],
135 'permission_callback' => [ $this, 'check_permissions' ],
136 'args' => [
137 'id' => [
138 'required' => true,
139 'validate_callback' => static function ( $param ) {
140 return is_numeric( $param );
141 },
142 ],
143 ],
144 ],
145
146 // Bulk actions.
147 '/donations/bulk' => [
148 'methods' => WP_REST_Server::EDITABLE,
149 'callback' => [ $this, 'bulk_action' ],
150 'permission_callback' => [ $this, 'check_permissions' ],
151 'args' => [
152 'action' => [
153 'required' => true,
154 'sanitize_callback' => 'sanitize_text_field',
155 'enum' => [ 'delete', 'update_status' ],
156 ],
157 'ids' => [
158 'required' => true,
159 'validate_callback' => static function ( $param ) {
160 return is_array( $param ) && ! empty( $param );
161 },
162 ],
163 'status' => [
164 'sanitize_callback' => 'sanitize_text_field',
165 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
166 ],
167 ],
168 ],
169
170 // Refund donation payment.
171 '/donations/(?P<id>\d+)/refund' => [
172 'methods' => WP_REST_Server::CREATABLE,
173 'callback' => [ $this, 'refund_donation' ],
174 'permission_callback' => [ $this, 'check_permissions' ],
175 'args' => [
176 'id' => [
177 'required' => true,
178 'validate_callback' => static function ( $param ) {
179 return is_numeric( $param );
180 },
181 ],
182 'transaction_id' => [
183 'required' => true,
184 'sanitize_callback' => 'sanitize_text_field',
185 ],
186 'refund_amount' => [
187 'required' => true,
188 'sanitize_callback' => 'absint',
189 ],
190 'refund_type' => [
191 'required' => true,
192 'sanitize_callback' => 'sanitize_text_field',
193 'enum' => [ 'full', 'partial' ],
194 ],
195 'refund_notes' => [
196 'sanitize_callback' => 'sanitize_textarea_field',
197 ],
198 ],
199 ],
200
201 // Delete donation log entry.
202 '/donations/(?P<id>\d+)/log/(?P<log_index>\d+)' => [
203 'methods' => WP_REST_Server::DELETABLE,
204 'callback' => [ $this, 'delete_donation_log' ],
205 'permission_callback' => [ $this, 'check_permissions' ],
206 'args' => [
207 'id' => [
208 'required' => true,
209 'validate_callback' => static function ( $param ) {
210 return is_numeric( $param );
211 },
212 ],
213 'log_index' => [
214 'required' => true,
215 'validate_callback' => static function ( $param ) {
216 return is_numeric( $param ) && $param >= 0;
217 },
218 ],
219 ],
220 ],
221
222 // Get and add donation notes.
223 '/donations/(?P<id>\d+)/notes' => [
224 [
225 'methods' => WP_REST_Server::READABLE,
226 'callback' => [ $this, 'get_donation_notes' ],
227 'permission_callback' => [ $this, 'check_permissions' ],
228 'args' => [
229 'id' => [
230 'required' => true,
231 'validate_callback' => static function ( $param ) {
232 return is_numeric( $param );
233 },
234 ],
235 'page' => [
236 'default' => 1,
237 'sanitize_callback' => 'absint',
238 ],
239 'per_page' => [
240 'default' => 3,
241 'sanitize_callback' => 'absint',
242 ],
243 ],
244 ],
245 [
246 'methods' => WP_REST_Server::CREATABLE,
247 'callback' => [ $this, 'add_donation_note' ],
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 'note' => [
257 'required' => true,
258 'sanitize_callback' => 'sanitize_textarea_field',
259 ],
260 ],
261 ],
262 ],
263
264 // Delete donation note.
265 '/donations/(?P<id>\d+)/notes/(?P<note_id>[\w.]+)' => [
266 'methods' => WP_REST_Server::DELETABLE,
267 'callback' => [ $this, 'delete_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_id' => [
277 'required' => true,
278 'sanitize_callback' => 'sanitize_text_field',
279 ],
280 ],
281 ],
282 ];
283 }
284
285 /**
286 * Get a single donation by ID.
287 *
288 * @param WP_REST_Request $request Request object.
289 * @return WP_REST_Response|WP_Error Response object.
290 * @since 0.0.1
291 */
292 public function get_donation( $request ) {
293 $donation_id = absint( $request->get_param( 'id' ) );
294
295 // Get the donation from database.
296 $donation = Donations::get( $donation_id );
297
298 if ( ! $donation ) {
299 return new WP_Error(
300 'donation_not_found',
301 __( 'Donation not found.', 'suredonation' ),
302 [ 'status' => 404 ]
303 );
304 }
305
306 // Format and return the donation data.
307 $formatted = $this->format_donation( $donation );
308
309 return new WP_REST_Response(
310 [
311 'success' => true,
312 'donation' => $formatted,
313 ],
314 200
315 );
316 }
317
318 /**
319 * Get donations list with filters, sorting, and pagination.
320 *
321 * @param WP_REST_Request $request Request object.
322 * @return WP_REST_Response|WP_Error Response object.
323 * @since 0.0.1
324 */
325 public function get_donations( $request ) {
326 $page = $request->get_param( 'page' ) ?? 1;
327 // Clamp to a minimum of 1 so the total_pages calculation below can never
328 // divide by zero (per_page=0 would otherwise trigger a DivisionByZeroError).
329 $per_page = max( 1, absint( $request->get_param( 'per_page' ) ?? 20 ) );
330 $search = $request->get_param( 'search' ) ?? '';
331 $status = $request->get_param( 'status' ) ?? 'all';
332 $campaign = $request->get_param( 'campaign' ) ?? '';
333 $donor = $request->get_param( 'donor' ) ?? '';
334 $sort_by = $request->get_param( 'sort_by' ) ?? 'created_at';
335 $order = $request->get_param( 'order' ) ?? 'desc';
336
337 // Calculate pagination.
338 $limit = absint( $per_page );
339 $offset = ( absint( $page ) - 1 ) * $limit;
340
341 // If filtering by donor, use the donor-specific query.
342 if ( ! empty( $donor ) ) {
343 $donor_data = Donations::get_by_donor_id( absint( $donor ), $limit, $offset );
344 $results = $donor_data['donations'];
345 $total = $donor_data['total'];
346 } else {
347 // Get donations from database using admin list method with filters.
348 $results = Donations::get_admin_list(
349 $status,
350 ! empty( $campaign ) ? absint( $campaign ) : 0,
351 sanitize_text_field( $search ),
352 $limit,
353 $offset,
354 $sort_by, // using whitelist validation in the method.
355 strtoupper( $order ) // using whitelist validation in the method.
356 );
357
358 // Get total count.
359 $total = Donations::get_total_donations_by_status( $status, ! empty( $campaign ) ? absint( $campaign ) : 0 );
360 }
361
362 // Format donations data.
363 $donations = [];
364 foreach ( $results as $donation ) {
365 if ( is_array( $donation ) ) {
366 $donations[] = $this->format_donation( $donation );
367 }
368 }
369
370 // Prepare response.
371 return new WP_REST_Response(
372 [
373 'donations' => $donations,
374 'pagination' => [
375 'total' => (int) $total,
376 'total_pages' => (int) ceil( $total / $per_page ),
377 'per_page' => (int) $per_page,
378 'current' => (int) $page,
379 ],
380 ]
381 );
382 }
383
384 /**
385 * Get donations for a specific campaign.
386 *
387 * @param WP_REST_Request $request Request object.
388 * @return WP_REST_Response|WP_Error Response object.
389 * @since 0.0.1
390 */
391 public function get_campaign_donations( $request ) {
392 $campaign_id = absint( $request->get_param( 'id' ) );
393 $limit = absint( $request->get_param( 'limit' ) ?? 5 );
394
395 $results = Donations::get_recent_donations( $campaign_id, $limit );
396
397 $donations = [];
398 foreach ( $results as $donation ) {
399 if ( is_array( $donation ) ) {
400 $donations[] = $this->format_donation( $donation );
401 }
402 }
403
404 return new WP_REST_Response(
405 [
406 'success' => true,
407 'donations' => $donations,
408 ],
409 200
410 );
411 }
412
413 /**
414 * Create a new donation.
415 *
416 * @param WP_REST_Request $request Request object.
417 * @return WP_REST_Response|WP_Error Response object.
418 * @since 0.0.1
419 */
420 public function create_donation( $request ) {
421 $campaign_id = $request->get_param( 'campaign_id' );
422 $donor_name = $request->get_param( 'donor_name' ) ?? '';
423 $donor_email = $request->get_param( 'donor_email' ) ?? '';
424 $donor_phone = $request->get_param( 'donor_phone' ) ?? '';
425 $amount = $request->get_param( 'amount' );
426 $fees_covered = $request->get_param( 'fees_covered' ) ?? 0;
427 $payment_status = $request->get_param( 'payment_status' ) ?? 'pending';
428 $donation_type = $request->get_param( 'donation_type' ) ?? 'one-time';
429 $is_anonymous = $request->get_param( 'is_anonymous' ) ?? false;
430 $donor_comment = $request->get_param( 'donor_comment' ) ?? '';
431 $gateway = $request->get_param( 'gateway' ) ?? 'manual';
432 $transaction_id = $request->get_param( 'transaction_id' ) ?? '';
433
434 // Get or create donor.
435 $donor_id = 0;
436 if ( ! empty( $donor_email ) ) {
437 $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
438 }
439
440 // Build donation data — pro can add subscription fields via filter.
441 $donation_data = [
442 'campaign_id' => $campaign_id,
443 'donor_id' => $donor_id ? $donor_id : 0,
444 'amount' => $amount,
445 'fees_covered' => $fees_covered,
446 'currency' => Payment_Helper::get_currency(),
447 'gateway' => $gateway,
448 'payment_status' => $payment_status,
449 'payment_mode' => Payment_Helper::get_payment_mode(),
450 'donor_name' => $donor_name,
451 'donor_email' => $donor_email,
452 'donor_phone' => $donor_phone,
453 'is_anonymous' => $is_anonymous ? 1 : 0,
454 'donation_type' => $donation_type,
455 'donor_comment' => $donor_comment,
456 'transaction_id' => $transaction_id,
457 ];
458
459 /**
460 * Filter donation data before insertion.
461 *
462 * Pro uses this to add subscription_id, subscription_status, parent_subscription_id.
463 *
464 * @param array<string, mixed> $donation_data Donation data to insert.
465 * @param \WP_REST_Request $request The original REST request.
466 * @since 1.0.0
467 */
468 $donation_data = apply_filters( 'suredonation_create_donation_data', $donation_data, $request );
469
470 // Create the donation in database.
471 $donation_id = Donations::add( $donation_data );
472
473 if ( ! $donation_id ) {
474 return new WP_Error(
475 'create_failed',
476 __( 'Failed to create donation.', 'suredonation' ),
477 [ 'status' => 500 ]
478 );
479 }
480
481 $donation = Donations::get( $donation_id );
482
483 return new WP_REST_Response(
484 [
485 'success' => true,
486 'message' => __( 'Donation created successfully.', 'suredonation' ),
487 'donation' => is_array( $donation ) ? $this->format_donation( $donation ) : [],
488 ],
489 201
490 );
491 }
492
493 /**
494 * Update an existing donation.
495 *
496 * @param WP_REST_Request $request Request object.
497 * @return WP_REST_Response|WP_Error Response object.
498 * @since 0.0.1
499 */
500 public function update_donation( $request ) {
501 $donation_id = absint( $request->get_param( 'id' ) );
502
503 // Check if donation exists.
504 $donation = Donations::get( $donation_id );
505 if ( ! $donation ) {
506 return new WP_Error(
507 'donation_not_found',
508 __( 'Donation not found.', 'suredonation' ),
509 [ 'status' => 404 ]
510 );
511 }
512
513 // Build update data.
514 $update_data = [];
515 $fields = [
516 'campaign_id',
517 'donor_name',
518 'donor_email',
519 'donor_phone',
520 'amount',
521 'fees_covered',
522 'donation_type',
523 'is_anonymous',
524 'donor_comment',
525 'payment_status',
526 'gateway',
527 'transaction_id',
528 ];
529
530 foreach ( $fields as $field ) {
531 $value = $request->get_param( $field );
532 if ( ! is_null( $value ) ) {
533 if ( 'is_anonymous' === $field ) {
534 $update_data[ $field ] = $value ? 1 : 0;
535 } else {
536 $update_data[ $field ] = $value;
537 }
538 }
539 }
540
541 /**
542 * Filter donation update data before saving.
543 *
544 * Pro uses this to add subscription fields to the update.
545 *
546 * @param array<string, mixed> $update_data Data to update.
547 * @param \WP_REST_Request $request The REST request.
548 * @param int $donation_id Donation ID.
549 * @since 1.0.0
550 */
551 $update_data = apply_filters( 'suredonation_update_donation_data', $update_data, $request, $donation_id );
552
553 if ( ! empty( $update_data ) ) {
554 Donations::update( $donation_id, $update_data );
555 }
556
557 $updated_donation = Donations::get( $donation_id );
558
559 return new WP_REST_Response(
560 [
561 'success' => true,
562 'message' => __( 'Donation updated successfully.', 'suredonation' ),
563 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
564 ],
565 200
566 );
567 }
568
569 /**
570 * Update donation payment status.
571 *
572 * @param WP_REST_Request $request Request object.
573 * @return WP_REST_Response|WP_Error Response object.
574 * @since 0.0.1
575 */
576 public function update_donation_status( $request ) {
577 $donation_id = absint( $request->get_param( 'id' ) );
578 $status = $request->get_param( 'status' );
579
580 $donation = Donations::get( $donation_id );
581 if ( ! $donation ) {
582 return new WP_Error(
583 'donation_not_found',
584 __( 'Donation not found.', 'suredonation' ),
585 [ 'status' => 404 ]
586 );
587 }
588
589 $old_status = $donation['payment_status'] ?? 'pending';
590 Donations::update_status( $donation_id, $status );
591
592 // If status changed to completed, update donor stats.
593 if ( 'completed' !== $old_status && 'completed' === $status ) {
594 if ( ! empty( $donation['donor_id'] ) ) {
595 Donors::record_donation( $donation['donor_id'], floatval( $donation['amount'] ) );
596 }
597 }
598
599 return new WP_REST_Response(
600 [
601 'success' => true,
602 'message' => __( 'Donation status updated successfully.', 'suredonation' ),
603 ],
604 200
605 );
606 }
607
608 /**
609 * Delete donation.
610 *
611 * @param WP_REST_Request $request Request object.
612 * @return WP_REST_Response|WP_Error Response object.
613 * @since 0.0.1
614 */
615 public function delete_donation( $request ) {
616 $donation_id = absint( $request->get_param( 'id' ) );
617
618 $result = Donations::delete( $donation_id );
619
620 if ( ! $result ) {
621 return new WP_Error(
622 'delete_failed',
623 __( 'Failed to delete donation.', 'suredonation' ),
624 [ 'status' => 500 ]
625 );
626 }
627
628 return new WP_REST_Response(
629 [
630 'success' => true,
631 'message' => __( 'Donation deleted successfully.', 'suredonation' ),
632 ],
633 200
634 );
635 }
636
637 /**
638 * Bulk action on donations.
639 *
640 * @param WP_REST_Request $request Request object.
641 * @return WP_REST_Response|WP_Error Response object.
642 * @since 0.0.1
643 */
644 public function bulk_action( $request ) {
645 $action = $request->get_param( 'action' );
646 $ids = $request->get_param( 'ids' );
647
648 if ( ! is_array( $ids ) ) {
649 $ids = [];
650 }
651
652 // Cap bulk operations at 200 IDs per request. Each ID triggers a
653 // per-row SELECT + DELETE / UPDATE — an arbitrarily large array in one
654 // request would chew through the database serially and time out the
655 // response. 200 is enough headroom for any realistic admin UI
656 // selection; larger jobs should be split client-side (parity with the
657 // donors bulk-action endpoint).
658 if ( count( $ids ) > 200 ) {
659 return new WP_Error(
660 'too_many_items',
661 __( 'Bulk actions are limited to 200 donations per request.', 'suredonation' ),
662 [ 'status' => 400 ]
663 );
664 }
665
666 $success_count = 0;
667 $error_count = 0;
668
669 foreach ( $ids as $id ) {
670 $result = false;
671
672 if ( 'delete' === $action ) {
673 $result = Donations::delete( absint( $id ) );
674 } elseif ( 'update_status' === $action ) {
675 $status = $request->get_param( 'status' );
676 if ( $status ) {
677 $result = Donations::update_status( absint( $id ), $status );
678 }
679 }
680
681 if ( $result ) {
682 ++$success_count;
683 } else {
684 ++$error_count;
685 }
686 }
687
688 return new WP_REST_Response(
689 [
690 'success' => true,
691 'message' => sprintf(
692 // translators: %1$d: success count, %2$d: error count.
693 __( 'Bulk action completed. Success: %1$d, Failed: %2$d', 'suredonation' ),
694 $success_count,
695 $error_count
696 ),
697 'success_count' => $success_count,
698 'error_count' => $error_count,
699 ],
700 200
701 );
702 }
703
704 /**
705 * Refund a donation payment.
706 *
707 * @param WP_REST_Request $request Request object.
708 * @return WP_REST_Response|WP_Error Response object.
709 * @since 0.0.1
710 */
711 public function refund_donation( $request ) {
712 $donation_id = absint( $request->get_param( 'id' ) );
713 $transaction_id = $request->get_param( 'transaction_id' );
714 $refund_amount = absint( $request->get_param( 'refund_amount' ) );
715
716 // Get the donation.
717 $donation = Donations::get( $donation_id );
718 if ( ! $donation ) {
719 return new WP_Error(
720 'donation_not_found',
721 __( 'Donation not found.', 'suredonation' ),
722 [ 'status' => 404 ]
723 );
724 }
725
726 // Verify the donation is in a refundable state.
727 $refundable_statuses = [ 'completed', 'partially_refunded' ];
728 if ( ! in_array( $donation['payment_status'], $refundable_statuses, true ) ) {
729 return new WP_Error(
730 'not_refundable',
731 __( 'Only completed or partially refunded donations can be refunded.', 'suredonation' ),
732 [ 'status' => 400 ]
733 );
734 }
735
736 // Verify transaction ID matches.
737 if ( $transaction_id !== $donation['transaction_id'] ) {
738 return new WP_Error(
739 'transaction_mismatch',
740 __( 'Transaction ID mismatch.', 'suredonation' ),
741 [ 'status' => 400 ]
742 );
743 }
744
745 // Validate refund amount.
746 $gateway = $donation['gateway'] ?? 'stripe';
747 $currency = $donation['currency'] ?? 'USD';
748 $total_amount = $this->amount_to_stripe_format( floatval( $donation['amount'] ), $currency );
749 $refunded_amount = $this->amount_to_stripe_format( floatval( $donation['refunded_amount'] ?? 0 ), $currency );
750 $refundable = $total_amount - $refunded_amount;
751
752 if ( $refund_amount > $refundable ) {
753 return new WP_Error(
754 'exceeds_refundable',
755 sprintf(
756 /* translators: %s: maximum refundable amount */
757 __( 'Refund amount exceeds maximum refundable amount of %s.', 'suredonation' ),
758 $this->amount_from_stripe_format( $refundable, $currency )
759 ),
760 [ 'status' => 400 ]
761 );
762 }
763
764 // Process refund through the appropriate gateway.
765 if ( 'paypal' === $gateway ) {
766 $refund_amount_major = $this->amount_from_stripe_format( $refund_amount, $currency );
767 $refund_result = \SureDonation\Inc\Payments\PayPal\PayPal_Api_Payments::refund_capture(
768 $transaction_id,
769 $refund_amount_major,
770 $currency
771 );
772 } else {
773 // Check if Stripe is connected.
774 if ( ! Stripe_Helper::is_stripe_connected() ) {
775 return new WP_Error(
776 'stripe_not_connected',
777 __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
778 [ 'status' => 400 ]
779 );
780 }
781 $refund_account_id = isset( $donation['stripe_account_id'] ) && is_string( $donation['stripe_account_id'] ) ? $donation['stripe_account_id'] : '';
782 $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer', $refund_account_id );
783 }
784
785 if ( is_wp_error( $refund_result ) ) {
786 return new WP_Error(
787 'refund_failed',
788 $refund_result->get_error_message(),
789 [ 'status' => 500 ]
790 );
791 }
792
793 // Calculate new refunded amount in cents for comparison.
794 $new_refunded_in_cents = $refunded_amount + $refund_amount;
795
796 // Determine new status by comparing in cents to avoid floating point precision issues.
797 $new_status = $new_refunded_in_cents >= $total_amount ? 'refunded' : 'partially_refunded';
798
799 // Convert back to major currency unit for storage.
800 $new_refunded_amount = $this->amount_from_stripe_format( $new_refunded_in_cents, $currency );
801
802 // Store refund in donation_data FIRST (prevents webhook duplicate processing).
803 $refund_id = $refund_result['id'] ?? '';
804 if ( ! empty( $refund_id ) ) {
805 $refund_data = [
806 'refund_id' => $refund_id,
807 'amount' => absint( $refund_amount ),
808 'currency' => strtoupper( $currency ),
809 'status' => $refund_result['status'] ?? 'succeeded',
810 'created' => time(),
811 'reason' => 'requested_by_customer',
812 'refunded_by' => 'admin',
813 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
814 ];
815 Donations::add_refund_to_donation_data( $donation_id, $refund_data );
816 }
817
818 // Update donation record with new status and refunded amount.
819 Donations::update(
820 $donation_id,
821 [
822 'payment_status' => $new_status,
823 'refunded_amount' => $new_refunded_amount,
824 ]
825 );
826
827 // Determine refund type for log message.
828 $refund_type = $new_refunded_in_cents >= $total_amount
829 ? __( 'Full', 'suredonation' )
830 : __( 'Partial', 'suredonation' );
831
832 // Add log entry.
833 Donations::add_log(
834 $donation_id,
835 'refund',
836 sprintf(
837 /* translators: %s: Refund type (Full/Partial) */
838 __( '%s refund processed via admin', 'suredonation' ),
839 $refund_type
840 ),
841 [
842 'refund_id' => $refund_id,
843 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
844 'total_refunded' => $new_refunded_amount,
845 'original_amount' => floatval( $donation['amount'] ),
846 'payment_status' => $new_status,
847 'currency' => strtoupper( $currency ),
848 ]
849 );
850
851 // Send refund email notifications.
852 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
853 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
854 $donation_data = [
855 'id' => $donation_id,
856 'donor_name' => $donation['donor_name'] ?? '',
857 'donor_email' => $donation['donor_email'] ?? '',
858 'amount' => $donation['amount'] ?? 0,
859 'currency' => strtoupper( $currency ),
860 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
861 'donation_type' => $donation['donation_type'] ?? 'one-time',
862 'gateway' => 'stripe',
863 ];
864
865 Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
866
867 // Get updated donation.
868 $updated_donation = Donations::get( $donation_id );
869
870 return new WP_REST_Response(
871 [
872 'success' => true,
873 'message' => __( 'Refund processed successfully.', 'suredonation' ),
874 'refund_id' => $refund_id,
875 'status' => $refund_result['status'] ?? 'succeeded',
876 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
877 ],
878 200
879 );
880 }
881 /**
882 * Check if user has permission to manage donations.
883 *
884 * @return bool True if user has permission.
885 * @since 0.0.1
886 */
887 public function check_permissions() {
888 return current_user_can( 'manage_options' );
889 }
890
891 /**
892 * Delete a log entry from a donation.
893 *
894 * @param WP_REST_Request $request Request object.
895 * @return WP_REST_Response|WP_Error Response object.
896 * @since 0.0.1
897 */
898 public function delete_donation_log( $request ) {
899 $donation_id = absint( $request->get_param( 'id' ) );
900 $log_index = absint( $request->get_param( 'log_index' ) );
901
902 // Get the donation from database.
903 $donation = Donations::get( $donation_id );
904
905 if ( ! $donation ) {
906 return new WP_Error(
907 'donation_not_found',
908 __( 'Donation not found.', 'suredonation' ),
909 [ 'status' => 404 ]
910 );
911 }
912
913 // Get current logs.
914 $logs = Donations::get_log( $donation_id );
915
916 if ( ! is_array( $logs ) || empty( $logs ) ) {
917 return new WP_Error(
918 'no_logs',
919 __( 'No logs found for this donation.', 'suredonation' ),
920 [ 'status' => 404 ]
921 );
922 }
923
924 // Check if log index exists.
925 if ( ! isset( $logs[ $log_index ] ) ) {
926 return new WP_Error(
927 'log_not_found',
928 __( 'Log entry not found.', 'suredonation' ),
929 [ 'status' => 404 ]
930 );
931 }
932
933 // Remove log at specified index.
934 array_splice( $logs, $log_index, 1 );
935
936 // Re-index array to prevent gaps.
937 $logs = array_values( $logs );
938
939 // Update log column with modified logs array.
940 $result = Donations::update( $donation_id, [ 'log' => $logs ] );
941
942 if ( false === $result ) {
943 return new WP_Error(
944 'update_failed',
945 __( 'Failed to delete log entry.', 'suredonation' ),
946 [ 'status' => 500 ]
947 );
948 }
949
950 return new WP_REST_Response(
951 [
952 'success' => true,
953 'message' => __( 'Log entry deleted successfully.', 'suredonation' ),
954 'logs' => $logs,
955 ],
956 200
957 );
958 }
959
960 /**
961 * Get notes for a donation.
962 *
963 * @param WP_REST_Request $request Request object.
964 * @return WP_REST_Response|WP_Error Response object.
965 * @since 0.0.1
966 */
967 public function get_donation_notes( $request ) {
968 $donation_id = absint( $request->get_param( 'id' ) );
969 $page = absint( $request->get_param( 'page' ) ) ?? 1;
970 $per_page = absint( $request->get_param( 'per_page' ) ) ?? 3;
971
972 // Get the donation from database.
973 $donation = Donations::get( $donation_id );
974
975 if ( ! $donation ) {
976 return new WP_Error(
977 'donation_not_found',
978 __( 'Donation not found.', 'suredonation' ),
979 [ 'status' => 404 ]
980 );
981 }
982
983 // Get paginated notes.
984 $notes_data = Donations::get_notes( $donation_id, $page, $per_page );
985
986 return new WP_REST_Response(
987 [
988 'success' => true,
989 'notes' => $notes_data['notes'],
990 'total' => $notes_data['total'],
991 'total_pages' => $notes_data['total_pages'],
992 ],
993 200
994 );
995 }
996
997 /**
998 * Add a note to a donation.
999 *
1000 * @param WP_REST_Request $request Request object.
1001 * @return WP_REST_Response|WP_Error Response object.
1002 * @since 0.0.1
1003 */
1004 public function add_donation_note( $request ) {
1005 $donation_id = absint( $request->get_param( 'id' ) );
1006 $note = $request->get_param( 'note' );
1007
1008 // Get the donation from database.
1009 $donation = Donations::get( $donation_id );
1010
1011 if ( ! $donation ) {
1012 return new WP_Error(
1013 'donation_not_found',
1014 __( 'Donation not found.', 'suredonation' ),
1015 [ 'status' => 404 ]
1016 );
1017 }
1018
1019 // Add the note.
1020 $result = Donations::add_note( $donation_id, $note, get_current_user_id() );
1021
1022 if ( ! $result['success'] ) {
1023 return new WP_Error(
1024 'note_failed',
1025 __( 'Failed to add note.', 'suredonation' ),
1026 [ 'status' => 500 ]
1027 );
1028 }
1029
1030 return new WP_REST_Response(
1031 [
1032 'success' => true,
1033 'message' => __( 'Note added successfully.', 'suredonation' ),
1034 'note_id' => $result['note_id'],
1035 ],
1036 201
1037 );
1038 }
1039
1040 /**
1041 * Delete a note from a donation.
1042 *
1043 * @param WP_REST_Request $request Request object.
1044 * @return WP_REST_Response|WP_Error Response object.
1045 * @since 0.0.1
1046 */
1047 public function delete_donation_note( $request ) {
1048 $donation_id = absint( $request->get_param( 'id' ) );
1049 $note_id = $request->get_param( 'note_id' );
1050
1051 // Get the donation from database.
1052 $donation = Donations::get( $donation_id );
1053
1054 if ( ! $donation ) {
1055 return new WP_Error(
1056 'donation_not_found',
1057 __( 'Donation not found.', 'suredonation' ),
1058 [ 'status' => 404 ]
1059 );
1060 }
1061
1062 // Delete the note.
1063 $result = Donations::delete_note( $donation_id, $note_id );
1064
1065 if ( ! $result ) {
1066 return new WP_Error(
1067 'note_not_found',
1068 __( 'Note not found or could not be deleted.', 'suredonation' ),
1069 [ 'status' => 404 ]
1070 );
1071 }
1072
1073 return new WP_REST_Response(
1074 [
1075 'success' => true,
1076 'message' => __( 'Note deleted successfully.', 'suredonation' ),
1077 ],
1078 200
1079 );
1080 }
1081
1082 /**
1083 * Get donation arguments schema.
1084 *
1085 * @param bool $required Whether fields are required.
1086 * @return array<string, array<string, mixed>>
1087 * @since 0.0.1
1088 */
1089 private function get_donation_args( $required = true ) {
1090 return [
1091 'campaign_id' => [
1092 'required' => $required,
1093 'sanitize_callback' => 'absint',
1094 ],
1095 'donor_name' => [
1096 'sanitize_callback' => 'sanitize_text_field',
1097 ],
1098 'donor_email' => [
1099 'sanitize_callback' => 'sanitize_email',
1100 ],
1101 'donor_phone' => [
1102 'sanitize_callback' => 'sanitize_text_field',
1103 ],
1104 'amount' => [
1105 'required' => $required,
1106 'sanitize_callback' => static function ( $value ) {
1107 return floatval( $value );
1108 },
1109 ],
1110 'fees_covered' => [
1111 'sanitize_callback' => static function ( $value ) {
1112 return floatval( $value );
1113 },
1114 ],
1115 'donation_type' => [
1116 'default' => 'one-time',
1117 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1118 'sanitize_callback' => 'sanitize_text_field',
1119 'validate_callback' => static function ( $param ) {
1120 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1121 },
1122 ],
1123 'is_anonymous' => [
1124 'sanitize_callback' => 'rest_sanitize_boolean',
1125 ],
1126 'donor_comment' => [
1127 'sanitize_callback' => 'wp_kses_post',
1128 ],
1129 'payment_status' => [
1130 'default' => 'pending',
1131 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
1132 'sanitize_callback' => 'sanitize_text_field',
1133 'validate_callback' => static function ( $param ) {
1134 return in_array( $param, [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], true );
1135 },
1136 ],
1137 'gateway' => [
1138 'sanitize_callback' => 'sanitize_text_field',
1139 ],
1140 'transaction_id' => [
1141 'sanitize_callback' => 'sanitize_text_field',
1142 ],
1143 ];
1144 }
1145
1146 /**
1147 * Validate REST date filter parameters.
1148 *
1149 * @param mixed $param Date parameter.
1150 * @return bool Whether the date is valid.
1151 * @since 0.0.1
1152 */
1153 public function validate_date_param( $param ) {
1154 if ( '' === $param || null === $param ) {
1155 return true;
1156 }
1157
1158 return is_string( $param ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $param );
1159 }
1160
1161 /**
1162 * Convert amount to Stripe's smallest currency unit.
1163 *
1164 * @param float $amount Amount in major currency unit.
1165 * @param string $currency Currency code.
1166 * @return int Amount in smallest currency unit.
1167 * @since 0.0.1
1168 */
1169 private function amount_to_stripe_format( $amount, $currency ) {
1170 $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1171 return in_array( strtoupper( $currency ), $zero_decimal, true )
1172 ? (int) round( $amount )
1173 : (int) round( $amount * 100 );
1174 }
1175
1176 /**
1177 * Convert amount from Stripe's smallest currency unit.
1178 *
1179 * @param int $amount Amount in smallest currency unit.
1180 * @param string $currency Currency code.
1181 * @return float Amount in major currency unit.
1182 * @since 0.0.1
1183 */
1184 private function amount_from_stripe_format( $amount, $currency ) {
1185 $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1186 return in_array( strtoupper( $currency ), $zero_decimal, true )
1187 ? (float) $amount
1188 : (float) $amount / 100;
1189 }
1190
1191 /**
1192 * Format donation data for API response.
1193 *
1194 * @param array<string, mixed> $donation Donation data from database.
1195 * @return array<string, mixed> Formatted donation data.
1196 * @since 0.0.1
1197 */
1198 private function format_donation( $donation ) {
1199 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
1200 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
1201 $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
1202
1203 // Get payment logs for this donation.
1204 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
1205
1206 // Get payment mode for Stripe dashboard URL.
1207 $payment_mode = $donation['payment_mode'] ?? 'test';
1208
1209 $form_edit_url = '';
1210 if ( $form_id && current_user_can( 'edit_post', $form_id ) ) {
1211 $form_edit_url = esc_url_raw( get_edit_post_link( $form_id, 'raw' ) );
1212 }
1213
1214 // Parse donation_data for subscription metadata.
1215 $donation_data = $donation['donation_data'] ?? [];
1216 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1217 $donation_data = json_decode( $donation_data, true );
1218 }
1219 if ( ! is_array( $donation_data ) ) {
1220 $donation_data = [];
1221 }
1222
1223 // Build the persisted submitted fields list (label/value/group). The
1224 // group is the parent block label (e.g. "Address") used to nest
1225 // sub-fields on the entry screen; '' for standalone fields.
1226 $submitted_fields = [];
1227 if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) {
1228 foreach ( $donation_data['fields'] as $field ) {
1229 if ( ! is_array( $field ) ) {
1230 continue;
1231 }
1232 // sanitize_text_field (not esc_html) for REST data: the values are
1233 // already sanitized at write time and React escapes on render, so
1234 // esc_html here would double-encode (e.g. "Cats & Dogs" -> "Cats &amp; Dogs").
1235 $submitted_fields[] = [
1236 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ),
1237 'value' => sanitize_text_field( Helper::get_string_value( $field['value'] ?? '' ) ),
1238 'group' => sanitize_text_field( Helper::get_string_value( $field['group'] ?? '' ) ),
1239 ];
1240 }
1241 }
1242
1243 return [
1244 'id' => $donation_id,
1245 'campaign_id' => $campaign_id,
1246 // Plain-text titles rendered by React (which escapes text nodes and does
1247 // not decode HTML entities). get_the_title() runs wptexturize, whose
1248 // default replacements are entities (e.g. " - " -> "&#8211;"), so decode
1249 // them here; wp_kses_post would leave the entity and it would show raw.
1250 'campaign_title' => $campaign_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $campaign_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1251 'form_id' => $form_id,
1252 'form_title' => $form_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $form_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1253 'form_edit_url' => $form_edit_url,
1254 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1255 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1256 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
1257 'donor_phone' => esc_html( Helper::get_string_value( $donation['donor_phone'] ?? '' ) ),
1258 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1259 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1260 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1261 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1262 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1263 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1264 'donor_comment' => wp_kses_post( Helper::get_string_value( $donation['donor_comment'] ?? '' ) ),
1265 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1266 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1267 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1268 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
1269 'stripe_customer_id' => esc_html( Helper::get_string_value( $donation['customer_id'] ?? '' ) ),
1270 'subscription_id' => esc_html( Helper::get_string_value( $donation['subscription_id'] ?? '' ) ),
1271 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1272 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1273 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1274 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1275 'fields' => $submitted_fields,
1276 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1277 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1278 'logs' => $logs,
1279 ];
1280 }
1281 }
1282