| 1 |
<?php |
| 2 |
/** |
| 3 |
* Donations REST API endpoints. |
| 4 |
* |
| 5 |
* @package SureDonation |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace SureDonation\Inc\API; |
| 9 |
|
| 10 |
use SureDonation\Inc\Database\Tables\Donations; |
| 11 |
use SureDonation\Inc\Database\Tables\Donors; |
| 12 |
use SureDonation\Inc\Emails\Email_Handler; |
| 13 |
use SureDonation\Inc\Helper; |
| 14 |
use SureDonation\Inc\Payments\Payment_Helper; |
| 15 |
use SureDonation\Inc\Pdf\Receipt_Generator; |
| 16 |
use SureDonation\Inc\Payments\Stripe\Stripe_Helper; |
| 17 |
use WP_Error; |
| 18 |
use WP_REST_Request; |
| 19 |
use WP_REST_Response; |
| 20 |
use WP_REST_Server; |
| 21 |
|
| 22 |
// Exit if accessed directly. |
| 23 |
if ( ! defined( 'ABSPATH' ) ) { |
| 24 |
exit; |
| 25 |
} |
| 26 |
|
| 27 |
/** |
| 28 |
* Donations API class. |
| 29 |
* |
| 30 |
* @since 0.0.1 |
| 31 |
*/ |
| 32 |
class Donations_API { |
| 33 |
/** |
| 34 |
* Get donation endpoints. |
| 35 |
* |
| 36 |
* @return array<string, mixed> |
| 37 |
* @since 0.0.1 |
| 38 |
*/ |
| 39 |
public function get_endpoints() { |
| 40 |
return [ |
| 41 |
// Get donations list & create donation. |
| 42 |
'/donations' => [ |
| 43 |
[ |
| 44 |
'methods' => WP_REST_Server::READABLE, |
| 45 |
'callback' => [ $this, 'get_donations' ], |
| 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 |
], |
| 57 |
], |
| 58 |
[ |
| 59 |
'methods' => WP_REST_Server::CREATABLE, |
| 60 |
'callback' => [ $this, 'create_donation' ], |
| 61 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 62 |
'args' => $this->get_donation_args(), |
| 63 |
], |
| 64 |
], |
| 65 |
|
| 66 |
// Get, update, delete single donation. |
| 67 |
'/donations/(?P<id>\d+)' => [ |
| 68 |
[ |
| 69 |
'methods' => WP_REST_Server::READABLE, |
| 70 |
'callback' => [ $this, 'get_donation' ], |
| 71 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 72 |
'args' => [ |
| 73 |
'id' => [ |
| 74 |
'required' => true, |
| 75 |
'validate_callback' => static function ( $param ) { |
| 76 |
return is_numeric( $param ); |
| 77 |
}, |
| 78 |
], |
| 79 |
], |
| 80 |
], |
| 81 |
[ |
| 82 |
'methods' => WP_REST_Server::EDITABLE, |
| 83 |
'callback' => [ $this, 'update_donation' ], |
| 84 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 85 |
'args' => array_merge( |
| 86 |
[ |
| 87 |
'id' => [ |
| 88 |
'required' => true, |
| 89 |
'validate_callback' => static function ( $param ) { |
| 90 |
return is_numeric( $param ); |
| 91 |
}, |
| 92 |
], |
| 93 |
], |
| 94 |
$this->get_donation_args( false ) |
| 95 |
), |
| 96 |
], |
| 97 |
[ |
| 98 |
'methods' => WP_REST_Server::DELETABLE, |
| 99 |
'callback' => [ $this, 'delete_donation' ], |
| 100 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 101 |
'args' => [ |
| 102 |
'id' => [ |
| 103 |
'required' => true, |
| 104 |
'validate_callback' => static function ( $param ) { |
| 105 |
return is_numeric( $param ); |
| 106 |
}, |
| 107 |
], |
| 108 |
], |
| 109 |
], |
| 110 |
], |
| 111 |
|
| 112 |
// Update donation status. |
| 113 |
'/donations/(?P<id>\d+)/status' => [ |
| 114 |
'methods' => WP_REST_Server::EDITABLE, |
| 115 |
'callback' => [ $this, 'update_donation_status' ], |
| 116 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 117 |
'args' => [ |
| 118 |
'id' => [ |
| 119 |
'required' => true, |
| 120 |
'validate_callback' => static function ( $param ) { |
| 121 |
return is_numeric( $param ); |
| 122 |
}, |
| 123 |
], |
| 124 |
'status' => [ |
| 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', |
| 138 |
], |
| 139 |
], |
| 140 |
], |
| 141 |
|
| 142 |
// Get donations by campaign. |
| 143 |
'/donations/campaign/(?P<id>\d+)' => [ |
| 144 |
'methods' => WP_REST_Server::READABLE, |
| 145 |
'callback' => [ $this, 'get_campaign_donations' ], |
| 146 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 147 |
'args' => [ |
| 148 |
'id' => [ |
| 149 |
'required' => true, |
| 150 |
'validate_callback' => static function ( $param ) { |
| 151 |
return is_numeric( $param ); |
| 152 |
}, |
| 153 |
], |
| 154 |
], |
| 155 |
], |
| 156 |
|
| 157 |
// Bulk actions. |
| 158 |
'/donations/bulk' => [ |
| 159 |
'methods' => WP_REST_Server::EDITABLE, |
| 160 |
'callback' => [ $this, 'bulk_action' ], |
| 161 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 162 |
'args' => [ |
| 163 |
'action' => [ |
| 164 |
'required' => true, |
| 165 |
'type' => 'string', |
| 166 |
'enum' => [ 'delete', 'update_status' ], |
| 167 |
'sanitize_callback' => 'sanitize_text_field', |
| 168 |
'validate_callback' => 'rest_validate_request_arg', |
| 169 |
], |
| 170 |
'ids' => [ |
| 171 |
'required' => true, |
| 172 |
'validate_callback' => static function ( $param ) { |
| 173 |
return is_array( $param ) && ! empty( $param ); |
| 174 |
}, |
| 175 |
], |
| 176 |
'status' => [ |
| 177 |
'type' => 'string', |
| 178 |
'enum' => Donations::get_valid_statuses(), |
| 179 |
'sanitize_callback' => 'sanitize_text_field', |
| 180 |
'validate_callback' => 'rest_validate_request_arg', |
| 181 |
], |
| 182 |
], |
| 183 |
], |
| 184 |
|
| 185 |
// Refund donation payment. |
| 186 |
'/donations/(?P<id>\d+)/refund' => [ |
| 187 |
'methods' => WP_REST_Server::CREATABLE, |
| 188 |
'callback' => [ $this, 'refund_donation' ], |
| 189 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 190 |
'args' => [ |
| 191 |
'id' => [ |
| 192 |
'required' => true, |
| 193 |
'validate_callback' => static function ( $param ) { |
| 194 |
return is_numeric( $param ); |
| 195 |
}, |
| 196 |
], |
| 197 |
'transaction_id' => [ |
| 198 |
'required' => true, |
| 199 |
'sanitize_callback' => 'sanitize_text_field', |
| 200 |
], |
| 201 |
'refund_amount' => [ |
| 202 |
'required' => true, |
| 203 |
'sanitize_callback' => 'absint', |
| 204 |
], |
| 205 |
'refund_type' => [ |
| 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', |
| 215 |
], |
| 216 |
'refund_notes' => [ |
| 217 |
'sanitize_callback' => 'sanitize_textarea_field', |
| 218 |
], |
| 219 |
], |
| 220 |
], |
| 221 |
|
| 222 |
// Delete donation log entry. |
| 223 |
'/donations/(?P<id>\d+)/log/(?P<log_index>\d+)' => [ |
| 224 |
'methods' => WP_REST_Server::DELETABLE, |
| 225 |
'callback' => [ $this, 'delete_donation_log' ], |
| 226 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 227 |
'args' => [ |
| 228 |
'id' => [ |
| 229 |
'required' => true, |
| 230 |
'validate_callback' => static function ( $param ) { |
| 231 |
return is_numeric( $param ); |
| 232 |
}, |
| 233 |
], |
| 234 |
'log_index' => [ |
| 235 |
'required' => true, |
| 236 |
'validate_callback' => static function ( $param ) { |
| 237 |
return is_numeric( $param ) && $param >= 0; |
| 238 |
}, |
| 239 |
], |
| 240 |
], |
| 241 |
], |
| 242 |
|
| 243 |
// Get and add donation notes. |
| 244 |
'/donations/(?P<id>\d+)/notes' => [ |
| 245 |
[ |
| 246 |
'methods' => WP_REST_Server::READABLE, |
| 247 |
'callback' => [ $this, 'get_donation_notes' ], |
| 248 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 249 |
'args' => [ |
| 250 |
'id' => [ |
| 251 |
'required' => true, |
| 252 |
'validate_callback' => static function ( $param ) { |
| 253 |
return is_numeric( $param ); |
| 254 |
}, |
| 255 |
], |
| 256 |
'page' => [ |
| 257 |
'default' => 1, |
| 258 |
'sanitize_callback' => 'absint', |
| 259 |
], |
| 260 |
'per_page' => [ |
| 261 |
'default' => 3, |
| 262 |
'sanitize_callback' => 'absint', |
| 263 |
], |
| 264 |
], |
| 265 |
], |
| 266 |
[ |
| 267 |
'methods' => WP_REST_Server::CREATABLE, |
| 268 |
'callback' => [ $this, 'add_donation_note' ], |
| 269 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 270 |
'args' => [ |
| 271 |
'id' => [ |
| 272 |
'required' => true, |
| 273 |
'validate_callback' => static function ( $param ) { |
| 274 |
return is_numeric( $param ); |
| 275 |
}, |
| 276 |
], |
| 277 |
'note' => [ |
| 278 |
'required' => true, |
| 279 |
'sanitize_callback' => 'sanitize_textarea_field', |
| 280 |
], |
| 281 |
], |
| 282 |
], |
| 283 |
], |
| 284 |
|
| 285 |
// Delete donation note. |
| 286 |
'/donations/(?P<id>\d+)/notes/(?P<note_id>[\w.]+)' => [ |
| 287 |
'methods' => WP_REST_Server::DELETABLE, |
| 288 |
'callback' => [ $this, 'delete_donation_note' ], |
| 289 |
'permission_callback' => [ $this, 'check_permissions' ], |
| 290 |
'args' => [ |
| 291 |
'id' => [ |
| 292 |
'required' => true, |
| 293 |
'validate_callback' => static function ( $param ) { |
| 294 |
return is_numeric( $param ); |
| 295 |
}, |
| 296 |
], |
| 297 |
'note_id' => [ |
| 298 |
'required' => true, |
| 299 |
'sanitize_callback' => 'sanitize_text_field', |
| 300 |
], |
| 301 |
], |
| 302 |
], |
| 303 |
]; |
| 304 |
} |
| 305 |
|
| 306 |
/** |
| 307 |
* Get a single donation by ID. |
| 308 |
* |
| 309 |
* @param WP_REST_Request $request Request object. |
| 310 |
* @return WP_REST_Response|WP_Error Response object. |
| 311 |
* @since 0.0.1 |
| 312 |
*/ |
| 313 |
public function get_donation( $request ) { |
| 314 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 315 |
|
| 316 |
// Get the donation from database. |
| 317 |
$donation = Donations::get( $donation_id ); |
| 318 |
|
| 319 |
if ( ! $donation ) { |
| 320 |
return new WP_Error( |
| 321 |
'donation_not_found', |
| 322 |
__( 'Donation not found.', 'suredonation' ), |
| 323 |
[ 'status' => 404 ] |
| 324 |
); |
| 325 |
} |
| 326 |
|
| 327 |
// Format and return the donation data. |
| 328 |
$formatted = $this->format_donation( $donation ); |
| 329 |
|
| 330 |
return new WP_REST_Response( |
| 331 |
[ |
| 332 |
'success' => true, |
| 333 |
'donation' => $formatted, |
| 334 |
], |
| 335 |
200 |
| 336 |
); |
| 337 |
} |
| 338 |
|
| 339 |
/** |
| 340 |
* Get donations list with filters, sorting, and pagination. |
| 341 |
* |
| 342 |
* @param WP_REST_Request $request Request object. |
| 343 |
* @return WP_REST_Response|WP_Error Response object. |
| 344 |
* @since 0.0.1 |
| 345 |
*/ |
| 346 |
public function get_donations( $request ) { |
| 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 ) ); |
| 351 |
$search = $request->get_param( 'search' ) ?? ''; |
| 352 |
$status = $request->get_param( 'status' ) ?? 'all'; |
| 353 |
$campaign = $request->get_param( 'campaign' ) ?? ''; |
| 354 |
$donor = $request->get_param( 'donor' ) ?? ''; |
| 355 |
$sort_by = $request->get_param( 'sort_by' ) ?? 'created_at'; |
| 356 |
$order = $request->get_param( 'order' ) ?? 'desc'; |
| 357 |
|
| 358 |
// Calculate pagination. |
| 359 |
$limit = absint( $per_page ); |
| 360 |
$offset = ( absint( $page ) - 1 ) * $limit; |
| 361 |
|
| 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 |
); |
| 378 |
|
| 379 |
// Get total count. |
| 380 |
$total = Donations::count_admin_list( $status, ! empty( $campaign ) ? absint( $campaign ) : 0, sanitize_text_field( $search ) ); |
| 381 |
} |
| 382 |
|
| 383 |
// Format donations data. |
| 384 |
$donations = []; |
| 385 |
foreach ( $results as $donation ) { |
| 386 |
if ( is_array( $donation ) ) { |
| 387 |
$donations[] = $this->format_donation( $donation ); |
| 388 |
} |
| 389 |
} |
| 390 |
|
| 391 |
// Prepare response. |
| 392 |
return new WP_REST_Response( |
| 393 |
[ |
| 394 |
'donations' => $donations, |
| 395 |
'pagination' => [ |
| 396 |
'total' => (int) $total, |
| 397 |
'total_pages' => (int) ceil( $total / $per_page ), |
| 398 |
'per_page' => (int) $per_page, |
| 399 |
'current' => (int) $page, |
| 400 |
], |
| 401 |
] |
| 402 |
); |
| 403 |
} |
| 404 |
|
| 405 |
/** |
| 406 |
* Get donations for a specific campaign. |
| 407 |
* |
| 408 |
* @param WP_REST_Request $request Request object. |
| 409 |
* @return WP_REST_Response|WP_Error Response object. |
| 410 |
* @since 0.0.1 |
| 411 |
*/ |
| 412 |
public function get_campaign_donations( $request ) { |
| 413 |
$campaign_id = absint( $request->get_param( 'id' ) ); |
| 414 |
$limit = absint( $request->get_param( 'limit' ) ?? 5 ); |
| 415 |
|
| 416 |
$results = Donations::get_recent_donations( $campaign_id, $limit ); |
| 417 |
|
| 418 |
$donations = []; |
| 419 |
foreach ( $results as $donation ) { |
| 420 |
if ( is_array( $donation ) ) { |
| 421 |
$donations[] = $this->format_donation( $donation ); |
| 422 |
} |
| 423 |
} |
| 424 |
|
| 425 |
return new WP_REST_Response( |
| 426 |
[ |
| 427 |
'success' => true, |
| 428 |
'donations' => $donations, |
| 429 |
], |
| 430 |
200 |
| 431 |
); |
| 432 |
} |
| 433 |
|
| 434 |
/** |
| 435 |
* Create a new donation. |
| 436 |
* |
| 437 |
* @param WP_REST_Request $request Request object. |
| 438 |
* @return WP_REST_Response|WP_Error Response object. |
| 439 |
* @since 0.0.1 |
| 440 |
*/ |
| 441 |
public function create_donation( $request ) { |
| 442 |
$campaign_id = $request->get_param( 'campaign_id' ); |
| 443 |
$donor_name = $request->get_param( 'donor_name' ) ?? ''; |
| 444 |
$donor_email = $request->get_param( 'donor_email' ) ?? ''; |
| 445 |
$donor_phone = $request->get_param( 'donor_phone' ) ?? ''; |
| 446 |
$amount = $request->get_param( 'amount' ); |
| 447 |
$fees_covered = $request->get_param( 'fees_covered' ) ?? 0; |
| 448 |
$payment_status = $request->get_param( 'payment_status' ) ?? 'pending'; |
| 449 |
$donation_type = $request->get_param( 'donation_type' ) ?? 'one-time'; |
| 450 |
$is_anonymous = $request->get_param( 'is_anonymous' ) ?? false; |
| 451 |
$donor_comment = $request->get_param( 'donor_comment' ) ?? ''; |
| 452 |
$gateway = $request->get_param( 'gateway' ) ?? 'manual'; |
| 453 |
$transaction_id = $request->get_param( 'transaction_id' ) ?? ''; |
| 454 |
|
| 455 |
// Get or create donor. |
| 456 |
$donor_id = 0; |
| 457 |
if ( ! empty( $donor_email ) ) { |
| 458 |
$donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone ); |
| 459 |
} |
| 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 |
|
| 491 |
// Create the donation in database. |
| 492 |
$donation_id = Donations::add( $donation_data ); |
| 493 |
|
| 494 |
if ( ! $donation_id ) { |
| 495 |
return new WP_Error( |
| 496 |
'create_failed', |
| 497 |
__( 'Failed to create donation.', 'suredonation' ), |
| 498 |
[ 'status' => 500 ] |
| 499 |
); |
| 500 |
} |
| 501 |
|
| 502 |
$donation = Donations::get( $donation_id ); |
| 503 |
|
| 504 |
return new WP_REST_Response( |
| 505 |
[ |
| 506 |
'success' => true, |
| 507 |
'message' => __( 'Donation created successfully.', 'suredonation' ), |
| 508 |
'donation' => is_array( $donation ) ? $this->format_donation( $donation ) : [], |
| 509 |
], |
| 510 |
201 |
| 511 |
); |
| 512 |
} |
| 513 |
|
| 514 |
/** |
| 515 |
* Update an existing donation. |
| 516 |
* |
| 517 |
* @param WP_REST_Request $request Request object. |
| 518 |
* @return WP_REST_Response|WP_Error Response object. |
| 519 |
* @since 0.0.1 |
| 520 |
*/ |
| 521 |
public function update_donation( $request ) { |
| 522 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 523 |
|
| 524 |
// Check if donation exists. |
| 525 |
$donation = Donations::get( $donation_id ); |
| 526 |
if ( ! $donation ) { |
| 527 |
return new WP_Error( |
| 528 |
'donation_not_found', |
| 529 |
__( 'Donation not found.', 'suredonation' ), |
| 530 |
[ 'status' => 404 ] |
| 531 |
); |
| 532 |
} |
| 533 |
|
| 534 |
// Build update data. |
| 535 |
$update_data = []; |
| 536 |
$fields = [ |
| 537 |
'campaign_id', |
| 538 |
'donor_name', |
| 539 |
'donor_email', |
| 540 |
'donor_phone', |
| 541 |
'amount', |
| 542 |
'fees_covered', |
| 543 |
'donation_type', |
| 544 |
'is_anonymous', |
| 545 |
'donor_comment', |
| 546 |
'donor_comment_status', |
| 547 |
'payment_status', |
| 548 |
'gateway', |
| 549 |
'transaction_id', |
| 550 |
]; |
| 551 |
|
| 552 |
foreach ( $fields as $field ) { |
| 553 |
$value = $request->get_param( $field ); |
| 554 |
if ( ! is_null( $value ) ) { |
| 555 |
if ( 'is_anonymous' === $field ) { |
| 556 |
$update_data[ $field ] = $value ? 1 : 0; |
| 557 |
} else { |
| 558 |
$update_data[ $field ] = $value; |
| 559 |
} |
| 560 |
} |
| 561 |
} |
| 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 |
|
| 575 |
if ( ! empty( $update_data ) ) { |
| 576 |
Donations::update( $donation_id, $update_data ); |
| 577 |
} |
| 578 |
|
| 579 |
$updated_donation = Donations::get( $donation_id ); |
| 580 |
|
| 581 |
return new WP_REST_Response( |
| 582 |
[ |
| 583 |
'success' => true, |
| 584 |
'message' => __( 'Donation updated successfully.', 'suredonation' ), |
| 585 |
'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [], |
| 586 |
], |
| 587 |
200 |
| 588 |
); |
| 589 |
} |
| 590 |
|
| 591 |
/** |
| 592 |
* Update donation payment status. |
| 593 |
* |
| 594 |
* @param WP_REST_Request $request Request object. |
| 595 |
* @return WP_REST_Response|WP_Error Response object. |
| 596 |
* @since 0.0.1 |
| 597 |
*/ |
| 598 |
public function update_donation_status( $request ) { |
| 599 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 600 |
$status = $request->get_param( 'status' ); |
| 601 |
|
| 602 |
$donation = Donations::get( $donation_id ); |
| 603 |
if ( ! $donation ) { |
| 604 |
return new WP_Error( |
| 605 |
'donation_not_found', |
| 606 |
__( 'Donation not found.', 'suredonation' ), |
| 607 |
[ 'status' => 404 ] |
| 608 |
); |
| 609 |
} |
| 610 |
|
| 611 |
$old_status = $donation['payment_status'] ?? 'pending'; |
| 612 |
$updated = Donations::update_status( $donation_id, $status ); |
| 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 |
|
| 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. |
| 640 |
if ( 'completed' !== $old_status && 'completed' === $status ) { |
| 641 |
if ( ! empty( $donation['donor_id'] ) ) { |
| 642 |
Donors::record_donation_once( $donation['donor_id'], floatval( $donation['amount'] ), $donation_id ); |
| 643 |
} |
| 644 |
} |
| 645 |
|
| 646 |
return new WP_REST_Response( |
| 647 |
[ |
| 648 |
'success' => true, |
| 649 |
'message' => __( 'Donation status updated successfully.', 'suredonation' ), |
| 650 |
], |
| 651 |
200 |
| 652 |
); |
| 653 |
} |
| 654 |
|
| 655 |
/** |
| 656 |
* Delete donation. |
| 657 |
* |
| 658 |
* @param WP_REST_Request $request Request object. |
| 659 |
* @return WP_REST_Response|WP_Error Response object. |
| 660 |
* @since 0.0.1 |
| 661 |
*/ |
| 662 |
public function delete_donation( $request ) { |
| 663 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 664 |
|
| 665 |
$result = Donations::delete( $donation_id ); |
| 666 |
|
| 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 |
|
| 689 |
return new WP_Error( |
| 690 |
'delete_failed', |
| 691 |
__( 'Failed to delete donation.', 'suredonation' ), |
| 692 |
[ 'status' => 500 ] |
| 693 |
); |
| 694 |
} |
| 695 |
|
| 696 |
return new WP_REST_Response( |
| 697 |
[ |
| 698 |
'success' => true, |
| 699 |
'message' => __( 'Donation deleted successfully.', 'suredonation' ), |
| 700 |
], |
| 701 |
200 |
| 702 |
); |
| 703 |
} |
| 704 |
|
| 705 |
/** |
| 706 |
* Bulk action on donations. |
| 707 |
* |
| 708 |
* @param WP_REST_Request $request Request object. |
| 709 |
* @return WP_REST_Response|WP_Error Response object. |
| 710 |
* @since 0.0.1 |
| 711 |
*/ |
| 712 |
public function bulk_action( $request ) { |
| 713 |
$action = $request->get_param( 'action' ); |
| 714 |
$ids = $request->get_param( 'ids' ); |
| 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 |
|
| 734 |
$success_count = 0; |
| 735 |
$error_count = 0; |
| 736 |
|
| 737 |
foreach ( $ids as $id ) { |
| 738 |
$result = false; |
| 739 |
|
| 740 |
if ( 'delete' === $action ) { |
| 741 |
$result = Donations::delete( absint( $id ) ); |
| 742 |
} elseif ( 'update_status' === $action ) { |
| 743 |
$status = $request->get_param( 'status' ); |
| 744 |
if ( $status ) { |
| 745 |
$result = Donations::update_status( absint( $id ), $status ); |
| 746 |
} |
| 747 |
} |
| 748 |
|
| 749 |
if ( $result ) { |
| 750 |
++$success_count; |
| 751 |
} else { |
| 752 |
++$error_count; |
| 753 |
} |
| 754 |
} |
| 755 |
|
| 756 |
return new WP_REST_Response( |
| 757 |
[ |
| 758 |
'success' => true, |
| 759 |
'message' => sprintf( |
| 760 |
// translators: %1$d: success count, %2$d: error count. |
| 761 |
__( 'Bulk action completed. Success: %1$d, Failed: %2$d', 'suredonation' ), |
| 762 |
$success_count, |
| 763 |
$error_count |
| 764 |
), |
| 765 |
'success_count' => $success_count, |
| 766 |
'error_count' => $error_count, |
| 767 |
], |
| 768 |
200 |
| 769 |
); |
| 770 |
} |
| 771 |
|
| 772 |
/** |
| 773 |
* Refund a donation payment. |
| 774 |
* |
| 775 |
* @param WP_REST_Request $request Request object. |
| 776 |
* @return WP_REST_Response|WP_Error Response object. |
| 777 |
* @since 0.0.1 |
| 778 |
*/ |
| 779 |
public function refund_donation( $request ) { |
| 780 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 781 |
$transaction_id = $request->get_param( 'transaction_id' ); |
| 782 |
$refund_amount = absint( $request->get_param( 'refund_amount' ) ); |
| 783 |
|
| 784 |
// Get the donation. |
| 785 |
$donation = Donations::get( $donation_id ); |
| 786 |
if ( ! $donation ) { |
| 787 |
return new WP_Error( |
| 788 |
'donation_not_found', |
| 789 |
__( 'Donation not found.', 'suredonation' ), |
| 790 |
[ 'status' => 404 ] |
| 791 |
); |
| 792 |
} |
| 793 |
|
| 794 |
// Verify the donation is in a refundable state. |
| 795 |
$refundable_statuses = [ 'completed', 'partially_refunded' ]; |
| 796 |
if ( ! in_array( $donation['payment_status'], $refundable_statuses, true ) ) { |
| 797 |
return new WP_Error( |
| 798 |
'not_refundable', |
| 799 |
__( 'Only completed or partially refunded donations can be refunded.', 'suredonation' ), |
| 800 |
[ 'status' => 400 ] |
| 801 |
); |
| 802 |
} |
| 803 |
|
| 804 |
// Verify transaction ID matches. |
| 805 |
if ( $transaction_id !== $donation['transaction_id'] ) { |
| 806 |
return new WP_Error( |
| 807 |
'transaction_mismatch', |
| 808 |
__( 'Transaction ID mismatch.', 'suredonation' ), |
| 809 |
[ 'status' => 400 ] |
| 810 |
); |
| 811 |
} |
| 812 |
|
| 813 |
// Validate refund amount. |
| 814 |
$gateway = $donation['gateway'] ?? 'stripe'; |
| 815 |
$currency = $donation['currency'] ?? 'USD'; |
| 816 |
$total_amount = $this->amount_to_stripe_format( floatval( $donation['amount'] ), $currency ); |
| 817 |
$refunded_amount = $this->amount_to_stripe_format( floatval( $donation['refunded_amount'] ?? 0 ), $currency ); |
| 818 |
$refundable = $total_amount - $refunded_amount; |
| 819 |
|
| 820 |
if ( $refund_amount > $refundable ) { |
| 821 |
return new WP_Error( |
| 822 |
'exceeds_refundable', |
| 823 |
sprintf( |
| 824 |
/* translators: %s: maximum refundable amount */ |
| 825 |
__( 'Refund amount exceeds maximum refundable amount of %s.', 'suredonation' ), |
| 826 |
$this->amount_from_stripe_format( $refundable, $currency ) |
| 827 |
), |
| 828 |
[ 'status' => 400 ] |
| 829 |
); |
| 830 |
} |
| 831 |
|
| 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 |
} |
| 852 |
|
| 853 |
if ( is_wp_error( $refund_result ) ) { |
| 854 |
return new WP_Error( |
| 855 |
'refund_failed', |
| 856 |
$refund_result->get_error_message(), |
| 857 |
[ 'status' => 500 ] |
| 858 |
); |
| 859 |
} |
| 860 |
|
| 861 |
// Calculate new refunded amount in cents for comparison. |
| 862 |
$new_refunded_in_cents = $refunded_amount + $refund_amount; |
| 863 |
|
| 864 |
// Determine new status by comparing in cents to avoid floating point precision issues. |
| 865 |
$new_status = $new_refunded_in_cents >= $total_amount ? 'refunded' : 'partially_refunded'; |
| 866 |
|
| 867 |
// Convert back to major currency unit for storage. |
| 868 |
$new_refunded_amount = $this->amount_from_stripe_format( $new_refunded_in_cents, $currency ); |
| 869 |
|
| 870 |
// Store refund in donation_data FIRST (prevents webhook duplicate processing). |
| 871 |
$refund_id = $refund_result['id'] ?? ''; |
| 872 |
if ( ! empty( $refund_id ) ) { |
| 873 |
$refund_data = [ |
| 874 |
'refund_id' => $refund_id, |
| 875 |
'amount' => absint( $refund_amount ), |
| 876 |
'currency' => strtoupper( $currency ), |
| 877 |
'status' => $refund_result['status'] ?? 'succeeded', |
| 878 |
'created' => time(), |
| 879 |
'reason' => 'requested_by_customer', |
| 880 |
'refunded_by' => 'admin', |
| 881 |
'refunded_at' => gmdate( 'Y-m-d H:i:s' ), |
| 882 |
]; |
| 883 |
Donations::add_refund_to_donation_data( $donation_id, $refund_data ); |
| 884 |
} |
| 885 |
|
| 886 |
// Update donation record with new status and refunded amount. |
| 887 |
Donations::update( |
| 888 |
$donation_id, |
| 889 |
[ |
| 890 |
'payment_status' => $new_status, |
| 891 |
'refunded_amount' => $new_refunded_amount, |
| 892 |
] |
| 893 |
); |
| 894 |
|
| 895 |
// Determine refund type for log message. |
| 896 |
$refund_type = $new_refunded_in_cents >= $total_amount |
| 897 |
? __( 'Full', 'suredonation' ) |
| 898 |
: __( 'Partial', 'suredonation' ); |
| 899 |
|
| 900 |
// Add log entry. |
| 901 |
Donations::add_log( |
| 902 |
$donation_id, |
| 903 |
'refund', |
| 904 |
sprintf( |
| 905 |
/* translators: %s: Refund type (Full/Partial) */ |
| 906 |
__( '%s refund processed via admin', 'suredonation' ), |
| 907 |
$refund_type |
| 908 |
), |
| 909 |
[ |
| 910 |
'refund_id' => $refund_id, |
| 911 |
'refund_amount' => $this->amount_from_stripe_format( $refund_amount, $currency ), |
| 912 |
'total_refunded' => $new_refunded_amount, |
| 913 |
'original_amount' => floatval( $donation['amount'] ), |
| 914 |
'payment_status' => $new_status, |
| 915 |
'currency' => strtoupper( $currency ), |
| 916 |
] |
| 917 |
); |
| 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 |
|
| 935 |
// Get updated donation. |
| 936 |
$updated_donation = Donations::get( $donation_id ); |
| 937 |
|
| 938 |
return new WP_REST_Response( |
| 939 |
[ |
| 940 |
'success' => true, |
| 941 |
'message' => __( 'Refund processed successfully.', 'suredonation' ), |
| 942 |
'refund_id' => $refund_id, |
| 943 |
'status' => $refund_result['status'] ?? 'succeeded', |
| 944 |
'donation' => is_array( $updated_donation ) ? $this->format_donation( $updated_donation ) : [], |
| 945 |
], |
| 946 |
200 |
| 947 |
); |
| 948 |
} |
| 949 |
/** |
| 950 |
* Check if user has permission to manage donations. |
| 951 |
* |
| 952 |
* @return bool True if user has permission. |
| 953 |
* @since 0.0.1 |
| 954 |
*/ |
| 955 |
public function check_permissions() { |
| 956 |
return current_user_can( 'manage_options' ); |
| 957 |
} |
| 958 |
|
| 959 |
/** |
| 960 |
* Delete a log entry from a donation. |
| 961 |
* |
| 962 |
* @param WP_REST_Request $request Request object. |
| 963 |
* @return WP_REST_Response|WP_Error Response object. |
| 964 |
* @since 0.0.1 |
| 965 |
*/ |
| 966 |
public function delete_donation_log( $request ) { |
| 967 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 968 |
$log_index = absint( $request->get_param( 'log_index' ) ); |
| 969 |
|
| 970 |
// Get the donation from database. |
| 971 |
$donation = Donations::get( $donation_id ); |
| 972 |
|
| 973 |
if ( ! $donation ) { |
| 974 |
return new WP_Error( |
| 975 |
'donation_not_found', |
| 976 |
__( 'Donation not found.', 'suredonation' ), |
| 977 |
[ 'status' => 404 ] |
| 978 |
); |
| 979 |
} |
| 980 |
|
| 981 |
// Get current logs. |
| 982 |
$logs = Donations::get_log( $donation_id ); |
| 983 |
|
| 984 |
if ( ! is_array( $logs ) || empty( $logs ) ) { |
| 985 |
return new WP_Error( |
| 986 |
'no_logs', |
| 987 |
__( 'No logs found for this donation.', 'suredonation' ), |
| 988 |
[ 'status' => 404 ] |
| 989 |
); |
| 990 |
} |
| 991 |
|
| 992 |
// Check if log index exists. |
| 993 |
if ( ! isset( $logs[ $log_index ] ) ) { |
| 994 |
return new WP_Error( |
| 995 |
'log_not_found', |
| 996 |
__( 'Log entry not found.', 'suredonation' ), |
| 997 |
[ 'status' => 404 ] |
| 998 |
); |
| 999 |
} |
| 1000 |
|
| 1001 |
// Remove log at specified index. |
| 1002 |
array_splice( $logs, $log_index, 1 ); |
| 1003 |
|
| 1004 |
// Re-index array to prevent gaps. |
| 1005 |
$logs = array_values( $logs ); |
| 1006 |
|
| 1007 |
// Update log column with modified logs array. |
| 1008 |
$result = Donations::update( $donation_id, [ 'log' => $logs ] ); |
| 1009 |
|
| 1010 |
if ( false === $result ) { |
| 1011 |
return new WP_Error( |
| 1012 |
'update_failed', |
| 1013 |
__( 'Failed to delete log entry.', 'suredonation' ), |
| 1014 |
[ 'status' => 500 ] |
| 1015 |
); |
| 1016 |
} |
| 1017 |
|
| 1018 |
return new WP_REST_Response( |
| 1019 |
[ |
| 1020 |
'success' => true, |
| 1021 |
'message' => __( 'Log entry deleted successfully.', 'suredonation' ), |
| 1022 |
'logs' => $logs, |
| 1023 |
], |
| 1024 |
200 |
| 1025 |
); |
| 1026 |
} |
| 1027 |
|
| 1028 |
/** |
| 1029 |
* Get notes for a donation. |
| 1030 |
* |
| 1031 |
* @param WP_REST_Request $request Request object. |
| 1032 |
* @return WP_REST_Response|WP_Error Response object. |
| 1033 |
* @since 0.0.1 |
| 1034 |
*/ |
| 1035 |
public function get_donation_notes( $request ) { |
| 1036 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 1037 |
$page = absint( $request->get_param( 'page' ) ) ?? 1; |
| 1038 |
$per_page = absint( $request->get_param( 'per_page' ) ) ?? 3; |
| 1039 |
|
| 1040 |
// Get the donation from database. |
| 1041 |
$donation = Donations::get( $donation_id ); |
| 1042 |
|
| 1043 |
if ( ! $donation ) { |
| 1044 |
return new WP_Error( |
| 1045 |
'donation_not_found', |
| 1046 |
__( 'Donation not found.', 'suredonation' ), |
| 1047 |
[ 'status' => 404 ] |
| 1048 |
); |
| 1049 |
} |
| 1050 |
|
| 1051 |
// Get paginated notes. |
| 1052 |
$notes_data = Donations::get_notes( $donation_id, $page, $per_page ); |
| 1053 |
|
| 1054 |
return new WP_REST_Response( |
| 1055 |
[ |
| 1056 |
'success' => true, |
| 1057 |
'notes' => $notes_data['notes'], |
| 1058 |
'total' => $notes_data['total'], |
| 1059 |
'total_pages' => $notes_data['total_pages'], |
| 1060 |
], |
| 1061 |
200 |
| 1062 |
); |
| 1063 |
} |
| 1064 |
|
| 1065 |
/** |
| 1066 |
* Add a note to a donation. |
| 1067 |
* |
| 1068 |
* @param WP_REST_Request $request Request object. |
| 1069 |
* @return WP_REST_Response|WP_Error Response object. |
| 1070 |
* @since 0.0.1 |
| 1071 |
*/ |
| 1072 |
public function add_donation_note( $request ) { |
| 1073 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 1074 |
$note = $request->get_param( 'note' ); |
| 1075 |
|
| 1076 |
// Get the donation from database. |
| 1077 |
$donation = Donations::get( $donation_id ); |
| 1078 |
|
| 1079 |
if ( ! $donation ) { |
| 1080 |
return new WP_Error( |
| 1081 |
'donation_not_found', |
| 1082 |
__( 'Donation not found.', 'suredonation' ), |
| 1083 |
[ 'status' => 404 ] |
| 1084 |
); |
| 1085 |
} |
| 1086 |
|
| 1087 |
// Add the note. |
| 1088 |
$result = Donations::add_note( $donation_id, $note, get_current_user_id() ); |
| 1089 |
|
| 1090 |
if ( ! $result['success'] ) { |
| 1091 |
return new WP_Error( |
| 1092 |
'note_failed', |
| 1093 |
__( 'Failed to add note.', 'suredonation' ), |
| 1094 |
[ 'status' => 500 ] |
| 1095 |
); |
| 1096 |
} |
| 1097 |
|
| 1098 |
return new WP_REST_Response( |
| 1099 |
[ |
| 1100 |
'success' => true, |
| 1101 |
'message' => __( 'Note added successfully.', 'suredonation' ), |
| 1102 |
'note_id' => $result['note_id'], |
| 1103 |
], |
| 1104 |
201 |
| 1105 |
); |
| 1106 |
} |
| 1107 |
|
| 1108 |
/** |
| 1109 |
* Delete a note from a donation. |
| 1110 |
* |
| 1111 |
* @param WP_REST_Request $request Request object. |
| 1112 |
* @return WP_REST_Response|WP_Error Response object. |
| 1113 |
* @since 0.0.1 |
| 1114 |
*/ |
| 1115 |
public function delete_donation_note( $request ) { |
| 1116 |
$donation_id = absint( $request->get_param( 'id' ) ); |
| 1117 |
$note_id = $request->get_param( 'note_id' ); |
| 1118 |
|
| 1119 |
// Get the donation from database. |
| 1120 |
$donation = Donations::get( $donation_id ); |
| 1121 |
|
| 1122 |
if ( ! $donation ) { |
| 1123 |
return new WP_Error( |
| 1124 |
'donation_not_found', |
| 1125 |
__( 'Donation not found.', 'suredonation' ), |
| 1126 |
[ 'status' => 404 ] |
| 1127 |
); |
| 1128 |
} |
| 1129 |
|
| 1130 |
// Delete the note. |
| 1131 |
$result = Donations::delete_note( $donation_id, $note_id ); |
| 1132 |
|
| 1133 |
if ( ! $result ) { |
| 1134 |
return new WP_Error( |
| 1135 |
'note_not_found', |
| 1136 |
__( 'Note not found or could not be deleted.', 'suredonation' ), |
| 1137 |
[ 'status' => 404 ] |
| 1138 |
); |
| 1139 |
} |
| 1140 |
|
| 1141 |
return new WP_REST_Response( |
| 1142 |
[ |
| 1143 |
'success' => true, |
| 1144 |
'message' => __( 'Note deleted successfully.', 'suredonation' ), |
| 1145 |
], |
| 1146 |
200 |
| 1147 |
); |
| 1148 |
} |
| 1149 |
|
| 1150 |
/** |
| 1151 |
* Get donation arguments schema. |
| 1152 |
* |
| 1153 |
* @param bool $required Whether fields are required. |
| 1154 |
* @return array<string, array<string, mixed>> |
| 1155 |
* @since 0.0.1 |
| 1156 |
*/ |
| 1157 |
private function get_donation_args( $required = true ) { |
| 1158 |
return [ |
| 1159 |
'campaign_id' => [ |
| 1160 |
'required' => $required, |
| 1161 |
'sanitize_callback' => 'absint', |
| 1162 |
], |
| 1163 |
'donor_name' => [ |
| 1164 |
'sanitize_callback' => 'sanitize_text_field', |
| 1165 |
], |
| 1166 |
'donor_email' => [ |
| 1167 |
'sanitize_callback' => 'sanitize_email', |
| 1168 |
], |
| 1169 |
'donor_phone' => [ |
| 1170 |
'sanitize_callback' => 'sanitize_text_field', |
| 1171 |
], |
| 1172 |
'amount' => [ |
| 1173 |
'required' => $required, |
| 1174 |
'sanitize_callback' => static function ( $value ) { |
| 1175 |
return floatval( $value ); |
| 1176 |
}, |
| 1177 |
], |
| 1178 |
'fees_covered' => [ |
| 1179 |
'sanitize_callback' => static function ( $value ) { |
| 1180 |
return floatval( $value ); |
| 1181 |
}, |
| 1182 |
], |
| 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 |
}, |
| 1198 |
], |
| 1199 |
'is_anonymous' => [ |
| 1200 |
'sanitize_callback' => 'rest_sanitize_boolean', |
| 1201 |
], |
| 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', |
| 1211 |
], |
| 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' => [ |
| 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(), |
| 1232 |
'sanitize_callback' => 'sanitize_text_field', |
| 1233 |
'validate_callback' => 'rest_validate_request_arg', |
| 1234 |
], |
| 1235 |
'gateway' => [ |
| 1236 |
'sanitize_callback' => 'sanitize_text_field', |
| 1237 |
], |
| 1238 |
'transaction_id' => [ |
| 1239 |
'sanitize_callback' => 'sanitize_text_field', |
| 1240 |
], |
| 1241 |
]; |
| 1242 |
} |
| 1243 |
|
| 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 |
/** |
| 1260 |
* Convert amount to Stripe's smallest currency unit. |
| 1261 |
* |
| 1262 |
* @param float $amount Amount in major currency unit. |
| 1263 |
* @param string $currency Currency code. |
| 1264 |
* @return int Amount in smallest currency unit. |
| 1265 |
* @since 0.0.1 |
| 1266 |
*/ |
| 1267 |
private function amount_to_stripe_format( $amount, $currency ) { |
| 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 ); |
| 1273 |
} |
| 1274 |
|
| 1275 |
/** |
| 1276 |
* Convert amount from Stripe's smallest currency unit. |
| 1277 |
* |
| 1278 |
* @param int $amount Amount in smallest currency unit. |
| 1279 |
* @param string $currency Currency code. |
| 1280 |
* @return float Amount in major currency unit. |
| 1281 |
* @since 0.0.1 |
| 1282 |
*/ |
| 1283 |
private function amount_from_stripe_format( $amount, $currency ) { |
| 1284 |
return Payment_Helper::amount_from_stripe_format( $amount, $currency ); |
| 1285 |
} |
| 1286 |
|
| 1287 |
/** |
| 1288 |
* Format donation data for API response. |
| 1289 |
* |
| 1290 |
* @param array<string, mixed> $donation Donation data from database. |
| 1291 |
* @return array<string, mixed> Formatted donation data. |
| 1292 |
* @since 0.0.1 |
| 1293 |
*/ |
| 1294 |
private function format_donation( $donation ) { |
| 1295 |
$campaign_id = isset( $donation['campaign_id'] ) ? Helper::get_integer_value( $donation['campaign_id'] ) : 0; |
| 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; |
| 1298 |
|
| 1299 |
// Get payment logs for this donation. |
| 1300 |
$logs = $donation_id ? Donations::get_log( $donation_id ) : []; |
| 1301 |
|
| 1302 |
// Get payment mode for Stripe dashboard URL. |
| 1303 |
$payment_mode = $donation['payment_mode'] ?? 'test'; |
| 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 & 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 |
|
| 1345 |
return [ |
| 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. " - " -> "–"), 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, |
| 1390 |
]; |
| 1391 |
} |
| 1392 |
} |
| 1393 |
|