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

1,236 lines 37.1 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 $per_page = $request->get_param( 'per_page' ) ?? 20;
328 $search = $request->get_param( 'search' ) ?? '';
329 $status = $request->get_param( 'status' ) ?? 'all';
330 $campaign = $request->get_param( 'campaign' ) ?? '';
331 $donor = $request->get_param( 'donor' ) ?? '';
332 $sort_by = $request->get_param( 'sort_by' ) ?? 'created_at';
333 $order = $request->get_param( 'order' ) ?? 'desc';
334
335 // Calculate pagination.
336 $limit = absint( $per_page );
337 $offset = ( absint( $page ) - 1 ) * $limit;
338
339 // If filtering by donor, use the donor-specific query.
340 if ( ! empty( $donor ) ) {
341 $donor_data = Donations::get_by_donor_id( absint( $donor ), $limit, $offset );
342 $results = $donor_data['donations'];
343 $total = $donor_data['total'];
344 } else {
345 // Get donations from database using admin list method with filters.
346 $results = Donations::get_admin_list(
347 $status,
348 ! empty( $campaign ) ? absint( $campaign ) : 0,
349 sanitize_text_field( $search ),
350 $limit,
351 $offset,
352 $sort_by, // using whitelist validation in the method.
353 strtoupper( $order ) // using whitelist validation in the method.
354 );
355
356 // Get total count.
357 $total = Donations::get_total_donations_by_status( $status, ! empty( $campaign ) ? absint( $campaign ) : 0 );
358 }
359
360 // Format donations data.
361 $donations = [];
362 foreach ( $results as $donation ) {
363 if ( is_array( $donation ) ) {
364 $donations[] = $this->format_donation( $donation );
365 }
366 }
367
368 // Prepare response.
369 return new WP_REST_Response(
370 [
371 'donations' => $donations,
372 'pagination' => [
373 'total' => (int) $total,
374 'total_pages' => (int) ceil( $total / $per_page ),
375 'per_page' => (int) $per_page,
376 'current' => (int) $page,
377 ],
378 ]
379 );
380 }
381
382 /**
383 * Get donations for a specific campaign.
384 *
385 * @param WP_REST_Request $request Request object.
386 * @return WP_REST_Response|WP_Error Response object.
387 * @since 0.0.1
388 */
389 public function get_campaign_donations( $request ) {
390 $campaign_id = absint( $request->get_param( 'id' ) );
391 $limit = absint( $request->get_param( 'limit' ) ?? 5 );
392
393 $results = Donations::get_recent_donations( $campaign_id, $limit );
394
395 $donations = [];
396 foreach ( $results as $donation ) {
397 if ( is_array( $donation ) ) {
398 $donations[] = $this->format_donation( $donation );
399 }
400 }
401
402 return new WP_REST_Response(
403 [
404 'success' => true,
405 'donations' => $donations,
406 ],
407 200
408 );
409 }
410
411 /**
412 * Create a new donation.
413 *
414 * @param WP_REST_Request $request Request object.
415 * @return WP_REST_Response|WP_Error Response object.
416 * @since 0.0.1
417 */
418 public function create_donation( $request ) {
419 $campaign_id = $request->get_param( 'campaign_id' );
420 $donor_name = $request->get_param( 'donor_name' ) ?? '';
421 $donor_email = $request->get_param( 'donor_email' ) ?? '';
422 $donor_phone = $request->get_param( 'donor_phone' ) ?? '';
423 $amount = $request->get_param( 'amount' );
424 $fees_covered = $request->get_param( 'fees_covered' ) ?? 0;
425 $payment_status = $request->get_param( 'payment_status' ) ?? 'pending';
426 $donation_type = $request->get_param( 'donation_type' ) ?? 'one-time';
427 $is_anonymous = $request->get_param( 'is_anonymous' ) ?? false;
428 $donor_comment = $request->get_param( 'donor_comment' ) ?? '';
429 $gateway = $request->get_param( 'gateway' ) ?? 'manual';
430 $transaction_id = $request->get_param( 'transaction_id' ) ?? '';
431
432 // Get or create donor.
433 $donor_id = 0;
434 if ( ! empty( $donor_email ) ) {
435 $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
436 }
437
438 // Build donation data — pro can add subscription fields via filter.
439 $donation_data = [
440 'campaign_id' => $campaign_id,
441 'donor_id' => $donor_id ? $donor_id : 0,
442 'amount' => $amount,
443 'fees_covered' => $fees_covered,
444 'currency' => Payment_Helper::get_currency(),
445 'gateway' => $gateway,
446 'payment_status' => $payment_status,
447 'payment_mode' => Payment_Helper::get_payment_mode(),
448 'donor_name' => $donor_name,
449 'donor_email' => $donor_email,
450 'donor_phone' => $donor_phone,
451 'is_anonymous' => $is_anonymous ? 1 : 0,
452 'donation_type' => $donation_type,
453 'donor_comment' => $donor_comment,
454 'transaction_id' => $transaction_id,
455 ];
456
457 /**
458 * Filter donation data before insertion.
459 *
460 * Pro uses this to add subscription_id, subscription_status, parent_subscription_id.
461 *
462 * @param array<string, mixed> $donation_data Donation data to insert.
463 * @param \WP_REST_Request $request The original REST request.
464 * @since 1.0.0
465 */
466 $donation_data = apply_filters( 'suredonation_create_donation_data', $donation_data, $request );
467
468 // Create the donation in database.
469 $donation_id = Donations::add( $donation_data );
470
471 if ( ! $donation_id ) {
472 return new WP_Error(
473 'create_failed',
474 __( 'Failed to create donation.', 'suredonation' ),
475 [ 'status' => 500 ]
476 );
477 }
478
479 $donation = Donations::get( $donation_id );
480
481 return new WP_REST_Response(
482 [
483 'success' => true,
484 'message' => __( 'Donation created successfully.', 'suredonation' ),
485 'donation' => is_array( $donation ) ? $this->format_donation( $donation ) : [],
486 ],
487 201
488 );
489 }
490
491 /**
492 * Update an existing donation.
493 *
494 * @param WP_REST_Request $request Request object.
495 * @return WP_REST_Response|WP_Error Response object.
496 * @since 0.0.1
497 */
498 public function update_donation( $request ) {
499 $donation_id = absint( $request->get_param( 'id' ) );
500
501 // Check if donation exists.
502 $donation = Donations::get( $donation_id );
503 if ( ! $donation ) {
504 return new WP_Error(
505 'donation_not_found',
506 __( 'Donation not found.', 'suredonation' ),
507 [ 'status' => 404 ]
508 );
509 }
510
511 // Build update data.
512 $update_data = [];
513 $fields = [
514 'campaign_id',
515 'donor_name',
516 'donor_email',
517 'donor_phone',
518 'amount',
519 'fees_covered',
520 'donation_type',
521 'is_anonymous',
522 'donor_comment',
523 'payment_status',
524 'gateway',
525 'transaction_id',
526 ];
527
528 foreach ( $fields as $field ) {
529 $value = $request->get_param( $field );
530 if ( ! is_null( $value ) ) {
531 if ( 'is_anonymous' === $field ) {
532 $update_data[ $field ] = $value ? 1 : 0;
533 } else {
534 $update_data[ $field ] = $value;
535 }
536 }
537 }
538
539 /**
540 * Filter donation update data before saving.
541 *
542 * Pro uses this to add subscription fields to the update.
543 *
544 * @param array<string, mixed> $update_data Data to update.
545 * @param \WP_REST_Request $request The REST request.
546 * @param int $donation_id Donation ID.
547 * @since 1.0.0
548 */
549 $update_data = apply_filters( 'suredonation_update_donation_data', $update_data, $request, $donation_id );
550
551 if ( ! empty( $update_data ) ) {
552 Donations::update( $donation_id, $update_data );
553 }
554
555 $updated_donation = Donations::get( $donation_id );
556
557 return new WP_REST_Response(
558 [
559 'success' => true,
560 'message' => __( 'Donation updated successfully.', 'suredonation' ),
561 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
562 ],
563 200
564 );
565 }
566
567 /**
568 * Update donation payment status.
569 *
570 * @param WP_REST_Request $request Request object.
571 * @return WP_REST_Response|WP_Error Response object.
572 * @since 0.0.1
573 */
574 public function update_donation_status( $request ) {
575 $donation_id = absint( $request->get_param( 'id' ) );
576 $status = $request->get_param( 'status' );
577
578 $donation = Donations::get( $donation_id );
579 if ( ! $donation ) {
580 return new WP_Error(
581 'donation_not_found',
582 __( 'Donation not found.', 'suredonation' ),
583 [ 'status' => 404 ]
584 );
585 }
586
587 $old_status = $donation['payment_status'] ?? 'pending';
588 Donations::update_status( $donation_id, $status );
589
590 // If status changed to completed, update donor stats.
591 if ( 'completed' !== $old_status && 'completed' === $status ) {
592 if ( ! empty( $donation['donor_id'] ) ) {
593 Donors::record_donation( $donation['donor_id'], floatval( $donation['amount'] ) );
594 }
595 }
596
597 return new WP_REST_Response(
598 [
599 'success' => true,
600 'message' => __( 'Donation status updated successfully.', 'suredonation' ),
601 ],
602 200
603 );
604 }
605
606 /**
607 * Delete donation.
608 *
609 * @param WP_REST_Request $request Request object.
610 * @return WP_REST_Response|WP_Error Response object.
611 * @since 0.0.1
612 */
613 public function delete_donation( $request ) {
614 $donation_id = absint( $request->get_param( 'id' ) );
615
616 $result = Donations::delete( $donation_id );
617
618 if ( ! $result ) {
619 return new WP_Error(
620 'delete_failed',
621 __( 'Failed to delete donation.', 'suredonation' ),
622 [ 'status' => 500 ]
623 );
624 }
625
626 return new WP_REST_Response(
627 [
628 'success' => true,
629 'message' => __( 'Donation deleted successfully.', 'suredonation' ),
630 ],
631 200
632 );
633 }
634
635 /**
636 * Bulk action on donations.
637 *
638 * @param WP_REST_Request $request Request object.
639 * @return WP_REST_Response|WP_Error Response object.
640 * @since 0.0.1
641 */
642 public function bulk_action( $request ) {
643 $action = $request->get_param( 'action' );
644 $ids = $request->get_param( 'ids' );
645
646 $success_count = 0;
647 $error_count = 0;
648
649 foreach ( $ids as $id ) {
650 $result = false;
651
652 if ( 'delete' === $action ) {
653 $result = Donations::delete( absint( $id ) );
654 } elseif ( 'update_status' === $action ) {
655 $status = $request->get_param( 'status' );
656 if ( $status ) {
657 $result = Donations::update_status( absint( $id ), $status );
658 }
659 }
660
661 if ( $result ) {
662 ++$success_count;
663 } else {
664 ++$error_count;
665 }
666 }
667
668 return new WP_REST_Response(
669 [
670 'success' => true,
671 'message' => sprintf(
672 // translators: %1$d: success count, %2$d: error count.
673 __( 'Bulk action completed. Success: %1$d, Failed: %2$d', 'suredonation' ),
674 $success_count,
675 $error_count
676 ),
677 'success_count' => $success_count,
678 'error_count' => $error_count,
679 ],
680 200
681 );
682 }
683
684 /**
685 * Refund a donation payment.
686 *
687 * @param WP_REST_Request $request Request object.
688 * @return WP_REST_Response|WP_Error Response object.
689 * @since 0.0.1
690 */
691 public function refund_donation( $request ) {
692 $donation_id = absint( $request->get_param( 'id' ) );
693 $transaction_id = $request->get_param( 'transaction_id' );
694 $refund_amount = absint( $request->get_param( 'refund_amount' ) );
695
696 // Get the donation.
697 $donation = Donations::get( $donation_id );
698 if ( ! $donation ) {
699 return new WP_Error(
700 'donation_not_found',
701 __( 'Donation not found.', 'suredonation' ),
702 [ 'status' => 404 ]
703 );
704 }
705
706 // Verify the donation is in a refundable state.
707 $refundable_statuses = [ 'completed', 'partially_refunded' ];
708 if ( ! in_array( $donation['payment_status'], $refundable_statuses, true ) ) {
709 return new WP_Error(
710 'not_refundable',
711 __( 'Only completed or partially refunded donations can be refunded.', 'suredonation' ),
712 [ 'status' => 400 ]
713 );
714 }
715
716 // Verify transaction ID matches.
717 if ( $transaction_id !== $donation['transaction_id'] ) {
718 return new WP_Error(
719 'transaction_mismatch',
720 __( 'Transaction ID mismatch.', 'suredonation' ),
721 [ 'status' => 400 ]
722 );
723 }
724
725 // Validate refund amount.
726 $gateway = $donation['gateway'] ?? 'stripe';
727 $currency = $donation['currency'] ?? 'USD';
728 $total_amount = $this->amount_to_stripe_format( floatval( $donation['amount'] ), $currency );
729 $refunded_amount = $this->amount_to_stripe_format( floatval( $donation['refunded_amount'] ?? 0 ), $currency );
730 $refundable = $total_amount - $refunded_amount;
731
732 if ( $refund_amount > $refundable ) {
733 return new WP_Error(
734 'exceeds_refundable',
735 sprintf(
736 /* translators: %s: maximum refundable amount */
737 __( 'Refund amount exceeds maximum refundable amount of %s.', 'suredonation' ),
738 $this->amount_from_stripe_format( $refundable, $currency )
739 ),
740 [ 'status' => 400 ]
741 );
742 }
743
744 // Process refund through the appropriate gateway.
745 if ( 'paypal' === $gateway ) {
746 $refund_amount_major = $this->amount_from_stripe_format( $refund_amount, $currency );
747 $refund_result = \SureDonation\Inc\Payments\PayPal\PayPal_Api_Payments::refund_capture(
748 $transaction_id,
749 $refund_amount_major,
750 $currency
751 );
752 } else {
753 // Check if Stripe is connected.
754 if ( ! Stripe_Helper::is_stripe_connected() ) {
755 return new WP_Error(
756 'stripe_not_connected',
757 __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
758 [ 'status' => 400 ]
759 );
760 }
761 $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer' );
762 }
763
764 if ( is_wp_error( $refund_result ) ) {
765 return new WP_Error(
766 'refund_failed',
767 $refund_result->get_error_message(),
768 [ 'status' => 500 ]
769 );
770 }
771
772 // Calculate new refunded amount in cents for comparison.
773 $new_refunded_in_cents = $refunded_amount + $refund_amount;
774
775 // Determine new status by comparing in cents to avoid floating point precision issues.
776 $new_status = $new_refunded_in_cents >= $total_amount ? 'refunded' : 'partially_refunded';
777
778 // Convert back to major currency unit for storage.
779 $new_refunded_amount = $this->amount_from_stripe_format( $new_refunded_in_cents, $currency );
780
781 // Store refund in donation_data FIRST (prevents webhook duplicate processing).
782 $refund_id = $refund_result['id'] ?? '';
783 if ( ! empty( $refund_id ) ) {
784 $refund_data = [
785 'refund_id' => $refund_id,
786 'amount' => absint( $refund_amount ),
787 'currency' => strtoupper( $currency ),
788 'status' => $refund_result['status'] ?? 'succeeded',
789 'created' => time(),
790 'reason' => 'requested_by_customer',
791 'refunded_by' => 'admin',
792 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
793 ];
794 Donations::add_refund_to_donation_data( $donation_id, $refund_data );
795 }
796
797 // Update donation record with new status and refunded amount.
798 Donations::update(
799 $donation_id,
800 [
801 'payment_status' => $new_status,
802 'refunded_amount' => $new_refunded_amount,
803 ]
804 );
805
806 // Determine refund type for log message.
807 $refund_type = $new_refunded_in_cents >= $total_amount
808 ? __( 'Full', 'suredonation' )
809 : __( 'Partial', 'suredonation' );
810
811 // Add log entry.
812 Donations::add_log(
813 $donation_id,
814 'refund',
815 sprintf(
816 /* translators: %s: Refund type (Full/Partial) */
817 __( '%s refund processed via admin', 'suredonation' ),
818 $refund_type
819 ),
820 [
821 'refund_id' => $refund_id,
822 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
823 'total_refunded' => $new_refunded_amount,
824 'original_amount' => floatval( $donation['amount'] ),
825 'payment_status' => $new_status,
826 'currency' => strtoupper( $currency ),
827 ]
828 );
829
830 // Send refund email notifications.
831 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
832 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
833 $donation_data = [
834 'id' => $donation_id,
835 'donor_name' => $donation['donor_name'] ?? '',
836 'donor_email' => $donation['donor_email'] ?? '',
837 'amount' => $donation['amount'] ?? 0,
838 'currency' => strtoupper( $currency ),
839 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
840 'donation_type' => $donation['donation_type'] ?? 'one-time',
841 'gateway' => 'stripe',
842 ];
843
844 Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
845
846 // Get updated donation.
847 $updated_donation = Donations::get( $donation_id );
848
849 return new WP_REST_Response(
850 [
851 'success' => true,
852 'message' => __( 'Refund processed successfully.', 'suredonation' ),
853 'refund_id' => $refund_id,
854 'status' => $refund_result['status'] ?? 'succeeded',
855 'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [],
856 ],
857 200
858 );
859 }
860 /**
861 * Check if user has permission to manage donations.
862 *
863 * @return bool True if user has permission.
864 * @since 0.0.1
865 */
866 public function check_permissions() {
867 return current_user_can( 'manage_options' );
868 }
869
870 /**
871 * Delete a log entry from a donation.
872 *
873 * @param WP_REST_Request $request Request object.
874 * @return WP_REST_Response|WP_Error Response object.
875 * @since 0.0.1
876 */
877 public function delete_donation_log( $request ) {
878 $donation_id = absint( $request->get_param( 'id' ) );
879 $log_index = absint( $request->get_param( 'log_index' ) );
880
881 // Get the donation from database.
882 $donation = Donations::get( $donation_id );
883
884 if ( ! $donation ) {
885 return new WP_Error(
886 'donation_not_found',
887 __( 'Donation not found.', 'suredonation' ),
888 [ 'status' => 404 ]
889 );
890 }
891
892 // Get current logs.
893 $logs = Donations::get_log( $donation_id );
894
895 if ( ! is_array( $logs ) || empty( $logs ) ) {
896 return new WP_Error(
897 'no_logs',
898 __( 'No logs found for this donation.', 'suredonation' ),
899 [ 'status' => 404 ]
900 );
901 }
902
903 // Check if log index exists.
904 if ( ! isset( $logs[ $log_index ] ) ) {
905 return new WP_Error(
906 'log_not_found',
907 __( 'Log entry not found.', 'suredonation' ),
908 [ 'status' => 404 ]
909 );
910 }
911
912 // Remove log at specified index.
913 array_splice( $logs, $log_index, 1 );
914
915 // Re-index array to prevent gaps.
916 $logs = array_values( $logs );
917
918 // Update log column with modified logs array.
919 $result = Donations::update( $donation_id, [ 'log' => $logs ] );
920
921 if ( false === $result ) {
922 return new WP_Error(
923 'update_failed',
924 __( 'Failed to delete log entry.', 'suredonation' ),
925 [ 'status' => 500 ]
926 );
927 }
928
929 return new WP_REST_Response(
930 [
931 'success' => true,
932 'message' => __( 'Log entry deleted successfully.', 'suredonation' ),
933 'logs' => $logs,
934 ],
935 200
936 );
937 }
938
939 /**
940 * Get notes for a donation.
941 *
942 * @param WP_REST_Request $request Request object.
943 * @return WP_REST_Response|WP_Error Response object.
944 * @since 0.0.1
945 */
946 public function get_donation_notes( $request ) {
947 $donation_id = absint( $request->get_param( 'id' ) );
948 $page = absint( $request->get_param( 'page' ) ) ?? 1;
949 $per_page = absint( $request->get_param( 'per_page' ) ) ?? 3;
950
951 // Get the donation from database.
952 $donation = Donations::get( $donation_id );
953
954 if ( ! $donation ) {
955 return new WP_Error(
956 'donation_not_found',
957 __( 'Donation not found.', 'suredonation' ),
958 [ 'status' => 404 ]
959 );
960 }
961
962 // Get paginated notes.
963 $notes_data = Donations::get_notes( $donation_id, $page, $per_page );
964
965 return new WP_REST_Response(
966 [
967 'success' => true,
968 'notes' => $notes_data['notes'],
969 'total' => $notes_data['total'],
970 'total_pages' => $notes_data['total_pages'],
971 ],
972 200
973 );
974 }
975
976 /**
977 * Add a note to a donation.
978 *
979 * @param WP_REST_Request $request Request object.
980 * @return WP_REST_Response|WP_Error Response object.
981 * @since 0.0.1
982 */
983 public function add_donation_note( $request ) {
984 $donation_id = absint( $request->get_param( 'id' ) );
985 $note = $request->get_param( 'note' );
986
987 // Get the donation from database.
988 $donation = Donations::get( $donation_id );
989
990 if ( ! $donation ) {
991 return new WP_Error(
992 'donation_not_found',
993 __( 'Donation not found.', 'suredonation' ),
994 [ 'status' => 404 ]
995 );
996 }
997
998 // Add the note.
999 $result = Donations::add_note( $donation_id, $note, get_current_user_id() );
1000
1001 if ( ! $result['success'] ) {
1002 return new WP_Error(
1003 'note_failed',
1004 __( 'Failed to add note.', 'suredonation' ),
1005 [ 'status' => 500 ]
1006 );
1007 }
1008
1009 return new WP_REST_Response(
1010 [
1011 'success' => true,
1012 'message' => __( 'Note added successfully.', 'suredonation' ),
1013 'note_id' => $result['note_id'],
1014 ],
1015 201
1016 );
1017 }
1018
1019 /**
1020 * Delete a note from a donation.
1021 *
1022 * @param WP_REST_Request $request Request object.
1023 * @return WP_REST_Response|WP_Error Response object.
1024 * @since 0.0.1
1025 */
1026 public function delete_donation_note( $request ) {
1027 $donation_id = absint( $request->get_param( 'id' ) );
1028 $note_id = $request->get_param( 'note_id' );
1029
1030 // Get the donation from database.
1031 $donation = Donations::get( $donation_id );
1032
1033 if ( ! $donation ) {
1034 return new WP_Error(
1035 'donation_not_found',
1036 __( 'Donation not found.', 'suredonation' ),
1037 [ 'status' => 404 ]
1038 );
1039 }
1040
1041 // Delete the note.
1042 $result = Donations::delete_note( $donation_id, $note_id );
1043
1044 if ( ! $result ) {
1045 return new WP_Error(
1046 'note_not_found',
1047 __( 'Note not found or could not be deleted.', 'suredonation' ),
1048 [ 'status' => 404 ]
1049 );
1050 }
1051
1052 return new WP_REST_Response(
1053 [
1054 'success' => true,
1055 'message' => __( 'Note deleted successfully.', 'suredonation' ),
1056 ],
1057 200
1058 );
1059 }
1060
1061 /**
1062 * Get donation arguments schema.
1063 *
1064 * @param bool $required Whether fields are required.
1065 * @return array<string, array<string, mixed>>
1066 * @since 0.0.1
1067 */
1068 private function get_donation_args( $required = true ) {
1069 return [
1070 'campaign_id' => [
1071 'required' => $required,
1072 'sanitize_callback' => 'absint',
1073 ],
1074 'donor_name' => [
1075 'sanitize_callback' => 'sanitize_text_field',
1076 ],
1077 'donor_email' => [
1078 'sanitize_callback' => 'sanitize_email',
1079 ],
1080 'donor_phone' => [
1081 'sanitize_callback' => 'sanitize_text_field',
1082 ],
1083 'amount' => [
1084 'required' => $required,
1085 'sanitize_callback' => static function ( $value ) {
1086 return floatval( $value );
1087 },
1088 ],
1089 'fees_covered' => [
1090 'sanitize_callback' => static function ( $value ) {
1091 return floatval( $value );
1092 },
1093 ],
1094 'donation_type' => [
1095 'default' => 'one-time',
1096 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1097 'sanitize_callback' => 'sanitize_text_field',
1098 'validate_callback' => static function ( $param ) {
1099 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1100 },
1101 ],
1102 'is_anonymous' => [
1103 'sanitize_callback' => 'rest_sanitize_boolean',
1104 ],
1105 'donor_comment' => [
1106 'sanitize_callback' => 'wp_kses_post',
1107 ],
1108 'payment_status' => [
1109 'default' => 'pending',
1110 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
1111 'sanitize_callback' => 'sanitize_text_field',
1112 'validate_callback' => static function ( $param ) {
1113 return in_array( $param, [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], true );
1114 },
1115 ],
1116 'gateway' => [
1117 'sanitize_callback' => 'sanitize_text_field',
1118 ],
1119 'transaction_id' => [
1120 'sanitize_callback' => 'sanitize_text_field',
1121 ],
1122 ];
1123 }
1124
1125 /**
1126 * Validate REST date filter parameters.
1127 *
1128 * @param mixed $param Date parameter.
1129 * @return bool Whether the date is valid.
1130 * @since 0.0.1
1131 */
1132 public function validate_date_param( $param ) {
1133 if ( '' === $param || null === $param ) {
1134 return true;
1135 }
1136
1137 return is_string( $param ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $param );
1138 }
1139
1140 /**
1141 * Convert amount to Stripe's smallest currency unit.
1142 *
1143 * @param float $amount Amount in major currency unit.
1144 * @param string $currency Currency code.
1145 * @return int Amount in smallest currency unit.
1146 * @since 0.0.1
1147 */
1148 private function amount_to_stripe_format( $amount, $currency ) {
1149 $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1150 return in_array( strtoupper( $currency ), $zero_decimal, true )
1151 ? (int) round( $amount )
1152 : (int) round( $amount * 100 );
1153 }
1154
1155 /**
1156 * Convert amount from Stripe's smallest currency unit.
1157 *
1158 * @param int $amount Amount in smallest currency unit.
1159 * @param string $currency Currency code.
1160 * @return float Amount in major currency unit.
1161 * @since 0.0.1
1162 */
1163 private function amount_from_stripe_format( $amount, $currency ) {
1164 $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1165 return in_array( strtoupper( $currency ), $zero_decimal, true )
1166 ? (float) $amount
1167 : (float) $amount / 100;
1168 }
1169
1170 /**
1171 * Format donation data for API response.
1172 *
1173 * @param array<string, mixed> $donation Donation data from database.
1174 * @return array<string, mixed> Formatted donation data.
1175 * @since 0.0.1
1176 */
1177 private function format_donation( $donation ) {
1178 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
1179 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
1180 $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
1181
1182 // Get payment logs for this donation.
1183 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
1184
1185 // Get payment mode for Stripe dashboard URL.
1186 $payment_mode = $donation['payment_mode'] ?? 'test';
1187
1188 $form_edit_url = '';
1189 if ( $form_id && current_user_can( 'edit_post', $form_id ) ) {
1190 $form_edit_url = esc_url_raw( get_edit_post_link( $form_id, 'raw' ) );
1191 }
1192
1193 // Parse donation_data for subscription metadata.
1194 $donation_data = $donation['donation_data'] ?? [];
1195 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1196 $donation_data = json_decode( $donation_data, true );
1197 }
1198 if ( ! is_array( $donation_data ) ) {
1199 $donation_data = [];
1200 }
1201
1202 return [
1203 'id' => $donation_id,
1204 'campaign_id' => $campaign_id,
1205 'campaign_title' => $campaign_id ? wp_kses_post( (string) get_the_title( $campaign_id ) ) : '',
1206 'form_id' => $form_id,
1207 'form_title' => $form_id ? wp_kses_post( (string) get_the_title( $form_id ) ) : '',
1208 'form_edit_url' => $form_edit_url,
1209 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1210 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1211 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
1212 'donor_phone' => esc_html( Helper::get_string_value( $donation['donor_phone'] ?? '' ) ),
1213 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1214 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1215 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1216 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1217 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1218 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1219 'donor_comment' => wp_kses_post( Helper::get_string_value( $donation['donor_comment'] ?? '' ) ),
1220 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1221 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1222 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1223 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
1224 'stripe_customer_id' => esc_html( Helper::get_string_value( $donation['customer_id'] ?? '' ) ),
1225 'subscription_id' => esc_html( Helper::get_string_value( $donation['subscription_id'] ?? '' ) ),
1226 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1227 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1228 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1229 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1230 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1231 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1232 'logs' => $logs,
1233 ];
1234 }
1235 }
1236