| 1 |
<?php |
| 2 |
/** |
| 3 |
* Donation abilities: list, read, manual creation, and period summary. |
| 4 |
* |
| 5 |
* Two constraints shape this file more than anything else. |
| 6 |
* |
| 7 |
* C7 — donor contact details are withheld unless the caller holds |
| 8 |
* `view_charitable_sensitive_data`. `edit_donations` is enough to see that a |
| 9 |
* donation exists and for how much; it is not enough to read the donor's email |
| 10 |
* or address. This mirrors the fix to the donors REST route, which exposed |
| 11 |
* emails to any Contributor. |
| 12 |
* |
| 13 |
* C3 — Pro's version of this file has every money figure come from a query |
| 14 |
* that reads COALESCE( base_amount, amount ), never the raw `amount` column: |
| 15 |
* base_amount is the gift converted into the site's base currency, and on a |
| 16 |
* multi-currency site summing `amount` adds yen to dollars. Lite has no |
| 17 |
* base_amount column and no multi-currency feature (see create_table() in |
| 18 |
* class-charitable-campaign-donations-db.php, which defines only |
| 19 |
* `amount decimal(13,4)`), so every money figure here reads `amount` directly. |
| 20 |
* See get_donations_summary_by_day_range() in that same file for the same |
| 21 |
* substitution and rationale. |
| 22 |
* |
| 23 |
* @package Charitable/Classes/Charitable_Donation_Abilities |
| 24 |
* @copyright Copyright (c) 2026, WP Charitable LLC |
| 25 |
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License |
| 26 |
* @since 1.8.13 |
| 27 |
* @version 1.8.13 |
| 28 |
*/ |
| 29 |
|
| 30 |
if ( ! defined( 'ABSPATH' ) ) { |
| 31 |
exit; |
| 32 |
} |
| 33 |
|
| 34 |
if ( ! class_exists( 'Charitable_Donation_Abilities' ) ) : |
| 35 |
|
| 36 |
/** |
| 37 |
* Charitable_Donation_Abilities |
| 38 |
* |
| 39 |
* @since 1.8.13 |
| 40 |
*/ |
| 41 |
class Charitable_Donation_Abilities extends Charitable_Abilities_Registrar { |
| 42 |
|
| 43 |
/** |
| 44 |
* Register the donation abilities. |
| 45 |
* |
| 46 |
* @since 1.8.13 |
| 47 |
* |
| 48 |
* @return void |
| 49 |
*/ |
| 50 |
public function register() { |
| 51 |
$this->register_ability( |
| 52 |
'charitable/list-donations', |
| 53 |
array( |
| 54 |
'label' => __( 'List Donations', 'charitable' ), |
| 55 |
'description' => __( "Lists donations newest first, optionally narrowed to one campaign, one donor or one status. Returns each donation's amount, status, date, gateway and the campaigns it was split across. Use to answer questions about recent giving or to find a specific donation before reading it in full. Donor email addresses are included only for users who can view sensitive data.", 'charitable' ), |
| 56 |
'execute_callback' => array( $this, 'execute_list_donations' ), |
| 57 |
'permission_callback' => array( __CLASS__, 'permission_donations' ), |
| 58 |
'annotations' => array( |
| 59 |
'readonly' => true, |
| 60 |
'idempotent' => true, |
| 61 |
), |
| 62 |
'input_schema' => array( |
| 63 |
'type' => 'object', |
| 64 |
'properties' => array( |
| 65 |
'campaign_id' => array( |
| 66 |
'type' => 'integer', |
| 67 |
'description' => __( 'Only donations that include this campaign.', 'charitable' ), |
| 68 |
'minimum' => 1, |
| 69 |
), |
| 70 |
'donor_id' => array( |
| 71 |
'type' => 'integer', |
| 72 |
'description' => __( 'Only donations from this donor. This is a Charitable donor id, not a WordPress user id.', 'charitable' ), |
| 73 |
'minimum' => 1, |
| 74 |
), |
| 75 |
'status' => array( |
| 76 |
'type' => 'string', |
| 77 |
'description' => __( 'Only donations with this status. Omit for every status.', 'charitable' ), |
| 78 |
'enum' => array_keys( charitable_get_valid_donation_statuses() ), |
| 79 |
), |
| 80 |
'start_date' => array( |
| 81 |
'type' => 'string', |
| 82 |
'description' => __( 'Earliest donation date, as YYYY-MM-DD.', 'charitable' ), |
| 83 |
), |
| 84 |
'end_date' => array( |
| 85 |
'type' => 'string', |
| 86 |
'description' => __( 'Latest donation date, as YYYY-MM-DD. The whole day is included.', 'charitable' ), |
| 87 |
), |
| 88 |
'per_page' => array( |
| 89 |
'type' => 'integer', |
| 90 |
'description' => __( 'Results per page, 1 to 100. Defaults to 20.', 'charitable' ), |
| 91 |
'minimum' => 1, |
| 92 |
'maximum' => 100, |
| 93 |
), |
| 94 |
'page' => array( |
| 95 |
'type' => 'integer', |
| 96 |
'description' => __( 'Page number, starting at 1.', 'charitable' ), |
| 97 |
'minimum' => 1, |
| 98 |
), |
| 99 |
), |
| 100 |
'additionalProperties' => false, |
| 101 |
), |
| 102 |
'output_schema' => array( |
| 103 |
'type' => 'object', |
| 104 |
'properties' => array( |
| 105 |
'donations' => array( |
| 106 |
'type' => 'array', |
| 107 |
'items' => self::donation_summary_schema(), |
| 108 |
), |
| 109 |
'total' => array( 'type' => 'integer' ), |
| 110 |
'pages' => array( 'type' => 'integer' ), |
| 111 |
), |
| 112 |
), |
| 113 |
) |
| 114 |
); |
| 115 |
|
| 116 |
$this->register_ability( |
| 117 |
'charitable/get-donation', |
| 118 |
array( |
| 119 |
'label' => __( 'Get Donation', 'charitable' ), |
| 120 |
'description' => __( 'Returns one donation in full: amount, status, date, gateway, transaction reference, the per-campaign breakdown, and the donor it came from. Use after List Donations when you need the detail of a specific gift. The donor email address is included only for users who can view sensitive data.', 'charitable' ), |
| 121 |
'execute_callback' => array( $this, 'execute_get_donation' ), |
| 122 |
|
| 123 |
/* |
| 124 |
* permission_read_donation(), NOT permission_donations(): this |
| 125 |
* one takes a donation_id, so it can and must ask whose |
| 126 |
* donation it is. See that callback's docblock. |
| 127 |
*/ |
| 128 |
'permission_callback' => array( __CLASS__, 'permission_read_donation' ), |
| 129 |
'annotations' => array( |
| 130 |
'readonly' => true, |
| 131 |
'idempotent' => true, |
| 132 |
), |
| 133 |
'input_schema' => array( |
| 134 |
'type' => 'object', |
| 135 |
'properties' => array( |
| 136 |
'donation_id' => array( |
| 137 |
'type' => 'integer', |
| 138 |
'description' => __( 'The donation to read.', 'charitable' ), |
| 139 |
'minimum' => 1, |
| 140 |
), |
| 141 |
), |
| 142 |
'required' => array( 'donation_id' ), |
| 143 |
'additionalProperties' => false, |
| 144 |
), |
| 145 |
'output_schema' => self::donation_detail_schema(), |
| 146 |
) |
| 147 |
); |
| 148 |
|
| 149 |
$this->register_ability( |
| 150 |
'charitable/create-manual-donation', |
| 151 |
array( |
| 152 |
'label' => __( 'Create Manual Donation', 'charitable' ), |
| 153 |
'description' => __( 'Records a donation entered by hand, the way an administrator would add one in the dashboard — for a check, cash or bank transfer received outside the site. No payment is processed and no gateway is charged. Use for donations that already happened offline. Do not use it to test the donation form or to correct an existing donation.', 'charitable' ), |
| 154 |
'execute_callback' => array( $this, 'execute_create_manual_donation' ), |
| 155 |
'permission_callback' => array( __CLASS__, 'permission_create_manual_donation' ), |
| 156 |
'annotations' => array( |
| 157 |
'readonly' => false, |
| 158 |
'destructive' => false, |
| 159 |
'idempotent' => false, |
| 160 |
), |
| 161 |
'input_schema' => array( |
| 162 |
'type' => 'object', |
| 163 |
'properties' => array( |
| 164 |
'campaign_id' => array( |
| 165 |
'type' => 'integer', |
| 166 |
'description' => __( 'The campaign the donation is for.', 'charitable' ), |
| 167 |
'minimum' => 1, |
| 168 |
), |
| 169 |
'amount' => array( |
| 170 |
'type' => array( 'number', 'string' ), |
| 171 |
'description' => __( 'The amount donated, in the site currency. On a currency with no decimal places, supply a whole number.', 'charitable' ), |
| 172 |
), |
| 173 |
'donor' => array( |
| 174 |
'type' => 'object', |
| 175 |
'description' => __( 'Who gave. An existing donor is matched on email address rather than duplicated.', 'charitable' ), |
| 176 |
'properties' => array( |
| 177 |
'first_name' => array( 'type' => 'string' ), |
| 178 |
'last_name' => array( 'type' => 'string' ), |
| 179 |
'email' => array( 'type' => 'string' ), |
| 180 |
), |
| 181 |
'required' => array( 'first_name', 'last_name', 'email' ), |
| 182 |
'additionalProperties' => false, |
| 183 |
), |
| 184 |
'status' => array( |
| 185 |
'type' => 'string', |
| 186 |
'description' => __( 'Whether the money has been received. Defaults to paid.', 'charitable' ), |
| 187 |
'enum' => array( 'charitable-completed', 'charitable-pending' ), |
| 188 |
), |
| 189 |
'date' => array( |
| 190 |
'type' => 'string', |
| 191 |
'description' => __( 'The date the donation was received, as YYYY-MM-DD. Defaults to today. A future date is refused.', 'charitable' ), |
| 192 |
), |
| 193 |
'note' => array( |
| 194 |
'type' => 'string', |
| 195 |
'description' => __( 'An internal note about the donation, such as a check number.', 'charitable' ), |
| 196 |
), |
| 197 |
'send_receipt' => array( |
| 198 |
'type' => 'boolean', |
| 199 |
'default' => false, |
| 200 |
'description' => __( 'Email the donor a receipt for this donation. Off by default, so a donation recorded in error does not email the donor.', 'charitable' ), |
| 201 |
), |
| 202 |
), |
| 203 |
'required' => array( 'campaign_id', 'amount', 'donor' ), |
| 204 |
'additionalProperties' => false, |
| 205 |
), |
| 206 |
'output_schema' => array( |
| 207 |
'type' => 'object', |
| 208 |
'properties' => array( |
| 209 |
'donation_id' => array( 'type' => 'integer' ), |
| 210 |
'donor_id' => array( 'type' => 'integer' ), |
| 211 |
'campaign_id' => array( 'type' => 'integer' ), |
| 212 |
'amount' => array( 'type' => 'number' ), |
| 213 |
'amount_formatted' => array( 'type' => 'string' ), |
| 214 |
'status' => array( 'type' => 'string' ), |
| 215 |
'gateway' => array( 'type' => 'string' ), |
| 216 |
'date' => array( 'type' => 'string' ), |
| 217 |
'receipt_sent' => array( |
| 218 |
'type' => 'boolean', |
| 219 |
'description' => __( 'Whether a receipt email was sent to the donor.', 'charitable' ), |
| 220 |
), |
| 221 |
), |
| 222 |
), |
| 223 |
) |
| 224 |
); |
| 225 |
|
| 226 |
$this->register_ability( |
| 227 |
'charitable/get-donation-summary', |
| 228 |
array( |
| 229 |
'label' => __( 'Get Donation Summary', 'charitable' ), |
| 230 |
'description' => __( 'Returns how much was raised over a date range: total, donation count, average gift, and a count of donations in each status. Defaults to the current month. Use to answer "how much have we raised this month?" without listing every donation. For per-campaign or per-day figures use the report abilities instead.', 'charitable' ), |
| 231 |
'execute_callback' => array( $this, 'execute_get_donation_summary' ), |
| 232 |
'permission_callback' => array( __CLASS__, 'permission_donations' ), |
| 233 |
'annotations' => array( |
| 234 |
'readonly' => true, |
| 235 |
'idempotent' => true, |
| 236 |
), |
| 237 |
'input_schema' => array( |
| 238 |
'type' => 'object', |
| 239 |
'properties' => array( |
| 240 |
'start_date' => array( |
| 241 |
'type' => 'string', |
| 242 |
'description' => __( 'Start of the range, as YYYY-MM-DD. Defaults to the first of the current month.', 'charitable' ), |
| 243 |
), |
| 244 |
'end_date' => array( |
| 245 |
'type' => 'string', |
| 246 |
'description' => __( 'End of the range, as YYYY-MM-DD. Defaults to today. The whole day is included.', 'charitable' ), |
| 247 |
), |
| 248 |
'campaign_id' => array( |
| 249 |
'type' => 'integer', |
| 250 |
'description' => __( 'Only donations that include this campaign.', 'charitable' ), |
| 251 |
'minimum' => 1, |
| 252 |
), |
| 253 |
), |
| 254 |
'additionalProperties' => false, |
| 255 |
), |
| 256 |
'output_schema' => array( |
| 257 |
'type' => 'object', |
| 258 |
'properties' => array( |
| 259 |
'start_date' => array( 'type' => 'string' ), |
| 260 |
'end_date' => array( 'type' => 'string' ), |
| 261 |
'total' => array( |
| 262 |
'type' => 'number', |
| 263 |
'description' => __( 'Amount raised, counting only donations in a status the site treats as money received. Does NOT include pending, failed, cancelled or refunded gifts, so this will not match the sum of by_status.', 'charitable' ), |
| 264 |
), |
| 265 |
'total_formatted' => array( 'type' => 'string' ), |
| 266 |
'count' => array( |
| 267 |
'type' => 'integer', |
| 268 |
'description' => __( 'How many donations make up total — again only those in a status the site treats as money received. by_status counts EVERY donation whatever its status, so by_status will usually add up to more than this. When asked how many donations came in, say which of the two the answer uses.', 'charitable' ), |
| 269 |
), |
| 270 |
'average' => array( |
| 271 |
'type' => 'number', |
| 272 |
'description' => __( 'total divided by count, so it is the average received gift and excludes pending, failed, cancelled and refunded donations.', 'charitable' ), |
| 273 |
), |
| 274 |
'average_formatted' => array( 'type' => 'string' ), |
| 275 |
'currency' => array( 'type' => 'string' ), |
| 276 |
'by_status' => self::status_count_schema(), |
| 277 |
), |
| 278 |
), |
| 279 |
) |
| 280 |
); |
| 281 |
} |
| 282 |
|
| 283 |
/** |
| 284 |
* Output schema for the by_status map. |
| 285 |
* |
| 286 |
* The keys are enumerated from the live status list rather than left as a |
| 287 |
* bare `type: object`. Two reasons: the contract suite requires every |
| 288 |
* object schema to declare its properties, and a client reading the |
| 289 |
* schema learns which statuses exist on this site — including any a |
| 290 |
* filter has added — instead of having to infer them from a response. |
| 291 |
* |
| 292 |
* @since 1.8.13 |
| 293 |
* |
| 294 |
* @return array |
| 295 |
*/ |
| 296 |
private static function status_count_schema() { |
| 297 |
$properties = array(); |
| 298 |
|
| 299 |
foreach ( charitable_get_valid_donation_statuses() as $status => $label ) { |
| 300 |
$properties[ $status ] = array( |
| 301 |
'type' => 'integer', |
| 302 |
'description' => sprintf( |
| 303 |
/* translators: %s: a donation status label, e.g. "Paid". */ |
| 304 |
__( 'Number of donations with the status "%s".', 'charitable' ), |
| 305 |
$label |
| 306 |
), |
| 307 |
); |
| 308 |
} |
| 309 |
|
| 310 |
return array( |
| 311 |
'type' => 'object', |
| 312 |
'properties' => $properties, |
| 313 |
); |
| 314 |
} |
| 315 |
|
| 316 |
/** |
| 317 |
* Permission callback for create-manual-donation. |
| 318 |
* |
| 319 |
* `edit_donations` is the base requirement. Recording a donation for |
| 320 |
* somebody other than yourself additionally requires |
| 321 |
* `edit_others_donations`, which is the same split the admin donation |
| 322 |
* form enforces (class-charitable-admin-donation-form.php:424-443). |
| 323 |
* |
| 324 |
* The admin form's response to a user lacking that capability is to |
| 325 |
* silently REWRITE the donation onto that user's own donor record. An |
| 326 |
* ability must not do that: quietly attributing a gift to a different |
| 327 |
* person than the caller named is worse than refusing, because the |
| 328 |
* caller has no way to see it happened. The email is compared here, at |
| 329 |
* the permission gate, so the refusal arrives before anything is written. |
| 330 |
* |
| 331 |
* @since 1.8.13 |
| 332 |
* |
| 333 |
* @param array|null $input Ability input. |
| 334 |
* @return boolean |
| 335 |
*/ |
| 336 |
public static function permission_create_manual_donation( $input = null ) { |
| 337 |
if ( ! current_user_can( 'edit_donations' ) ) { |
| 338 |
return false; |
| 339 |
} |
| 340 |
|
| 341 |
if ( current_user_can( 'edit_others_donations' ) ) { |
| 342 |
return true; |
| 343 |
} |
| 344 |
|
| 345 |
$email = ''; |
| 346 |
|
| 347 |
if ( is_array( $input ) && isset( $input['donor']['email'] ) ) { |
| 348 |
$email = strtolower( trim( (string) $input['donor']['email'] ) ); |
| 349 |
} |
| 350 |
|
| 351 |
$current = strtolower( (string) wp_get_current_user()->user_email ); |
| 352 |
|
| 353 |
return '' !== $email && $email === $current; |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* Execute charitable/list-donations. |
| 358 |
* |
| 359 |
* @since 1.8.13 |
| 360 |
* |
| 361 |
* @param array $input Ability input. |
| 362 |
* @return array|WP_Error |
| 363 |
*/ |
| 364 |
public function execute_list_donations( $input ) { |
| 365 |
$per_page = self::input_int( $input, 'per_page', 20, 1, 100 ); |
| 366 |
$page = self::input_int( $input, 'page', 1, 1 ); |
| 367 |
|
| 368 |
/* |
| 369 |
* Every charitable-* donation status is registered with |
| 370 |
* `exclude_from_search => true`. WP_Query's default post_status is |
| 371 |
* `publish`, which no donation ever has, and `'any'` explicitly |
| 372 |
* EXCLUDES exclude_from_search statuses — so both return zero rows. |
| 373 |
* Charitable_Donations::query() supplies no post_status default of |
| 374 |
* its own, so the explicit list has to be passed here. Proven |
| 375 |
* repeatedly elsewhere in this codebase; see the notes in |
| 376 |
* charitable-paypal-commerce-hooks.php. |
| 377 |
*/ |
| 378 |
$statuses = array_keys( charitable_get_valid_donation_statuses() ); |
| 379 |
|
| 380 |
/* |
| 381 |
* Read through input_string(): charitable_is_valid_donation_status() |
| 382 |
* calls array_key_exists(), which THROWS a TypeError on an array or |
| 383 |
* object key rather than returning false. |
| 384 |
*/ |
| 385 |
$requested_status = self::input_string( $input, 'status' ); |
| 386 |
|
| 387 |
if ( '' !== $requested_status ) { |
| 388 |
if ( ! charitable_is_valid_donation_status( $requested_status ) ) { |
| 389 |
return new WP_Error( |
| 390 |
'charitable_invalid_status', |
| 391 |
__( 'That is not a donation status on this site.', 'charitable' ) |
| 392 |
); |
| 393 |
} |
| 394 |
|
| 395 |
$statuses = array( $requested_status ); |
| 396 |
} |
| 397 |
|
| 398 |
$args = array( |
| 399 |
'post_status' => $statuses, |
| 400 |
'posts_per_page' => $per_page, |
| 401 |
'paged' => $page, |
| 402 |
'orderby' => 'date', |
| 403 |
'order' => 'DESC', |
| 404 |
); |
| 405 |
|
| 406 |
/* |
| 407 |
* Author-scoped for anyone without `edit_others_donations`, which is |
| 408 |
* exactly what wp-admin's own donation list shows such a user |
| 409 |
* (class-charitable-donation-list-table.php:783 applies the same |
| 410 |
* `author` filter for the same reason). This listing cannot use an |
| 411 |
* object-level capability — it has no donation_id to check — so the |
| 412 |
* scope has to be applied to the QUERY instead. Same shape as |
| 413 |
* execute_list_campaigns() (class-charitable-campaign-abilities.php:1129), |
| 414 |
* where this defect was already fixed for list-campaigns. |
| 415 |
* |
| 416 |
* Without it, a role holding `edit_donations` without |
| 417 |
* `edit_others_donations` receives every donation on the site: amount, |
| 418 |
* status, gateway, donor name and the ids needed to address |
| 419 |
* get-donation. Verified over wp-abilities/v1 before the fix — HTTP |
| 420 |
* 200, all 98 donations on the audit's test site. |
| 421 |
* |
| 422 |
* Invisible on a default install: both stock roles that hold |
| 423 |
* `edit_donations` (administrator, campaign_manager) also hold |
| 424 |
* `edit_others_donations`, so neither is scoped by this and neither |
| 425 |
* sees one donation fewer. That is precisely why it has to be written |
| 426 |
* rather than assumed. |
| 427 |
* |
| 428 |
* DIVERGENCE FROM PRO, deliberate and documented: Pro 1.8.18 (the |
| 429 |
* pinned source this file was ported from) has the same defect and |
| 430 |
* should adopt this change so the shared schema does not drift |
| 431 |
* silently. |
| 432 |
*/ |
| 433 |
if ( ! current_user_can( 'edit_others_donations' ) ) { |
| 434 |
$args['author'] = get_current_user_id(); |
| 435 |
} |
| 436 |
|
| 437 |
$dates = self::parse_date_range( $input, '', '' ); |
| 438 |
|
| 439 |
if ( is_wp_error( $dates ) ) { |
| 440 |
return $dates; |
| 441 |
} |
| 442 |
|
| 443 |
if ( '' !== $dates['start_date'] || '' !== $dates['end_date'] ) { |
| 444 |
$args['date_query'] = array( self::date_query_clause( $dates ) ); |
| 445 |
} |
| 446 |
|
| 447 |
/* |
| 448 |
* campaign_id and donor_id are columns of the campaign_donations |
| 449 |
* table, not post meta, so they cannot be expressed as a meta_query. |
| 450 |
* Resolve them to a donation id list first and constrain the post |
| 451 |
* query with post__in. An empty list means "no matches" and must |
| 452 |
* short-circuit: passing an empty post__in to WP_Query is ignored, |
| 453 |
* which would return every donation instead of none. |
| 454 |
*/ |
| 455 |
$restrict_to = null; |
| 456 |
|
| 457 |
/* |
| 458 |
* is_array( $input ) guards the array subscript, not a |
| 459 |
* ! empty()-replaces-0 rewrite: $input itself can be a non-array |
| 460 |
* (a bare stdClass reaches this method directly, since execute_* |
| 461 |
* has to be public), and subscripting a non-array throws before |
| 462 |
* ! empty() ever gets a chance to evaluate it. is_array() has to |
| 463 |
* come first so PHP short-circuits before the subscript runs. |
| 464 |
* |
| 465 |
* ! empty( $input['campaign_id'] ) is kept deliberately, rather |
| 466 |
* than read through input_id() and tested against 0: a supplied |
| 467 |
* id that casts to 0 (a non-numeric string such as "abc", or a |
| 468 |
* decimal like "0.0") must still narrow the query to NOTHING, |
| 469 |
* not fall through as if campaign_id had never been supplied at |
| 470 |
* all. An earlier version of this fix read the id through |
| 471 |
* input_id() first and tested `0 !== $campaign_id`, which is |
| 472 |
* fail-OPEN for exactly that input: input_id() casts "abc" to 0, |
| 473 |
* 0 doesn't pass the check, the filter is skipped entirely, and |
| 474 |
* the query returns every donation instead of none. Caught by |
| 475 |
* test_list_donations_with_an_unusable_campaign_id_returns_nothing_not_everything(). |
| 476 |
* Not in this task's original list of seven reads — found by the |
| 477 |
* same hostile-input sweep that found the eighth, in |
| 478 |
* parse_named_fields()'s description branch. |
| 479 |
*/ |
| 480 |
if ( is_array( $input ) && ! empty( $input['campaign_id'] ) ) { |
| 481 |
$campaign_id = self::input_id( $input, 'campaign_id' ); |
| 482 |
$restrict_to = array_map( |
| 483 |
'intval', |
| 484 |
(array) charitable_get_table( 'campaign_donations' )->get_donation_ids_for_campaign( $campaign_id ) |
| 485 |
); |
| 486 |
} |
| 487 |
|
| 488 |
if ( is_array( $input ) && ! empty( $input['donor_id'] ) ) { |
| 489 |
$donor_id = self::input_id( $input, 'donor_id' ); |
| 490 |
$by_donor = charitable_get_table( 'campaign_donations' )->get_donations_by_donor( $donor_id, true ); |
| 491 |
$donor_ids = array(); |
| 492 |
|
| 493 |
foreach ( (array) $by_donor as $row ) { |
| 494 |
$donor_ids[] = (int) ( is_object( $row ) ? $row->donation_id : $row ); |
| 495 |
} |
| 496 |
|
| 497 |
$restrict_to = is_null( $restrict_to ) ? $donor_ids : array_intersect( $restrict_to, $donor_ids ); |
| 498 |
} |
| 499 |
|
| 500 |
if ( is_array( $restrict_to ) ) { |
| 501 |
$restrict_to = array_values( array_unique( array_filter( $restrict_to ) ) ); |
| 502 |
|
| 503 |
if ( empty( $restrict_to ) ) { |
| 504 |
return array( |
| 505 |
'donations' => array(), |
| 506 |
'total' => 0, |
| 507 |
'pages' => 0, |
| 508 |
); |
| 509 |
} |
| 510 |
|
| 511 |
$args['post__in'] = $restrict_to; |
| 512 |
} |
| 513 |
|
| 514 |
$query = Charitable_Donations::query( $args ); |
| 515 |
|
| 516 |
$donation_ids = wp_list_pluck( $query->posts, 'ID' ); |
| 517 |
$amounts = charitable_get_table( 'campaign_donations' )->get_donated_amounts_by_donation( $donation_ids ); |
| 518 |
|
| 519 |
/* |
| 520 |
* The per-campaign split, batched into ONE query for the page. |
| 521 |
* |
| 522 |
* This ability's description has always said it returns "the |
| 523 |
* campaigns it was split across", and no row carried them — only |
| 524 |
* get-donation did. So "which campaign did each of these go to?" had |
| 525 |
* nothing behind it (item 53's shape: a description promising content |
| 526 |
* the ability does not deliver). |
| 527 |
* |
| 528 |
* Batched rather than per row deliberately. |
| 529 |
* Charitable_Donation::get_campaign_donations() issues its own query |
| 530 |
* per donation, which is exactly why the amounts above are batched; |
| 531 |
* satisfying the description with an N+1 would trade one defect for a |
| 532 |
* slower one. |
| 533 |
*/ |
| 534 |
$campaign_rows = charitable_get_table( 'campaign_donations' )->get_campaign_rows_by_donation( $donation_ids ); |
| 535 |
|
| 536 |
$donations = array(); |
| 537 |
|
| 538 |
foreach ( $query->posts as $post ) { |
| 539 |
$batched = isset( $amounts[ $post->ID ] ) ? $amounts[ $post->ID ] : array(); |
| 540 |
|
| 541 |
/* |
| 542 |
* 0, not null, when the batch has no entry for this donation. |
| 543 |
* Passing null makes donation_summary() fall back to |
| 544 |
* Charitable_Donation::get_donor_id(), and that fallback is |
| 545 |
* provably useless here: the batch was built from these exact |
| 546 |
* ids, so a missing entry means the donation has no |
| 547 |
* campaign_donations rows at all — and get_donor_id() resolves |
| 548 |
* through those same rows, so it returns false and costs a query |
| 549 |
* to do it. Measured: at per_page=100 over a database holding 18 |
| 550 |
* such orphaned records, the fallback took the ability from 6 |
| 551 |
* queries to 21. The null path is kept for get-donation, which |
| 552 |
* is a single read with no batch to draw on. |
| 553 |
*/ |
| 554 |
$row = $this->donation_summary( |
| 555 |
charitable_get_donation( $post->ID ), |
| 556 |
isset( $batched['amount'] ) ? $batched['amount'] : 0, |
| 557 |
isset( $batched['donor_id'] ) ? $batched['donor_id'] : 0 |
| 558 |
); |
| 559 |
|
| 560 |
$row['campaigns'] = $this->campaign_split( |
| 561 |
isset( $campaign_rows[ $post->ID ] ) ? $campaign_rows[ $post->ID ] : array() |
| 562 |
); |
| 563 |
|
| 564 |
$donations[] = $row; |
| 565 |
} |
| 566 |
|
| 567 |
return array( |
| 568 |
'donations' => $donations, |
| 569 |
'total' => (int) $query->found_posts, |
| 570 |
'pages' => (int) $query->max_num_pages, |
| 571 |
); |
| 572 |
} |
| 573 |
|
| 574 |
/** |
| 575 |
* Execute charitable/get-donation. |
| 576 |
* |
| 577 |
* @since 1.8.13 |
| 578 |
* |
| 579 |
* @param array $input Ability input. |
| 580 |
* @return array|WP_Error |
| 581 |
*/ |
| 582 |
public function execute_get_donation( $input ) { |
| 583 |
$donation = $this->find_donation( self::input_id( $input, 'donation_id' ) ); |
| 584 |
|
| 585 |
if ( is_wp_error( $donation ) ) { |
| 586 |
return $donation; |
| 587 |
} |
| 588 |
|
| 589 |
/* include_address: true — a single read, so no per-row cost and no bulk-export concern. */ |
| 590 |
$summary = $this->donation_summary( $donation, $donation->get_total_donation_amount(), null, true ); |
| 591 |
|
| 592 |
$campaigns = array(); |
| 593 |
|
| 594 |
foreach ( (array) $donation->get_campaign_donations() as $campaign_donation ) { |
| 595 |
/* |
| 596 |
* Reads cd.amount directly rather than Pro's |
| 597 |
* isset( base_amount ) fallback: base_amount belongs to the |
| 598 |
* multi-currency schema and does not exist on this table in |
| 599 |
* Lite (see get_donations_summary_by_day_range() in |
| 600 |
* class-charitable-campaign-donations-db.php for the same |
| 601 |
* substitution and rationale). |
| 602 |
*/ |
| 603 |
$campaign_amount = $campaign_donation->amount; |
| 604 |
|
| 605 |
$money = self::money( $campaign_amount ); |
| 606 |
|
| 607 |
$campaigns[] = array( |
| 608 |
'campaign_id' => (int) $campaign_donation->campaign_id, |
| 609 |
'campaign_name' => (string) $campaign_donation->campaign_name, |
| 610 |
'amount' => $money['raw'], |
| 611 |
'amount_formatted' => $money['formatted'], |
| 612 |
); |
| 613 |
} |
| 614 |
|
| 615 |
return array_merge( |
| 616 |
$summary, |
| 617 |
array( |
| 618 |
'campaigns' => $campaigns, |
| 619 |
'note' => (string) $donation->get_notes(), |
| 620 |
'transaction_id' => (string) $donation->get_gateway_transaction_id(), |
| 621 |
'test_mode' => (bool) $donation->get_test_mode( false ), |
| 622 |
) |
| 623 |
); |
| 624 |
} |
| 625 |
|
| 626 |
/** |
| 627 |
* Execute charitable/create-manual-donation. |
| 628 |
* |
| 629 |
* @since 1.8.13 |
| 630 |
* |
| 631 |
* @param array $input Ability input. |
| 632 |
* @return array|WP_Error |
| 633 |
*/ |
| 634 |
public function execute_create_manual_donation( $input ) { |
| 635 |
$ability = 'charitable/create-manual-donation'; |
| 636 |
|
| 637 |
$campaign_id = self::input_id( $input, 'campaign_id' ); |
| 638 |
$campaign = get_post( $campaign_id ); |
| 639 |
|
| 640 |
if ( ! $campaign || 'campaign' !== $campaign->post_type ) { |
| 641 |
$error = new WP_Error( |
| 642 |
'charitable_campaign_not_found', |
| 643 |
__( 'No campaign with that id exists. Use List Campaigns to find the right one.', 'charitable' ) |
| 644 |
); |
| 645 |
|
| 646 |
self::log_failure( $ability, $error ); |
| 647 |
|
| 648 |
return $error; |
| 649 |
} |
| 650 |
|
| 651 |
if ( in_array( $campaign->post_status, array( 'trash', 'auto-draft' ), true ) ) { |
| 652 |
$error = new WP_Error( |
| 653 |
'charitable_campaign_not_available', |
| 654 |
__( 'That campaign is in the trash, so a donation cannot be recorded against it. Restore the campaign first.', 'charitable' ) |
| 655 |
); |
| 656 |
|
| 657 |
self::log_failure( $ability, $error ); |
| 658 |
|
| 659 |
return $error; |
| 660 |
} |
| 661 |
|
| 662 |
$parsed = self::parse_money( isset( $input['amount'] ) ? $input['amount'] : null ); |
| 663 |
|
| 664 |
if ( is_wp_error( $parsed ) ) { |
| 665 |
self::log_failure( $ability, $parsed ); |
| 666 |
|
| 667 |
return $parsed; |
| 668 |
} |
| 669 |
|
| 670 |
if ( $parsed <= 0 ) { |
| 671 |
$error = new WP_Error( |
| 672 |
'charitable_invalid_amount', |
| 673 |
__( 'The amount must be greater than zero.', 'charitable' ) |
| 674 |
); |
| 675 |
|
| 676 |
self::log_failure( $ability, $error ); |
| 677 |
|
| 678 |
return $error; |
| 679 |
} |
| 680 |
|
| 681 |
$email = isset( $input['donor']['email'] ) ? sanitize_email( (string) $input['donor']['email'] ) : ''; |
| 682 |
|
| 683 |
if ( ! is_email( $email ) ) { |
| 684 |
$error = new WP_Error( |
| 685 |
'charitable_invalid_donor', |
| 686 |
__( 'A valid donor email address is required.', 'charitable' ) |
| 687 |
); |
| 688 |
|
| 689 |
self::log_failure( $ability, $error ); |
| 690 |
|
| 691 |
return $error; |
| 692 |
} |
| 693 |
|
| 694 |
$date = $this->parse_donation_date( self::input_string( $input, 'date' ) ); |
| 695 |
|
| 696 |
if ( is_wp_error( $date ) ) { |
| 697 |
self::log_failure( $ability, $date ); |
| 698 |
|
| 699 |
return $date; |
| 700 |
} |
| 701 |
|
| 702 |
$status = self::input_string( $input, 'status', 'charitable-completed' ); |
| 703 |
|
| 704 |
if ( ! in_array( $status, array( 'charitable-completed', 'charitable-pending' ), true ) ) { |
| 705 |
$error = new WP_Error( |
| 706 |
'charitable_invalid_status', |
| 707 |
__( 'A manual donation can only be recorded as paid or pending.', 'charitable' ) |
| 708 |
); |
| 709 |
|
| 710 |
self::log_failure( $ability, $error ); |
| 711 |
|
| 712 |
return $error; |
| 713 |
} |
| 714 |
|
| 715 |
$values = array( |
| 716 |
|
| 717 |
/* |
| 718 |
* `user_id => 0` is load-bearing, not defensive. |
| 719 |
* Charitable_Donation_Processor::get_donor_id() falls through to |
| 720 |
* `new Charitable_User( get_donation_data_value( 'user_id', |
| 721 |
* get_current_user_id() ) )` and then calls add_donor(), which |
| 722 |
* stores `user_id => $this->ID`. Omitting the key therefore binds |
| 723 |
* a brand-new donor record to the WordPress account of whoever is |
| 724 |
* driving the ability — the administrator, not the donor. The |
| 725 |
* admin donation form passes the same explicit 0 |
| 726 |
* (class-charitable-admin-donation-form.php:410), and every |
| 727 |
* `manual` donation row in the dev database has post_author 0, |
| 728 |
* which confirms that is the real behavior rather than a guess. |
| 729 |
*/ |
| 730 |
'user_id' => 0, |
| 731 |
|
| 732 |
/* |
| 733 |
* The key is `gateway`. save_donation_meta() (line 747) reads |
| 734 |
* `get_donation_data_value( 'gateway' )`; the admin form's |
| 735 |
* `donation_gateway` key holds a TRANSLATED "Manual" label under a |
| 736 |
* different key that the create path never reads, and the value |
| 737 |
* only lands as `manual` because |
| 738 |
* charitable_sanitize_donation_meta() defaults an empty gateway. |
| 739 |
* Pass the lowercase slug explicitly rather than relying on that |
| 740 |
* default. `manual` is NOT `offline` — offline is a donor-facing |
| 741 |
* gateway a site can enable, and conflating the two misreports |
| 742 |
* how the money arrived. |
| 743 |
*/ |
| 744 |
'gateway' => 'manual', |
| 745 |
'status' => $status, |
| 746 |
'date_gmt' => $date['date_gmt'], |
| 747 |
'note' => sanitize_textarea_field( self::input_string( $input, 'note' ) ), |
| 748 |
'campaigns' => array( |
| 749 |
array( |
| 750 |
'campaign_id' => $campaign_id, |
| 751 |
'campaign_name' => $campaign->post_title, |
| 752 |
'amount' => $parsed, |
| 753 |
), |
| 754 |
), |
| 755 |
'user' => array( |
| 756 |
'first_name' => isset( $input['donor']['first_name'] ) ? sanitize_text_field( (string) $input['donor']['first_name'] ) : '', |
| 757 |
'last_name' => isset( $input['donor']['last_name'] ) ? sanitize_text_field( (string) $input['donor']['last_name'] ) : '', |
| 758 |
'email' => $email, |
| 759 |
), |
| 760 |
); |
| 761 |
|
| 762 |
$send_receipt = ! empty( self::input_array( $input )['send_receipt'] ); |
| 763 |
|
| 764 |
/* |
| 765 |
* Receipts are OFF unless asked for. |
| 766 |
* |
| 767 |
* save_donation() fires charitable_after_save_donation, which sends |
| 768 |
* the donor receipt and the admin notification. An assistant that |
| 769 |
* mis-reads a figure would otherwise email a stranger a donation |
| 770 |
* receipt in the organisation's name - a document with tax |
| 771 |
* significance in many jurisdictions. Pro sends by default; Lite |
| 772 |
* does not, because an AI client is a likelier source of a wrong |
| 773 |
* donation than a human on the Add Donation screen. |
| 774 |
* |
| 775 |
* Removed in the finally block below, not at the end of the |
| 776 |
* request, so an exception thrown between the add and the remove |
| 777 |
* cannot leave receipts suppressed for the rest of the request - |
| 778 |
* including real donations from real donors made later in the |
| 779 |
* same request. |
| 780 |
* |
| 781 |
* @since 1.8.13 |
| 782 |
*/ |
| 783 |
$suppress = static function () { |
| 784 |
return false; |
| 785 |
}; |
| 786 |
|
| 787 |
if ( ! $send_receipt ) { |
| 788 |
add_filter( 'charitable_send_donation_receipt', $suppress, 999 ); |
| 789 |
} |
| 790 |
|
| 791 |
try { |
| 792 |
$donation_id = charitable_create_donation( $values ); |
| 793 |
} finally { |
| 794 |
if ( ! $send_receipt ) { |
| 795 |
remove_filter( 'charitable_send_donation_receipt', $suppress, 999 ); |
| 796 |
} |
| 797 |
} |
| 798 |
|
| 799 |
/* |
| 800 |
* save_donation() returns the integer 0 on every failure path rather |
| 801 |
* than a WP_Error, and pushes its reason into charitable_get_notices() |
| 802 |
* instead of returning it. So 0 is the only signal available here. |
| 803 |
*/ |
| 804 |
if ( ! $donation_id ) { |
| 805 |
$error = new WP_Error( |
| 806 |
'charitable_donation_not_created', |
| 807 |
__( 'The donation could not be recorded. Check the campaign and donor details and try again.', 'charitable' ) |
| 808 |
); |
| 809 |
|
| 810 |
self::log_failure( $ability, $error ); |
| 811 |
|
| 812 |
return $error; |
| 813 |
} |
| 814 |
|
| 815 |
$donation = charitable_get_donation( $donation_id ); |
| 816 |
$money = self::money( $donation->get_total_donation_amount() ); |
| 817 |
|
| 818 |
self::log_write( |
| 819 |
$ability, |
| 820 |
sprintf( |
| 821 |
/* translators: 1: formatted donation amount, 2: campaign title. */ |
| 822 |
__( 'Recorded a manual donation of %1$s to "%2$s".', 'charitable' ), |
| 823 |
$money['formatted'], |
| 824 |
$campaign->post_title |
| 825 |
), |
| 826 |
array( |
| 827 |
'donation_id' => $donation_id, |
| 828 |
'campaign_id' => $campaign_id, |
| 829 |
'donor_id' => (int) $donation->get_donor_id(), |
| 830 |
'object_id' => $donation_id, |
| 831 |
) |
| 832 |
); |
| 833 |
|
| 834 |
return array( |
| 835 |
'donation_id' => (int) $donation_id, |
| 836 |
'donor_id' => (int) $donation->get_donor_id(), |
| 837 |
'campaign_id' => $campaign_id, |
| 838 |
'amount' => $money['raw'], |
| 839 |
'amount_formatted' => $money['formatted'], |
| 840 |
'status' => $status, |
| 841 |
'gateway' => 'manual', |
| 842 |
'date' => $donation->get_date( 'Y-m-d' ), |
| 843 |
'receipt_sent' => $send_receipt, |
| 844 |
); |
| 845 |
} |
| 846 |
|
| 847 |
/** |
| 848 |
* Execute charitable/get-donation-summary. |
| 849 |
* |
| 850 |
* @since 1.8.13 |
| 851 |
* |
| 852 |
* @param array $input Ability input. |
| 853 |
* @return array|WP_Error |
| 854 |
*/ |
| 855 |
public function execute_get_donation_summary( $input ) { |
| 856 |
/* |
| 857 |
* current_time(), NOT gmdate(). Both the day-range accessor and |
| 858 |
* WP_Query's date_query compare against `post_date`, which is the |
| 859 |
* site's LOCAL time, so a range computed in UTC is compared against |
| 860 |
* local timestamps. Reproduced: on a site 9.5 hours ahead of UTC a |
| 861 |
* donation recorded "now" carries post_date 2026-09-05 while |
| 862 |
* gmdate( 'Y-m-d' ) is still 2026-09-04, and the default |
| 863 |
* "this month to today" range silently excluded it — the summary |
| 864 |
* reported 0 raised on a site that had just taken a donation. |
| 865 |
*/ |
| 866 |
$dates = self::parse_date_range( $input, current_time( 'Y-m-01' ), current_time( 'Y-m-d' ) ); |
| 867 |
|
| 868 |
if ( is_wp_error( $dates ) ) { |
| 869 |
return $dates; |
| 870 |
} |
| 871 |
|
| 872 |
$table = charitable_get_table( 'campaign_donations' ); |
| 873 |
|
| 874 |
/* |
| 875 |
* One query shape for both the filtered and unfiltered case, and all |
| 876 |
* of them reusing get_report_sql_where_clause(). The earlier version |
| 877 |
* branched — day-range accessor when no campaign was given, |
| 878 |
* get_amount_report() when one was — and paid for the branch by |
| 879 |
* counting donations with an UNBOUNDED get_posts() per status: |
| 880 |
* `posts_per_page => -1` six times over. On a site with a few hundred |
| 881 |
* thousand donations in range that loads every id into PHP just to |
| 882 |
* call count() on it. |
| 883 |
* |
| 884 |
* Taking the total and the count from the same $args also makes them |
| 885 |
* coherent by construction, so `average = total / count` cannot |
| 886 |
* disagree with itself. |
| 887 |
*/ |
| 888 |
$args = array( |
| 889 |
'start_date' => $dates['start_date'] . ' 00:00:00', |
| 890 |
'end_date' => $dates['end_date'] . ' 23:59:59', |
| 891 |
); |
| 892 |
|
| 893 |
$campaign_filter = self::input_id( $input, 'campaign_id' ); |
| 894 |
|
| 895 |
if ( $campaign_filter > 0 ) { |
| 896 |
$args['campaign_id'] = $campaign_filter; |
| 897 |
} |
| 898 |
|
| 899 |
/* |
| 900 |
* Author-scoped for anyone without `edit_others_donations`, the same |
| 901 |
* rule and the same reason as execute_list_donations() above and as |
| 902 |
* wp-admin's own donation list |
| 903 |
* (class-charitable-donation-list-table.php:783). A summary has no |
| 904 |
* donation_id either, so again the scope goes on the QUERY. |
| 905 |
* |
| 906 |
* Set on $args BEFORE $approved is derived from it, so the total, the |
| 907 |
* count and the per-status breakdown are all scoped the same way and |
| 908 |
* `average = total / count` stays coherent. A summary scoped for the |
| 909 |
* total but not the count would report an average no donation |
| 910 |
* resembles. |
| 911 |
* |
| 912 |
* `author` is honoured by get_report_sql_where_clause() as |
| 913 |
* `p.post_author = %d`. Both stock roles holding `edit_donations` also |
| 914 |
* hold `edit_others_donations`, so neither is scoped by this. |
| 915 |
* |
| 916 |
* DIVERGENCE FROM PRO, deliberate and documented: Pro 1.8.18 has the |
| 917 |
* same defect and should adopt this change. |
| 918 |
*/ |
| 919 |
if ( ! current_user_can( 'edit_others_donations' ) ) { |
| 920 |
$args['author'] = get_current_user_id(); |
| 921 |
} |
| 922 |
|
| 923 |
$approved = array_merge( $args, array( 'status' => charitable_get_approval_statuses() ) ); |
| 924 |
|
| 925 |
$total = (float) $table->get_amount_report( $approved, false ); |
| 926 |
$count = $table->count_donations_report( $approved ); |
| 927 |
|
| 928 |
/* One grouped query for every status, rather than one query each. */ |
| 929 |
$counts_by_status = $table->count_donations_by_status_report( $args ); |
| 930 |
$by_status = array(); |
| 931 |
|
| 932 |
foreach ( array_keys( charitable_get_valid_donation_statuses() ) as $status ) { |
| 933 |
$by_status[ $status ] = isset( $counts_by_status[ $status ] ) ? (int) $counts_by_status[ $status ] : 0; |
| 934 |
} |
| 935 |
|
| 936 |
$average = $count > 0 ? $total / $count : 0.0; |
| 937 |
|
| 938 |
$total_money = self::money( $total ); |
| 939 |
$average_money = self::money( $average ); |
| 940 |
|
| 941 |
return array( |
| 942 |
'start_date' => $dates['start_date'], |
| 943 |
'end_date' => $dates['end_date'], |
| 944 |
'total' => $total_money['raw'], |
| 945 |
'total_formatted' => $total_money['formatted'], |
| 946 |
'count' => $count, |
| 947 |
'average' => $average_money['raw'], |
| 948 |
'average_formatted' => $average_money['formatted'], |
| 949 |
'currency' => charitable_get_currency(), |
| 950 |
'by_status' => $by_status, |
| 951 |
); |
| 952 |
} |
| 953 |
|
| 954 |
/** |
| 955 |
* Build one donation's summary row. |
| 956 |
* |
| 957 |
* The amount is supplied by the caller rather than read here, so a list |
| 958 |
* can batch it into a single query. |
| 959 |
* |
| 960 |
* @since 1.8.13 |
| 961 |
* |
| 962 |
* @param Charitable_Donation $donation The donation. |
| 963 |
* @param mixed $amount Pre-resolved converted total. |
| 964 |
* @param int|null $donor_id Pre-resolved donor id. Null makes this |
| 965 |
* read it from the donation, which costs a |
| 966 |
* query -- pass it when a batch already has it. |
| 967 |
* @param boolean $include_address Whether to include the postal address. |
| 968 |
* Single reads only; see the note below. |
| 969 |
* @return array |
| 970 |
*/ |
| 971 |
private function donation_summary( $donation, $amount, $donor_id = null, $include_address = false ) { |
| 972 |
/* |
| 973 |
* self::money() rather than $donation->get_amount_formatted(). That |
| 974 |
* method formats with $donation->get_currency() — the DONOR's |
| 975 |
* currency — while the amount above is the gift converted into the |
| 976 |
* site's base currency. On a multi-currency site that pairing prints |
| 977 |
* a converted figure under the wrong symbol. |
| 978 |
*/ |
| 979 |
$money = self::money( $amount ); |
| 980 |
|
| 981 |
/* |
| 982 |
* The donor id is supplied by the caller when it already has it, so a |
| 983 |
* list can batch it. Charitable_Donation::get_donor_id() resolves it |
| 984 |
* through get_campaign_donations(), which issues its own query per |
| 985 |
* donation — 20 extra queries for a page of 20, to learn something |
| 986 |
* the batched amount query returned in the same row. |
| 987 |
*/ |
| 988 |
if ( is_null( $donor_id ) ) { |
| 989 |
$donor_id = (int) $donation->get_donor_id(); |
| 990 |
} |
| 991 |
|
| 992 |
$row = array( |
| 993 |
'donation_id' => (int) $donation->get_donation_id(), |
| 994 |
'number' => (string) $donation->get_number(), |
| 995 |
'date' => $donation->get_date( 'Y-m-d' ), |
| 996 |
'status' => $donation->get_status(), |
| 997 |
'status_label' => $donation->get_status_label(), |
| 998 |
'gateway' => (string) $donation->get_gateway(), |
| 999 |
'gateway_label' => (string) $donation->get_gateway_label(), |
| 1000 |
'amount' => $money['raw'], |
| 1001 |
'amount_formatted' => $money['formatted'], |
| 1002 |
'currency' => charitable_get_currency(), |
| 1003 |
'donor' => array( |
| 1004 |
'donor_id' => (int) $donor_id, |
| 1005 |
'name' => $this->donor_display_name( $donation ), |
| 1006 |
), |
| 1007 |
); |
| 1008 |
|
| 1009 |
/* |
| 1010 |
* C7. `edit_donations` is enough to see that a donation exists and for |
| 1011 |
* how much; the donor's contact details are not part of that. The |
| 1012 |
* filed precedent is the donors REST route, which exposed emails to |
| 1013 |
* any Contributor. Both stock Charitable roles bundle |
| 1014 |
* view_charitable_sensitive_data with edit_donations, so this gate is |
| 1015 |
* invisible on a default install and only bites on a role-editor |
| 1016 |
* setup — which is exactly why it must be written rather than assumed. |
| 1017 |
*/ |
| 1018 |
if ( self::permission_sensitive() ) { |
| 1019 |
$donor_data = (array) $donation->get_donor_data(); |
| 1020 |
|
| 1021 |
$row['donor']['email'] = isset( $donor_data['email'] ) ? (string) $donor_data['email'] : ''; |
| 1022 |
|
| 1023 |
/* |
| 1024 |
* The postal address is returned by the single-donation read |
| 1025 |
* only, never by a list, and for two reasons that point the same |
| 1026 |
* way. Privacy: a page of addresses turns one permitted read |
| 1027 |
* into a bulk export of donor addresses, which is the same |
| 1028 |
* reasoning that keeps all contact detail out of list-donors. |
| 1029 |
* Cost: get_donor_address() constructs a Charitable_Donor per |
| 1030 |
* row — measured at 2 queries each, 40 of the 76 queries a |
| 1031 |
* 20-row page used to issue. |
| 1032 |
*/ |
| 1033 |
if ( $include_address ) { |
| 1034 |
$row['donor']['address'] = $donation->get_donor_address(); |
| 1035 |
} |
| 1036 |
} |
| 1037 |
|
| 1038 |
return $row; |
| 1039 |
} |
| 1040 |
|
| 1041 |
/** |
| 1042 |
* Shape a batch of campaign_donations rows into the reported split. |
| 1043 |
* |
| 1044 |
* Shared so a list row and get-donation's single read describe a campaign |
| 1045 |
* split identically — the money is formatted through self::money() in both, |
| 1046 |
* which is what keeps a converted amount under the site's own symbol |
| 1047 |
* rather than the donor's (C3). |
| 1048 |
* |
| 1049 |
* @since 1.8.13 |
| 1050 |
* |
| 1051 |
* @param array $rows Rows from get_campaign_rows_by_donation(). |
| 1052 |
* @return array |
| 1053 |
*/ |
| 1054 |
private function campaign_split( array $rows ) { |
| 1055 |
$campaigns = array(); |
| 1056 |
|
| 1057 |
foreach ( $rows as $row ) { |
| 1058 |
$money = self::money( isset( $row['amount'] ) ? $row['amount'] : 0 ); |
| 1059 |
|
| 1060 |
$campaigns[] = array( |
| 1061 |
'campaign_id' => isset( $row['campaign_id'] ) ? (int) $row['campaign_id'] : 0, |
| 1062 |
'campaign_name' => isset( $row['campaign_name'] ) ? (string) $row['campaign_name'] : '', |
| 1063 |
'amount' => $money['raw'], |
| 1064 |
'amount_formatted' => $money['formatted'], |
| 1065 |
); |
| 1066 |
} |
| 1067 |
|
| 1068 |
return $campaigns; |
| 1069 |
} |
| 1070 |
|
| 1071 |
/** |
| 1072 |
* The donor's display name, without reaching for contact details. |
| 1073 |
* |
| 1074 |
* @since 1.8.13 |
| 1075 |
* |
| 1076 |
* @param Charitable_Donation $donation The donation. |
| 1077 |
* @return string |
| 1078 |
*/ |
| 1079 |
private function donor_display_name( $donation ) { |
| 1080 |
$donor_data = (array) $donation->get_donor_data(); |
| 1081 |
|
| 1082 |
$name = trim( |
| 1083 |
sprintf( |
| 1084 |
'%s %s', |
| 1085 |
isset( $donor_data['first_name'] ) ? $donor_data['first_name'] : '', |
| 1086 |
isset( $donor_data['last_name'] ) ? $donor_data['last_name'] : '' |
| 1087 |
) |
| 1088 |
); |
| 1089 |
|
| 1090 |
return '' !== $name ? $name : __( 'Unknown donor', 'charitable' ); |
| 1091 |
} |
| 1092 |
|
| 1093 |
/** |
| 1094 |
* Find a donation, or return a scrubbed WP_Error. |
| 1095 |
* |
| 1096 |
* Charitable_get_donation() constructs an object for any post id, so the |
| 1097 |
* post type has to be checked separately — otherwise a campaign id passed |
| 1098 |
* where a donation id belongs reads back as an empty donation rather than |
| 1099 |
* an error. |
| 1100 |
* |
| 1101 |
* @since 1.8.13 |
| 1102 |
* |
| 1103 |
* @param int $donation_id The donation id. |
| 1104 |
* @return Charitable_Donation|WP_Error |
| 1105 |
*/ |
| 1106 |
private function find_donation( $donation_id ) { |
| 1107 |
$post = $donation_id > 0 ? get_post( $donation_id ) : null; |
| 1108 |
|
| 1109 |
if ( ! $post || 'donation' !== $post->post_type ) { |
| 1110 |
return new WP_Error( |
| 1111 |
'charitable_donation_not_found', |
| 1112 |
__( 'No donation with that id exists. Use List Donations to find the right one.', 'charitable' ) |
| 1113 |
); |
| 1114 |
} |
| 1115 |
|
| 1116 |
$donation = charitable_get_donation( $donation_id ); |
| 1117 |
|
| 1118 |
if ( ! $donation ) { |
| 1119 |
return new WP_Error( |
| 1120 |
'charitable_donation_not_found', |
| 1121 |
__( 'No donation with that id exists. Use List Donations to find the right one.', 'charitable' ) |
| 1122 |
); |
| 1123 |
} |
| 1124 |
|
| 1125 |
return $donation; |
| 1126 |
} |
| 1127 |
|
| 1128 |
/** |
| 1129 |
* Parse and validate a donation date. |
| 1130 |
* |
| 1131 |
* @since 1.8.13 |
| 1132 |
* |
| 1133 |
* @param string $value A date as YYYY-MM-DD, or empty for now. |
| 1134 |
* @return array|WP_Error |
| 1135 |
*/ |
| 1136 |
private function parse_donation_date( $value ) { |
| 1137 |
$value = trim( (string) $value ); |
| 1138 |
|
| 1139 |
if ( '' === $value ) { |
| 1140 |
return array( 'date_gmt' => current_time( 'mysql', true ) ); |
| 1141 |
} |
| 1142 |
|
| 1143 |
$parsed = self::validate_ymd( $value ); |
| 1144 |
|
| 1145 |
if ( is_wp_error( $parsed ) ) { |
| 1146 |
return $parsed; |
| 1147 |
} |
| 1148 |
|
| 1149 |
/* |
| 1150 |
* A future donation date is refused rather than accepted. Charitable's |
| 1151 |
* reports read post_date, so a gift dated next month is invisible in |
| 1152 |
* this month's totals and then appears retroactively — which reads as |
| 1153 |
* data loss rather than a date entry mistake. |
| 1154 |
* |
| 1155 |
* Compared against current_time(), not gmdate(): the supplied date is |
| 1156 |
* a date in the site's own calendar, and on a site ahead of UTC |
| 1157 |
* "today" locally is already tomorrow in UTC, so a UTC comparison |
| 1158 |
* refuses a perfectly ordinary same-day entry. |
| 1159 |
*/ |
| 1160 |
if ( $parsed > current_time( 'Y-m-d' ) ) { |
| 1161 |
return new WP_Error( |
| 1162 |
'charitable_invalid_date', |
| 1163 |
__( 'A donation cannot be dated in the future.', 'charitable' ) |
| 1164 |
); |
| 1165 |
} |
| 1166 |
|
| 1167 |
/* |
| 1168 |
* The caller names a LOCAL date; the processor wants `date_gmt` and |
| 1169 |
* derives post_date from it with get_date_from_gmt(). Converting local |
| 1170 |
* noon rather than passing the bare date as if it were GMT keeps the |
| 1171 |
* donation on the day the caller asked for: storing "2026-09-04 |
| 1172 |
* 00:00:00" as GMT lands on 2026-09-03 locally anywhere behind UTC. |
| 1173 |
* Noon is far enough from both boundaries for every real offset |
| 1174 |
* (-12 to +14). |
| 1175 |
*/ |
| 1176 |
return array( 'date_gmt' => get_gmt_from_date( $parsed . ' 12:00:00' ) ); |
| 1177 |
} |
| 1178 |
|
| 1179 |
/** |
| 1180 |
* Shared output schema for a donation list row. |
| 1181 |
* |
| 1182 |
* PUBLIC because charitable/get-donor-donations reports these same rows — |
| 1183 |
* execute_get_donor_donations() delegates straight to |
| 1184 |
* execute_list_donations() — and it declared only five of the eleven keys |
| 1185 |
* it actually returned. Reusing this is what stops the two drifting. |
| 1186 |
* |
| 1187 |
* @since 1.8.13 |
| 1188 |
* |
| 1189 |
* @return array |
| 1190 |
*/ |
| 1191 |
public static function donation_summary_schema() { |
| 1192 |
return array( |
| 1193 |
'type' => 'object', |
| 1194 |
'properties' => array( |
| 1195 |
'donation_id' => array( 'type' => 'integer' ), |
| 1196 |
'number' => array( 'type' => 'string' ), |
| 1197 |
'date' => array( 'type' => 'string' ), |
| 1198 |
'status' => array( 'type' => 'string' ), |
| 1199 |
'status_label' => array( 'type' => 'string' ), |
| 1200 |
'gateway' => array( 'type' => 'string' ), |
| 1201 |
'gateway_label' => array( 'type' => 'string' ), |
| 1202 |
'amount' => array( 'type' => 'number' ), |
| 1203 |
'amount_formatted' => array( 'type' => 'string' ), |
| 1204 |
'currency' => array( 'type' => 'string' ), |
| 1205 |
'donor' => array( |
| 1206 |
'type' => 'object', |
| 1207 |
'properties' => array( |
| 1208 |
'donor_id' => array( 'type' => 'integer' ), |
| 1209 |
'name' => array( 'type' => 'string' ), |
| 1210 |
'email' => array( |
| 1211 |
'type' => 'string', |
| 1212 |
'description' => __( 'Present only for users who can view sensitive data.', 'charitable' ), |
| 1213 |
), |
| 1214 |
), |
| 1215 |
), |
| 1216 |
'campaigns' => self::campaign_split_schema(), |
| 1217 |
), |
| 1218 |
); |
| 1219 |
} |
| 1220 |
|
| 1221 |
/** |
| 1222 |
* Output schema for a donation's per-campaign split. |
| 1223 |
* |
| 1224 |
* Declared once and used by both the list row and the single read. A |
| 1225 |
* client reads the output schema to decide what it can answer, so a key |
| 1226 |
* the ability returns but does not declare is a key the model does not |
| 1227 |
* know it has. |
| 1228 |
* |
| 1229 |
* @since 1.8.13 |
| 1230 |
* |
| 1231 |
* @return array |
| 1232 |
*/ |
| 1233 |
private static function campaign_split_schema() { |
| 1234 |
return array( |
| 1235 |
'type' => 'array', |
| 1236 |
'description' => __( 'The campaigns this donation was split across, with the amount each received. A donation to a single campaign has one entry.', 'charitable' ), |
| 1237 |
'items' => array( |
| 1238 |
'type' => 'object', |
| 1239 |
'properties' => array( |
| 1240 |
'campaign_id' => array( 'type' => 'integer' ), |
| 1241 |
'campaign_name' => array( 'type' => 'string' ), |
| 1242 |
'amount' => array( 'type' => 'number' ), |
| 1243 |
'amount_formatted' => array( 'type' => 'string' ), |
| 1244 |
), |
| 1245 |
), |
| 1246 |
); |
| 1247 |
} |
| 1248 |
|
| 1249 |
/** |
| 1250 |
* Output schema for a single donation read. |
| 1251 |
* |
| 1252 |
* @since 1.8.13 |
| 1253 |
* |
| 1254 |
* @return array |
| 1255 |
*/ |
| 1256 |
private static function donation_detail_schema() { |
| 1257 |
$schema = self::donation_summary_schema(); |
| 1258 |
|
| 1259 |
/* `campaigns` now comes from the summary schema — both surfaces report it. */ |
| 1260 |
|
| 1261 |
/* |
| 1262 |
* The address is added to the DETAIL schema only. The list row does not |
| 1263 |
* carry one: a page of addresses is a bulk export, and resolving each |
| 1264 |
* costs its own pair of queries. |
| 1265 |
*/ |
| 1266 |
$schema['properties']['donor']['properties']['address'] = array( |
| 1267 |
'type' => 'string', |
| 1268 |
'description' => __( 'Present only for users who can view sensitive data.', 'charitable' ), |
| 1269 |
); |
| 1270 |
|
| 1271 |
$schema['properties']['note'] = array( 'type' => 'string' ); |
| 1272 |
$schema['properties']['transaction_id'] = array( 'type' => 'string' ); |
| 1273 |
$schema['properties']['test_mode'] = array( 'type' => 'boolean' ); |
| 1274 |
|
| 1275 |
return $schema; |
| 1276 |
} |
| 1277 |
} |
| 1278 |
|
| 1279 |
endif; |
| 1280 |
|