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

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

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