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 / abstracts / abstract-class-charitable-abilities-registrar.php

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

1,398 lines 52.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Base class for Charitable ability registrars.
4 *
5 * Supplies the shared permission callbacks, input sanitising, money helpers,
6 * error scrubbing and logging so subclasses only describe schema and behaviour.
7 *
8 * @package Charitable/Abstracts/Charitable_Abilities_Registrar
9 * @copyright Copyright (c) 2026, WP Charitable LLC
10 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
11 * @since 1.8.13
12 * @version 1.8.13
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 if ( ! class_exists( 'Charitable_Abilities_Registrar' ) ) :
20
21 /**
22 * Charitable_Abilities_Registrar
23 *
24 * @since 1.8.13
25 */
26 abstract class Charitable_Abilities_Registrar {
27
28 /**
29 * The ability category all Charitable abilities belong to.
30 *
31 * @since 1.8.13
32 */
33 const CATEGORY = 'charitable';
34
35 /**
36 * Register every ability owned by this registrar.
37 *
38 * @since 1.8.13
39 *
40 * @return void
41 */
42 abstract public function register();
43
44 /**
45 * Register an ability with Charitable's shared defaults applied.
46 *
47 * Defaults: category `charitable`, and **both** `meta.public` and
48 * `meta.show_in_rest` set to true. Setting both is not belt-and-braces;
49 * it is required, and setting only `public` shipped a bug.
50 *
51 * WordPress 7.1 resolves `show_in_rest` from most specific to least:
52 * an explicit `show_in_rest` wins, otherwise the high-level `public`
53 * flag seeds it (class-wp-ability.php:366-367). **WordPress 7.0.x does
54 * not do that second step at all** — it reads `meta['show_in_rest']`
55 * and defaults it to `self::DEFAULT_SHOW_IN_REST`, which is false.
56 * `public` is simply ignored there.
57 *
58 * So an ability registered with only `public => true` is invisible over
59 * `wp-abilities/v1` on 7.0.x. Verified on a live WordPress 7.0.2 site:
60 * all 28 Charitable abilities registered in PHP, and the REST list route
61 * returned 21 — ours absent, `show_in_rest` resolved to **false**.
62 * PushEngage's appeared because they set `show_in_rest` explicitly.
63 *
64 * That is precisely the Formidable failure mode this whole feature
65 * exists to avoid: abilities that register but are invisible to the
66 * install base. And the test suite could not catch it, because the test
67 * environment runs 7.1, where `public` alone works.
68 *
69 * Accepts a top-level `annotations` key for convenience and relocates it
70 * to `meta.annotations`, which is where WP_Ability expects it.
71 *
72 * @since 1.8.13
73 *
74 * @param string $slug Ability slug, e.g. "charitable/list-campaigns".
75 * @param array $args Ability config; see wp_register_ability().
76 * @return void
77 */
78 protected function register_ability( $slug, array $args ) {
79 $meta = self::default_meta();
80
81 if ( isset( $args['annotations'] ) ) {
82 $meta['annotations'] = $args['annotations'];
83 unset( $args['annotations'] );
84 }
85
86 if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) {
87 $meta = array_merge( $meta, $args['meta'] );
88 }
89
90 $args['meta'] = $meta;
91 $args['category'] = self::CATEGORY;
92
93 /**
94 * Filters a Charitable ability's meta array before registration.
95 *
96 * The Abilities API `meta` array is free-form, and different MCP
97 * clients read different keys out of it. Charitable sets `public`
98 * and `show_in_rest` — the WordPress-standard exposure signals that
99 * the mainstream bridges (WordPress MCP Adapter, wordpress-mcp,
100 * WPVibe) surface abilities from — plus any `annotations`. A
101 * third-party MCP layer may instead gate its discovery on its own
102 * key, e.g. `meta['mcp']['public']`. This filter lets a site add
103 * whatever such a client expects without editing the plugin, and
104 * without Charitable having to track each vendor's convention in
105 * core.
106 *
107 * The AI write gate is decided from the unfiltered annotations
108 * above, so a filter cannot loosen it: adding `annotations.readonly`
109 * here changes only the advertised meta, never whether a write
110 * actually executes.
111 *
112 * @since 1.8.13
113 *
114 * @param array $meta The assembled meta: default_meta() plus this
115 * ability's annotations and any meta overrides.
116 * @param string $slug The ability slug, e.g. "charitable/list-campaigns".
117 * @param array $args The full registration args, for context.
118 */
119 $args['meta'] = (array) apply_filters( 'charitable_ability_meta', $args['meta'], $slug, $args );
120
121 /*
122 * A callback that is not callable fails SILENTLY, and that is the whole
123 * reason for this guard. WordPress drops an ability whose
124 * permission_callback cannot be invoked from the wp-abilities/v1 list
125 * with no error at registration, none in the response, and none in the
126 * log — the ability is simply absent.
127 *
128 * Found by deploying one file of a two-file change: the campaign
129 * registrar shipped referencing permission_read_campaign() and
130 * permission_edit_campaign() while the abstract registrar defining them
131 * did not, and discover_abilities returned 20 Charitable abilities where
132 * 29 were registered. Same failure shape as the show_in_rest defect:
133 * registered in PHP, invisible over REST, healthy-looking count on the
134 * wrong channel.
135 *
136 * The ability is still registered rather than skipped, deliberately.
137 * Skipping would quietly drop the registered count to match the visible
138 * one and hide the discrepancy; registering leaves
139 * get-site-environment's registered_count and rest_visible_count
140 * disagreeing, which is the signal that survives into production —
141 * _doing_it_wrong() is silent without WP_DEBUG.
142 */
143 foreach ( array( 'permission_callback', 'execute_callback' ) as $callback_key ) {
144 if ( isset( $args[ $callback_key ] ) && ! is_callable( $args[ $callback_key ] ) ) {
145 _doing_it_wrong(
146 'Charitable_Abilities_Registrar::register_ability',
147 sprintf(
148 /* translators: 1: the ability slug, 2: the callback argument name. */
149 esc_html__( 'Ability "%1$s" was registered with a %2$s that is not callable, so WordPress will hide it from the abilities REST route without reporting an error. Check that the method exists and that every file of the change was deployed.', 'charitable' ),
150 esc_html( $slug ),
151 esc_html( $callback_key )
152 ),
153 '1.8.13'
154 );
155 }
156 }
157
158 /*
159 * The AI write gate. Wrapped HERE, deliberately AFTER the callable guard
160 * above: wrapping first would hide a non-callable original inside the
161 * closure, the guard would see a perfectly callable Closure, and the
162 * silent-drop defect that guard exists to catch would come straight back.
163 *
164 * Gated by the `readonly` annotation rather than a list of ability slugs,
165 * so an ability registered without `readonly => true` is gated the moment
166 * it is added. A hand-maintained list of the nine current writes would
167 * silently miss the tenth.
168 *
169 * The setting is read inside the closure, at EXECUTION time, so flipping
170 * the toggle takes effect on the next request without anything
171 * re-registering.
172 *
173 * @since 1.8.13
174 */
175 if ( empty( $meta['annotations']['readonly'] ) && isset( $args['execute_callback'] ) ) {
176 $args['execute_callback'] = self::gate_write_callback( $slug, $args['execute_callback'] );
177 }
178
179 if ( isset( $args['input_schema'] ) && is_array( $args['input_schema'] ) ) {
180 $args['input_schema'] = self::allow_absent_input( $args['input_schema'] );
181 }
182
183 wp_register_ability( $slug, $args );
184 }
185
186 /**
187 * Let an ability that REQUIRES nothing be called with nothing.
188 *
189 * Widens `type: object` to `type: [object, null]` on any input schema
190 * that declares no required properties. One place decides this, applied
191 * to every ability at registration, so no individual schema has to
192 * remember - and a schema added later gets it for free.
193 *
194 * ── WHY ──────────────────────────────────────────────────────────────
195 *
196 * Core validates a missing input envelope as NULL, and `type: object`
197 * fails it with 400 `ability_invalid_input`, "input is not of type
198 * object". For a READONLY ability that is fatal rather than merely
199 * unfriendly: core answers POST with 405 `rest_ability_invalid_method`,
200 * so GET is the only method it accepts, a GET carries no body, and the
201 * only call it permits was the only call it refused. All six abilities
202 * registered with no_input_schema() were unreachable over REST -
203 * including get-plugin-info, the orientation call an assistant is told
204 * to make first.
205 *
206 * Reported from 1.8.13 testing as "a read-only ability that takes no
207 * settings shouldn't reject a plain GET with no input", and confirmed
208 * over rest_do_request() before this changed.
209 *
210 * ── WHY IT IS SCOPED TO "NO REQUIRED PROPERTIES" ─────────────────────
211 *
212 * An ability that genuinely needs an argument must still fail, and fail
213 * on the missing ARGUMENT rather than being handed null and improvising.
214 * get-campaign without a campaign_id is a client bug and should say so.
215 * So `required` being present and non-empty leaves the schema alone.
216 *
217 * `additionalProperties` is never touched, so this widens what counts as
218 * "nothing sent" and never what counts as valid input.
219 *
220 * ── THE CONSEQUENCE, WRITTEN DOWN ────────────────────────────────────
221 *
222 * This deliberately diverges from core's uniform behaviour, which
223 * test-abilities-rest-channel.php had recorded as a client contract:
224 * "always send an input envelope". After this, that guidance is only
225 * needed for abilities with required properties. Any client-facing
226 * documentation of the envelope rule needs to say so.
227 *
228 * @since 1.8.13
229 *
230 * @param array $schema An ability's input schema.
231 * @return array The schema, widened if it requires nothing.
232 */
233 private static function allow_absent_input( array $schema ) {
234
235 if ( ! isset( $schema['type'] ) || 'object' !== $schema['type'] ) {
236 return $schema;
237 }
238
239 if ( ! empty( $schema['required'] ) ) {
240 return $schema;
241 }
242
243 $schema['type'] = array( 'object', 'null' );
244
245 return $schema;
246 }
247
248 /**
249 * Wrap a write ability's callback so it refuses when AI writes are off.
250 *
251 * Refuses BEFORE the callback runs, so nothing is read, validated or
252 * written on a gated request.
253 *
254 * Asks self::write_enabled(), the one master switch. 1.8.13 briefly
255 * asked a per-consequence scope instead; that was reverted before release
256 * in favour of matching Pro, and write_enabled()'s docblock records why.
257 *
258 * Note this is refuse-only: the ability stays visible in
259 * `/wp-abilities/v1/abilities` and still reports itself to a client. That
260 * is a deliberate choice over hiding it, so the operation count a site
261 * reports does not change with a setting — the changelog says twenty-four
262 * and the e2e floor asserts 24, and they stay true whichever way the
263 * switch is set. System Info and the Tools > AI MCP tab COMPUTE their
264 * counts at request time rather than stating a number, so they follow
265 * the registry automatically.
266 * The trade is that an assistant may offer a write and then be refused,
267 * which is why the message below has to explain itself properly.
268 *
269 * @since 1.8.13
270 *
271 * @param string $slug The ability slug, for the refusal log entry.
272 * @param callable $callback The ability's real execute callback.
273 * @return Closure
274 */
275 private static function gate_write_callback( $slug, $callback ) {
276
277 return function ( ...$params ) use ( $callback, $slug ) {
278
279 if ( ! self::write_enabled() ) {
280 Charitable_Abilities_Usage::record_refusal( $slug );
281
282 /*
283 * The LOG row stays generic on purpose. log_write_refused()
284 * takes a slug and writes a fixed message, and the AI Activity
285 * panel classifies refusals against `source => 'write-gate'`
286 * rather than message text.
287 */
288 self::log_write_refused( $slug );
289
290 return new WP_Error(
291 'charitable_write_access_disabled',
292 __( 'AI write access is turned off for this site, so this cannot be changed. An administrator can turn it on under Charitable > Tools > AI MCP. Reading campaigns, donations and reports works either way.', 'charitable' ),
293 array( 'status' => 403 )
294 );
295 }
296
297 return call_user_func_array( $callback, $params );
298 };
299 }
300
301 /**
302 * Whether AI write access is on for this site. THIS IS THE WRITE GATE.
303 *
304 * One master switch, the same model Charitable Pro uses and the same key
305 * both editions read: `ai_mcp_write_enabled` in the shared
306 * charitable_settings row. Reads are never gated - only abilities that
307 * carry no `readonly` annotation reach the gate at all.
308 *
309 * NOT the only check a write passes. Every ability still runs its own
310 * permission_callback against the WordPress capability it always
311 * required, so an assistant acting as a user who cannot edit campaigns
312 * still cannot, however this switch is set.
313 *
314 * 1.8.13 briefly split this three ways by consequence, behind a
315 * Charitable_Abilities_Scopes class, an `ai_mcp_write_scopes` option and
316 * a second `charitable_abilities_allow_write_scope` filter. Reverted
317 * before release in favour of matching Pro: three switches meant three
318 * ways to be half-on, a refusal that had to name which one applied, and a
319 * legacy boolean kept in AND agreement with the map so a partial Lite
320 * grant could not open all nine of Pro's writes on upgrade. Removed
321 * rather than deprecated, because no released version exposed them.
322 *
323 * What protects the irreversible operations is unaffected and predates
324 * the split: an assistant must confirm explicitly before trashing a
325 * campaign that has donations, or taking a live donation-receiving
326 * campaign offline.
327 *
328 * @since 1.8.13
329 *
330 * @return bool Whether an AI assistant may make changes on this site.
331 */
332 public static function write_enabled() {
333
334 $default = ! empty( charitable_get_option( 'ai_mcp_write_enabled', '' ) );
335
336 /**
337 * Filters whether an AI assistant may make changes on this site.
338 *
339 * A real switch in both directions: `__return_false` refuses every
340 * write, `__return_true` allows them, subject as always to each
341 * ability's own WordPress capability check. Applied at exactly ONE
342 * call site, which is this one.
343 *
344 * Both of those were briefly untrue: 1.8.13's scoped permissions made
345 * this a veto only, and applied the same hook name at two call sites
346 * with different defaults. Reverting to one switch restores the
347 * behaviour Pro's changelog documents.
348 *
349 * @since 1.8.13
350 *
351 * @param bool $enabled Defaults to the `ai_mcp_write_enabled` setting,
352 * which is OFF on a site that has never set it.
353 */
354 return (bool) apply_filters( 'charitable_abilities_allow_write', $default );
355 }
356
357 /**
358 * The REST-visibility meta every Charitable ability registers with.
359 *
360 * Factored out of register_ability() so it can be asserted directly.
361 * That is not tidiness — it is the only way to test this correctly.
362 * Reading a REGISTERED ability's `get_meta()` cannot tell you what we
363 * passed, because WordPress 7.1 seeds `show_in_rest` from `public`
364 * itself; on 7.1 the resolved meta says `show_in_rest => true` whether
365 * we set it or not. A test against the registry therefore passes even
366 * with the bug present, which is exactly how the bug shipped. Asserting
367 * this method's return value asserts OUR contribution.
368 *
369 * @since 1.8.13
370 *
371 * @return array
372 */
373 protected static function default_meta() {
374 return array(
375
376 /*
377 * `public` is the documented high-level flag on 7.1, and
378 * `show_in_rest` is what 7.0.x actually reads. BOTH are needed:
379 * 7.0.x ignores `public` and defaults `show_in_rest` to false,
380 * so `public` alone hides the ability from every MCP client on
381 * the current WordPress release. An explicit `show_in_rest`
382 * wins over `public` on 7.1, so setting both is consistent
383 * rather than contradictory.
384 */
385 'public' => true,
386 'show_in_rest' => true,
387
388 /*
389 * Some MCP layers gate their tool discovery on a nested
390 * `meta['mcp']['public']` flag rather than the top-level `public`
391 * above. Our sibling plugin WPForms sets this same key, and
392 * WPVibe reads it, so it is the de-facto convention across the
393 * AM/WPVibe MCP stack even though core does not define it (the
394 * meta array is free-form). Set it on every ability so
395 * Charitable's operations are discoverable out of the box by
396 * those clients rather than only by ones that read `public`.
397 *
398 * This affects DISCOVERY only, and deliberately does NOT copy
399 * WPForms' model of hiding writes from discovery when writes are
400 * off. Charitable keeps all abilities discoverable — the
401 * "every registered ability is also REST-visible" invariant
402 * that System Info, the Tools > AI MCP tab and the e2e floor
403 * all assert stays true (24 of 24 in Lite today) — and a write is
404 * still refused at execution time when AI write access is off,
405 * via gate_write_callback(). A site can still override this per
406 * ability through the `charitable_ability_meta` filter.
407 */
408 'mcp' => array(
409 'public' => true,
410 ),
411 );
412 }
413
414 /**
415 * Normalise ability input, and read one integer id out of it safely.
416 *
417 * Every `execute_*` method has to be **public**, because core needs it
418 * as a callback. That makes each one a reachable entry point for any
419 * internal caller holding a registrar instance — and this codebase
420 * already does exactly that:
421 * `Charitable_Donor_Abilities::execute_get_donor_donations()` calls
422 * `Charitable_Donation_Abilities::execute_list_donations()` directly, to
423 * keep the money-safe listing logic in one place rather than duplicating
424 * it. That call bypasses core's schema validation entirely.
425 *
426 * Fuzzed rather than assumed. 252 hostile calls through `execute()`
427 * produced ZERO PHP errors — core's validator catches everything. The
428 * same 252 made directly against the `execute_*` methods produced 83:
429 * `null`, a bare string, an int and a `stdClass` all reached
430 * `$input['campaign_id']` and raised "Trying to access array offset on
431 * null", "Undefined array key" or a TypeError instead of a WP_Error.
432 * Latent rather than live, but a public method that fatals on `null` is
433 * the same class of landmine as
434 * `Charitable_Campaign_Builder_Templates::get_instance()` returning the
435 * wrong class — catalogued in this codebase as exactly that.
436 *
437 * Returning 0 for a missing or unusable id is the correct behaviour, not
438 * a swallow: every `find_*()` guard rejects 0 with a "not found"
439 * WP_Error, which is what a caller passing nothing should get back.
440 *
441 * @since 1.8.13
442 *
443 * @param mixed $input Ability input. Anything at all.
444 * @param string $key The key to read.
445 * @return int
446 */
447 protected static function input_id( $input, $key ) {
448 if ( ! is_array( $input ) || ! isset( $input[ $key ] ) || ! is_scalar( $input[ $key ] ) ) {
449 return 0;
450 }
451
452 return (int) $input[ $key ];
453 }
454
455 /**
456 * Normalise ability input to an array.
457 *
458 * The companion to input_id() for methods that read several optional
459 * keys. See that method's docblock for why this is needed at all.
460 *
461 * @since 1.8.13
462 *
463 * @param mixed $input Ability input. Anything at all.
464 * @return array
465 */
466 protected static function input_array( $input ) {
467 return is_array( $input ) ? $input : array();
468 }
469
470 /**
471 * Read a bounded integer out of ability input.
472 *
473 * Replaces the `isset( $input[ $k ] ) ? max( 1, min( 100, (int) … ) ) : 20`
474 * idiom, which reads safely but casts unsafely: `isset()` is TRUE for an
475 * array or object value, so `(int) $input['per_page']` on an array still
476 * raises "Array to int conversion" and on an object throws. The
477 * is_scalar() check is the part that idiom was missing.
478 *
479 * An absent or unusable value returns $default; a supplied one is
480 * clamped. That distinction matters: `per_page => 0` should become the
481 * minimum of 1, not silently jump back to the default of 20.
482 *
483 * @since 1.8.13
484 *
485 * @param mixed $input Ability input. Anything at all.
486 * @param string $key The key to read.
487 * @param int $default Returned when the key is absent or unusable.
488 * @param int|null $min Optional lower bound.
489 * @param int|null $max Optional upper bound.
490 * @return int
491 */
492 protected static function input_int( $input, $key, $default, $min = null, $max = null ) {
493 if ( ! is_array( $input ) || ! isset( $input[ $key ] ) || ! is_scalar( $input[ $key ] ) ) {
494 return (int) $default;
495 }
496
497 $value = (int) $input[ $key ];
498
499 if ( null !== $min ) {
500 $value = max( (int) $min, $value );
501 }
502
503 if ( null !== $max ) {
504 $value = min( (int) $max, $value );
505 }
506
507 return $value;
508 }
509
510 /**
511 * Read a string out of ability input.
512 *
513 * Same reasoning as input_int(): a non-scalar value returns the default
514 * rather than raising "Array to string conversion" or throwing on an
515 * object.
516 *
517 * @since 1.8.13
518 *
519 * @param mixed $input Ability input. Anything at all.
520 * @param string $key The key to read.
521 * @param string $default Returned when the key is absent or unusable.
522 * @return string
523 */
524 protected static function input_string( $input, $key, $default = '' ) {
525 if ( ! is_array( $input ) || ! isset( $input[ $key ] ) || ! is_scalar( $input[ $key ] ) ) {
526 return (string) $default;
527 }
528
529 return (string) $input[ $key ];
530 }
531
532 /**
533 * Permission callback: plugin administration and diagnostics.
534 *
535 * @since 1.8.13
536 *
537 * @return boolean
538 */
539 public static function permission_manage() {
540 return current_user_can( 'manage_charitable_settings' );
541 }
542
543 /**
544 * Permission callback: abilities that address NO PARTICULAR campaign.
545 *
546 * `edit_campaigns` is the plural, GENERAL capability: it carries no post
547 * id and no author check. Use it only where there is genuinely no object
548 * to check — listing the available builder templates, or a listing whose
549 * own query is author-scoped.
550 *
551 * For anything taking a `campaign_id`, use permission_read_campaign() or
552 * permission_edit_campaign() instead. The `campaign` post type registers
553 * `map_meta_cap => true` (Charitable_Post_Types::register_post_types()),
554 * so WordPress resolves the SINGULAR meta capabilities against the post's
555 * author — which is the check wp-admin makes and this one does not.
556 *
557 * Getting that wrong is not theoretical. Charitable Ambassadors grants its
558 * `campaign_creator` role exactly `read` + `edit_campaigns` and nothing
559 * else, automatically, to any user who starts a campaign through the
560 * front-end form. Gating a campaign_id-bearing ability on this callback
561 * therefore lets one ambassador read and rewrite another's fundraiser.
562 *
563 * @since 1.8.13
564 *
565 * @return boolean
566 */
567 public static function permission_campaigns() {
568 return current_user_can( 'edit_campaigns' );
569 }
570
571 /**
572 * Permission callback: reading ONE campaign.
573 *
574 * Requires the general `edit_campaigns` capability as a floor before any
575 * object-level check runs, so a caller holding no Charitable capability
576 * at all is refused regardless of the target campaign's status.
577 *
578 * Above that floor, `read_campaign` is the meta capability that NARROWS
579 * access, so core resolves it against the post: a published campaign is
580 * readable for its goal and amount raised, which are already public on
581 * its own page — `get-campaign-performance`, one of the four abilities
582 * gated on this callback, additionally exposes donor_count and
583 * average_gift to any `edit_campaigns` holder this way, an accepted
584 * boundary rather than an oversight (see U4 in the audit findings doc).
585 * A draft campaign falls through to `edit_others_campaigns`, and a
586 * private one to `read_private_campaigns`; Ambassadors'
587 * `campaign_creator` holds neither.
588 *
589 * @since 1.8.13
590 *
591 * @param array|null $input Ability input.
592 * @return boolean
593 */
594 public static function permission_read_campaign( $input = null ) {
595 /*
596 * THE FLOOR, and it must come first.
597 *
598 * The object-level check below NARROWS access; it must never widen it.
599 * Without this line the callback REPLACED the general capability, and
600 * for a PUBLISHED campaign `read_campaign` resolves — via core's
601 * map_meta_cap() `read_post` branch, which short-circuits on any public
602 * post status — to `$post_type->cap->read`. The `campaign` post type
603 * registers `capability_type => 'campaign'` with no explicit
604 * `capabilities` array, so get_post_type_capabilities() leaves that as
605 * the plain core `read`, which every logged-in role holds. Four read
606 * abilities were therefore open to any Subscriber: verified over
607 * wp-abilities/v1, HTTP 200 carrying donor_count and average_gift.
608 *
609 * Caught by
610 * test_a_user_with_no_charitable_capability_cannot_read_a_published_campaign().
611 */
612 if ( ! current_user_can( 'edit_campaigns' ) ) {
613 return false;
614 }
615
616 $campaign_id = is_array( $input ) ? self::input_id( $input, 'campaign_id' ) : 0;
617
618 if ( $campaign_id < 1 ) {
619 return true;
620 }
621
622 $post = get_post( $campaign_id );
623
624 /*
625 * Not a campaign: defer so the ability's own 404 "no campaign was
626 * found with that ID" is what the caller sees.
627 *
628 * This is load-bearing, not tidiness. current_user_can( 'read_campaign',
629 * <id that does not exist> ) is false, which would turn every mistyped
630 * id into a 403 — and the REST-channel suite asserts a missing record
631 * is a 404 precisely because a permission error tells an assistant the
632 * wrong thing: 404 makes it ask which campaign you meant, 403 makes it
633 * report an access problem that does not exist. Caught by
634 * test_a_missing_record_is_a_404_not_a_500().
635 */
636 if ( ! $post || 'campaign' !== $post->post_type ) {
637 return true;
638 }
639
640 /* The caller's own campaign, whatever its status. */
641 if ( (int) $post->post_author === get_current_user_id() ) {
642 return true;
643 }
644
645 /*
646 * Somebody else's campaign. `read_campaign` is the meta capability, so
647 * core resolves it against the post: a published campaign stays
648 * readable — its goal and amount raised are already public on its
649 * own page, and get-campaign-performance additionally exposes
650 * donor_count and average_gift to any edit_campaigns holder this
651 * way, an accepted boundary (U4 in the audit findings doc) rather
652 * than an oversight — while a draft or private one requires
653 * read_private_campaigns or edit_others_campaigns, which that role
654 * does not hold.
655 */
656 return current_user_can( 'read_campaign', $campaign_id );
657 }
658
659 /**
660 * Permission callback: writing to ONE campaign.
661 *
662 * `edit_campaign` is the meta capability, which core resolves to
663 * `edit_others_campaigns` when the caller is not the post's author. That
664 * is precisely the check that stops a `campaign_creator` opening someone
665 * else's campaign in wp-admin, and gating on the plural `edit_campaigns`
666 * bypassed it.
667 *
668 * @since 1.8.13
669 *
670 * @param array|null $input Ability input.
671 * @return boolean
672 */
673 public static function permission_edit_campaign( $input = null ) {
674 $campaign_id = is_array( $input ) ? self::input_id( $input, 'campaign_id' ) : 0;
675
676 if ( $campaign_id < 1 ) {
677 return current_user_can( 'edit_campaigns' );
678 }
679
680 $post = get_post( $campaign_id );
681
682 /*
683 * Not a campaign: defer to the general capability so the ability's own
684 * "no campaign was found with that ID" error is what the caller sees,
685 * rather than a permission error that would confirm nothing either way.
686 */
687 if ( ! $post || 'campaign' !== $post->post_type ) {
688 return current_user_can( 'edit_campaigns' );
689 }
690
691 /*
692 * Somebody else's campaign: `edit_others_campaigns`, which is what
693 * core's map_meta_cap resolves `edit_campaign` to for a non-author,
694 * and therefore what wp-admin enforces.
695 */
696 if ( (int) $post->post_author !== get_current_user_id() ) {
697 return current_user_can( 'edit_others_campaigns' );
698 }
699
700 /*
701 * The caller's OWN campaign: the general capability is enough, and
702 * `edit_campaign` deliberately is NOT used here.
703 *
704 * That looks like the stricter, more obvious choice and it is wrong.
705 * For a PUBLISHED post core resolves `edit_campaign` to
706 * `edit_published_campaigns`, which Ambassadors' `campaign_creator`
707 * role does not hold — so using the meta capability would stop an
708 * ambassador editing their own live fundraiser through an ability,
709 * which is the entire purpose of that role. Caught by
710 * test_a_campaign_creator_can_still_write_to_its_own_campaign().
711 *
712 * Authorship is the axis that matters for the vulnerability this
713 * guards; the published/draft split is not. Same shape as
714 * permission_create_manual_donation(), which likewise falls back to an
715 * ownership test rather than a broader capability.
716 */
717 return current_user_can( 'edit_campaigns' );
718 }
719
720 /**
721 * Permission callback: publishing or re-statusing ONE campaign.
722 *
723 * Charitable's own role model (Charitable_Roles::get_core_caps(),
724 * includes/users/class-charitable-roles.php) defines `publish_campaigns`
725 * as a capability DISTINCT from `edit_campaigns` — mirroring core
726 * WordPress's own `edit_posts`/`publish_posts` split. Both stock
727 * Charitable roles (`administrator`, `campaign_manager`) happen to be
728 * granted both capabilities as a bundle, which is exactly why gating a
729 * status-changing write on `permission_campaigns()` alone can look safe
730 * in testing and still be wrong: `wp_insert_post()`/`wp_update_post()`
731 * perform NO internal capability check of their own, so a role-editor
732 * plugin that splits "draft" from "publish" — a common nonprofit setup
733 * ("volunteers draft, staff approve and publish") — would let a
734 * draft-only user publish through an ability even though the equivalent
735 * admin-UI Publish button would be hidden from them.
736 *
737 * INPUT-AWARE as of the Audit 1 remediation. The capability alone is not
738 * enough: it carries no post id and no author check, so a role holding
739 * publish_campaigns without edit_others_campaigns could take ANOTHER
740 * author's live fundraiser offline through this ability — access wp-admin
741 * refuses, because core's map_meta_cap() resolves `edit_post` to
742 * `edit_others_campaigns` for a non-author. Confirmed by execution before
743 * the fix. Same axis as permission_edit_campaign(): authorship, not
744 * published-vs-draft.
745 *
746 * @since 1.8.13
747 *
748 * @param array|null $input Ability input.
749 * @return boolean
750 */
751 public static function permission_publish_campaigns( $input = null ) {
752 if ( ! current_user_can( 'publish_campaigns' ) ) {
753 return false;
754 }
755
756 $campaign_id = is_array( $input ) ? self::input_id( $input, 'campaign_id' ) : 0;
757
758 if ( $campaign_id < 1 ) {
759 return true;
760 }
761
762 $post = get_post( $campaign_id );
763
764 /*
765 * Not a campaign: defer, so the ability's own 404 answers rather than a
766 * permission error that would confirm nothing either way. Same reasoning
767 * as permission_read_campaign() and permission_edit_campaign().
768 */
769 if ( ! $post || 'campaign' !== $post->post_type ) {
770 return true;
771 }
772
773 if ( (int) $post->post_author !== get_current_user_id() ) {
774 return current_user_can( 'edit_others_campaigns' );
775 }
776
777 return true;
778 }
779
780 /**
781 * Permission callback: donation read and manual creation.
782 *
783 * The plural, GENERAL capability: it carries no donation id and no author
784 * check. It is the FLOOR for every donation ability, and it is enough on
785 * its own only where there is no single donation to check — a listing
786 * whose own query is author-scoped (execute_list_donations()), or a
787 * summary whose own query is (execute_get_donation_summary()).
788 *
789 * For anything taking a `donation_id`, use permission_read_donation()
790 * instead, which additionally asks whose donation it is.
791 *
792 * @since 1.8.13
793 *
794 * @return boolean
795 */
796 public static function permission_donations() {
797 return current_user_can( 'edit_donations' );
798 }
799
800 /**
801 * Permission callback: reading ONE donation.
802 *
803 * `edit_donations` is the floor and must come first, so a caller holding
804 * no Charitable capability at all is refused whoever authored the
805 * donation. Above that floor this asks the question the general
806 * capability cannot: whose donation is it?
807 *
808 * The rule is Charitable's own, taken from the wp-admin donation list
809 * table, which filters its query by author for anyone without
810 * `edit_others_donations`
811 * (includes/admin/donations/class-charitable-donation-list-table.php:783).
812 * A donation's `post_author` is the donor's WordPress user id —
813 * Charitable_Donation_Processor::parse_donation_data() sets
814 * `post_author => $this->get_donation_data_value( 'user_id',
815 * get_current_user_id() )` (line 1022) — so author IS the ownership axis
816 * here, exactly as it is for campaigns. A guest or manually recorded
817 * donation carries post_author 0 and is therefore NOT the caller's own,
818 * which is the same answer the list table gives: its `author` filter is
819 * the current user id, and 0 never matches it.
820 *
821 * The author comparison is written out rather than delegated to
822 * `current_user_can( 'read_donation', $id )`, and that is a choice, not an
823 * oversight. The meta capability would in fact work — `donation`
824 * registers `map_meta_cap => true`
825 * (Charitable_Post_Types::register_post_types():322), and since every
826 * `charitable-*` status is registered `public => false` with no `private`
827 * flag (register_post_statuses(), same file), core's `read_post` branch
828 * falls through to map_meta_cap( 'edit_post' ) and lands on
829 * `edit_others_donations` for a non-author. It is not used because that
830 * result is four hops of core derivation through non-public custom
831 * statuses: change any one of those `public` flags and this gate silently
832 * becomes a different check. Writing the rule out keeps it identical to
833 * the list table's, and identical to the two query-level sites
834 * (execute_list_donations(), execute_get_donation_summary()) which have no
835 * meta capability available to them at all.
836 *
837 * A missing id, or an id belonging to some other post type, DEFERS to the
838 * floor rather than refusing. That is load-bearing: the ability's own
839 * "no donation with that id exists" is a 404, and the REST-channel suite
840 * asserts it, because a 403 tells an assistant it has an access problem
841 * when what it really has is a wrong id. Same reasoning as
842 * permission_read_campaign(). It must also tolerate `array()` or `null`
843 * without a notice — the contract suite calls check_permissions( array() )
844 * on every registered ability.
845 *
846 * DIVERGENCE FROM PRO, deliberate and documented. Pro 1.8.18 (the pinned
847 * source these abilities were ported from) gates get-donation on
848 * permission_donations() alone and has the same defect. Pro should adopt
849 * this change so the shared schema does not drift silently.
850 *
851 * @since 1.8.13
852 *
853 * @param array|null $input Ability input.
854 * @return boolean
855 */
856 public static function permission_read_donation( $input = null ) {
857 if ( ! current_user_can( 'edit_donations' ) ) {
858 return false;
859 }
860
861 $donation_id = is_array( $input ) ? self::input_id( $input, 'donation_id' ) : 0;
862
863 if ( $donation_id < 1 ) {
864 return true;
865 }
866
867 $post = get_post( $donation_id );
868
869 /* Not a donation: defer, so the ability's own 404 is what the caller sees. */
870 if ( ! $post || 'donation' !== $post->post_type ) {
871 return true;
872 }
873
874 /* The caller's own donation. */
875 if ( (int) $post->post_author === get_current_user_id() ) {
876 return true;
877 }
878
879 return current_user_can( 'edit_others_donations' );
880 }
881
882 /**
883 * Permission callback: donor personally identifiable information.
884 *
885 * @since 1.8.13
886 *
887 * @return boolean
888 */
889 public static function permission_sensitive() {
890 return current_user_can( 'view_charitable_sensitive_data' );
891 }
892
893 /**
894 * Permission callback: reporting.
895 *
896 * @since 1.8.13
897 *
898 * @return boolean
899 */
900 public static function permission_reports() {
901 return current_user_can( 'export_charitable_reports' );
902 }
903
904 /**
905 * Permission callback: a REPORT that also names donors.
906 *
907 * BOTH capabilities, not either. `export_charitable_reports` is what every
908 * other report ability requires — get-reports-overview,
909 * get-reports-activity, get-top-campaigns and get-reports-date-ranges all
910 * gate on permission_reports() — and `view_charitable_sensitive_data` is
911 * the PII gate on top, because the output carries donor names and email
912 * addresses.
913 *
914 * get-top-donors required the sensitive capability ALONE, so a role
915 * holding it without the reports capability was served a ranked list of
916 * donor names, emails and lifetime totals from an ability its four
917 * siblings would have refused. The sensitive capability is the narrower
918 * of the two and reads like the stricter choice, which is exactly why
919 * this was easy to miss: it is narrower on a different axis. It says
920 * "may see donor PII", not "may run reports".
921 *
922 * DIVERGENCE FROM PRO, deliberate and documented. Pro 1.8.18 (the pinned
923 * source these abilities were ported from) has the same defect. Pro
924 * should adopt this change so the shared schema does not drift silently.
925 *
926 * @since 1.8.13
927 *
928 * @return boolean
929 */
930 public static function permission_sensitive_reports() {
931 return current_user_can( 'export_charitable_reports' )
932 && current_user_can( 'view_charitable_sensitive_data' );
933 }
934
935 /**
936 * Return a monetary amount in both raw and formatted form.
937 *
938 * Abilities always return both so a client never has to parse a currency
939 * symbol or a thousands separator. The decimal count is resolved via
940 * get_decimals() and passed to get_monetary_amount() explicitly — its
941 * own `false` default falls back to
942 * `charitable_get_option( 'decimal_count', 2 )`, and
943 * charitable_get_option() returns that hardcoded 2 without ever calling
944 * apply_filters() when the `decimal_count` key is absent from the saved
945 * settings (true for any site whose admin has never saved General
946 * Settings). That silently skips the `charitable_option_decimal_count`
947 * filter Charitable_Currency::maybe_force_zero_decimals() hooks into, so
948 * a zero-decimal currency like JPY would render with 2 decimals.
949 * get_decimals() does not have this gap: it computes a currency-aware
950 * default (0 for zero-decimal currencies) before calling the option
951 * getter, so it is correct even when nothing has been saved. Do not
952 * revert the explicit `$helper->get_decimals()` argument below back to
953 * `false` — the filter does not cover this case.
954 *
955 * @since 1.8.13
956 *
957 * @param mixed $amount The amount. May be a locale-formatted string. Non-scalar
958 * input (e.g. an array) is treated as 0 rather than coerced.
959 * @return array {
960 * @type float $raw Numeric amount.
961 * @type string $formatted Localised, currency-symbolised amount.
962 * }
963 */
964 protected static function money( $amount ) {
965 if ( ! is_scalar( $amount ) ) {
966 $amount = 0;
967 }
968
969 $helper = charitable_get_currency_helper();
970 $raw = $helper->sanitize_monetary_amount( (string) $amount, true );
971 $formatted = $helper->get_monetary_amount( $raw, $helper->get_decimals(), true );
972
973 /*
974 * get_currency_symbol() (class-charitable-currency.php) returns HTML
975 * entities by design — '&#36;' for USD/AUD/CAD/etc, '&yen;' for
976 * JPY/CNY — which is correct wherever Charitable renders this into
977 * HTML. get_monetary_amount() above embeds that symbol, so
978 * $formatted inherits the entities (reproduced live: goal_formatted
979 * came back as '&#36;12,500.00'). This is a JSON surface read by AI
980 * clients, not HTML, so the entities must be decoded here rather
981 * than "fixed" upstream in the currency class — an assistant reading
982 * the raw entity aloud says "and pound 36, 12,500.00" instead of
983 * "$12,500.00". Do not remove this decode.
984 */
985 $formatted = html_entity_decode( $formatted, ENT_QUOTES, 'UTF-8' );
986
987 return array(
988 'raw' => (float) $raw,
989 'formatted' => $formatted,
990 );
991 }
992
993 /**
994 * Parse an inbound monetary amount, rejecting excess precision.
995 *
996 * On a zero-decimal currency site an amount carrying decimal places is
997 * refused rather than silently floored — quietly altering money is worse
998 * than failing the call.
999 *
1000 * Non-scalar input (e.g. an array) is rejected with a WP_Error rather
1001 * than coerced, which would otherwise emit an "Array to string
1002 * conversion" notice.
1003 *
1004 * @since 1.8.13
1005 *
1006 * @param mixed $amount Inbound amount. JSON may deliver int, float or string.
1007 * @return float|WP_Error
1008 */
1009 protected static function parse_money( $amount ) {
1010 if ( ! is_scalar( $amount ) ) {
1011 return new WP_Error(
1012 'charitable_invalid_amount',
1013 __( 'The amount must be a number or numeric string.', 'charitable' )
1014 );
1015 }
1016
1017 $helper = charitable_get_currency_helper();
1018 $decimals = $helper->get_decimals();
1019
1020 /* sanitize_monetary_amount() requires a string; a float triggers doing_it_wrong(). */
1021 $sanitized = $helper->sanitize_monetary_amount( (string) $amount, true );
1022
1023 if ( 0 === (int) $decimals && (float) $sanitized !== floor( (float) $sanitized ) ) {
1024 return new WP_Error(
1025 'charitable_invalid_amount',
1026 sprintf(
1027 /* translators: %s: currency code. */
1028 __( 'The site currency (%s) does not use decimal places. Supply a whole number amount.', 'charitable' ),
1029 charitable_get_currency()
1030 )
1031 );
1032 }
1033
1034 return (float) $sanitized;
1035 }
1036
1037 /**
1038 * Strip the data payload from a WP_Error before it leaves an ability.
1039 *
1040 * Error data can carry licence keys, admin email and gateway envelopes.
1041 * An MCP bridge that serialises get_error_data() would log all of it, so
1042 * the data payload is dropped at the boundary. The message is preserved.
1043 *
1044 * @since 1.8.13
1045 *
1046 * @param mixed $error Possibly a WP_Error.
1047 * @return mixed Scrubbed WP_Error, or the original value untouched.
1048 */
1049 protected static function scrub_error( $error ) {
1050 if ( ! is_wp_error( $error ) ) {
1051 return $error;
1052 }
1053
1054 return new WP_Error( $error->get_error_code(), $error->get_error_message() );
1055 }
1056
1057 /**
1058 * Log a successful write performed by an ability.
1059 *
1060 * Reads are deliberately not logged — 26 abilities inside an agentic loop
1061 * would flood the log table. Opt in with the
1062 * `charitable_abilities_log_reads` filter.
1063 *
1064 * @since 1.8.13
1065 *
1066 * @param string $ability The ability slug.
1067 * @param string $message Human-readable message.
1068 * @param array $context Optional. Correlation ids: campaign_id, donation_id, donor_id.
1069 * @return void
1070 */
1071 protected static function log_write( $ability, $message, array $context = array() ) {
1072 charitable_log(
1073 $ability,
1074 $message,
1075 array_merge(
1076 array(
1077 'type' => 'abilities',
1078 'level' => 'info',
1079 'source' => 'core',
1080 'object_type' => 'ability',
1081 'user_id' => get_current_user_id(),
1082 ),
1083 $context
1084 )
1085 );
1086 }
1087
1088 /**
1089 * Log a read, but only if a site has opted in.
1090 *
1091 * Reads are NOT logged by default and this is a deliberate default, not
1092 * an omission: an agentic loop calling a couple of dozen read abilities
1093 * per turn would fill the log table with rows nobody will read, and
1094 * Charitable has already had one debug line put 3,033 entries into a
1095 * customer's log in a day.
1096 *
1097 * A site that is debugging what an assistant is actually doing opts in:
1098 *
1099 * add_filter( 'charitable_abilities_log_reads', '__return_true' );
1100 *
1101 * Level is `debug`, so opting in does not mix read noise in with the
1102 * writes and failures at `info` and above.
1103 *
1104 * @since 1.8.13
1105 *
1106 * @param string $ability The ability slug.
1107 * @param array $context Optional. Correlation ids.
1108 * @return void
1109 */
1110 public static function log_read( $ability, array $context = array() ) {
1111 /**
1112 * Filter whether read abilities write a log entry.
1113 *
1114 * Off by default. Turn it on to trace what an AI client is reading;
1115 * expect a row per ability call.
1116 *
1117 * @since 1.8.13
1118 *
1119 * @param boolean $enabled Whether to log reads. Default false.
1120 * @param string $ability The ability slug about to be logged.
1121 */
1122 if ( ! apply_filters( 'charitable_abilities_log_reads', false, $ability ) ) {
1123 return;
1124 }
1125
1126 charitable_log(
1127 $ability,
1128 __( 'Read.', 'charitable' ),
1129 array_merge(
1130 array(
1131 'type' => 'abilities',
1132 'level' => 'debug',
1133 'source' => 'core',
1134 'object_type' => 'ability',
1135 'user_id' => get_current_user_id(),
1136 ),
1137 $context
1138 )
1139 );
1140 }
1141
1142 /**
1143 * Log a write refused by the AI write gate.
1144 *
1145 * Public because the refusal is recorded from inside the gate closure
1146 * rather than from a subclass, and because the AI Activity panel's tests
1147 * drive it directly.
1148 *
1149 * `source` is `write-gate`, not the `core` default every other log_*()
1150 * method here uses: it is the machine-readable discriminator
1151 * `Charitable_Tools_AI_MCP::outcome_from_record()` switches on to tell
1152 * this refusal apart from a permission denial, since the two share a
1153 * log level and their message text is not safe to match on a
1154 * translated site. Registered for Tools > Logs by
1155 * `Charitable_Abilities::register_log_sources()`. Pro 1.8.18 still logs
1156 * this call with `source => 'core'`; adopt `write-gate` there when Pro
1157 * gains an equivalent panel.
1158 *
1159 * The MESSAGE is a FIXED string. It named which of three scopes was off
1160 * while 1.8.13 had scoped permissions; with one switch there is only one
1161 * thing it could name, and the sentence already says it.
1162 *
1163 * Fixed is also what the panel relies on: outcome_from_record()
1164 * classifies a refusal on `source => 'write-gate'` and never on this
1165 * text, which is what lets the row survive translation.
1166 * `Test_Charitable_Abilities_Activity::test_refused_and_denied_survive_translation`
1167 * keeps that true.
1168 *
1169 * @since 1.8.13
1170 *
1171 * @param string $ability The ability slug.
1172 * @return void
1173 */
1174 public static function log_write_refused( $ability ) {
1175
1176 charitable_log(
1177 $ability,
1178 __( 'Refused: AI write access is turned off for this site.', 'charitable' ),
1179 array(
1180 'type' => 'abilities',
1181 'level' => 'warning',
1182 'source' => 'write-gate',
1183 'object_type' => 'ability',
1184 'user_id' => get_current_user_id(),
1185 )
1186 );
1187 }
1188
1189 /**
1190 * Log a permission denial.
1191 *
1192 * `source` is `permission`, not the `core` default every other
1193 * log_*() method here uses: it is the machine-readable discriminator
1194 * `Charitable_Tools_AI_MCP::outcome_from_record()` switches on to
1195 * tell this denial apart from a write-gate refusal, since the two
1196 * share a log level and their message text is not safe to match on a
1197 * translated site. Registered for Tools > Logs by
1198 * `Charitable_Abilities::register_log_sources()`. Pro 1.8.18 still logs
1199 * this call with `source => 'core'`; adopt `permission` there when Pro
1200 * gains an equivalent panel.
1201 *
1202 * @since 1.8.13
1203 *
1204 * @param string $ability The ability slug.
1205 * @return void
1206 */
1207 public static function log_denial( $ability ) {
1208 charitable_log(
1209 $ability,
1210 __( 'Permission denied.', 'charitable' ),
1211 array(
1212 'type' => 'abilities',
1213 'level' => 'warning',
1214 'source' => 'permission',
1215 'object_type' => 'ability',
1216 'user_id' => get_current_user_id(),
1217 )
1218 );
1219 }
1220
1221 /**
1222 * Log a validation failure or returned WP_Error.
1223 *
1224 * @since 1.8.13
1225 *
1226 * @param string $ability The ability slug.
1227 * @param mixed $error WP_Error or message string.
1228 * @return void
1229 */
1230 protected static function log_failure( $ability, $error ) {
1231 $message = is_wp_error( $error ) ? $error->get_error_message() : (string) $error;
1232
1233 charitable_log(
1234 $ability,
1235 $message,
1236 array(
1237 'type' => 'abilities',
1238 'level' => 'error',
1239 'source' => 'core',
1240 'object_type' => 'ability',
1241 'user_id' => get_current_user_id(),
1242 )
1243 );
1244 }
1245
1246 /*
1247 * ---------------------------------------------------------------------
1248 * Shared date handling.
1249 *
1250 * Lives on the base class because every ability taking a date range needs
1251 * the same three things and must not get any of them wrong twice: a real
1252 * calendar-date check (strtotime() accepts "2026-02-31"), an inverted-range
1253 * refusal, and an INCLUSIVE date_query so an end date of today does not
1254 * drop today.
1255 * ---------------------------------------------------------------------
1256 */
1257
1258 /**
1259 * Validate a YYYY-MM-DD string, rejecting impossible calendar dates.
1260 *
1261 * Note that strtotime() accepts "2026-02-31" and rolls it forward to March, so a
1262 * checkdate() pass is required rather than a parse alone.
1263 *
1264 * @since 1.8.13
1265 *
1266 * @param string $value The candidate date.
1267 * @return string|WP_Error
1268 */
1269 protected static function validate_ymd( $value ) {
1270 /* Non-scalar in, clean rejection out — never an "Array to string" notice. */
1271 if ( ! is_scalar( $value ) ) {
1272 return new WP_Error(
1273 'charitable_invalid_date',
1274 __( 'Dates must be supplied as YYYY-MM-DD.', 'charitable' )
1275 );
1276 }
1277
1278 $value = trim( (string) $value );
1279
1280 if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches ) ) {
1281 return new WP_Error(
1282 'charitable_invalid_date',
1283 __( 'Dates must be supplied as YYYY-MM-DD.', 'charitable' )
1284 );
1285 }
1286
1287 if ( ! checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ) ) {
1288 return new WP_Error(
1289 'charitable_invalid_date',
1290 __( 'That is not a real calendar date.', 'charitable' )
1291 );
1292 }
1293
1294 return $value;
1295 }
1296
1297 /**
1298 * Parse an optional start_date/end_date pair out of ability input.
1299 *
1300 * @since 1.8.13
1301 *
1302 * @param array $input Ability input.
1303 * @param string $default_start Fallback start date.
1304 * @param string $default_end Fallback end date.
1305 * @return array|WP_Error
1306 */
1307 protected static function parse_date_range( $input, $default_start, $default_end ) {
1308 /*
1309 * Read through input_string(), not isset() plus a cast. isset() is TRUE
1310 * for an array or object value, so `trim( (string) $input['start_date'] )`
1311 * raised "Array to string conversion" on an array — found by fuzzing the
1312 * execute_* methods directly, which is a reachable path because they must
1313 * be public. A non-scalar date now falls through to the default exactly
1314 * as an absent one does.
1315 */
1316 $supplied_start = self::input_string( $input, 'start_date' );
1317
1318 $start = '' !== trim( $supplied_start )
1319 ? self::validate_ymd( $supplied_start )
1320 : $default_start;
1321
1322 if ( is_wp_error( $start ) ) {
1323 return $start;
1324 }
1325
1326 $supplied_end = self::input_string( $input, 'end_date' );
1327
1328 $end = '' !== trim( $supplied_end )
1329 ? self::validate_ymd( $supplied_end )
1330 : $default_end;
1331
1332 if ( is_wp_error( $end ) ) {
1333 return $end;
1334 }
1335
1336 if ( '' !== $start && '' !== $end && $start > $end ) {
1337 return new WP_Error(
1338 'charitable_invalid_date_range',
1339 __( 'The start date must not be later than the end date.', 'charitable' )
1340 );
1341 }
1342
1343 return array(
1344 'start_date' => $start,
1345 'end_date' => $end,
1346 );
1347 }
1348
1349 /**
1350 * Build a WP_Query date_query clause from a parsed range.
1351 *
1352 * `inclusive => true` matters: without it an end_date of 2026-09-04
1353 * excludes everything donated on 2026-09-04, so "up to today" silently
1354 * drops today's donations.
1355 *
1356 * @since 1.8.13
1357 *
1358 * @param array $dates Parsed start_date/end_date.
1359 * @return array
1360 */
1361 protected static function date_query_clause( array $dates ) {
1362 $clause = array( 'inclusive' => true );
1363
1364 if ( '' !== $dates['start_date'] ) {
1365 $clause['after'] = $dates['start_date'];
1366 }
1367
1368 if ( '' !== $dates['end_date'] ) {
1369 $clause['before'] = $dates['end_date'];
1370 }
1371
1372 return $clause;
1373 }
1374
1375 /**
1376 * A reusable empty-object input schema.
1377 *
1378 * @since 1.8.13
1379 *
1380 * @return array
1381 */
1382 protected static function no_input_schema() {
1383 return array(
1384 /*
1385 * Plain `object` here. register_ability() widens it to
1386 * [object, null] through allow_absent_input(), which does the
1387 * same for every schema that requires nothing - see that method
1388 * for why a bare call has to be valid.
1389 */
1390 'type' => 'object',
1391 'properties' => array(),
1392 'additionalProperties' => false,
1393 );
1394 }
1395 }
1396
1397 endif;
1398