PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
charitable / includes / abilities / registrars / class-charitable-donation-abilities.php

class-charitable-donation-abilities.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/abilities/registrars/class-charitable-donation-abilities.php

1,280 lines 47.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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