| @@ -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; |
| @@ -541,8 +542,9 @@ | ||
| 541 | 542 | 'fees_covered', |
| 542 | 543 | 'donation_type', |
| 543 | 544 | 'is_anonymous', |
| 544 | 545 | 'donor_comment', |
| 546 | + 'donor_comment_status', | |
| 545 | 547 | 'payment_status', |
| 546 | 548 | 'gateway', |
| 547 | 549 | 'transaction_id', |
| 548 | 550 | ]; |
| @@ -662,8 +664,29 @@ | ||
| 662 | 664 | |
| 663 | 665 | $result = Donations::delete( $donation_id ); |
| 664 | 666 | |
| 665 | 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 | + | |
| 666 | 689 | return new WP_Error( |
| 667 | 690 | 'delete_failed', |
| 668 | 691 | __( 'Failed to delete donation.', 'suredonation' ), |
| 669 | 692 | [ 'status' => 500 ] |
| @@ -1132,34 +1155,42 @@ | ||
| 1132 | 1155 | * @since 0.0.1 |
| 1133 | 1156 | */ |
| 1134 | 1157 | private function get_donation_args( $required = true ) { |
| 1135 | 1158 | return [ |
| 1136 | - 'campaign_id' => [ | |
| 1159 | + 'campaign_id' => [ | |
| 1137 | 1160 | 'required' => $required, |
| 1138 | 1161 | 'sanitize_callback' => 'absint', |
| 1139 | 1162 | ], |
| 1140 | - 'donor_name' => [ | |
| 1163 | + 'donor_name' => [ | |
| 1141 | 1164 | 'sanitize_callback' => 'sanitize_text_field', |
| 1142 | 1165 | ], |
| 1143 | - 'donor_email' => [ | |
| 1166 | + 'donor_email' => [ | |
| 1144 | 1167 | 'sanitize_callback' => 'sanitize_email', |
| 1145 | 1168 | ], |
| 1146 | - 'donor_phone' => [ | |
| 1169 | + 'donor_phone' => [ | |
| 1147 | 1170 | 'sanitize_callback' => 'sanitize_text_field', |
| 1148 | 1171 | ], |
| 1149 | - 'amount' => [ | |
| 1172 | + 'amount' => [ | |
| 1150 | 1173 | 'required' => $required, |
| 1151 | 1174 | 'sanitize_callback' => static function ( $value ) { |
| 1152 | 1175 | return floatval( $value ); |
| 1153 | 1176 | }, |
| 1154 | 1177 | ], |
| 1155 | - 'fees_covered' => [ | |
| 1178 | + 'fees_covered' => [ | |
| 1156 | 1179 | 'sanitize_callback' => static function ( $value ) { |
| 1157 | 1180 | return floatval( $value ); |
| 1158 | 1181 | }, |
| 1159 | 1182 | ], |
| 1160 | - 'donation_type' => [ | |
| 1161 | - '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' => [ | |
| 1162 | 1193 | 'enum' => [ 'one-time', 'recurring', 'renewal' ], |
| 1163 | 1194 | 'sanitize_callback' => 'sanitize_text_field', |
| 1164 | 1195 | 'validate_callback' => static function ( $param ) { |
| 1165 | 1196 | return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true ); |
| @@ -1164,25 +1195,48 @@ | ||
| 1164 | 1195 | 'validate_callback' => static function ( $param ) { |
| 1165 | 1196 | return in_array( $param, [ 'one-time', 'recurring', 'renewal' ], true ); |
| 1166 | 1197 | }, |
| 1167 | 1198 | ], |
| 1168 | - 'is_anonymous' => [ | |
| 1199 | + 'is_anonymous' => [ | |
| 1169 | 1200 | 'sanitize_callback' => 'rest_sanitize_boolean', |
| 1170 | 1201 | ], |
| 1171 | - 'donor_comment' => [ | |
| 1172 | - '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', | |
| 1173 | 1211 | ], |
| 1174 | - 'payment_status' => [ | |
| 1175 | - 'default' => 'pending', | |
| 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 | + }, | |
| 1221 | + ], | |
| 1222 | + 'payment_status' => [ | |
| 1176 | 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(). | |
| 1177 | 1231 | 'enum' => Donations::get_valid_statuses(), |
| 1178 | 1232 | 'sanitize_callback' => 'sanitize_text_field', |
| 1179 | 1233 | 'validate_callback' => 'rest_validate_request_arg', |
| 1180 | 1234 | ], |
| 1181 | - 'gateway' => [ | |
| 1235 | + 'gateway' => [ | |
| 1182 | 1236 | 'sanitize_callback' => 'sanitize_text_field', |
| 1183 | 1237 | ], |
| 1184 | - 'transaction_id' => [ | |
| 1238 | + 'transaction_id' => [ | |
| 1185 | 1239 | 'sanitize_callback' => 'sanitize_text_field', |
| 1186 | 1240 | ], |
| 1187 | 1241 | ]; |
| 1188 | 1242 | } |
| @@ -1266,9 +1320,9 @@ | ||
| 1266 | 1320 | // group is the parent block label (e.g. "Address") used to nest |
| 1267 | 1321 | // sub-fields on the entry screen; '' for standalone fields. |
| 1268 | 1322 | $submitted_fields = []; |
| 1269 | 1323 | if ( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ) { |
| 1270 | - foreach ( $donation_data['fields'] as $field ) { | |
| 1324 | + foreach ( $donation_data['fields'] as $slug => $field ) { | |
| 1271 | 1325 | if ( ! is_array( $field ) ) { |
| 1272 | 1326 | continue; |
| 1273 | 1327 | } |
| 1274 | 1328 | // sanitize_text_field (not esc_html) for REST data: the values are |
| @@ -1274,8 +1328,11 @@ | ||
| 1274 | 1328 | // sanitize_text_field (not esc_html) for REST data: the values are |
| 1275 | 1329 | // already sanitized at write time and React escapes on render, so |
| 1276 | 1330 | // esc_html here would double-encode (e.g. "Cats & Dogs" -> "Cats & Dogs"). |
| 1277 | 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 ) ), | |
| 1278 | 1335 | 'label' => sanitize_text_field( Helper::get_string_value( $field['label'] ?? '' ) ), |
| 1279 | 1336 | // Checkbox fields store a canonical untranslated token so the |
| 1280 | 1337 | // stored column stays locale-stable; it is translated here, on |
| 1281 | 1338 | // read, for the entry screen. Non-checkbox values pass through. |
| @@ -1305,9 +1362,18 @@ | ||
| 1305 | 1362 | 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ), |
| 1306 | 1363 | 'currency' => esc_html( Helper::get_string_value( $donation['currency'] ?? 'USD' ) ), |
| 1307 | 1364 | 'donation_type' => esc_html( Helper::get_string_value( $donation['donation_type'] ?? 'one-time' ) ), |
| 1308 | 1365 | 'is_anonymous' => ! empty( $donation['is_anonymous'] ), |
| 1309 | - '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' ) ), | |
| 1310 | 1376 | 'payment_status' => esc_html( Helper::get_string_value( $donation['payment_status'] ?? 'pending' ) ), |
| 1311 | 1377 | 'payment_mode' => esc_html( Helper::get_string_value( $payment_mode ) ), |
| 1312 | 1378 | 'gateway' => esc_html( Helper::get_string_value( $donation['gateway'] ?? '' ) ), |
| 1313 | 1379 | 'transaction_id' => esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) ), |