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 +195 -38 1.1.0 → 1.6.1 View file →
@@ -11,8 +11,9 @@
11 11 use SureDonation\Inc\Database\Tables\Donors;
12 12 use SureDonation\Inc\Emails\Email_Handler;
13 13 use SureDonation\Inc\Helper;
14 14 use SureDonation\Inc\Payments\Payment_Helper;
15 +use SureDonation\Inc\Pdf\Receipt_Generator;
15 16 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
16 17 use WP_Error;
17 18 use WP_REST_Request;
18 19 use WP_REST_Response;
@@ -121,10 +122,20 @@
121 122 },
122 123 ],
123 124 'status' => [
124 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(),
125 131 'sanitize_callback' => 'sanitize_text_field',
126 - 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
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',
127 138 ],
128 139 ],
129 140 ],
130 141
@@ -150,10 +161,12 @@
150 161 'permission_callback' => [ $this, 'check_permissions' ],
151 162 'args' => [
152 163 'action' => [
153 164 'required' => true,
165 + 'type' => 'string',
166 + 'enum' => [ 'delete', 'update_status' ],
154 167 'sanitize_callback' => 'sanitize_text_field',
155 - 'enum' => [ 'delete', 'update_status' ],
168 + 'validate_callback' => 'rest_validate_request_arg',
156 169 ],
157 170 'ids' => [
158 171 'required' => true,
159 172 'validate_callback' => static function ( $param ) {
@@ -160,10 +173,12 @@
160 173 return is_array( $param ) && ! empty( $param );
161 174 },
162 175 ],
163 176 'status' => [
177 + 'type' => 'string',
178 + 'enum' => Donations::get_valid_statuses(),
164 179 'sanitize_callback' => 'sanitize_text_field',
165 - 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
180 + 'validate_callback' => 'rest_validate_request_arg',
166 181 ],
167 182 ],
168 183 ],
169 184
@@ -188,10 +203,16 @@
188 203 'sanitize_callback' => 'absint',
189 204 ],
190 205 'refund_type' => [
191 206 'required' => true,
207 + 'type' => 'string',
208 + 'enum' => [ 'full', 'partial' ],
192 209 'sanitize_callback' => 'sanitize_text_field',
193 - 'enum' => [ 'full', 'partial' ],
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',
194 215 ],
195 216 'refund_notes' => [
196 217 'sanitize_callback' => 'sanitize_textarea_field',
197 218 ],
@@ -322,10 +343,12 @@
322 343 * @return WP_REST_Response|WP_Error Response object.
323 344 * @since 0.0.1
324 345 */
325 346 public function get_donations( $request ) {
326 - $page = $request->get_param( 'page' ) ?? 1;
327 - $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 ) );
328 351 $search = $request->get_param( 'search' ) ?? '';
329 352 $status = $request->get_param( 'status' ) ?? 'all';
330 353 $campaign = $request->get_param( 'campaign' ) ?? '';
331 354 $donor = $request->get_param( 'donor' ) ?? '';
@@ -353,9 +376,9 @@
353 376 strtoupper( $order ) // using whitelist validation in the method.
354 377 );
355 378
356 379 // Get total count.
357 - $total = Donations::get_total_donations_by_status( $status, ! empty( $campaign ) ? absint( $campaign ) : 0 );
380 + $total = Donations::count_admin_list( $status, ! empty( $campaign ) ? absint( $campaign ) : 0, sanitize_text_field( $search ) );
358 381 }
359 382
360 383 // Format donations data.
361 384 $donations = [];
@@ -519,8 +542,9 @@
519 542 'fees_covered',
520 543 'donation_type',
521 544 'is_anonymous',
522 545 'donor_comment',
546 + 'donor_comment_status',
523 547 'payment_status',
524 548 'gateway',
525 549 'transaction_id',
526 550 ];
@@ -584,14 +608,39 @@
584 608 );
585 609 }
586 610
587 611 $old_status = $donation['payment_status'] ?? 'pending';
588 - Donations::update_status( $donation_id, $status );
612 + $updated = Donations::update_status( $donation_id, $status );
589 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 +
590 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.
591 640 if ( 'completed' !== $old_status && 'completed' === $status ) {
592 641 if ( ! empty( $donation['donor_id'] ) ) {
593 - Donors::record_donation( $donation['donor_id'], floatval( $donation['amount'] ) );
642 + Donors::record_donation_once( $donation['donor_id'], floatval( $donation['amount'] ), $donation_id );
594 643 }
595 644 }
596 645
597 646 return new WP_REST_Response(
@@ -615,8 +664,29 @@
615 664
616 665 $result = Donations::delete( $donation_id );
617 666
618 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 +
619 689 return new WP_Error(
620 690 'delete_failed',
621 691 __( 'Failed to delete donation.', 'suredonation' ),
622 692 [ 'status' => 500 ]
@@ -642,8 +712,26 @@
642 712 public function bulk_action( $request ) {
643 713 $action = $request->get_param( 'action' );
644 714 $ids = $request->get_param( 'ids' );
645 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 +
646 734 $success_count = 0;
647 735 $error_count = 0;
648 736
649 737 foreach ( $ids as $id ) {
@@ -757,9 +845,10 @@
757 845 __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
758 846 [ 'status' => 400 ]
759 847 );
760 848 }
761 - $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer' );
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 );
762 851 }
763 852
764 853 if ( is_wp_error( $refund_result ) ) {
765 854 return new WP_Error(
@@ -1066,34 +1155,42 @@
1066 1155 * @since 0.0.1
1067 1156 */
1068 1157 private function get_donation_args( $required = true ) {
1069 1158 return [
1070 - 'campaign_id' => [
1159 + 'campaign_id' => [
1071 1160 'required' => $required,
1072 1161 'sanitize_callback' => 'absint',
1073 1162 ],
1074 - 'donor_name' => [
1163 + 'donor_name' => [
1075 1164 'sanitize_callback' => 'sanitize_text_field',
1076 1165 ],
1077 - 'donor_email' => [
1166 + 'donor_email' => [
1078 1167 'sanitize_callback' => 'sanitize_email',
1079 1168 ],
1080 - 'donor_phone' => [
1169 + 'donor_phone' => [
1081 1170 'sanitize_callback' => 'sanitize_text_field',
1082 1171 ],
1083 - 'amount' => [
1172 + 'amount' => [
1084 1173 'required' => $required,
1085 1174 'sanitize_callback' => static function ( $value ) {
1086 1175 return floatval( $value );
1087 1176 },
1088 1177 ],
1089 - 'fees_covered' => [
1178 + 'fees_covered' => [
1090 1179 'sanitize_callback' => static function ( $value ) {
1091 1180 return floatval( $value );
1092 1181 },
1093 1182 ],
1094 - 'donation_type' => [
1095 - 'default' => 'one-time',
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' => [
1096 1193 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1097 1194 'sanitize_callback' => 'sanitize_text_field',
1098 1195 'validate_callback' => static function ( $param ) {
1099 1196 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
@@ -1098,28 +1195,50 @@
1098 1195 'validate_callback' => static function ( $param ) {
1099 1196 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1100 1197 },
1101 1198 ],
1102 - 'is_anonymous' => [
1199 + 'is_anonymous' => [
1103 1200 'sanitize_callback' => 'rest_sanitize_boolean',
1104 1201 ],
1105 - 'donor_comment' => [
1106 - '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',
1107 1211 ],
1108 - 'payment_status' => [
1109 - 'default' => 'pending',
1110 - 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
1212 + 'donor_comment_status' => [
1213 + 'enum' => [ 'approved', 'pending', 'rejected' ],
1111 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.
1112 1218 'validate_callback' => static function ( $param ) {
1113 - return in_array( $param, [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], true );
1219 + return in_array( $param, Donations::get_valid_comment_statuses(), true );
1114 1220 },
1115 1221 ],
1116 - '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(),
1117 1232 'sanitize_callback' => 'sanitize_text_field',
1233 + 'validate_callback' => 'rest_validate_request_arg',
1118 1234 ],
1119 - 'transaction_id' => [
1235 + 'gateway' => [
1120 1236 'sanitize_callback' => 'sanitize_text_field',
1121 1237 ],
1238 + 'transaction_id' => [
1239 + 'sanitize_callback' => 'sanitize_text_field',
1240 + ],
1122 1241 ];
1123 1242 }
1124 1243
1125 1244 /**
@@ -1145,12 +1264,13 @@
1145 1264 * @return int Amount in smallest currency unit.
1146 1265 * @since 0.0.1
1147 1266 */
1148 1267 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 );
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 );
1153 1273 }
1154 1274
1155 1275 /**
1156 1276 * Convert amount from Stripe's smallest currency unit.
@@ -1160,12 +1280,9 @@
1160 1280 * @return float Amount in major currency unit.
1161 1281 * @since 0.0.1
1162 1282 */
1163 1283 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;
1284 + return Payment_Helper::amount_from_stripe_format( $amount, $currency );
1168 1285 }
1169 1286
1170 1287 /**
1171 1288 * Format donation data for API response.
@@ -1198,14 +1315,44 @@
1198 1315 if ( ! is_array( $donation_data ) ) {
1199 1316 $donation_data = [];
1200 1317 }
1201 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 +
1202 1345 return [
1203 1346 'id' => $donation_id,
1204 1347 'campaign_id' => $campaign_id,
1205 - 'campaign_title' => $campaign_id ? wp_kses_post( (string) get_the_title( $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' ) : '',
1206 1353 'form_id' => $form_id,
1207 - 'form_title' => $form_id ? wp_kses_post( (string) get_the_title( $form_id ) ) : '',
1354 + 'form_title' => $form_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $form_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1208 1355 'form_edit_url' => $form_edit_url,
1209 1356 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1210 1357 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1211 1358 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
@@ -1215,9 +1362,18 @@
1215 1362 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1216 1363 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1217 1364 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1218 1365 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1219 - 'donor_comment' => wp_kses_post( Helper::get_string_value( $donation['donor_comment'] ?? '' ) ),
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' ) ),
1220 1376 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1221 1377 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1222 1378 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1223 1379 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
@@ -1226,8 +1382,9 @@
1226 1382 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1227 1383 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1228 1384 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1229 1385 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1386 + 'fields' => $submitted_fields,
1230 1387 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1231 1388 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1232 1389 'logs' => $logs,
1233 1390 ];