| @@ -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 = []; |
| @@ -606,39 +584,14 @@ | ||
| 606 | 584 | ); |
| 607 | 585 | } |
| 608 | 586 | |
| 609 | 587 | $old_status = $donation['payment_status'] ?? 'pending'; |
| 610 | - $updated = Donations::update_status( $donation_id, $status ); | |
| 588 | + Donations::update_status( $donation_id, $status ); | |
| 611 | 589 | |
| 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 | 590 | // 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 | 591 | if ( 'completed' !== $old_status && 'completed' === $status ) { |
| 639 | 592 | if ( ! empty( $donation['donor_id'] ) ) { |
| 640 | - Donors::record_donation_once( $donation['donor_id'], floatval( $donation['amount'] ), $donation_id ); | |
| 593 | + Donors::record_donation( $donation['donor_id'], floatval( $donation['amount'] ) ); | |
| 641 | 594 | } |
| 642 | 595 | } |
| 643 | 596 | |
| 644 | 597 | return new WP_REST_Response( |
| @@ -689,26 +642,8 @@ | ||
| 689 | 642 | public function bulk_action( $request ) { |
| 690 | 643 | $action = $request->get_param( 'action' ); |
| 691 | 644 | $ids = $request->get_param( 'ids' ); |
| 692 | 645 | |
| 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 | 646 | $success_count = 0; |
| 712 | 647 | $error_count = 0; |
| 713 | 648 | |
| 714 | 649 | foreach ( $ids as $id ) { |
| @@ -822,10 +757,9 @@ | ||
| 822 | 757 | __( 'Stripe is not connected. Please configure Stripe in settings.', 'suredonation' ), |
| 823 | 758 | [ 'status' => 400 ] |
| 824 | 759 | ); |
| 825 | 760 | } |
| 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 ); | |
| 761 | + $refund_result = Stripe_Helper::create_refund( $transaction_id, $refund_amount, 'requested_by_customer' ); | |
| 828 | 762 | } |
| 829 | 763 | |
| 830 | 764 | if ( is_wp_error( $refund_result ) ) { |
| 831 | 765 | return new WP_Error( |
| @@ -1172,12 +1106,13 @@ | ||
| 1172 | 1106 | 'sanitize_callback' => 'wp_kses_post', |
| 1173 | 1107 | ], |
| 1174 | 1108 | 'payment_status' => [ |
| 1175 | 1109 | 'default' => 'pending', |
| 1176 | - 'type' => 'string', | |
| 1177 | - 'enum' => Donations::get_valid_statuses(), | |
| 1110 | + 'enum' => [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], | |
| 1178 | 1111 | 'sanitize_callback' => 'sanitize_text_field', |
| 1179 | - 'validate_callback' => 'rest_validate_request_arg', | |
| 1112 | + 'validate_callback' => static function ( $param ) { | |
| 1113 | + return in_array( $param, [ 'pending', 'processing', 'completed', 'failed', 'refunded', 'partially_refunded', 'cancelled' ], true ); | |
| 1114 | + }, | |
| 1180 | 1115 | ], |
| 1181 | 1116 | 'gateway' => [ |
| 1182 | 1117 | 'sanitize_callback' => 'sanitize_text_field', |
| 1183 | 1118 | ], |
| @@ -1210,13 +1145,12 @@ | ||
| 1210 | 1145 | * @return int Amount in smallest currency unit. |
| 1211 | 1146 | * @since 0.0.1 |
| 1212 | 1147 | */ |
| 1213 | 1148 | 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 ); | |
| 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 ); | |
| 1219 | 1153 | } |
| 1220 | 1154 | |
| 1221 | 1155 | /** |
| 1222 | 1156 | * Convert amount from Stripe's smallest currency unit. |
| @@ -1226,9 +1160,12 @@ | ||
| 1226 | 1160 | * @return float Amount in major currency unit. |
| 1227 | 1161 | * @since 0.0.1 |
| 1228 | 1162 | */ |
| 1229 | 1163 | private function amount_from_stripe_format( $amount, $currency ) { |
| 1230 | - 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; | |
| 1231 | 1168 | } |
| 1232 | 1169 | |
| 1233 | 1170 | /** |
| 1234 | 1171 | * Format donation data for API response. |
| @@ -1261,41 +1198,14 @@ | ||
| 1261 | 1198 | if ( ! is_array( $donation_data ) ) { |
| 1262 | 1199 | $donation_data = []; |
| 1263 | 1200 | } |
| 1264 | 1201 | |
| 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 & 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 | 1202 | return [ |
| 1289 | 1203 | 'id' => $donation_id, |
| 1290 | 1204 | '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. " - " -> "–"), 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' ) : '', | |
| 1205 | + 'campaign_title' => $campaign_id ? wp_kses_post( (string) get_the_title( $campaign_id ) ) : '', | |
| 1296 | 1206 | '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' ) : '', | |
| 1207 | + 'form_title' => $form_id ? wp_kses_post( (string) get_the_title( $form_id ) ) : '', | |
| 1298 | 1208 | 'form_edit_url' => $form_edit_url, |
| 1299 | 1209 | 'donor_id' => isset( $donation['donor_id'] ) ? Helper::get_integer_value( $donation['donor_id'] ) : 0, |
| 1300 | 1210 | 'donor_name' => esc_html( Helper::get_string_value( $donation['donor_name'] ?? '' ) ), |
| 1301 | 1211 | 'donor_email' => sanitize_email( Helper::get_string_value( $donation['donor_email'] ?? '' ) ), |
| @@ -1316,9 +1226,8 @@ | ||
| 1316 | 1226 | 'subscription_status' => esc_html( Helper::get_string_value( $donation['subscription_status'] ?? '' ) ), |
| 1317 | 1227 | 'parent_subscription_id' => isset( $donation['parent_subscription_id'] ) ? Helper::get_integer_value( $donation['parent_subscription_id'] ) : 0, |
| 1318 | 1228 | 'subscription_interval' => esc_html( Helper::get_string_value( $donation_data['subscription_interval'] ?? '' ) ), |
| 1319 | 1229 | 'billing_cycles' => esc_html( Helper::get_string_value( $donation_data['billing_cycles'] ?? '' ) ), |
| 1320 | - 'fields' => $submitted_fields, | |
| 1321 | 1230 | 'created_at' => esc_html( Helper::get_string_value( $donation['created_at'] ?? '' ) ), |
| 1322 | 1231 | 'updated_at' => esc_html( Helper::get_string_value( $donation['updated_at'] ?? '' ) ), |
| 1323 | 1232 | 'logs' => $logs, |
| 1324 | 1233 | ]; |