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

1,281 lines 39.3 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_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer' );
782 }
783
784 if ( is_wp_error( $refund_result ) ) {
785 return new WP_Error(
786 'refund_failed',
787 $refund_result->get_error_message(),
788 [ 'status' => 500 ]
789 );
790 }
791
792 // Calculate new refunded amount in cents for comparison.
793 $new_refunded_in_cents = $refunded_amount + $refund_amount;
794
795 // Determine new status by comparing in cents to avoid floating point precision issues.
796 $new_status = $new_refunded_in_cents >= $total_amount ? 'refunded' : 'partially_refunded';
797
798 // Convert back to major currency unit for storage.
799 $new_refunded_amount = $this->amount_from_stripe_format( $new_refunded_in_cents, $currency );
800
801 // Store refund in donation_data FIRST (prevents webhook duplicate processing).
802 $refund_id = $refund_result['id'] ?? '';
803 if ( ! empty( $refund_id ) ) {
804 $refund_data = [
805 'refund_id' => $refund_id,
806 'amount' => absint( $refund_amount ),
807 'currency' => strtoupper( $currency ),
808 'status' => $refund_result['status'] ?? 'succeeded',
809 'created' => time(),
810 'reason' => 'requested_by_customer',
811 'refunded_by' => 'admin',
812 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
813 ];
814 Donations::add_refund_to_donation_data( $donation_id, $refund_data );
815 }
816
817 // Update donation record with new status and refunded amount.
818 Donations::update(
819 $donation_id,
820 [
821 'payment_status' => $new_status,
822 'refunded_amount' => $new_refunded_amount,
823 ]
824 );
825
826 // Determine refund type for log message.
827 $refund_type = $new_refunded_in_cents >= $total_amount
828 ? __( 'Full', 'suredonation' )
829 : __( 'Partial', 'suredonation' );
830
831 // Add log entry.
832 Donations::add_log(
833 $donation_id,
834 'refund',
835 sprintf(
836 /* translators: %s: Refund type (Full/Partial) */
837 __( '%s refund processed via admin', 'suredonation' ),
838 $refund_type
839 ),
840 [
841 'refund_id' => $refund_id,
842 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
843 'total_refunded' => $new_refunded_amount,
844 'original_amount' => floatval( $donation['amount'] ),
845 'payment_status' => $new_status,
846 'currency' => strtoupper( $currency ),
847 ]
848 );
849
850 // Send refund email notifications.
851 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
852 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
853 $donation_data = [
854 'id' => $donation_id,
855 'donor_name' => $donation['donor_name'] ?? '',
856 'donor_email' => $donation['donor_email'] ?? '',
857 'amount' => $donation['amount'] ?? 0,
858 'currency' => strtoupper( $currency ),
859 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
860 'donation_type' => $donation['donation_type'] ?? 'one-time',
861 'gateway' => 'stripe',
862 ];
863
864 Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
865
866 // Get updated donation.
867 $updated_donation = Donations::get( $donation_id );
868
869 return new WP_REST_Response(
870 [
871 'success' => true,
872 'message' => __( 'Refund processed successfully.', 'suredonation' ),
873 'refund_id' => $refund_id,
874 'status' => $refund_result['status'] ?? 'succeeded',
875 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
876 ],
877 200
878 );
879 }
880 /**
881 * Check if user has permission to manage donations.
882 *
883 * @return bool True if user has permission.
884 * @since 0.0.1
885 */
886 public function check_permissions() {
887 return current_user_can( 'manage_options' );
888 }
889
890 /**
891 * Delete a log entry from a donation.
892 *
893 * @param WP_REST_Request $request Request object.
894 * @return WP_REST_Response|WP_Error Response object.
895 * @since 0.0.1
896 */
897 public function delete_donation_log( $request ) {
898 $donation_id = absint( $request->get_param( 'id' ) );
899 $log_index = absint( $request->get_param( 'log_index' ) );
900
901 // Get the donation from database.
902 $donation = Donations::get( $donation_id );
903
904 if ( ! $donation ) {
905 return new WP_Error(
906 'donation_not_found',
907 __( 'Donation not found.', 'suredonation' ),
908 [ 'status' => 404 ]
909 );
910 }
911
912 // Get current logs.
913 $logs = Donations::get_log( $donation_id );
914
915 if ( ! is_array( $logs ) || empty( $logs ) ) {
916 return new WP_Error(
917 'no_logs',
918 __( 'No logs found for this donation.', 'suredonation' ),
919 [ 'status' => 404 ]
920 );
921 }
922
923 // Check if log index exists.
924 if ( ! isset( $logs[ $log_index ] ) ) {
925 return new WP_Error(
926 'log_not_found',
927 __( 'Log entry not found.', 'suredonation' ),
928 [ 'status' => 404 ]
929 );
930 }
931
932 // Remove log at specified index.
933 array_splice( $logs, $log_index, 1 );
934
935 // Re-index array to prevent gaps.
936 $logs = array_values( $logs );
937
938 // Update log column with modified logs array.
939 $result = Donations::update( $donation_id, [ 'log' => $logs ] );
940
941 if ( false === $result ) {
942 return new WP_Error(
943 'update_failed',
944 __( 'Failed to delete log entry.', 'suredonation' ),
945 [ 'status' => 500 ]
946 );
947 }
948
949 return new WP_REST_Response(
950 [
951 'success' => true,
952 'message' => __( 'Log entry deleted successfully.', 'suredonation' ),
953 'logs' => $logs,
954 ],
955 200
956 );
957 }
958
959 /**
960 * Get notes for 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 get_donation_notes( $request ) {
967 $donation_id = absint( $request->get_param( 'id' ) );
968 $page = absint( $request->get_param( 'page' ) ) ?? 1;
969 $per_page = absint( $request->get_param( 'per_page' ) ) ?? 3;
970
971 // Get the donation from database.
972 $donation = Donations::get( $donation_id );
973
974 if ( ! $donation ) {
975 return new WP_Error(
976 'donation_not_found',
977 __( 'Donation not found.', 'suredonation' ),
978 [ 'status' => 404 ]
979 );
980 }
981
982 // Get paginated notes.
983 $notes_data = Donations::get_notes( $donation_id, $page, $per_page );
984
985 return new WP_REST_Response(
986 [
987 'success' => true,
988 'notes' => $notes_data['notes'],
989 'total' => $notes_data['total'],
990 'total_pages' => $notes_data['total_pages'],
991 ],
992 200
993 );
994 }
995
996 /**
997 * Add a note to a donation.
998 *
999 * @param WP_REST_Request $request Request object.
1000 * @return WP_REST_Response|WP_Error Response object.
1001 * @since 0.0.1
1002 */
1003 public function add_donation_note( $request ) {
1004 $donation_id = absint( $request->get_param( 'id' ) );
1005 $note = $request->get_param( 'note' );
1006
1007 // Get the donation from database.
1008 $donation = Donations::get( $donation_id );
1009
1010 if ( ! $donation ) {
1011 return new WP_Error(
1012 'donation_not_found',
1013 __( 'Donation not found.', 'suredonation' ),
1014 [ 'status' => 404 ]
1015 );
1016 }
1017
1018 // Add the note.
1019 $result = Donations::add_note( $donation_id, $note, get_current_user_id() );
1020
1021 if ( ! $result['success'] ) {
1022 return new WP_Error(
1023 'note_failed',
1024 __( 'Failed to add note.', 'suredonation' ),
1025 [ 'status' => 500 ]
1026 );
1027 }
1028
1029 return new WP_REST_Response(
1030 [
1031 'success' => true,
1032 'message' => __( 'Note added successfully.', 'suredonation' ),
1033 'note_id' => $result['note_id'],
1034 ],
1035 201
1036 );
1037 }
1038
1039 /**
1040 * Delete a note from a donation.
1041 *
1042 * @param WP_REST_Request $request Request object.
1043 * @return WP_REST_Response|WP_Error Response object.
1044 * @since 0.0.1
1045 */
1046 public function delete_donation_note( $request ) {
1047 $donation_id = absint( $request->get_param( 'id' ) );
1048 $note_id = $request->get_param( 'note_id' );
1049
1050 // Get the donation from database.
1051 $donation = Donations::get( $donation_id );
1052
1053 if ( ! $donation ) {
1054 return new WP_Error(
1055 'donation_not_found',
1056 __( 'Donation not found.', 'suredonation' ),
1057 [ 'status' => 404 ]
1058 );
1059 }
1060
1061 // Delete the note.
1062 $result = Donations::delete_note( $donation_id, $note_id );
1063
1064 if ( ! $result ) {
1065 return new WP_Error(
1066 'note_not_found',
1067 __( 'Note not found or could not be deleted.', 'suredonation' ),
1068 [ 'status' => 404 ]
1069 );
1070 }
1071
1072 return new WP_REST_Response(
1073 [
1074 'success' => true,
1075 'message' => __( 'Note deleted successfully.', 'suredonation' ),
1076 ],
1077 200
1078 );
1079 }
1080
1081 /**
1082 * Get donation arguments schema.
1083 *
1084 * @param bool $required Whether fields are required.
1085 * @return array<string, array<string, mixed>>
1086 * @since 0.0.1
1087 */
1088 private function get_donation_args( $required = true ) {
1089 return [
1090 'campaign_id' => [
1091 'required' => $required,
1092 'sanitize_callback' => 'absint',
1093 ],
1094 'donor_name' => [
1095 'sanitize_callback' => 'sanitize_text_field',
1096 ],
1097 'donor_email' => [
1098 'sanitize_callback' => 'sanitize_email',
1099 ],
1100 'donor_phone' => [
1101 'sanitize_callback' => 'sanitize_text_field',
1102 ],
1103 'amount' => [
1104 'required' => $required,
1105 'sanitize_callback' => static function ( $value ) {
1106 return floatval( $value );
1107 },
1108 ],
1109 'fees_covered' => [
1110 'sanitize_callback' => static function ( $value ) {
1111 return floatval( $value );
1112 },
1113 ],
1114 'donation_type' => [
1115 'default' => 'one-time',
1116 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1117 'sanitize_callback' => 'sanitize_text_field',
1118 'validate_callback' => static function ( $param ) {
1119 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1120 },
1121 ],
1122 'is_anonymous' => [
1123 'sanitize_callback' => 'rest_sanitize_boolean',
1124 ],
1125 'donor_comment' => [
1126 'sanitize_callback' => 'wp_kses_post',
1127 ],
1128 'payment_status' => [
1129 'default' => 'pending',
1130 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
1131 'sanitize_callback' => 'sanitize_text_field',
1132 'validate_callback' => static function ( $param ) {
1133 return in_array( $param, [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], true );
1134 },
1135 ],
1136 'gateway' => [
1137 'sanitize_callback' => 'sanitize_text_field',
1138 ],
1139 'transaction_id' => [
1140 'sanitize_callback' => 'sanitize_text_field',
1141 ],
1142 ];
1143 }
1144
1145 /**
1146 * Validate REST date filter parameters.
1147 *
1148 * @param mixed $param Date parameter.
1149 * @return bool Whether the date is valid.
1150 * @since 0.0.1
1151 */
1152 public function validate_date_param( $param ) {
1153 if ( '' === $param || null === $param ) {
1154 return true;
1155 }
1156
1157 return is_string( $param ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $param );
1158 }
1159
1160 /**
1161 * Convert amount to Stripe's smallest currency unit.
1162 *
1163 * @param float $amount Amount in major currency unit.
1164 * @param string $currency Currency code.
1165 * @return int Amount in smallest currency unit.
1166 * @since 0.0.1
1167 */
1168 private function amount_to_stripe_format( $amount, $currency ) {
1169 $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1170 return in_array( strtoupper( $currency ), $zero_decimal, true )
1171 ? (int) round( $amount )
1172 : (int) round( $amount * 100 );
1173 }
1174
1175 /**
1176 * Convert amount from Stripe's smallest currency unit.
1177 *
1178 * @param int $amount Amount in smallest currency unit.
1179 * @param string $currency Currency code.
1180 * @return float Amount in major currency unit.
1181 * @since 0.0.1
1182 */
1183 private function amount_from_stripe_format( $amount, $currency ) {
1184 $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1185 return in_array( strtoupper( $currency ), $zero_decimal, true )
1186 ? (float) $amount
1187 : (float) $amount / 100;
1188 }
1189
1190 /**
1191 * Format donation data for API response.
1192 *
1193 * @param array<string, mixed> $donation Donation data from database.
1194 * @return array<string, mixed> Formatted donation data.
1195 * @since 0.0.1
1196 */
1197 private function format_donation( $donation ) {
1198 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
1199 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
1200 $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
1201
1202 // Get payment logs for this donation.
1203 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
1204
1205 // Get payment mode for Stripe dashboard URL.
1206 $payment_mode = $donation['payment_mode'] ?? 'test';
1207
1208 $form_edit_url = '';
1209 if ( $form_id && current_user_can( 'edit_post', $form_id ) ) {
1210 $form_edit_url = esc_url_raw( get_edit_post_link( $form_id, 'raw' ) );
1211 }
1212
1213 // Parse donation_data for subscription metadata.
1214 $donation_data = $donation['donation_data'] ?? [];
1215 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1216 $donation_data = json_decode( $donation_data, true );
1217 }
1218 if ( ! is_array( $donation_data ) ) {
1219 $donation_data = [];
1220 }
1221
1222 // Build the persisted submitted fields list (label/value/group). The
1223 // group is the parent block label (e.g. "Address") used to nest
1224 // sub-fields on the entry screen; '' for standalone fields.
1225 $submitted_fields = [];
1226 if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) {
1227 foreach ( $donation_data['fields'] as $field ) {
1228 if ( ! is_array( $field ) ) {
1229 continue;
1230 }
1231 // sanitize_text_field (not esc_html) for REST data: the values are
1232 // already sanitized at write time and React escapes on render, so
1233 // esc_html here would double-encode (e.g. "Cats & Dogs" -> "Cats &amp; Dogs").
1234 $submitted_fields[] = [
1235 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ),
1236 'value' => sanitize_text_field( Helper::get_string_value( $field['value'] ?? '' ) ),
1237 'group' => sanitize_text_field( Helper::get_string_value( $field['group'] ?? '' ) ),
1238 ];
1239 }
1240 }
1241
1242 return [
1243 'id' => $donation_id,
1244 'campaign_id' => $campaign_id,
1245 // Plain-text titles rendered by React (which escapes text nodes and does
1246 // not decode HTML entities). get_the_title() runs wptexturize, whose
1247 // default replacements are entities (e.g. " - " -> "&#8211;"), so decode
1248 // them here; wp_kses_post would leave the entity and it would show raw.
1249 'campaign_title' => $campaign_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $campaign_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1250 'form_id' => $form_id,
1251 'form_title' => $form_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $form_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1252 'form_edit_url' => $form_edit_url,
1253 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1254 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1255 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
1256 'donor_phone' => esc_html( Helper::get_string_value( $donation['donor_phone'] ?? '' ) ),
1257 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1258 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1259 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1260 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1261 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1262 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1263 'donor_comment' => wp_kses_post( Helper::get_string_value( $donation['donor_comment'] ?? '' ) ),
1264 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1265 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1266 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1267 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
1268 'stripe_customer_id' => esc_html( Helper::get_string_value( $donation['customer_id'] ?? '' ) ),
1269 'subscription_id' => esc_html( Helper::get_string_value( $donation['subscription_id'] ?? '' ) ),
1270 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1271 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1272 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1273 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1274 'fields' => $submitted_fields,
1275 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1276 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1277 'logs' => $logs,
1278 ];
1279 }
1280 }
1281