| 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 |
|