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 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 All 212 releases
charitable / includes / abilities / registrars / class-charitable-report-abilities.php

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

1,043 lines 41.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Report abilities: overview, day-by-day activity, top campaigns, top donors,
4 * and the date ranges the other four accept.
5 *
6 * Three constraints govern this file.
7 *
8 * C3 — Pro's version of this file has every figure come from a query that
9 * reads COALESCE( base_amount, amount ), never the raw `amount` column, so
10 * that for the two RANKED abilities a donor who gave 792 JPY is never ranked
11 * above one who gave 100 USD. Lite has no base_amount column and no
12 * multi-currency feature (see create_table() in
13 * class-charitable-campaign-donations-db.php, which defines only
14 * `amount decimal(13,4)`), so every figure here reads `amount` directly — the
15 * JPY-vs-USD scenario cannot occur, because every donation is already in the
16 * site's one currency. See get_donations_summary_by_day_range() in that same
17 * file for the same substitution and rationale.
18 *
19 * The days trap — charitable_get_days_between_dates() returns
20 * array( 'Y-m-d' => 'M d' ). Iterating the VALUE and re-parsing it stamps every
21 * day with the CURRENT year, so across an N-year range every date collapses
22 * onto the same current-year day and the total is accumulated N times ($800
23 * became $20,022 once). This file does not call that helper at all: the day
24 * series is built here from unambiguous Y-m-d strings.
25 *
26 * Bounded answers — a day-by-day series over a very long range is slow by
27 * design, so get-reports-activity caps the range and its description states the
28 * cap. A model asking for "all time" gets a refusal naming the maximum rather
29 * than a timeout.
30 *
31 * @package Charitable/Classes/Charitable_Report_Abilities
32 * @copyright Copyright (c) 2026, WP Charitable LLC
33 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
34 * @since 1.8.13
35 * @version 1.8.13
36 */
37
38 if ( ! defined( 'ABSPATH' ) ) {
39 exit;
40 }
41
42 if ( ! class_exists( 'Charitable_Report_Abilities' ) ) :
43
44 /**
45 * Charitable_Report_Abilities
46 *
47 * @since 1.8.13
48 */
49 class Charitable_Report_Abilities extends Charitable_Abilities_Registrar {
50
51 /**
52 * Longest range get-reports-activity will build a day series for.
53 *
54 * 732 days is two years plus a leap day. Chosen so "the last two years"
55 * always fits regardless of which years the caller means, while still
56 * refusing the all-time ranges that made the reports screen time out.
57 * get-reports-overview has no such cap: it is a single grouped query and
58 * returns one row however long the range.
59 *
60 * @since 1.8.13
61 */
62 const MAX_ACTIVITY_DAYS = 732;
63
64 /**
65 * Register the report abilities.
66 *
67 * @since 1.8.13
68 *
69 * @return void
70 */
71 public function register() {
72 $this->register_ability(
73 'charitable/get-reports-overview',
74 array(
75 'label' => __( 'Get Reports Overview', 'charitable' ),
76 'description' => __( 'Returns the headline fundraising figures for a date range: total raised, number of donations, average donation, number of donors, and refunds. Defaults to the current month. Use this to answer "how much have we raised?" for any period. Any range length is accepted, because this returns a single set of totals rather than a day-by-day series.', 'charitable' ),
77 'execute_callback' => array( $this, 'execute_get_reports_overview' ),
78 'permission_callback' => array( __CLASS__, 'permission_reports' ),
79 'annotations' => array(
80 'readonly' => true,
81 'idempotent' => true,
82 ),
83 'input_schema' => self::range_input_schema(),
84 'output_schema' => array(
85 'type' => 'object',
86 'properties' => array(
87 'start_date' => array( 'type' => 'string' ),
88 'end_date' => array( 'type' => 'string' ),
89 'currency' => array( 'type' => 'string' ),
90 'total_raised' => array( 'type' => 'number' ),
91 'total_raised_formatted' => array( 'type' => 'string' ),
92 'donation_count' => array( 'type' => 'integer' ),
93 'donor_count' => array( 'type' => 'integer' ),
94 'average_donation' => array( 'type' => 'number' ),
95 'average_donation_formatted' => array( 'type' => 'string' ),
96 'refunded' => array( 'type' => 'number' ),
97 'refunded_formatted' => array( 'type' => 'string' ),
98 'refund_count' => array( 'type' => 'integer' ),
99 ),
100 ),
101 )
102 );
103
104 $this->register_ability(
105 'charitable/get-reports-activity',
106 array(
107 'label' => __( 'Get Reports Activity', 'charitable' ),
108 'description' => sprintf(
109 /* translators: %d: the maximum number of days the range may span. */
110 __( 'Returns a day-by-day series of amount raised and donation count across a date range, including days with no donations, so it can be charted directly. Defaults to the current month. The range may span at most %d days — for a longer period use Get Reports Overview, which returns totals for any range. Use this to answer when donations arrived rather than how much in total.', 'charitable' ),
111 self::MAX_ACTIVITY_DAYS
112 ),
113 'execute_callback' => array( $this, 'execute_get_reports_activity' ),
114 'permission_callback' => array( __CLASS__, 'permission_reports' ),
115 'annotations' => array(
116 'readonly' => true,
117 'idempotent' => true,
118 ),
119 'input_schema' => self::range_input_schema(),
120 'output_schema' => array(
121 'type' => 'object',
122 'properties' => array(
123 'start_date' => array( 'type' => 'string' ),
124 'end_date' => array( 'type' => 'string' ),
125 'currency' => array( 'type' => 'string' ),
126 'total_raised' => array( 'type' => 'number' ),
127 'total_raised_formatted' => array( 'type' => 'string' ),
128 'days' => array(
129 'type' => 'array',
130 'items' => array(
131 'type' => 'object',
132 'properties' => array(
133 'date' => array( 'type' => 'string' ),
134 'amount' => array( 'type' => 'number' ),
135 'amount_formatted' => array( 'type' => 'string' ),
136 'count' => array( 'type' => 'integer' ),
137 ),
138 ),
139 ),
140 ),
141 ),
142 )
143 );
144
145 $this->register_ability(
146 'charitable/get-top-campaigns',
147 array(
148 'label' => __( 'Get Top Campaigns', 'charitable' ),
149 'description' => __( 'Returns the campaigns that raised the most, highest first, with the amount raised and the number of donations for each. With no dates supplied it ranks over ALL TIME, which answers "which campaign has done best?" directly. Pass start_date and end_date to rank within a period instead — a campaign that raised far more in an earlier year will then be ranked below a newer one, which is the point of asking for a period. Each amount is what that campaign raised within the period covered, not its lifetime total unless the period is all time. The reply echoes the start_date and end_date it used, and the answer should state that period. For one campaign\'s own figures use Get Campaign Performance.', 'charitable' ),
150 'execute_callback' => array( $this, 'execute_get_top_campaigns' ),
151 'permission_callback' => array( __CLASS__, 'permission_reports' ),
152 'annotations' => array(
153 'readonly' => true,
154 'idempotent' => true,
155 ),
156 'input_schema' => self::range_input_schema(
157 array(
158 'limit' => array(
159 'type' => 'integer',
160 'description' => __( 'How many campaigns to return, 1 to 50. Defaults to 10.', 'charitable' ),
161 'minimum' => 1,
162 'maximum' => 50,
163 ),
164 ),
165 true
166 ),
167 'output_schema' => array(
168 'type' => 'object',
169 'properties' => array(
170 'start_date' => self::ranking_start_date_schema(),
171 'end_date' => array( 'type' => 'string' ),
172 'currency' => array( 'type' => 'string' ),
173 'campaigns' => array(
174 'type' => 'array',
175 'items' => array(
176 'type' => 'object',
177 'properties' => array(
178 'campaign_id' => array( 'type' => 'integer' ),
179 'title' => array( 'type' => 'string' ),
180 'raised' => array( 'type' => 'number' ),
181 'raised_formatted' => array( 'type' => 'string' ),
182 'donation_count' => array( 'type' => 'integer' ),
183 ),
184 ),
185 ),
186 ),
187 ),
188 )
189 );
190
191 $this->register_ability(
192 'charitable/get-top-donors',
193 array(
194 'label' => __( 'Get Top Donors', 'charitable' ),
195 'description' => __( "Returns the donors who gave the most, highest first, with each donor's total, donation count and email address. Requires permission to run reports AND to view sensitive data, because it is a report that names donors. With no dates supplied it ranks over ALL TIME, so \"who are our biggest supporters?\" and \"our top donors\" are answered over the site's whole history — use it for that and to build a thank-you list. Pass start_date and end_date to rank within a period instead, such as one appeal or one year. Each total is what that donor gave within the period covered, not their lifetime total unless the period is all time. The reply echoes the start_date and end_date it used, and the answer should state that period.", 'charitable' ),
196 'execute_callback' => array( $this, 'execute_get_top_donors' ),
197
198 /*
199 * BOTH capabilities. This is a report, so it requires what
200 * its four siblings above and below require
201 * (`export_charitable_reports`), and it names donors, so the
202 * PII gate sits on top of that rather than instead of it.
203 * permission_sensitive() alone served a ranked list of donor
204 * names, emails and totals to a caller the other four report
205 * abilities refused. See permission_sensitive_reports().
206 */
207 'permission_callback' => array( __CLASS__, 'permission_sensitive_reports' ),
208 'annotations' => array(
209 'readonly' => true,
210 'idempotent' => true,
211 ),
212 'input_schema' => self::range_input_schema(
213 array(
214 'limit' => array(
215 'type' => 'integer',
216 'description' => __( 'How many donors to return, 1 to 50. Defaults to 10.', 'charitable' ),
217 'minimum' => 1,
218 'maximum' => 50,
219 ),
220 ),
221 true
222 ),
223 'output_schema' => array(
224 'type' => 'object',
225 'properties' => array(
226 'start_date' => self::ranking_start_date_schema(),
227 'end_date' => array( 'type' => 'string' ),
228 'currency' => array( 'type' => 'string' ),
229 'donors' => array(
230 'type' => 'array',
231 'items' => array(
232 'type' => 'object',
233 'properties' => array(
234 'donor_id' => array( 'type' => 'integer' ),
235 'name' => array( 'type' => 'string' ),
236 'email' => array( 'type' => 'string' ),
237 'total_donated' => array( 'type' => 'number' ),
238 'total_donated_formatted' => array( 'type' => 'string' ),
239 'donation_count' => array( 'type' => 'integer' ),
240 ),
241 ),
242 ),
243 ),
244 ),
245 )
246 );
247
248 $this->register_ability(
249 'charitable/get-reports-date-ranges',
250 array(
251 'label' => __( 'Get Reports Date Ranges', 'charitable' ),
252 'description' => __( 'Turns a period named in words into real dates on this site, in the site\'s own timezone rather than a guess about where the site is. Returns two SEPARATE blocks that must not be mixed up. `ranges` holds REPORT periods only: each looks backward and its end_date is never later than today, because no donation can have happened on a day that has not happened yet — so `this-month` runs from the 1st to TODAY, not to the last day of the month. Pass a range\'s start_date and end_date to the other report abilities, which take dates rather than a period name. `future_dates` holds forward-looking single calendar dates — the real end of this month, next month and this year — and is the block to use for a DEADLINE, such as a campaign end_date. Never take a future date out of `ranges`: reading a deadline from `this-month.end_date` gives today, which creates a campaign that expires the day it was made. Never pass a `future_dates` date to a report ability either. A period in neither block — a quarter, a season, a named appeal — has to be turned into dates yourself, and the answer should say which dates were used.', 'charitable' ),
253 'execute_callback' => array( $this, 'execute_get_reports_date_ranges' ),
254 'permission_callback' => array( __CLASS__, 'permission_reports' ),
255 'annotations' => array(
256 'readonly' => true,
257 'idempotent' => true,
258 ),
259 'input_schema' => self::no_input_schema(),
260 'output_schema' => array(
261 'type' => 'object',
262 'properties' => array(
263 'today' => array( 'type' => 'string' ),
264 'timezone' => array( 'type' => 'string' ),
265 'ranges' => array(
266 'type' => 'array',
267 'description' => __( 'Backward-looking REPORT periods. Every end_date is today or earlier. These are the only entries valid as start_date/end_date input to the other report abilities. Not a source of future dates — see future_dates.', 'charitable' ),
268 'items' => array(
269 'type' => 'object',
270 'properties' => array(
271 'key' => array( 'type' => 'string' ),
272 'label' => array( 'type' => 'string' ),
273 'start_date' => array( 'type' => 'string' ),
274 'end_date' => array( 'type' => 'string' ),
275 'days' => array( 'type' => 'integer' ),
276 ),
277 ),
278 ),
279 'future_dates' => array(
280 'type' => 'object',
281 'description' => __( 'Forward-looking single calendar dates, for DEADLINES — a campaign end_date, for example. Each entry is one date, deliberately NOT a start_date/end_date pair, so it cannot be mistaken for a report range. Never pass one to a report ability: it lies in the future and no donation data exists for it.', 'charitable' ),
282 'properties' => array(
283 'end-of-this-month' => self::future_date_schema(),
284 'end-of-next-month' => self::future_date_schema(),
285 'end-of-this-year' => self::future_date_schema(),
286 ),
287 ),
288 ),
289 ),
290 )
291 );
292 }
293
294 /**
295 * Execute charitable/get-reports-overview.
296 *
297 * @since 1.8.13
298 *
299 * @param array $input Ability input.
300 * @return array|WP_Error
301 */
302 public function execute_get_reports_overview( $input ) {
303 $dates = $this->resolve_range( $input );
304
305 if ( is_wp_error( $dates ) ) {
306 return $dates;
307 }
308
309 $table = charitable_get_table( 'campaign_donations' );
310 $args = $this->report_args( $dates, $input );
311
312 /*
313 * One grouped query for the whole range, whatever its length —
314 * which is why this ability has no range cap. get_amount_report()
315 * reads `amount` directly; Lite has no base_amount column (see the
316 * C3 note at the top of this file).
317 */
318 $approved = array_merge( $args, array( 'status' => charitable_get_approval_statuses() ) );
319 $total = (float) $table->get_amount_report( $approved, false );
320 $count = $table->count_donations_report( $approved );
321
322 $refund_args = array_merge( $args, array( 'status' => array( 'charitable-refunded' ) ) );
323 $refunded = (float) $table->get_amount_report( $refund_args, false );
324 $refund_count = $table->count_donations_report( $refund_args );
325
326 $average = $count > 0 ? $total / $count : 0.0;
327
328 $total_money = self::money( $total );
329 $average_money = self::money( $average );
330 $refunded_money = self::money( $refunded );
331
332 return array(
333 'start_date' => $dates['start_date'],
334 'end_date' => $dates['end_date'],
335 'currency' => charitable_get_currency(),
336 'total_raised' => $total_money['raw'],
337 'total_raised_formatted' => $total_money['formatted'],
338 'donation_count' => $count,
339 'donor_count' => $table->count_donors_report( $approved ),
340 'average_donation' => $average_money['raw'],
341 'average_donation_formatted' => $average_money['formatted'],
342 'refunded' => $refunded_money['raw'],
343 'refunded_formatted' => $refunded_money['formatted'],
344 'refund_count' => $refund_count,
345 );
346 }
347
348 /**
349 * Execute charitable/get-reports-activity.
350 *
351 * @since 1.8.13
352 *
353 * @param array $input Ability input.
354 * @return array|WP_Error
355 */
356 public function execute_get_reports_activity( $input ) {
357 $dates = $this->resolve_range( $input );
358
359 if ( is_wp_error( $dates ) ) {
360 return $dates;
361 }
362
363 /*
364 * COUNTED before it is BUILT, and that order is the whole point —
365 * see count_days_in_range(). The cap used to be checked against
366 * count( $days ), which meant the series had to be materialised
367 * before it could be judged too long. "From the year 1000 to today"
368 * is 375,012 date strings and a measured 99.6MB, on top of a
369 * WordPress baseline near 78MB: on a 128M site that is a fatal
370 * BEFORE this refusal can be returned, so the caller got a blank
371 * screen instead of the sentence explaining what to ask for
372 * instead. Guarded by
373 * test_an_ancient_range_is_refused_without_building_the_series().
374 */
375 $requested_days = $this->count_days_in_range( $dates['start_date'], $dates['end_date'] );
376
377 if ( $requested_days > self::MAX_ACTIVITY_DAYS ) {
378 return new WP_Error(
379 'charitable_range_too_long',
380 sprintf(
381 /* translators: 1: requested number of days, 2: the maximum allowed. */
382 __( 'That range covers %1$d days, and a day-by-day series is limited to %2$d. Ask for a shorter range, or use Get Reports Overview, which returns totals for any range.', 'charitable' ),
383 $requested_days,
384 self::MAX_ACTIVITY_DAYS
385 )
386 );
387 }
388
389 $days = $this->day_series( $dates['start_date'], $dates['end_date'] );
390
391 /*
392 * One query for the whole range. get_donations_summary_by_day_range()
393 * is the batched, already-converted accessor added in 1.8.18 to
394 * replace a per-calendar-day loop whose cost scaled with the LENGTH
395 * of the range rather than the amount of data. Days with no
396 * donations are absent from its return value, which is why the
397 * series is built from $days and the result merely looked up.
398 *
399 * The campaign filter is read through report_args(), the same helper
400 * the other three report abilities use, rather than from $input
401 * directly — that is what keeps the four of them agreeing about what
402 * `campaign_id` means. This ability declared the input and silently
403 * discarded it, so a request for one campaign's daily figures
404 * returned the whole site's. Caught by
405 * test_activity_honours_the_campaign_filter().
406 */
407 $args = $this->report_args( $dates, $input );
408
409 $by_day = (array) charitable_get_table( 'campaign_donations' )->get_donations_summary_by_day_range(
410 $dates['start_date'],
411 $dates['end_date'],
412 charitable_get_approval_statuses(),
413 isset( $args['campaign_id'] ) ? (int) $args['campaign_id'] : 0
414 );
415
416 $series = array();
417 $total = 0.0;
418
419 foreach ( $days as $day ) {
420 $amount = isset( $by_day[ $day ] ) ? (float) $by_day[ $day ]->amount : 0.0;
421 $count = isset( $by_day[ $day ] ) ? (int) $by_day[ $day ]->count : 0;
422 $money = self::money( $amount );
423
424 $total += $money['raw'];
425
426 $series[] = array(
427 'date' => $day,
428 'amount' => $money['raw'],
429 'amount_formatted' => $money['formatted'],
430 'count' => $count,
431 );
432 }
433
434 $total_money = self::money( $total );
435
436 return array(
437 'start_date' => $dates['start_date'],
438 'end_date' => $dates['end_date'],
439 'currency' => charitable_get_currency(),
440 'total_raised' => $total_money['raw'],
441 'total_raised_formatted' => $total_money['formatted'],
442 'days' => $series,
443 );
444 }
445
446 /**
447 * Execute charitable/get-top-campaigns.
448 *
449 * @since 1.8.13
450 *
451 * @param array $input Ability input.
452 * @return array|WP_Error
453 */
454 public function execute_get_top_campaigns( $input ) {
455 $dates = $this->resolve_ranking_range( $input );
456
457 if ( is_wp_error( $dates ) ) {
458 return $dates;
459 }
460
461 $args = array_merge(
462 $this->report_args( $dates, $input ),
463 array(
464 'status' => charitable_get_approval_statuses(),
465 'limit' => self::input_int( $input, 'limit', 10, 1, 50 ),
466 )
467 );
468
469 $rows = (array) charitable_get_table( 'campaign_donations' )->get_top_campaigns_report( $args );
470
471 $campaigns = array();
472
473 foreach ( $rows as $row ) {
474 $money = self::money( $row->total );
475
476 /*
477 * The live post title is preferred over cd.campaign_name, which
478 * is a snapshot taken when the donation was recorded and is stale
479 * for any campaign renamed since. The snapshot is the fallback
480 * for a campaign whose post has since been deleted, where it is
481 * the only surviving name.
482 */
483 $title = get_post_field( 'post_title', (int) $row->campaign_id, 'raw' );
484
485 $campaigns[] = array(
486 'campaign_id' => (int) $row->campaign_id,
487 'title' => '' !== (string) $title ? (string) $title : (string) $row->campaign_name,
488 'raised' => $money['raw'],
489 'raised_formatted' => $money['formatted'],
490 'donation_count' => (int) $row->donation_count,
491 );
492 }
493
494 return array(
495 'start_date' => $this->reported_start_date( $dates ),
496 'end_date' => $dates['end_date'],
497 'currency' => charitable_get_currency(),
498 'campaigns' => $campaigns,
499 );
500 }
501
502 /**
503 * Execute charitable/get-top-donors.
504 *
505 * @since 1.8.13
506 *
507 * @param array $input Ability input.
508 * @return array|WP_Error
509 */
510 public function execute_get_top_donors( $input ) {
511 $dates = $this->resolve_ranking_range( $input );
512
513 if ( is_wp_error( $dates ) ) {
514 return $dates;
515 }
516
517 $args = array_merge(
518 $this->report_args( $dates, $input ),
519 array(
520 'status' => charitable_get_approval_statuses(),
521 'limit' => self::input_int( $input, 'limit', 10, 1, 50 ),
522 )
523 );
524
525 $rows = (array) charitable_get_table( 'campaign_donations' )->get_top_donors_report( $args );
526
527 $donors = array();
528
529 foreach ( $rows as $row ) {
530 $money = self::money( $row->total );
531
532 $name = trim( sprintf( '%s %s', (string) $row->first_name, (string) $row->last_name ) );
533
534 $donors[] = array(
535 'donor_id' => (int) $row->donor_id,
536 'name' => '' !== $name ? $name : __( 'Unknown donor', 'charitable' ),
537 'email' => (string) $row->email,
538 'total_donated' => $money['raw'],
539 'total_donated_formatted' => $money['formatted'],
540 'donation_count' => (int) $row->donation_count,
541 );
542 }
543
544 return array(
545 'start_date' => $this->reported_start_date( $dates ),
546 'end_date' => $dates['end_date'],
547 'currency' => charitable_get_currency(),
548 'donors' => $donors,
549 );
550 }
551
552 /**
553 * Execute charitable/get-reports-date-ranges.
554 *
555 * @since 1.8.13
556 *
557 * @return array
558 */
559 public function execute_get_reports_date_ranges() {
560 /*
561 * Every date here comes from current_time(), so the ranges are in the
562 * SITE's timezone. That is the reason this ability exists at all: a
563 * client working out "this month" from its own clock can be a day out
564 * either side of the site's, and post_date — the column every report
565 * query filters on — is local time.
566 */
567 $today = current_time( 'Y-m-d' );
568 $now = strtotime( $today );
569
570 $definitions = array(
571 array(
572 'key' => 'today',
573 'label' => __( 'Today', 'charitable' ),
574 'start_date' => $today,
575 'end_date' => $today,
576 ),
577 array(
578 'key' => 'last-7-days',
579 'label' => __( 'Last 7 days', 'charitable' ),
580 'start_date' => gmdate( 'Y-m-d', strtotime( '-6 days', $now ) ),
581 'end_date' => $today,
582 ),
583 array(
584 'key' => 'last-30-days',
585 'label' => __( 'Last 30 days', 'charitable' ),
586 'start_date' => gmdate( 'Y-m-d', strtotime( '-29 days', $now ) ),
587 'end_date' => $today,
588 ),
589 array(
590 'key' => 'this-month',
591 'label' => __( 'This month', 'charitable' ),
592 'start_date' => current_time( 'Y-m-01' ),
593 'end_date' => $today,
594 ),
595 array(
596 'key' => 'last-month',
597 'label' => __( 'Last month', 'charitable' ),
598 'start_date' => gmdate( 'Y-m-01', strtotime( 'first day of last month', $now ) ),
599 'end_date' => gmdate( 'Y-m-t', strtotime( 'first day of last month', $now ) ),
600 ),
601 array(
602 'key' => 'this-year',
603 'label' => __( 'This year', 'charitable' ),
604 'start_date' => current_time( 'Y-01-01' ),
605 'end_date' => $today,
606 ),
607 array(
608 'key' => 'last-year',
609 'label' => __( 'Last year', 'charitable' ),
610 'start_date' => gmdate( 'Y-01-01', strtotime( '-1 year', strtotime( current_time( 'Y-01-01' ) ) ) ),
611 'end_date' => gmdate( 'Y-12-31', strtotime( '-1 year', strtotime( current_time( 'Y-01-01' ) ) ) ),
612 ),
613 );
614
615 $ranges = array();
616
617 foreach ( $definitions as $definition ) {
618 $definition['days'] = count(
619 $this->day_series( $definition['start_date'], $definition['end_date'] )
620 );
621
622 $ranges[] = $definition;
623 }
624
625 return array(
626 'today' => $today,
627 'timezone' => (string) wp_timezone_string(),
628 'ranges' => $ranges,
629 'future_dates' => $this->future_dates( $now ),
630 );
631 }
632
633 /**
634 * Forward-looking calendar dates, for deadlines.
635 *
636 * Why this block exists, and why it is deliberately NOT part of
637 * `ranges`: every entry in `ranges` is a report period bounded by
638 * today, which is correct — you cannot report on days that have not
639 * happened. But the ability's description used to tell callers to look
640 * a period up here "whenever a question mentions a period in words",
641 * with nothing scoping that to reports. So a real request — "the
642 * deadline is end of this month" — was answered by reading
643 * `this-month.end_date`, which is TODAY, and passing it to
644 * charitable/create-campaign as end_date. That creates a campaign
645 * which expires the day it was made, and every call in the chain
646 * returns 200 with nothing reporting a problem.
647 *
648 * These entries are single dates, NOT start_date/end_date pairs, and
649 * that shape is load-bearing rather than cosmetic. A forward-looking
650 * entry shaped like a range would be fed straight into the report
651 * abilities — the same silent failure pointing the other way, this
652 * time a report padded with empty future days. Keep them here, keep
653 * them single, and do not "tidy" them into $definitions above.
654 *
655 * Computed from current_time()/$now like the ranges are, so the dates
656 * are the SITE's, not the server's or the caller's.
657 *
658 * @since 1.8.13
659 *
660 * @param int $now Timestamp of the site's today at midnight, as built
661 * by execute_get_reports_date_ranges().
662 * @return array
663 */
664 private function future_dates( $now ) {
665 /*
666 * 'first day of next month' rather than '+1 month'. On the 31st,
667 * strtotime( '+1 month' ) overflows into the month AFTER next
668 * (31 Jan +1 month = 3 March), so a 'Y-m-t' taken from it returns
669 * the end of the wrong month entirely. Anchoring to the 1st first
670 * removes the overflow.
671 */
672 $first_of_next_month = strtotime( 'first day of next month', $now );
673
674 return array(
675 'end-of-this-month' => array(
676 'date' => current_time( 'Y-m-t' ),
677 'label' => __( 'End of this month', 'charitable' ),
678 ),
679 'end-of-next-month' => array(
680 'date' => gmdate( 'Y-m-t', $first_of_next_month ),
681 'label' => __( 'End of next month', 'charitable' ),
682 ),
683 'end-of-this-year' => array(
684 'date' => current_time( 'Y-12-31' ),
685 'label' => __( 'End of this year', 'charitable' ),
686 ),
687 );
688 }
689
690 /**
691 * The `start_date` the two ranked abilities return.
692 *
693 * Documented because it is not simply the input echoed back: when no
694 * start_date is supplied the ranking has no lower bound, and the field
695 * reports the date of the site's earliest donation so it stays a real date.
696 * See reported_start_date().
697 *
698 * @since 1.8.13
699 *
700 * @return array
701 */
702 private static function ranking_start_date_schema() {
703 return array(
704 'type' => 'string',
705 'description' => __( 'The start of the period this ranking covers, YYYY-MM-DD. When no start_date was supplied the ranking is over all time and this is the date of the site\'s earliest donation, or today if the site has none. State this period alongside the answer.', 'charitable' ),
706 );
707 }
708
709 /**
710 * The repeated sub-schema for one `future_dates` entry.
711 *
712 * A single `date`, and no start_date/end_date — see future_dates().
713 *
714 * @since 1.8.13
715 *
716 * @return array
717 */
718 private static function future_date_schema() {
719 return array(
720 'type' => 'object',
721 'properties' => array(
722 'date' => array(
723 'type' => 'string',
724 'description' => __( 'A single future calendar date, YYYY-MM-DD, in the site\'s timezone.', 'charitable' ),
725 ),
726 'label' => array( 'type' => 'string' ),
727 ),
728 );
729 }
730
731 /**
732 * Build the inclusive list of Y-m-d dates between two dates.
733 *
734 * The charitable_get_days_between_dates() helper is DELIBERATELY not used.
735 * It returns array( 'Y-m-d' => 'M d' ), and iterating the VALUE —
736 * 'Sep 04' — then re-parsing it stamps the CURRENT year on every entry,
737 * so across an N-year range every date collapses onto the same day and
738 * any total accumulated in that loop is multiplied by N. That mistake has
739 * already turned $800 into $20,022 on a reporting surface. Building the
740 * series here from unambiguous Y-m-d strings removes the ambiguity rather
741 * than relying on remembering to iterate keys.
742 *
743 * @since 1.8.13
744 *
745 * @param string $start_date Start, Y-m-d, inclusive.
746 * @param string $end_date End, Y-m-d, inclusive.
747 * @return string[]
748 */
749 private function day_series( $start_date, $end_date ) {
750 $days = array();
751
752 $current = strtotime( $start_date . ' 00:00:00' );
753 $last = strtotime( $end_date . ' 00:00:00' );
754
755 if ( ! $current || ! $last || $current > $last ) {
756 return $days;
757 }
758
759 /*
760 * Stepped with gmdate()/strtotime() on a midnight UTC timestamp
761 * rather than a DateTime in the site timezone: '+1 day' across a
762 * daylight-saving boundary can land on the same calendar date twice,
763 * or skip one, which would either duplicate or drop a day of the
764 * series.
765 */
766 while ( $current <= $last ) {
767 $days[] = gmdate( 'Y-m-d', $current );
768 $current += DAY_IN_SECONDS;
769 }
770
771 return $days;
772 }
773
774 /**
775 * How many inclusive days a range covers, WITHOUT building them.
776 *
777 * The cap on get-reports-activity has to be decided before
778 * day_series() runs, because the cost being capped is the series
779 * itself. A range the cap exists to refuse is exactly the range that
780 * cannot afford to be built first: "the year 1000 to today" is 375,012
781 * entries and 99.6MB, and the refusal was unreachable on any site whose
782 * memory limit is the usual 128M.
783 *
784 * Deliberately the same arithmetic day_series() steps with — integer
785 * division of the timestamp difference by DAY_IN_SECONDS, inclusive of
786 * both ends — rather than a DateTime::diff(). That is what makes the two
787 * agree BY CONSTRUCTION, including across a daylight-saving boundary,
788 * where the difference between two midnights is not a whole number of
789 * days: day_series() steps in fixed DAY_IN_SECONDS increments and
790 * compares against the same `$last`, so flooring here lands on the same
791 * count it would have produced. A DateTime diff would be calendar-correct
792 * and therefore sometimes one MORE than the series it is meant to
793 * measure, which would admit a range the series cannot afford.
794 * test_the_reported_day_count_is_exact_at_the_cap_boundary() holds the
795 * two together at the only place the difference changes the outcome.
796 *
797 * Returns 0 for an unparseable or inverted range, matching day_series()'s
798 * own empty return, so a caller cannot be refused for length when the
799 * real problem is the dates — those keep their own error codes in
800 * parse_date_range().
801 *
802 * @since 1.8.13
803 *
804 * @param string $start_date Start, Y-m-d, inclusive.
805 * @param string $end_date End, Y-m-d, inclusive.
806 * @return int
807 */
808 private function count_days_in_range( $start_date, $end_date ) {
809 $first = strtotime( $start_date . ' 00:00:00' );
810 $last = strtotime( $end_date . ' 00:00:00' );
811
812 if ( ! $first || ! $last || $first > $last ) {
813 return 0;
814 }
815
816 return (int) floor( ( $last - $first ) / DAY_IN_SECONDS ) + 1;
817 }
818
819 /**
820 * Resolve the requested range.
821 *
822 * @since 1.8.13
823 *
824 * @param array $input Ability input.
825 * @param string|null $default_start Start to use when the caller supplies
826 * none. Null means the first of the
827 * current month; an empty string means
828 * NO lower bound at all — see
829 * resolve_ranking_range().
830 * @return array|WP_Error
831 */
832 private function resolve_range( $input, $default_start = null ) {
833 /*
834 * current_time(), not gmdate(): every report query filters on
835 * post_date, which is the site's LOCAL time. A default range computed
836 * in UTC is compared against local timestamps, and on a site ahead of
837 * UTC that silently drops today's donations — reproduced while
838 * building the donation abilities, where the summary reported 0
839 * raised on a site that had just taken a donation.
840 */
841 return self::parse_date_range(
842 is_array( $input ) ? $input : array(),
843 null === $default_start ? current_time( 'Y-m-01' ) : $default_start,
844 current_time( 'Y-m-d' )
845 );
846 }
847
848 /**
849 * Resolve the range for the two RANKED abilities, which default to all time.
850 *
851 * Why these two differ from get-reports-overview and get-reports-activity,
852 * which keep the current-month default: a TOTAL that echoes its date range
853 * carries its own scope, and "raised in September" is a coherent answer. A
854 * RANKING does not — "your best campaign is X" reads as a fact about the
855 * site, whatever range produced it. On the development site
856 * get-top-campaigns with no dates answered "Las Vegas Poker Night, $100",
857 * a campaign an hour old holding a single cheque, where the all-time answer
858 * was "Sunshine Animal Sanctuary, $15,030". 150x out, and a different
859 * campaign, with nothing in the reply signalling either.
860 *
861 * The default is an EMPTY start, not an early date: get_report_sql_where_clause()
862 * omits the `p.post_date >= %s` clause entirely for an empty start_date, so
863 * all time means no lower bound rather than a guessed sentinel year. What is
864 * ECHOED is filled in by reported_start_date() afterwards, so the query stays
865 * unbounded while the output still names a real date.
866 *
867 * @since 1.8.13
868 *
869 * @param array $input Ability input.
870 * @return array|WP_Error
871 */
872 private function resolve_ranking_range( $input ) {
873 return $this->resolve_range( $input, '' );
874 }
875
876 /**
877 * The start_date the two ranked abilities REPORT for a range with no lower bound.
878 *
879 * Both abilities echo start_date and end_date, and both must stay truthful
880 * now that the default range is open-ended: an empty string would leave a
881 * typed date field blank, and a sentinel year would be a date the site never
882 * saw. So an unbounded range reports the date of the site's earliest
883 * donation, which is the real beginning of the period the answer covers and
884 * something a caller can state back.
885 *
886 * A site with NO donations at all reports TODAY — the same date as end_date.
887 * That path is deliberate rather than undefined: the ranking that comes with
888 * it is empty, and an empty one-day period is a truthful description of it.
889 *
890 * @since 1.8.13
891 *
892 * @param array $dates Resolved range, as returned by resolve_ranking_range().
893 * @return string Y-m-d.
894 */
895 private function reported_start_date( array $dates ) {
896 if ( '' !== (string) $dates['start_date'] ) {
897 return $dates['start_date'];
898 }
899
900 $earliest = $this->earliest_donation_date();
901
902 return '' !== $earliest ? $earliest : $dates['end_date'];
903 }
904
905 /**
906 * The date of the site's earliest donation, or '' when it has none.
907 *
908 * EVERY valid status counts, not just the approved ones, so this agrees
909 * with the reporting screen's own All Time preset
910 * (Charitable_Reports::get_earliest_donation_date()): the two must not
911 * disagree about when the site's history starts.
912 *
913 * post_date, and the first ten characters of it rather than a
914 * strtotime()/gmdate() round trip. post_date is already the site's local
915 * wall clock — it is the column every report query filters on — so parsing
916 * it and reformatting through gmdate() can shift the answer by a day on any
917 * site whose PHP timezone is not UTC.
918 *
919 * Deliberately NOT cached, unlike the reporting screen's version. This is
920 * one indexed MIN() on an ability call, and a cached value would leave the
921 * echoed period a day stale on exactly the site that just imported four
922 * years of history.
923 *
924 * @since 1.8.13
925 *
926 * @global wpdb $wpdb
927 *
928 * @return string Y-m-d, or '' when the site has no donations.
929 */
930 private function earliest_donation_date() {
931 global $wpdb;
932
933 $statuses = array_keys( charitable_get_valid_donation_statuses() );
934
935 /*
936 * `charitable_donation_statuses` is a public filter, and an empty list
937 * would build `IN ( )`. Nothing to measure in that case.
938 */
939 if ( empty( $statuses ) ) {
940 return '';
941 }
942
943 $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
944
945 $earliest = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
946 $wpdb->prepare(
947 "SELECT MIN( post_date ) FROM {$wpdb->posts}
948 WHERE post_type = 'donation'
949 AND post_status IN ( {$placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
950 $statuses
951 )
952 );
953
954 if ( empty( $earliest ) || '0000-00-00 00:00:00' === $earliest ) {
955 return '';
956 }
957
958 return substr( (string) $earliest, 0, 10 );
959 }
960
961 /**
962 * Build the shared report query arguments from a resolved range.
963 *
964 * The get_report_sql_where_clause() builder compares start_date and end_date
965 * against post_date directly, so the end date is widened to the end of the day.
966 * Without it an end_date of 2026-09-04 excludes everything donated that
967 * day, because '2026-09-04 14:00:00' > '2026-09-04'.
968 *
969 * An EMPTY bound is omitted entirely rather than passed through, which is what
970 * makes the ranked abilities' all-time default unbounded. It also has to be
971 * omitted rather than merely empty-checked downstream: ' 00:00:00' — the
972 * result of concatenating onto an empty date — is not empty, so
973 * get_report_sql_where_clause() would add `p.post_date >= ' 00:00:00'` and
974 * compare a datetime column against a malformed string.
975 *
976 * @since 1.8.13
977 *
978 * @param array $dates Resolved start_date/end_date. Either may be ''.
979 * @param array $input Ability input, for the optional campaign filter.
980 * @return array
981 */
982 private function report_args( array $dates, $input ) {
983 $args = array();
984
985 if ( '' !== (string) $dates['start_date'] ) {
986 $args['start_date'] = $dates['start_date'] . ' 00:00:00';
987 }
988
989 if ( '' !== (string) $dates['end_date'] ) {
990 $args['end_date'] = $dates['end_date'] . ' 23:59:59';
991 }
992
993 $campaign_filter = self::input_id( $input, 'campaign_id' );
994
995 if ( $campaign_filter > 0 ) {
996 $args['campaign_id'] = $campaign_filter;
997 }
998
999 return $args;
1000 }
1001
1002 /**
1003 * The shared date-range input schema.
1004 *
1005 * @since 1.8.13
1006 *
1007 * @param array $extra Optional additional properties.
1008 * @param boolean $all_time Whether this ability's default range is all time
1009 * rather than the current month. The start_date
1010 * description has to say which, or the schema
1011 * contradicts the ability. See
1012 * resolve_ranking_range().
1013 * @return array
1014 */
1015 private static function range_input_schema( array $extra = array(), $all_time = false ) {
1016 $properties = array(
1017 'start_date' => array(
1018 'type' => 'string',
1019 'description' => $all_time
1020 ? __( 'Start of the range, as YYYY-MM-DD, in the site\'s timezone. Omit it to rank over all time — there is then no lower bound, and the start_date returned alongside the answer is the date of the site\'s earliest donation.', 'charitable' )
1021 : __( 'Start of the range, as YYYY-MM-DD. Defaults to the first of the current month, in the site\'s timezone.', 'charitable' ),
1022 ),
1023 'end_date' => array(
1024 'type' => 'string',
1025 'description' => __( 'End of the range, as YYYY-MM-DD. Defaults to today. The whole day is included.', 'charitable' ),
1026 ),
1027 'campaign_id' => array(
1028 'type' => 'integer',
1029 'description' => __( 'Restrict to donations that include this campaign.', 'charitable' ),
1030 'minimum' => 1,
1031 ),
1032 );
1033
1034 return array(
1035 'type' => 'object',
1036 'properties' => array_merge( $properties, $extra ),
1037 'additionalProperties' => false,
1038 );
1039 }
1040 }
1041
1042 endif;
1043