PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 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
← All changes | inc/api/donations-api.php +381 -103 0.0.1 → 1.6.1 View file →
@@ -8,10 +8,12 @@
8 8 namespace SureDonation\Inc\API;
9 9
10 10 use SureDonation\Inc\Database\Tables\Donations;
11 11 use SureDonation\Inc\Database\Tables\Donors;
12 +use SureDonation\Inc\Emails\Email_Handler;
12 13 use SureDonation\Inc\Helper;
13 14 use SureDonation\Inc\Payments\Payment_Helper;
15 +use SureDonation\Inc\Pdf\Receipt_Generator;
14 16 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
15 17 use WP_Error;
16 18 use WP_REST_Request;
17 19 use WP_REST_Response;
@@ -41,8 +43,18 @@
41 43 [
42 44 'methods' => WP_REST_Server::READABLE,
43 45 'callback' => [ $this, 'get_donations' ],
44 46 'permission_callback' => [ $this, 'check_permissions' ],
47 + 'args' => [
48 + 'after' => [
49 + 'sanitize_callback' => 'sanitize_text_field',
50 + 'validate_callback' => [ $this, 'validate_date_param' ],
51 + ],
52 + 'before' => [
53 + 'sanitize_callback' => 'sanitize_text_field',
54 + 'validate_callback' => [ $this, 'validate_date_param' ],
55 + ],
56 + ],
45 57 ],
46 58 [
47 59 'methods' => WP_REST_Server::CREATABLE,
48 60 'callback' => [ $this, 'create_donation' ],
@@ -109,10 +121,21 @@
109 121 return is_numeric( $param );
110 122 },
111 123 ],
112 124 'status' => [
113 - 'required' => true,
114 - 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
125 + 'required' => true,
126 + 'type' => 'string',
127 + // Sourced from the table's own whitelist rather than
128 + // restated: the two lists had already drifted — suspicious
129 + // is written on an amount mismatch and was missing here.
130 + 'enum' => Donations::get_valid_statuses(),
131 + 'sanitize_callback' => 'sanitize_text_field',
132 + // Required for the enum to be enforced at all. An arg with
133 + // a sanitize_callback and no validate_callback has its enum
134 + // silently skipped (see #340), so this endpoint answered
135 + // "updated successfully" to a status it had refused to
136 + // write.
137 + 'validate_callback' => 'rest_validate_request_arg',
115 138 ],
116 139 ],
117 140 ],
118 141
@@ -137,10 +160,13 @@
137 160 'callback' => [ $this, 'bulk_action' ],
138 161 'permission_callback' => [ $this, 'check_permissions' ],
139 162 'args' => [
140 163 'action' => [
141 - 'required' => true,
142 - 'enum' => [ 'delete', 'update_status' ],
164 + 'required' => true,
165 + 'type' => 'string',
166 + 'enum' => [ 'delete', 'update_status' ],
167 + 'sanitize_callback' => 'sanitize_text_field',
168 + 'validate_callback' => 'rest_validate_request_arg',
143 169 ],
144 170 'ids' => [
145 171 'required' => true,
146 172 'validate_callback' => static function ( $param ) {
@@ -147,9 +173,12 @@
147 173 return is_array( $param ) && ! empty( $param );
148 174 },
149 175 ],
150 176 'status' => [
151 - 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
177 + 'type' => 'string',
178 + 'enum' => Donations::get_valid_statuses(),
179 + 'sanitize_callback' => 'sanitize_text_field',
180 + 'validate_callback' => 'rest_validate_request_arg',
152 181 ],
153 182 ],
154 183 ],
155 184
@@ -173,10 +202,17 @@
173 202 'required' => true,
174 203 'sanitize_callback' => 'absint',
175 204 ],
176 205 'refund_type' => [
177 - 'required' => true,
178 - 'enum' => [ 'full', 'partial' ],
206 + 'required' => true,
207 + 'type' => 'string',
208 + 'enum' => [ 'full', 'partial' ],
209 + 'sanitize_callback' => 'sanitize_text_field',
210 + // The last arg in this file carrying the #340 shape: an
211 + // enum that reads as enforced and is not. rest_validate_
212 + // request_arg() reads the schema's type, so the type above
213 + // is not decoration.
214 + 'validate_callback' => 'rest_validate_request_arg',
179 215 ],
180 216 'refund_notes' => [
181 217 'sanitize_callback' => 'sanitize_textarea_field',
182 218 ],
@@ -307,13 +343,16 @@
307 343 * @return WP_REST_Response|WP_Error Response object.
308 344 * @since 0.0.1
309 345 */
310 346 public function get_donations( $request ) {
311 - $page = $request->get_param( 'page' ) ?? 1;
312 - $per_page = $request->get_param( 'per_page' ) ?? 20;
347 + $page = $request->get_param( 'page' ) ?? 1;
348 + // Clamp to a minimum of 1 so the total_pages calculation below can never
349 + // divide by zero (per_page=0 would otherwise trigger a DivisionByZeroError).
350 + $per_page = max( 1, absint( $request->get_param( 'per_page' ) ?? 20 ) );
313 351 $search = $request->get_param( 'search' ) ?? '';
314 352 $status = $request->get_param( 'status' ) ?? 'all';
315 353 $campaign = $request->get_param( 'campaign' ) ?? '';
354 + $donor = $request->get_param( 'donor' ) ?? '';
316 355 $sort_by = $request->get_param( 'sort_by' ) ?? 'created_at';
317 356 $order = $request->get_param( 'order' ) ?? 'desc';
318 357
319 358 // Calculate pagination.
@@ -319,21 +358,28 @@
319 358 // Calculate pagination.
320 359 $limit = absint( $per_page );
321 360 $offset = ( absint( $page ) - 1 ) * $limit;
322 361
323 - // Get donations from database using admin list method with filters.
324 - $results = Donations::get_admin_list(
325 - $status,
326 - ! empty( $campaign ) ? absint( $campaign ) : 0,
327 - sanitize_text_field( $search ),
328 - $limit,
329 - $offset,
330 - $sort_by, // using whitelist validation in the method.
331 - strtoupper( $order ) // using whitelist validation in the method.
332 - );
362 + // If filtering by donor, use the donor-specific query.
363 + if ( ! empty( $donor ) ) {
364 + $donor_data = Donations::get_by_donor_id( absint( $donor ), $limit, $offset );
365 + $results = $donor_data['donations'];
366 + $total = $donor_data['total'];
367 + } else {
368 + // Get donations from database using admin list method with filters.
369 + $results = Donations::get_admin_list(
370 + $status,
371 + ! empty( $campaign ) ? absint( $campaign ) : 0,
372 + sanitize_text_field( $search ),
373 + $limit,
374 + $offset,
375 + $sort_by, // using whitelist validation in the method.
376 + strtoupper( $order ) // using whitelist validation in the method.
377 + );
333 378
334 - // Get total count.
335 - $total = Donations::get_total_donations_by_status( $status, ! empty( $campaign ) ? absint( $campaign ) : 0 );
379 + // Get total count.
380 + $total = Donations::count_admin_list( $status, ! empty( $campaign ) ? absint( $campaign ) : 0, sanitize_text_field( $search ) );
381 + }
336 382
337 383 // Format donations data.
338 384 $donations = [];
339 385 foreach ( $results as $donation ) {
@@ -411,28 +457,40 @@
411 457 if ( ! empty( $donor_email ) ) {
412 458 $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
413 459 }
414 460
461 + // Build donation data — pro can add subscription fields via filter.
462 + $donation_data = [
463 + 'campaign_id' => $campaign_id,
464 + 'donor_id' => $donor_id ? $donor_id : 0,
465 + 'amount' => $amount,
466 + 'fees_covered' => $fees_covered,
467 + 'currency' => Payment_Helper::get_currency(),
468 + 'gateway' => $gateway,
469 + 'payment_status' => $payment_status,
470 + 'payment_mode' => Payment_Helper::get_payment_mode(),
471 + 'donor_name' => $donor_name,
472 + 'donor_email' => $donor_email,
473 + 'donor_phone' => $donor_phone,
474 + 'is_anonymous' => $is_anonymous ? 1 : 0,
475 + 'donation_type' => $donation_type,
476 + 'donor_comment' => $donor_comment,
477 + 'transaction_id' => $transaction_id,
478 + ];
479 +
480 + /**
481 + * Filter donation data before insertion.
482 + *
483 + * Pro uses this to add subscription_id, subscription_status, parent_subscription_id.
484 + *
485 + * @param array<string, mixed> $donation_data Donation data to insert.
486 + * @param \WP_REST_Request $request The original REST request.
487 + * @since 1.0.0
488 + */
489 + $donation_data = apply_filters( 'suredonation_create_donation_data', $donation_data, $request );
490 +
415 491 // Create the donation in database.
416 - $donation_id = Donations::add(
417 - [
418 - 'campaign_id' => $campaign_id,
419 - 'donor_id' => $donor_id ? $donor_id : 0,
420 - 'amount' => $amount,
421 - 'fees_covered' => $fees_covered,
422 - 'currency' => Payment_Helper::get_currency(),
423 - 'gateway' => $gateway,
424 - 'payment_status' => $payment_status,
425 - 'payment_mode' => Payment_Helper::get_payment_mode(),
426 - 'donor_name' => $donor_name,
427 - 'donor_email' => $donor_email,
428 - 'donor_phone' => $donor_phone,
429 - 'is_anonymous' => $is_anonymous ? 1 : 0,
430 - 'donation_type' => $donation_type,
431 - 'donor_comment' => $donor_comment,
432 - 'transaction_id' => $transaction_id,
433 - ]
434 - );
492 + $donation_id = Donations::add( $donation_data );
435 493
436 494 if ( ! $donation_id ) {
437 495 return new WP_Error(
438 496 'create_failed',
@@ -484,8 +542,9 @@
484 542 'fees_covered',
485 543 'donation_type',
486 544 'is_anonymous',
487 545 'donor_comment',
546 + 'donor_comment_status',
488 547 'payment_status',
489 548 'gateway',
490 549 'transaction_id',
491 550 ];
@@ -500,8 +559,20 @@
500 559 }
501 560 }
502 561 }
503 562
563 + /**
564 + * Filter donation update data before saving.
565 + *
566 + * Pro uses this to add subscription fields to the update.
567 + *
568 + * @param array<string, mixed> $update_data Data to update.
569 + * @param \WP_REST_Request $request The REST request.
570 + * @param int $donation_id Donation ID.
571 + * @since 1.0.0
572 + */
573 + $update_data = apply_filters( 'suredonation_update_donation_data', $update_data, $request, $donation_id );
574 +
504 575 if ( ! empty( $update_data ) ) {
505 576 Donations::update( $donation_id, $update_data );
506 577 }
507 578
@@ -537,14 +608,39 @@
537 608 );
538 609 }
539 610
540 611 $old_status = $donation['payment_status'] ?? 'pending';
541 - Donations::update_status( $donation_id, $status );
612 + $updated = Donations::update_status( $donation_id, $status );
542 613
614 + // Strictly false, which is update_status() refusing the value. A 0 is
615 + // $wpdb->update() reporting that no row changed, which cannot mean "no
616 + // such row" here because the 404 above already proved it exists, and
617 + // cannot mean "same status" either because update() always writes
618 + // updated_at. Treating both as success is how a refused write looked
619 + // like a successful one to every client.
620 + //
621 + // Note for anyone comparing this with bulk_action(): that path has no
622 + // existence check, so a 0 there does mean "no such row" and is
623 + // correctly counted as a failure. The two are not in conflict.
624 + if ( false === $updated ) {
625 + return new WP_Error(
626 + 'donation_status_not_updated',
627 + __( 'The donation status could not be updated.', 'suredonation' ),
628 + [ 'status' => 500 ]
629 + );
630 + }
631 +
543 632 // If status changed to completed, update donor stats.
633 + //
634 + // Guarded, not plain: an admin completing a still-pending donation here
635 + // does not stop the gateway webhook arriving for the same row later
636 + // (Stripe retries for days), and the webhook's donor block has no
637 + // "still pending" check of its own. Without a marker written here, that
638 + // webhook would record the same donation a second time and double the
639 + // donor's total, count and largest gift.
544 640 if ( 'completed' !== $old_status && 'completed' === $status ) {
545 641 if ( ! empty( $donation['donor_id'] ) ) {
546 - Donors::record_donation( $donation['donor_id'], floatval( $donation['amount'] ) );
642 + Donors::record_donation_once( $donation['donor_id'], floatval( $donation['amount'] ), $donation_id );
547 643 }
548 644 }
549 645
550 646 return new WP_REST_Response(
@@ -568,8 +664,29 @@
568 664
569 665 $result = Donations::delete( $donation_id );
570 666
571 667 if ( ! $result ) {
668 + // A donation is kept, deliberately, when its receipt PDF could not
669 + // be removed, so that the pointer stays reachable for a retry
670 + // instead of the file being orphaned. That reads as an unexplained
671 + // failure unless it is named: the admin has to fix the filesystem,
672 + // not retry.
673 + //
674 + // Ask the helper again rather than inferring from the surviving
675 + // pointer. It is idempotent and reports whether a file is still
676 + // there, so this is the fact rather than a guess: a row whose
677 + // DELETE failed after its receipt was already removed would
678 + // otherwise be reported as an uploads-permissions problem.
679 + $donation = Donations::get( $donation_id );
680 +
681 + if ( is_array( $donation ) && ! Receipt_Generator::delete_receipt( Helper::get_string_value( $donation['receipt_pdf_url'] ?? '' ) ) ) {
682 + return new WP_Error(
683 + 'receipt_delete_failed',
684 + __( 'This donation was kept because its PDF receipt could not be removed from the uploads folder. Deleting the record on its own would leave the receipt behind. Check the permissions on the uploads folder, then try again.', 'suredonation' ),
685 + [ 'status' => 500 ]
686 + );
687 + }
688 +
572 689 return new WP_Error(
573 690 'delete_failed',
574 691 __( 'Failed to delete donation.', 'suredonation' ),
575 692 [ 'status' => 500 ]
@@ -595,8 +712,26 @@
595 712 public function bulk_action( $request ) {
596 713 $action = $request->get_param( 'action' );
597 714 $ids = $request->get_param( 'ids' );
598 715
716 + if ( ! is_array( $ids ) ) {
717 + $ids = [];
718 + }
719 +
720 + // Cap bulk operations at 200 IDs per request. Each ID triggers a
721 + // per-row SELECT + DELETE / UPDATE — an arbitrarily large array in one
722 + // request would chew through the database serially and time out the
723 + // response. 200 is enough headroom for any realistic admin UI
724 + // selection; larger jobs should be split client-side (parity with the
725 + // donors bulk-action endpoint).
726 + if ( count( $ids ) > 200 ) {
727 + return new WP_Error(
728 + 'too_many_items',
729 + __( 'Bulk actions are limited to 200 donations per request.', 'suredonation' ),
730 + [ 'status' => 400 ]
731 + );
732 + }
733 +
599 734 $success_count = 0;
600 735 $error_count = 0;
601 736
602 737 foreach ( $ids as $id ) {
@@ -674,18 +809,10 @@
674 809 [ 'status' => 400 ]
675 810 );
676 811 }
677 812
678 - // Check if Stripe is connected.
679 - if ( ! Stripe_Helper::is_stripe_connected() ) {
680 - return new WP_Error(
681 - 'stripe_not_connected',
682 - __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
683 - [ 'status' => 400 ]
684 - );
685 - }
686 -
687 813 // Validate refund amount.
814 + $gateway = $donation['gateway'] ?? 'stripe';
688 815 $currency = $donation['currency'] ?? 'USD';
689 816 $total_amount = $this->amount_to_stripe_format( floatval( $donation['amount'] ), $currency );
690 817 $refunded_amount = $this->amount_to_stripe_format( floatval( $donation['refunded_amount'] ?? 0 ), $currency );
691 818 $refundable = $total_amount - $refunded_amount;
@@ -701,10 +828,28 @@
701 828 [ 'status' => 400 ]
702 829 );
703 830 }
704 831
705 - // Process refund through Stripe.
706 - $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer' );
832 + // Process refund through the appropriate gateway.
833 + if ( 'paypal' === $gateway ) {
834 + $refund_amount_major = $this->amount_from_stripe_format( $refund_amount, $currency );
835 + $refund_result = \SureDonation\Inc\Payments\PayPal\PayPal_Api_Payments::refund_capture(
836 + $transaction_id,
837 + $refund_amount_major,
838 + $currency
839 + );
840 + } else {
841 + // Check if Stripe is connected.
842 + if ( ! Stripe_Helper::is_stripe_connected() ) {
843 + return new WP_Error(
844 + 'stripe_not_connected',
845 + __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
846 + [ 'status' => 400 ]
847 + );
848 + }
849 + $refund_account_id = isset( $donation['stripe_account_id'] ) && is_string( $donation['stripe_account_id'] ) ? $donation['stripe_account_id'] : '';
850 + $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer', $refund_account_id );
851 + }
707 852
708 853 if ( is_wp_error( $refund_result ) ) {
709 854 return new WP_Error(
710 855 'refund_failed',
@@ -770,8 +915,24 @@
770 915 'currency' => strtoupper( $currency ),
771 916 ]
772 917 );
773 918
919 + // Send refund email notifications.
920 + $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
921 + $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
922 + $donation_data = [
923 + 'id' => $donation_id,
924 + 'donor_name' => $donation['donor_name'] ?? '',
925 + 'donor_email' => $donation['donor_email'] ?? '',
926 + 'amount' => $donation['amount'] ?? 0,
927 + 'currency' => strtoupper( $currency ),
928 + 'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ),
929 + 'donation_type' => $donation['donation_type'] ?? 'one-time',
930 + 'gateway' => 'stripe',
931 + ];
932 +
933 + Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
934 +
774 935 // Get updated donation.
775 936 $updated_donation = Donations::get( $donation_id );
776 937
777 938 return new WP_REST_Response(
@@ -784,9 +945,8 @@
784 945 ],
785 946 200
786 947 );
787 948 }
788 -
789 949 /**
790 950 * Check if user has permission to manage donations.
791 951 *
792 952 * @return bool True if user has permission.
@@ -995,52 +1155,109 @@
995 1155 * @since 0.0.1
996 1156 */
997 1157 private function get_donation_args( $required = true ) {
998 1158 return [
999 - 'campaign_id' => [
1159 + 'campaign_id' => [
1000 1160 'required' => $required,
1001 1161 'sanitize_callback' => 'absint',
1002 1162 ],
1003 - 'donor_name' => [
1163 + 'donor_name' => [
1004 1164 'sanitize_callback' => 'sanitize_text_field',
1005 1165 ],
1006 - 'donor_email' => [
1166 + 'donor_email' => [
1007 1167 'sanitize_callback' => 'sanitize_email',
1008 1168 ],
1009 - 'donor_phone' => [
1169 + 'donor_phone' => [
1010 1170 'sanitize_callback' => 'sanitize_text_field',
1011 1171 ],
1012 - 'amount' => [
1172 + 'amount' => [
1013 1173 'required' => $required,
1014 - 'sanitize_callback' => 'floatval',
1174 + 'sanitize_callback' => static function ( $value ) {
1175 + return floatval( $value );
1176 + },
1015 1177 ],
1016 - 'fees_covered' => [
1017 - 'sanitize_callback' => 'floatval',
1178 + 'fees_covered' => [
1179 + 'sanitize_callback' => static function ( $value ) {
1180 + return floatval( $value );
1181 + },
1018 1182 ],
1019 - 'donation_type' => [
1020 - 'default' => 'one-time',
1021 - 'enum' => [ 'one-time', 'recurring' ],
1183 + // No 'default' on this or 'payment_status' below, deliberately. These args
1184 + // are shared with the update route, where WordPress fills an absent param
1185 + // with its declared default before the callback runs — so update_donation()'
1186 + // s `! is_null()` test passes and the field is written even though the
1187 + // client never sent it. A partial update (e.g. the Donor Comment panel
1188 + // sending only donor_comment_status) therefore reset payment_status to
1189 + // 'pending' and donation_type to 'one-time', un-completing the donation and
1190 + // downgrading a subscription. create_donation() supplies its own fallbacks
1191 + // (`?? 'pending'`, `?? 'one-time'`), so nothing depends on the defaults here.
1192 + 'donation_type' => [
1193 + 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1194 + 'sanitize_callback' => 'sanitize_text_field',
1195 + 'validate_callback' => static function ( $param ) {
1196 + return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1197 + },
1022 1198 ],
1023 - 'is_anonymous' => [
1199 + 'is_anonymous' => [
1024 1200 'sanitize_callback' => 'rest_sanitize_boolean',
1025 1201 ],
1026 - 'donor_comment' => [
1027 - 'sanitize_callback' => 'wp_kses_post',
1202 + 'donor_comment' => [
1203 + // sanitize_textarea_field, matching the capture path in
1204 + // Payment_Helper::get_mapped_donor_comment(). wp_kses_post() was
1205 + // actively destructive here: it parses anything tag-shaped, so a
1206 + // moderator saving the comment "a < b and 3 > 2" stored "a <b> 2"
1207 + // — losing " and 3 " — and any surviving markup then rendered as
1208 + // literal angle brackets, because the campaign page esc_html()s.
1209 + // Both sanitizers preserve the donor's newlines.
1210 + 'sanitize_callback' => 'sanitize_textarea_field',
1028 1211 ],
1029 - 'payment_status' => [
1030 - 'default' => 'pending',
1031 - 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
1212 + 'donor_comment_status' => [
1213 + 'enum' => [ 'approved', 'pending', 'rejected' ],
1214 + 'sanitize_callback' => 'sanitize_text_field',
1215 + // A sanitize_callback silently disables `enum` enforcement, so the
1216 + // allowed set is checked here too — otherwise any string would reach
1217 + // the column and every comment would read as un-approved.
1218 + 'validate_callback' => static function ( $param ) {
1219 + return in_array( $param, Donations::get_valid_comment_statuses(), true );
1220 + },
1032 1221 ],
1033 - 'gateway' => [
1222 + 'payment_status' => [
1223 + 'type' => 'string',
1224 + // Deliberately no 'default'. These args are shared with the
1225 + // update route, and WordPress fills an absent param with its
1226 + // default before the callback runs — so update_donation()'s
1227 + // `! is_null()` test passes and the status is overwritten on a
1228 + // partial update the client never sent it in. dev carries the
1229 + // default; keeping it here would reinstate that bug. See
1230 + // Test_Donations_API::test_update_donation_ignores_unsent_fields().
1231 + 'enum' => Donations::get_valid_statuses(),
1034 1232 'sanitize_callback' => 'sanitize_text_field',
1233 + 'validate_callback' => 'rest_validate_request_arg',
1035 1234 ],
1036 - 'transaction_id' => [
1235 + 'gateway' => [
1037 1236 'sanitize_callback' => 'sanitize_text_field',
1038 1237 ],
1238 + 'transaction_id' => [
1239 + 'sanitize_callback' => 'sanitize_text_field',
1240 + ],
1039 1241 ];
1040 1242 }
1041 1243
1042 1244 /**
1245 + * Validate REST date filter parameters.
1246 + *
1247 + * @param mixed $param Date parameter.
1248 + * @return bool Whether the date is valid.
1249 + * @since 0.0.1
1250 + */
1251 + public function validate_date_param( $param ) {
1252 + if ( '' === $param || null === $param ) {
1253 + return true;
1254 + }
1255 +
1256 + return is_string( $param ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $param );
1257 + }
1258 +
1259 + /**
1043 1260 * Convert amount to Stripe's smallest currency unit.
1044 1261 *
1045 1262 * @param float $amount Amount in major currency unit.
1046 1263 * @param string $currency Currency code.
@@ -1047,12 +1264,13 @@
1047 1264 * @return int Amount in smallest currency unit.
1048 1265 * @since 0.0.1
1049 1266 */
1050 1267 private function amount_to_stripe_format( $amount, $currency ) {
1051 - $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1052 - return in_array( strtoupper( $currency ), $zero_decimal, true )
1053 - ? (int) round( $amount )
1054 - : (int) round( $amount * 100 );
1268 + // Delegates rather than repeating the zero-decimal list: the abilities
1269 + // layer guards refunds with Payment_Helper, so a second hardcoded list
1270 + // here could disagree with the guard about what a currency's minor unit
1271 + // is. Payment_Helper derives it from the currency data table.
1272 + return Payment_Helper::amount_to_stripe_format( $amount, $currency );
1055 1273 }
1056 1274
1057 1275 /**
1058 1276 * Convert amount from Stripe's smallest currency unit.
@@ -1062,12 +1280,9 @@
1062 1280 * @return float Amount in major currency unit.
1063 1281 * @since 0.0.1
1064 1282 */
1065 1283 private function amount_from_stripe_format( $amount, $currency ) {
1066 - $zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];
1067 - return in_array( strtoupper( $currency ), $zero_decimal, true )
1068 - ? (float) $amount
1069 - : (float) $amount / 100;
1284 + return Payment_Helper::amount_from_stripe_format( $amount, $currency );
1070 1285 }
1071 1286
1072 1287 /**
1073 1288 * Format donation data for API response.
@@ -1078,8 +1293,9 @@
1078 1293 */
1079 1294 private function format_donation( $donation ) {
1080 1295 $campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0;
1081 1296 $donation_id = isset( $donation['id'] ) ? Helper::get_integer_value( $donation['id'] ) : 0;
1297 + $form_id = isset( $donation['form_id'] ) ? Helper::get_integer_value( $donation['form_id'] ) : 0;
1082 1298
1083 1299 // Get payment logs for this donation.
1084 1300 $logs = $donation_id ? Donations::get_log( $donation_id ) : [];
1085 1301
@@ -1085,30 +1301,92 @@
1085 1301
1086 1302 // Get payment mode for Stripe dashboard URL.
1087 1303 $payment_mode = $donation['payment_mode'] ?? 'test';
1088 1304
1305 + $form_edit_url = '';
1306 + if ( $form_id && current_user_can( 'edit_post', $form_id ) ) {
1307 + $form_edit_url = esc_url_raw( get_edit_post_link( $form_id, 'raw' ) );
1308 + }
1309 +
1310 + // Parse donation_data for subscription metadata.
1311 + $donation_data = $donation['donation_data'] ?? [];
1312 + if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1313 + $donation_data = json_decode( $donation_data, true );
1314 + }
1315 + if ( ! is_array( $donation_data ) ) {
1316 + $donation_data = [];
1317 + }
1318 +
1319 + // Build the persisted submitted fields list (label/value/group). The
1320 + // group is the parent block label (e.g. "Address") used to nest
1321 + // sub-fields on the entry screen; '' for standalone fields.
1322 + $submitted_fields = [];
1323 + if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) {
1324 + foreach ( $donation_data['fields'] as $slug => $field ) {
1325 + if ( ! is_array( $field ) ) {
1326 + continue;
1327 + }
1328 + // sanitize_text_field (not esc_html) for REST data: the values are
1329 + // already sanitized at write time and React escapes on render, so
1330 + // esc_html here would double-encode (e.g. "Cats & Dogs" -> "Cats &amp; Dogs").
1331 + $submitted_fields[] = [
1332 + // The stored key. Labels are admin-editable and translatable;
1333 + // an add-on that presents a group its own way matches on this.
1334 + 'slug' => sanitize_text_field( Helper::get_string_value( $slug ) ),
1335 + 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ),
1336 + // Checkbox fields store a canonical untranslated token so the
1337 + // stored column stays locale-stable; it is translated here, on
1338 + // read, for the entry screen. Non-checkbox values pass through.
1339 + 'value' => sanitize_text_field( Helper::format_checkbox_field_value( $field['value'] ?? '' ) ),
1340 + 'group' => sanitize_text_field( Helper::get_string_value( $field['group'] ?? '' ) ),
1341 + ];
1342 + }
1343 + }
1344 +
1089 1345 return [
1090 - 'id' => $donation_id,
1091 - 'campaign_id' => $campaign_id,
1092 - 'campaign_title' => $campaign_id ? wp_kses_post( get_the_title( $campaign_id ) ) : '',
1093 - 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1094 - 'donor_name' => $donation['donor_name'] ?? '',
1095 - 'donor_email' => $donation['donor_email'] ?? '',
1096 - 'donor_phone' => $donation['donor_phone'] ?? '',
1097 - 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1098 - 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1099 - 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1100 - 'currency' => $donation['currency'] ?? 'USD',
1101 - 'donation_type' => $donation['donation_type'] ?? 'one-time',
1102 - 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1103 - 'donor_comment' => $donation['donor_comment'] ?? '',
1104 - 'payment_status' => $donation['payment_status'] ?? 'pending',
1105 - 'payment_mode' => $payment_mode,
1106 - 'gateway' => $donation['gateway'] ?? '',
1107 - 'transaction_id' => $donation['transaction_id'] ?? '',
1108 - 'stripe_customer_id' => $donation['customer_id'] ?? '',
1109 - 'created_at' => $donation['created_at'] ?? '',
1110 - 'updated_at' => $donation['updated_at'] ?? '',
1111 - 'logs' => $logs,
1346 + 'id' => $donation_id,
1347 + 'campaign_id' => $campaign_id,
1348 + // Plain-text titles rendered by React (which escapes text nodes and does
1349 + // not decode HTML entities). get_the_title() runs wptexturize, whose
1350 + // default replacements are entities (e.g. " - " -> "&#8211;"), so decode
1351 + // them here; wp_kses_post would leave the entity and it would show raw.
1352 + 'campaign_title' => $campaign_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $campaign_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1353 + 'form_id' => $form_id,
1354 + 'form_title' => $form_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $form_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1355 + 'form_edit_url' => $form_edit_url,
1356 + 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1357 + 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1358 + 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
1359 + 'donor_phone' => esc_html( Helper::get_string_value( $donation['donor_phone'] ?? '' ) ),
1360 + 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
1361 + 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
1362 + 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1363 + 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1364 + 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1365 + 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1366 + // Returned raw, unlike its neighbours. The only consumer is the React
1367 + // moderation panel, which renders it as a text child and so escapes it
1368 + // itself; and DonorCommentSection writes this value straight back on
1369 + // Save. Running it through wp_kses_post() here therefore did not
1370 + // protect anything — it parsed anything tag-shaped and the moderator
1371 + // persisted the parsed result, so "a < b and 3 > 2" was shown as
1372 + // "a <b> 2" and saved as "a 2". esc_html() would be just as wrong:
1373 + // the panel would display the entities rather than the donor's text.
1374 + 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
1375 + 'donor_comment_status' => esc_html( Helper::get_string_value( $donation['donor_comment_status'] ?? 'approved' ) ),
1376 + 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1377 + 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1378 + 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1379 + 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
1380 + 'stripe_customer_id' => esc_html( Helper::get_string_value( $donation['customer_id'] ?? '' ) ),
1381 + 'subscription_id' => esc_html( Helper::get_string_value( $donation['subscription_id'] ?? '' ) ),
1382 + 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1383 + 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1384 + 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1385 + 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1386 + 'fields' => $submitted_fields,
1387 + 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1388 + 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1389 + 'logs' => $logs,
1112 1390 ];
1113 1391 }
1114 1392 }