PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/api/donations-api.php +38 -170 trunk1.1.0 View file →
@@ -121,20 +121,10 @@
121 121 },
122 122 ],
123 123 'status' => [
124 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 125 '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',
126 + 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
137 127 ],
138 128 ],
139 129 ],
140 130
@@ -160,12 +150,10 @@
160 150 'permission_callback' => [ $this, 'check_permissions' ],
161 151 'args' => [
162 152 'action' => [
163 153 'required' => true,
164 - 'type' => 'string',
154 + 'sanitize_callback' => 'sanitize_text_field',
165 155 'enum' => [ 'delete', 'update_status' ],
166 - 'sanitize_callback' => 'sanitize_text_field',
167 - 'validate_callback' => 'rest_validate_request_arg',
168 156 ],
169 157 'ids' => [
170 158 'required' => true,
171 159 'validate_callback' => static function ( $param ) {
@@ -172,12 +160,10 @@
172 160 return is_array( $param ) && ! empty( $param );
173 161 },
174 162 ],
175 163 'status' => [
176 - 'type' => 'string',
177 - 'enum' => Donations::get_valid_statuses(),
178 164 'sanitize_callback' => 'sanitize_text_field',
179 - 'validate_callback' => 'rest_validate_request_arg',
165 + 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
180 166 ],
181 167 ],
182 168 ],
183 169
@@ -202,16 +188,10 @@
202 188 'sanitize_callback' => 'absint',
203 189 ],
204 190 'refund_type' => [
205 191 'required' => true,
206 - 'type' => 'string',
192 + 'sanitize_callback' => 'sanitize_text_field',
207 193 '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 194 ],
215 195 'refund_notes' => [
216 196 'sanitize_callback' => 'sanitize_textarea_field',
217 197 ],
@@ -342,12 +322,10 @@
342 322 * @return WP_REST_Response|WP_Error Response object.
343 323 * @since 0.0.1
344 324 */
345 325 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 ) );
326 + $page = $request->get_param( 'page' ) ?? 1;
327 + $per_page = $request->get_param( 'per_page' ) ?? 20;
350 328 $search = $request->get_param( 'search' ) ?? '';
351 329 $status = $request->get_param( 'status' ) ?? 'all';
352 330 $campaign = $request->get_param( 'campaign' ) ?? '';
353 331 $donor = $request->get_param( 'donor' ) ?? '';
@@ -375,9 +353,9 @@
375 353 strtoupper( $order ) // using whitelist validation in the method.
376 354 );
377 355
378 356 // Get total count.
379 - $total = Donations::count_admin_list( $status, ! empty( $campaign ) ? absint( $campaign ) : 0, sanitize_text_field( $search ) );
357 + $total = Donations::get_total_donations_by_status( $status, ! empty( $campaign ) ? absint( $campaign ) : 0 );
380 358 }
381 359
382 360 // Format donations data.
383 361 $donations = [];
@@ -541,9 +519,8 @@
541 519 'fees_covered',
542 520 'donation_type',
543 521 'is_anonymous',
544 522 'donor_comment',
545 - 'donor_comment_status',
546 523 'payment_status',
547 524 'gateway',
548 525 'transaction_id',
549 526 ];
@@ -607,39 +584,14 @@
607 584 );
608 585 }
609 586
610 587 $old_status = $donation['payment_status'] ?? 'pending';
611 - $updated = Donations::update_status( $donation_id, $status );
588 + Donations::update_status( $donation_id, $status );
612 589
613 - // Strictly false, which is update_status() refusing the value. A 0 is
614 - // $wpdb->update() reporting that no row changed, which cannot mean "no
615 - // such row" here because the 404 above already proved it exists, and
616 - // cannot mean "same status" either because update() always writes
617 - // updated_at. Treating both as success is how a refused write looked
618 - // like a successful one to every client.
619 - //
620 - // Note for anyone comparing this with bulk_action(): that path has no
621 - // existence check, so a 0 there does mean "no such row" and is
622 - // correctly counted as a failure. The two are not in conflict.
623 - if ( false === $updated ) {
624 - return new WP_Error(
625 - 'donation_status_not_updated',
626 - __( 'The donation status could not be updated.', 'suredonation' ),
627 - [ 'status' => 500 ]
628 - );
629 - }
630 -
631 590 // If status changed to completed, update donor stats.
632 - //
633 - // Guarded, not plain: an admin completing a still-pending donation here
634 - // does not stop the gateway webhook arriving for the same row later
635 - // (Stripe retries for days), and the webhook's donor block has no
636 - // "still pending" check of its own. Without a marker written here, that
637 - // webhook would record the same donation a second time and double the
638 - // donor's total, count and largest gift.
639 591 if ( 'completed' !== $old_status && 'completed' === $status ) {
640 592 if ( ! empty( $donation['donor_id'] ) ) {
641 - Donors::record_donation_once( $donation['donor_id'], floatval( $donation['amount'] ), $donation_id );
593 + Donors::record_donation( $donation['donor_id'], floatval( $donation['amount'] ) );
642 594 }
643 595 }
644 596
645 597 return new WP_REST_Response(
@@ -690,26 +642,8 @@
690 642 public function bulk_action( $request ) {
691 643 $action = $request->get_param( 'action' );
692 644 $ids = $request->get_param( 'ids' );
693 645
694 - if ( ! is_array( $ids ) ) {
695 - $ids = [];
696 - }
697 -
698 - // Cap bulk operations at 200 IDs per request. Each ID triggers a
699 - // per-row SELECT + DELETE / UPDATE — an arbitrarily large array in one
700 - // request would chew through the database serially and time out the
701 - // response. 200 is enough headroom for any realistic admin UI
702 - // selection; larger jobs should be split client-side (parity with the
703 - // donors bulk-action endpoint).
704 - if ( count( $ids ) > 200 ) {
705 - return new WP_Error(
706 - 'too_many_items',
707 - __( 'Bulk actions are limited to 200 donations per request.', 'suredonation' ),
708 - [ 'status' => 400 ]
709 - );
710 - }
711 -
712 646 $success_count = 0;
713 647 $error_count = 0;
714 648
715 649 foreach ( $ids as $id ) {
@@ -823,10 +757,9 @@
823 757 __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ),
824 758 [ 'status' => 400 ]
825 759 );
826 760 }
827 - $refund_account_id = isset( $donation['stripe_account_id'] ) && is_string( $donation['stripe_account_id'] ) ? $donation['stripe_account_id'] : '';
828 - $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer', $refund_account_id );
761 + $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer' );
829 762 }
830 763
831 764 if ( is_wp_error( $refund_result ) ) {
832 765 return new WP_Error(
@@ -1133,42 +1066,34 @@
1133 1066 * @since 0.0.1
1134 1067 */
1135 1068 private function get_donation_args( $required = true ) {
1136 1069 return [
1137 - 'campaign_id' => [
1070 + 'campaign_id' => [
1138 1071 'required' => $required,
1139 1072 'sanitize_callback' => 'absint',
1140 1073 ],
1141 - 'donor_name' => [
1074 + 'donor_name' => [
1142 1075 'sanitize_callback' => 'sanitize_text_field',
1143 1076 ],
1144 - 'donor_email' => [
1077 + 'donor_email' => [
1145 1078 'sanitize_callback' => 'sanitize_email',
1146 1079 ],
1147 - 'donor_phone' => [
1080 + 'donor_phone' => [
1148 1081 'sanitize_callback' => 'sanitize_text_field',
1149 1082 ],
1150 - 'amount' => [
1083 + 'amount' => [
1151 1084 'required' => $required,
1152 1085 'sanitize_callback' => static function ( $value ) {
1153 1086 return floatval( $value );
1154 1087 },
1155 1088 ],
1156 - 'fees_covered' => [
1089 + 'fees_covered' => [
1157 1090 'sanitize_callback' => static function ( $value ) {
1158 1091 return floatval( $value );
1159 1092 },
1160 1093 ],
1161 - // No 'default' on this or 'payment_status' below, deliberately. These args
1162 - // are shared with the update route, where WordPress fills an absent param
1163 - // with its declared default before the callback runs — so update_donation()'
1164 - // s `! is_null()` test passes and the field is written even though the
1165 - // client never sent it. A partial update (e.g. the Donor Comment panel
1166 - // sending only donor_comment_status) therefore reset payment_status to
1167 - // 'pending' and donation_type to 'one-time', un-completing the donation and
1168 - // downgrading a subscription. create_donation() supplies its own fallbacks
1169 - // (`?? 'pending'`, `?? 'one-time'`), so nothing depends on the defaults here.
1170 - 'donation_type' => [
1094 + 'donation_type' => [
1095 + 'default' => 'one-time',
1171 1096 'enum' => [ 'one-time', 'recurring', 'renewal' ],
1172 1097 'sanitize_callback' => 'sanitize_text_field',
1173 1098 'validate_callback' => static function ( $param ) {
1174 1099 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
@@ -1173,50 +1098,28 @@
1173 1098 'validate_callback' => static function ( $param ) {
1174 1099 return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true );
1175 1100 },
1176 1101 ],
1177 - 'is_anonymous' => [
1102 + 'is_anonymous' => [
1178 1103 'sanitize_callback' => 'rest_sanitize_boolean',
1179 1104 ],
1180 - 'donor_comment' => [
1181 - // sanitize_textarea_field, matching the capture path in
1182 - // Payment_Helper::get_mapped_donor_comment(). wp_kses_post() was
1183 - // actively destructive here: it parses anything tag-shaped, so a
1184 - // moderator saving the comment "a < b and 3 > 2" stored "a <b> 2"
1185 - // — losing " and 3 " — and any surviving markup then rendered as
1186 - // literal angle brackets, because the campaign page esc_html()s.
1187 - // Both sanitizers preserve the donor's newlines.
1188 - 'sanitize_callback' => 'sanitize_textarea_field',
1105 + 'donor_comment' => [
1106 + 'sanitize_callback' => 'wp_kses_post',
1189 1107 ],
1190 - 'donor_comment_status' => [
1191 - 'enum' => [ 'approved', 'pending', 'rejected' ],
1108 + 'payment_status' => [
1109 + 'default' => 'pending',
1110 + 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ],
1192 1111 'sanitize_callback' => 'sanitize_text_field',
1193 - // A sanitize_callback silently disables `enum` enforcement, so the
1194 - // allowed set is checked here too — otherwise any string would reach
1195 - // the column and every comment would read as un-approved.
1196 1112 'validate_callback' => static function ( $param ) {
1197 - return in_array( $param, Donations::get_valid_comment_statuses(), true );
1113 + return in_array( $param, [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], true );
1198 1114 },
1199 1115 ],
1200 - 'payment_status' => [
1201 - 'type' => 'string',
1202 - // Deliberately no 'default'. These args are shared with the
1203 - // update route, and WordPress fills an absent param with its
1204 - // default before the callback runs — so update_donation()'s
1205 - // `! is_null()` test passes and the status is overwritten on a
1206 - // partial update the client never sent it in. dev carries the
1207 - // default; keeping it here would reinstate that bug. See
1208 - // Test_Donations_API::test_update_donation_ignores_unsent_fields().
1209 - 'enum' => Donations::get_valid_statuses(),
1116 + 'gateway' => [
1210 1117 'sanitize_callback' => 'sanitize_text_field',
1211 - 'validate_callback' => 'rest_validate_request_arg',
1212 1118 ],
1213 - 'gateway' => [
1119 + 'transaction_id' => [
1214 1120 'sanitize_callback' => 'sanitize_text_field',
1215 1121 ],
1216 - 'transaction_id' => [
1217 - 'sanitize_callback' => 'sanitize_text_field',
1218 - ],
1219 1122 ];
1220 1123 }
1221 1124
1222 1125 /**
@@ -1242,13 +1145,12 @@
1242 1145 * @return int Amount in smallest currency unit.
1243 1146 * @since 0.0.1
1244 1147 */
1245 1148 private function amount_to_stripe_format( $amount, $currency ) {
1246 - // Delegates rather than repeating the zero-decimal list: the abilities
1247 - // layer guards refunds with Payment_Helper, so a second hardcoded list
1248 - // here could disagree with the guard about what a currency's minor unit
1249 - // is. Payment_Helper derives it from the currency data table.
1250 - return Payment_Helper::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 );
1251 1153 }
1252 1154
1253 1155 /**
1254 1156 * Convert amount from Stripe's smallest currency unit.
@@ -1258,9 +1160,12 @@
1258 1160 * @return float Amount in major currency unit.
1259 1161 * @since 0.0.1
1260 1162 */
1261 1163 private function amount_from_stripe_format( $amount, $currency ) {
1262 - return Payment_Helper::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;
1263 1168 }
1264 1169
1265 1170 /**
1266 1171 * Format donation data for API response.
@@ -1293,41 +1198,14 @@
1293 1198 if ( ! is_array( $donation_data ) ) {
1294 1199 $donation_data = [];
1295 1200 }
1296 1201
1297 - // Build the persisted submitted fields list (label/value/group). The
1298 - // group is the parent block label (e.g. "Address") used to nest
1299 - // sub-fields on the entry screen; '' for standalone fields.
1300 - $submitted_fields = [];
1301 - if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) {
1302 - foreach ( $donation_data['fields'] as $field ) {
1303 - if ( ! is_array( $field ) ) {
1304 - continue;
1305 - }
1306 - // sanitize_text_field (not esc_html) for REST data: the values are
1307 - // already sanitized at write time and React escapes on render, so
1308 - // esc_html here would double-encode (e.g. "Cats & Dogs" -> "Cats &amp; Dogs").
1309 - $submitted_fields[] = [
1310 - 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ),
1311 - // Checkbox fields store a canonical untranslated token so the
1312 - // stored column stays locale-stable; it is translated here, on
1313 - // read, for the entry screen. Non-checkbox values pass through.
1314 - 'value' => sanitize_text_field( Helper::format_checkbox_field_value( $field['value'] ?? '' ) ),
1315 - 'group' => sanitize_text_field( Helper::get_string_value( $field['group'] ?? '' ) ),
1316 - ];
1317 - }
1318 - }
1319 -
1320 1202 return [
1321 1203 'id' => $donation_id,
1322 1204 'campaign_id' => $campaign_id,
1323 - // Plain-text titles rendered by React (which escapes text nodes and does
1324 - // not decode HTML entities). get_the_title() runs wptexturize, whose
1325 - // default replacements are entities (e.g. " - " -> "&#8211;"), so decode
1326 - // them here; wp_kses_post would leave the entity and it would show raw.
1327 - 'campaign_title' => $campaign_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $campaign_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1205 + 'campaign_title' => $campaign_id ? wp_kses_post( (string) get_the_title( $campaign_id ) ) : '',
1328 1206 'form_id' => $form_id,
1329 - 'form_title' => $form_id ? html_entity_decode( wp_strip_all_tags( (string) get_the_title( $form_id ) ), ENT_QUOTES, 'UTF-8' ) : '',
1207 + 'form_title' => $form_id ? wp_kses_post( (string) get_the_title( $form_id ) ) : '',
1330 1208 'form_edit_url' => $form_edit_url,
1331 1209 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0,
1332 1210 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ),
1333 1211 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ),
@@ -1337,18 +1215,9 @@
1337 1215 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
1338 1216 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ),
1339 1217 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ),
1340 1218 'is_anonymous' => ! empty( $donation['is_anonymous'] ),
1341 - // Returned raw, unlike its neighbours. The only consumer is the React
1342 - // moderation panel, which renders it as a text child and so escapes it
1343 - // itself; and DonorCommentSection writes this value straight back on
1344 - // Save. Running it through wp_kses_post() here therefore did not
1345 - // protect anything — it parsed anything tag-shaped and the moderator
1346 - // persisted the parsed result, so "a < b and 3 > 2" was shown as
1347 - // "a <b> 2" and saved as "a 2". esc_html() would be just as wrong:
1348 - // the panel would display the entities rather than the donor's text.
1349 - 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
1350 - 'donor_comment_status' => esc_html( Helper::get_string_value( $donation['donor_comment_status'] ?? 'approved' ) ),
1219 + 'donor_comment' => wp_kses_post( Helper::get_string_value( $donation['donor_comment'] ?? '' ) ),
1351 1220 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ),
1352 1221 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ),
1353 1222 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ),
1354 1223 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ),
@@ -1357,9 +1226,8 @@
1357 1226 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ),
1358 1227 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0,
1359 1228 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ),
1360 1229 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ),
1361 - 'fields' => $submitted_fields,
1362 1230 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ),
1363 1231 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ),
1364 1232 'logs' => $logs,
1365 1233 ];