PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/database/tables/donations.php +120 -1053 1.6.0 → 1.1.0 View file →
@@ -36,27 +36,11 @@
36 36 *
37 37 * @var int
38 38 * @since 0.0.1
39 39 */
40 - protected $table_version = 7;
40 + protected $table_version = 4;
41 41
42 42 /**
43 - * Valid donor-comment moderation statuses.
44 - *
45 - * `approved` comments are public; `pending` is awaiting review (only reachable
46 - * when the "Hold donor comments for review" setting is on); `rejected` is
47 - * hidden but kept, so a moderator's decision is not destructive.
48 - *
49 - * @var array<string>
50 - * @since 1.6.0
51 - */
52 - private static $valid_comment_statuses = [
53 - 'approved',
54 - 'pending',
55 - 'rejected',
56 - ];
57 -
58 - /**
59 43 * Valid payment statuses.
60 44 *
61 45 * @var array<string>
62 46 * @since 0.0.1
@@ -69,14 +53,8 @@
69 53 'refunded',
70 54 'partially_refunded',
71 55 'cancelled',
72 56 'suspicious',
73 - // Deliberately not 'failed'. A donor who closed the gateway window never
74 - // attempted a payment, and collapsing the two destroys the signal we most
75 - // need: our own capture failure rate. If abandonment and genuine failures
76 - // share a status, "most donors walk away at the gateway" (a product
77 - // problem) is indistinguishable from "our captures are breaking" (a bug).
78 - 'abandoned',
79 57 ];
80 58
81 59 /**
82 60 * Valid order columns.
@@ -139,12 +117,8 @@
139 117 'customer_id' => [
140 118 'type' => 'string',
141 119 'default' => '',
142 120 ],
143 - 'stripe_account_id' => [
144 - 'type' => 'string',
145 - 'default' => '',
146 - ],
147 121 'gateway' => [
148 122 'type' => 'string',
149 123 'default' => 'stripe',
150 124 ],
@@ -191,12 +165,8 @@
191 165 'donor_comment' => [
192 166 'type' => 'string',
193 167 'default' => '',
194 168 ],
195 - 'donor_comment_status' => [
196 - 'type' => 'string',
197 - 'default' => 'approved',
198 - ],
199 169 'receipt_sent' => [
200 170 'type' => 'boolean',
201 171 'default' => false,
202 172 ],
@@ -231,12 +201,8 @@
231 201 'import_source' => [
232 202 'type' => 'string',
233 203 'default' => '',
234 204 ],
235 - 'import_provenance' => [
236 - 'type' => 'string',
237 - 'default' => '',
238 - ],
239 205 'created_at' => [
240 206 'type' => 'datetime',
241 207 ],
242 208 'updated_at' => [
@@ -259,9 +225,8 @@
259 225 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
260 226 'currency VARCHAR(10) NOT NULL',
261 227 'transaction_id VARCHAR(255) NOT NULL',
262 228 'customer_id VARCHAR(50) NOT NULL',
263 - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'',
264 229 'gateway VARCHAR(20) NOT NULL',
265 230 'payment_status VARCHAR(50) NOT NULL',
266 231 'payment_mode VARCHAR(20) NOT NULL',
267 232 'donor_name VARCHAR(255) NOT NULL',
@@ -272,9 +237,8 @@
272 237 'subscription_id VARCHAR(255) NOT NULL',
273 238 'subscription_status VARCHAR(30) NOT NULL',
274 239 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
275 240 'donor_comment TEXT',
276 - 'donor_comment_status VARCHAR(20) NOT NULL DEFAULT \'approved\'',
277 241 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0',
278 242 'receipt_pdf_url VARCHAR(255) NOT NULL',
279 243 'donation_data LONGTEXT',
280 244 'log LONGTEXT',
@@ -281,10 +245,9 @@
281 245 'ip_address VARCHAR(45) NOT NULL',
282 246 'user_agent TEXT',
283 247 'referer_url TEXT',
284 248 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
285 - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
286 - 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\'',
249 + 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
287 250 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
288 251 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
289 252 'INDEX idx_campaign (campaign_id)',
290 253 'INDEX idx_donor (donor_id)',
@@ -295,10 +258,8 @@
295 258 'INDEX idx_subscription (subscription_id)',
296 259 'INDEX idx_subscription_status (subscription_status)',
297 260 'INDEX idx_parent_subscription (parent_subscription_id)',
298 261 'INDEX idx_import_source (import_source_id, import_source)',
299 - 'INDEX idx_import_provenance (import_source, import_provenance)',
300 - 'INDEX idx_stripe_account (stripe_account_id)',
301 262 ];
302 263 }
303 264
304 265 /**
@@ -305,32 +266,10 @@
305 266 * New columns added across versions.
306 267 *
307 268 * Version 2 added subscription support; version 4 added the
308 269 * source-agnostic pair `import_source_id` + `import_source` used by
309 - * the migration tool for duplicate detection and rollback; version 5
310 - * added `stripe_account_id` so donations record which connected Stripe
311 - * account processed them (multiple Stripe accounts support); version 6
312 - * added `import_provenance` — an indexed `(donation_post_id, source_campaign_id)`
313 - * key the Charitable importer dedupes on with a single indexed lookup per
314 - * row, instead of scanning + JSON-decoding every prior imported row per batch;
315 - * version 7 added `donor_comment_status`, defaulting to `approved` so
316 - * comments that predate moderation stay visible.
270 + * the migration tool for duplicate detection and rollback.
317 271 *
318 - * Version 7 rather than 6: `import_provenance` had already taken 6 on dev
319 - * while this branch was open, and the upgrade only runs when the number
320 - * increases (Database\Base::set_db_upgradable()). Leaving both columns on 6
321 - * would mean any site already upgraded to 6 never receives
322 - * `donor_comment_status`, while get_schema() still declares it and
323 - * prepare_data() names every declared column in the INSERT — so every
324 - * donation would fail with "Unknown column 'donor_comment_status'".
325 - *
326 - * No index accompanies `donor_comment_status`: it is `approved` on virtually
327 - * every row, so a `(campaign_id, donor_comment_status)` index measured ~3%
328 - * better than the existing `idx_campaign` on a 200k-row table and still
329 - * filesorted, while adding write cost to the plugin's hottest table. Its one
330 - * reader (Campaign_Stats::get_donor_comments()) is also behind a 5-minute
331 - * transient. Revisit only if that query shows up in real profiling.
332 - *
333 272 * {@inheritDoc}
334 273 *
335 274 * @since 1.0.0
336 275 */
@@ -339,199 +278,17 @@
339 278 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
340 279 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
341 280 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
342 281 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
343 - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id',
344 - 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\' AFTER import_source',
345 - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id',
346 - 'donor_comment_status VARCHAR(20) NOT NULL DEFAULT \'approved\' AFTER donor_comment',
282 + 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id',
347 283 'INDEX idx_subscription (subscription_id)',
348 284 'INDEX idx_subscription_status (subscription_status)',
349 285 'INDEX idx_parent_subscription (parent_subscription_id)',
350 286 'INDEX idx_import_source (import_source_id, import_source)',
351 - 'INDEX idx_import_provenance (import_source, import_provenance)',
352 - 'INDEX idx_stripe_account (stripe_account_id)',
353 287 ];
354 288 }
355 289
356 290 /**
357 - * One-time data migrations for the donations table.
358 - *
359 - * Each backfill is gated on the version being upgraded *into* (via
360 - * $this->prev_version) so it runs exactly once, on the upgrade that adds the
361 - * column, and is skipped on fresh installs (which create the column already
362 - * populated / empty as appropriate) and on later upgrades.
363 - *
364 - * @return void
365 - * @since 1.3.0
366 - */
367 - public function run_data_migrations() {
368 - // A failed CREATE/ALTER earlier in this upgrade already cleared the flag;
369 - // the column may not exist, so don't run an UPDATE against it.
370 - if ( ! $this->db_upgradable ) {
371 - return;
372 - }
373 -
374 - if ( $this->prev_version < 5 ) {
375 - $this->backfill_stripe_account_id();
376 - }
377 -
378 - if ( $this->prev_version < 6 ) {
379 - $this->backfill_import_provenance();
380 - }
381 - }
382 -
383 - /**
384 - * Backfill `stripe_account_id` on the upgrade into v5.
385 - *
386 - * Before multi-account there could only be a single connected Stripe account,
387 - * so every pre-v5 Stripe donation belongs to the current (single) default
388 - * account. Backfill it so refunds and subscription lifecycle actions keep
389 - * routing to the originating account after a second account is connected and
390 - * the default is switched. Idempotent (touches only empty rows).
391 - *
392 - * @return void
393 - * @since 1.3.0
394 - */
395 - private function backfill_stripe_account_id() {
396 - if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) {
397 - return;
398 - }
399 -
400 - // Runs during the v5 DB upgrade — before any second account can be
401 - // connected via the UI — so the default is still the single legacy account.
402 - $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id();
403 - if ( ! is_string( $account_id ) || '' === $account_id ) {
404 - return;
405 - }
406 -
407 - global $wpdb;
408 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable.
409 - $result = $wpdb->query(
410 - $wpdb->prepare(
411 - 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )',
412 - $this->get_tablename(),
413 - $account_id,
414 - 'stripe',
415 - ''
416 - )
417 - );
418 -
419 - // A transient failure (e.g. lock wait timeout on a busy table) must not
420 - // persist the new version: `prev_version >= 5` would then skip this
421 - // one-shot backfill forever. Leaving the version unwritten makes the
422 - // idempotent sequence retry on the next request.
423 - if ( false === $result ) {
424 - $this->db_upgradable = false;
425 - }
426 - }
427 -
428 - /**
429 - * Backfill `import_provenance` on the upgrade into v6.
430 - *
431 - * The Charitable importer moved its dedupe key out of a per-batch scan of
432 - * `donation_data` and onto this indexed column. Rows imported before v6 have
433 - * an empty key, so a re-import after upgrade would fail to match them and
434 - * insert duplicates. Reconstruct the key from the stored
435 - * `donation_data.charitable` block — the same `(donation_post_id,
436 - * source_campaign_id | campaign label)` rule the importer keys on — for every
437 - * pre-v6 one-time Charitable row. Chunked so a large migrated table does not
438 - * exhaust memory during the upgrade; idempotent (touches only empty keys).
439 - *
440 - * @return void
441 - * @since 1.5.1
442 - */
443 - private function backfill_import_provenance() {
444 - global $wpdb;
445 - $table = $this->get_tablename();
446 -
447 - do {
448 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time chunked backfill of a newly added column; not cacheable.
449 - $rows = $wpdb->get_results(
450 - $wpdb->prepare(
451 - 'SELECT id, donation_data FROM %i WHERE import_source = %s AND donation_type != %s AND import_provenance = %s LIMIT 500',
452 - $table,
453 - 'charitable',
454 - 'recurring',
455 - ''
456 - ),
457 - ARRAY_A
458 - );
459 -
460 - if ( empty( $rows ) || ! is_array( $rows ) ) {
461 - break;
462 - }
463 -
464 - $fetched = count( $rows );
465 -
466 - foreach ( $rows as $row ) {
467 - $data = json_decode( (string) ( $row['donation_data'] ?? '' ), true );
468 - $c = is_array( $data ) && isset( $data['charitable'] ) && is_array( $data['charitable'] ) ? $data['charitable'] : [];
469 - $post = isset( $c['donation_post_id'] ) ? absint( $c['donation_post_id'] ) : 0;
470 -
471 - // A row with no resolvable donation post can never be dedupe-matched
472 - // or rolled back; leave its key empty (it is already un-reversible)
473 - // rather than fabricate a colliding "0:…" key.
474 - if ( $post <= 0 ) {
475 - $key = '';
476 - } else {
477 - $campaign = isset( $c['source_campaign_id'] ) ? absint( $c['source_campaign_id'] ) : 0;
478 - // DB-path rows carry `campaign_name`; CSV-path rows carry
479 - // `campaign_title`. Either serves as the blank-id fallback label.
480 - $label = '';
481 - if ( isset( $c['campaign_title'] ) && is_scalar( $c['campaign_title'] ) ) {
482 - $label = (string) $c['campaign_title'];
483 - } elseif ( isset( $c['campaign_name'] ) && is_scalar( $c['campaign_name'] ) ) {
484 - $label = (string) $c['campaign_name'];
485 - }
486 - $key = self::build_provenance_key( $post, $campaign, $label );
487 - }
488 -
489 - if ( '' === $key ) {
490 - // Nothing to store, but stamp a sentinel so the WHERE clause
491 - // stops selecting this row and the loop terminates.
492 - $key = '-';
493 - }
494 -
495 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill update; not cacheable.
496 - $wpdb->update( $table, [ 'import_provenance' => $key ], [ 'id' => absint( $row['id'] ) ] );
497 - }
498 - } while ( 500 === $fetched );
499 - }
500 -
501 - /**
502 - * Build the indexed dedupe key for a Charitable donation row.
503 - *
504 - * `"<donation_post_id>:<token>"`, where the token is the numeric campaign id
505 - * when present, otherwise a short hash of the campaign label (so the
506 - * per-campaign rows of a multi-campaign donation whose export left the
507 - * Campaign ID cell blank stay distinct instead of collapsing to "<post>:0"),
508 - * otherwise "0". Static so both the importer (Provenance_Dedupe) and the v6
509 - * backfill derive identical keys.
510 - *
511 - * @param int $donation_post_id Charitable donation post ID.
512 - * @param int $source_campaign_id Charitable campaign ID (0 when absent).
513 - * @param string $campaign_label Campaign title/name fallback (optional).
514 - * @return string
515 - * @since 1.5.1
516 - */
517 - public static function build_provenance_key( $donation_post_id, $source_campaign_id, $campaign_label = '' ) {
518 - $post = absint( $donation_post_id );
519 - $cid = absint( $source_campaign_id );
520 - $label = trim( (string) $campaign_label );
521 -
522 - if ( $cid > 0 ) {
523 - $token = (string) $cid;
524 - } elseif ( '' !== $label ) {
525 - $token = 't:' . substr( md5( strtolower( $label ) ), 0, 12 );
526 - } else {
527 - $token = '0';
528 - }
529 -
530 - return $post . ':' . $token;
531 - }
532 -
533 - /**
534 291 * Add a new donation record.
535 292 *
536 293 * @param array<mixed> $data Donation data to insert.
537 294 * @return int|false The donation ID on success, false on error.
@@ -562,48 +319,16 @@
562 319 $donation_id = absint( $result );
563 320 $donation = self::get( $donation_id );
564 321 $donation = is_array( $donation ) ? $donation : [];
565 322
566 - // Curated payload (internal/gateway-only columns omitted; donor
567 - // identity included, see the note in get_integration_payload())
568 - // shared by every hook below.
569 - $payload = self::get_integration_payload( $donation );
570 -
571 323 /**
572 324 * Fires when a new donation record is created.
573 325 *
574 326 * @param int $donation_id Newly created donation ID.
575 - * @param array<mixed> $donation Curated donation payload.
327 + * @param array<mixed> $donation Complete donation record.
576 328 * @since 1.1.0
577 329 */
578 - do_action( 'suredonation_donation_created', $donation_id, $payload );
579 -
580 - /**
581 - * Fires when a new donation record is created.
582 - *
583 - * Mirrors `suredonation_donation_created`; the OttoKit (formerly
584 - * SureTriggers) "New Donation" trigger listens on this hook name.
585 - *
586 - * @param int $donation_id Newly created donation ID.
587 - * @param array<mixed> $donation Curated donation payload.
588 - * @since 1.2.0
589 - */
590 - do_action( 'suredonation_new_donation', $donation_id, $payload );
591 -
592 - // Some donations are created already-completed rather than
593 - // transitioning through update() — recurring renewals and
594 - // admin-recorded paid donations. Fire the completion event here
595 - // too so integration hooks still see them.
596 - if ( 'completed' === ( $data['payment_status'] ?? '' ) ) {
597 - /**
598 - * Fires when a donation payment is completed.
599 - *
600 - * @param int $donation_id Donation ID.
601 - * @param array<mixed> $donation Curated donation payload after insertion.
602 - * @since 1.2.0
603 - */
604 - do_action( 'suredonation_donation_completed', $donation_id, $payload );
605 - }
330 + do_action( 'suredonation_donation_created', $donation_id, $donation );
606 331 }
607 332 }
608 333
609 334 return $result;
@@ -621,17 +346,15 @@
621 346 if ( empty( $donation_id ) ) {
622 347 return false;
623 348 }
624 349
625 - // Capture the current status and refunded amount before the write so
626 - // integration hooks (e.g. OttoKit) can react to the transition and to
627 - // refund events, not just the resulting values.
628 - $old_status = '';
629 - $old_refunded = 0.0;
630 - if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) {
631 - $existing = self::get( absint( $donation_id ) );
632 - $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
633 - $old_refunded = is_array( $existing ) ? Helper::get_float_value( $existing['refunded_amount'] ?? 0 ) : 0.0;
350 + // Capture the current status before the write so integration hooks
351 + // (e.g. OttoKit) can react to the actual status transition, not just
352 + // the resulting value.
353 + $old_status = '';
354 + if ( isset( $data['payment_status'] ) ) {
355 + $existing = self::get( absint( $donation_id ) );
356 + $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
634 357 }
635 358
636 359 // Set updated_at.
637 360 $data['updated_at'] = current_time( 'mysql' );
@@ -641,22 +364,22 @@
641 364 // Status/amount changes (e.g. a webhook completing a pending donation)
642 365 // affect the cached stats and donor lists.
643 366 if ( $updated ) {
644 367 $donation = self::get( absint( $donation_id ) );
645 - $donation = is_array( $donation ) ? $donation : [];
646 368 if ( ! empty( $donation['campaign_id'] ) ) {
647 369 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
648 370 }
649 371
650 - // Curated payload (internal/gateway-only columns omitted; donor
651 - // identity included, see the note in get_integration_payload())
652 - // shared by every hook below.
653 - $payload = self::get_integration_payload( $donation );
654 -
372 + // Notify integration hooks about a genuine status transition.
373 + // Fired from update() — the single choke point every status write
374 + // passes through (update_status() delegates here, as do the payment
375 + // frontends and webhooks) — so all transitions are caught.
655 376 if ( isset( $data['payment_status'] ) ) {
656 377 $new_status = Helper::get_string_value( $data['payment_status'] );
657 378
658 379 if ( $new_status !== $old_status ) {
380 + $donation = is_array( $donation ) ? $donation : [];
381 +
659 382 /**
660 383 * Fires when a donation's payment status changes.
661 384 *
662 385 * @param int $donation_id Donation ID.
@@ -661,51 +384,14 @@
661 384 *
662 385 * @param int $donation_id Donation ID.
663 386 * @param string $new_status New payment status.
664 387 * @param string $old_status Previous payment status (empty string if unknown).
665 - * @param array<mixed> $donation Curated donation payload after the update.
388 + * @param array<mixed> $donation Complete donation record after the update.
666 389 * @since 1.1.0
667 390 */
668 - do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload );
669 -
670 - // Fire the completion event for any genuine transition into
671 - // 'completed' — including admin review states (suspicious,
672 - // cancelled) — but never for refund reversals that restore
673 - // the 'completed' status (refunded/partially_refunded ->
674 - // completed), which would replay the completion automation.
675 - if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) {
676 - /**
677 - * Fires when a donation payment is completed.
678 - *
679 - * @param int $donation_id Donation ID.
680 - * @param array<mixed> $donation Curated donation payload after the update.
681 - * @since 1.2.0
682 - */
683 - do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload );
684 - }
391 + do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $donation );
685 392 }
686 393 }
687 -
688 - // A rise in refunded_amount means a refund was processed. Keying off
689 - // the amount (not the status string) catches repeat partial refunds
690 - // that leave the status as partially_refunded, and excludes refund
691 - // reversals where the amount drops.
692 - if ( isset( $data['refunded_amount'] ) ) {
693 - $new_refunded = Helper::get_float_value( $data['refunded_amount'] );
694 -
695 - if ( $new_refunded - $old_refunded > 0.0001 ) {
696 - /**
697 - * Fires when a donation is refunded, fully or partially.
698 - *
699 - * @param int $donation_id Donation ID.
700 - * @param float $refund_amount Amount refunded in this event.
701 - * @param float $total_refunded Cumulative amount refunded to date.
702 - * @param array<mixed> $donation Curated donation payload after the update.
703 - * @since 1.2.0
704 - */
705 - do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload );
706 - }
707 - }
708 394 }
709 395
710 396 return $updated;
711 397 }
@@ -710,82 +396,8 @@
710 396 return $updated;
711 397 }
712 398
713 399 /**
714 - * Build a curated donation payload for integration hooks.
715 - *
716 - * Trims the raw database row to the fields advertised in the OttoKit embed
717 - * `sample_response`, omitting internal and gateway-only columns that must not
718 - * leave the site (ip_address, user_agent, referer_url, the admin `log`, the
719 - * gateway `customer_id`, and the full `donation_data` submission). Monetary
720 - * values are cast to float to match the sample the automation builder maps
721 - * against (the raw column is a DECIMAL string). Shared by every `do_action`
722 - * in add()/update() so no listener — OttoKit or otherwise — receives the raw
723 - * row.
724 - *
725 - * Anonymous donations carry their real donor identity here. The anonymous
726 - * checkbox is a display-only flag — the data is stored and processed as
727 - * usual, and only the public donor wall / recent donations / top donors mask
728 - * it. Automations that need to treat anonymous donors differently branch on
729 - * the `is_anonymous` field in this payload; blanking the identity instead
730 - * would silently break receipting and CRM sync for those donations.
731 - *
732 - * @param array<string,mixed> $donation Raw donation record from self::get().
733 - * @return array<string,mixed> Curated, integration-safe payload.
734 - * @since 1.2.0
735 - */
736 - public static function get_integration_payload( $donation ) {
737 - if ( ! is_array( $donation ) ) {
738 - return [];
739 - }
740 -
741 - $is_anonymous = ! empty( $donation['is_anonymous'] );
742 -
743 - $payload = [
744 - 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0,
745 - 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0,
746 - 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0,
747 - 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0,
748 - 'donor_name' => Helper::get_string_value( $donation['donor_name'] ?? '' ),
749 - 'donor_email' => Helper::get_string_value( $donation['donor_email'] ?? '' ),
750 - 'donor_phone' => Helper::get_string_value( $donation['donor_phone'] ?? '' ),
751 - 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
752 - 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
753 - 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
754 - 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ),
755 - 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ),
756 - 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ),
757 - 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ),
758 - 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ),
759 - 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ),
760 - 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
761 - 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
762 - 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
763 - 'donor_comment_status' => Helper::get_string_value( $donation['donor_comment_status'] ?? '' ),
764 - 'is_anonymous' => $is_anonymous,
765 - 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ),
766 - 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ),
767 - ];
768 -
769 - /**
770 - * Filter the curated donation payload passed to every integration hook.
771 - *
772 - * The payload carries the donor's real identity even for anonymous
773 - * donations, because the anonymous checkbox only masks public donor
774 - * lists — automations still need a usable record, and they can branch on
775 - * the `is_anonymous` field. A site with a stricter policy (for example an
776 - * automation that posts donor names somewhere public) can use this filter
777 - * to blank or drop fields before they reach OttoKit or any third-party
778 - * listener.
779 - *
780 - * @param array<string,mixed> $payload Curated payload.
781 - * @param array<string,mixed> $donation Raw donation record.
782 - * @since 1.4.0
783 - */
784 - return apply_filters( 'suredonation_integration_payload', $payload, $donation );
785 - }
786 -
787 - /**
788 400 * Get a single donation by ID.
789 401 *
790 402 * @param int $donation_id Donation ID.
791 403 * @return array<mixed>|null Donation data or null if not found.
@@ -905,14 +517,8 @@
905 517 // Build query based on filters.
906 518 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
907 519 // They are shown alongside parent subscriptions so admins can see all transaction activity.
908 520 // Renewals are also accessible from the parent donation's subscription detail billing history.
909 - // With no status filter, abandoned rows are left out: they are kept as
910 - // funnel data (a campaign with 40 starts against 3 completions has
911 - // learned something real) but a donor who walked away from the gateway is
912 - // not a transaction an admin needs in their default view. Asking for the
913 - // status explicitly still returns them, and count_admin_list() mirrors
914 - // this or the pagination totals disagree with the rows.
915 521 $has_status = 'all' !== $status;
916 522 $has_campaign = $campaign_id > 0;
917 523 $has_search = ! empty( $search );
918 524 $is_asc = 'ASC' === $order;
@@ -1014,9 +620,9 @@
1014 620 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1015 621 $results = $is_asc
1016 622 ? $wpdb->get_results(
1017 623 $wpdb->prepare(
1018 - 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
624 + 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
1019 625 $table,
1020 626 absint( $campaign_id ),
1021 627 $search_term,
1022 628 $search_term,
@@ -1028,9 +634,9 @@
1028 634 ARRAY_A
1029 635 )
1030 636 : $wpdb->get_results(
1031 637 $wpdb->prepare(
1032 - 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
638 + 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
1033 639 $table,
1034 640 absint( $campaign_id ),
1035 641 $search_term,
1036 642 $search_term,
@@ -1068,9 +674,9 @@
1068 674 } elseif ( $has_campaign ) {
1069 675 $results = $is_asc
1070 676 ? $wpdb->get_results(
1071 677 $wpdb->prepare(
1072 - 'SELECT * FROM %i WHERE campaign_id = %d AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
678 + 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
1073 679 $table,
1074 680 absint( $campaign_id ),
1075 681 $orderby,
1076 682 absint( $offset ),
@@ -1079,9 +685,9 @@
1079 685 ARRAY_A
1080 686 )
1081 687 : $wpdb->get_results(
1082 688 $wpdb->prepare(
1083 - 'SELECT * FROM %i WHERE campaign_id = %d AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
689 + 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
1084 690 $table,
1085 691 absint( $campaign_id ),
1086 692 $orderby,
1087 693 absint( $offset ),
@@ -1093,9 +699,9 @@
1093 699 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1094 700 $results = $is_asc
1095 701 ? $wpdb->get_results(
1096 702 $wpdb->prepare(
1097 - 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
703 + 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
1098 704 $table,
1099 705 $search_term,
1100 706 $search_term,
1101 707 $search_term,
@@ -1106,9 +712,9 @@
1106 712 ARRAY_A
1107 713 )
1108 714 : $wpdb->get_results(
1109 715 $wpdb->prepare(
1110 - 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
716 + 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
1111 717 $table,
1112 718 $search_term,
1113 719 $search_term,
1114 720 $search_term,
@@ -1121,9 +727,9 @@
1121 727 } else {
1122 728 $results = $is_asc
1123 729 ? $wpdb->get_results(
1124 730 $wpdb->prepare(
1125 - 'SELECT * FROM %i WHERE payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
731 + 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
1126 732 $table,
1127 733 $orderby,
1128 734 absint( $offset ),
1129 735 absint( $limit )
@@ -1131,9 +737,9 @@
1131 737 ARRAY_A
1132 738 )
1133 739 : $wpdb->get_results(
1134 740 $wpdb->prepare(
1135 - 'SELECT * FROM %i WHERE payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
741 + 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
1136 742 $table,
1137 743 $orderby,
1138 744 absint( $offset ),
1139 745 absint( $limit )
@@ -1151,147 +757,8 @@
1151 757 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1152 758 }
1153 759
1154 760 /**
1155 - * Build the WHERE clause + prepare-args for an export query.
1156 - *
1157 - * Always constrains to one-time donations (subscription_id = '' AND
1158 - * parent_subscription_id = 0) so recurring/renewal rows never leak into the
1159 - * free export — recurring export is Pro (see the Import & Export spec, #237).
1160 - * Optional filters: status, campaign_id, payment_mode, gateway, and a
1161 - * created_at date range (after / before).
1162 - *
1163 - * @param array<string, mixed> $filters Filter map.
1164 - * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order.
1165 - * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values.
1166 - * @since 1.3.0
1167 - */
1168 - private static function build_export_where( $filters, &$args ) {
1169 - $conditions = [ '1=1' ];
1170 -
1171 - /**
1172 - * Whether the donations export is restricted to one-time donations.
1173 - *
1174 - * True by default so recurring/renewal rows never leak into the free
1175 - * export; Pro returns false to include subscriptions and renewals.
1176 - *
1177 - * @param bool $one_time_only Whether to restrict to one-time donations.
1178 - */
1179 - if ( apply_filters( 'suredonation_export_one_time_only', true ) ) {
1180 - $conditions[] = 'subscription_id = %s';
1181 - $conditions[] = 'parent_subscription_id = %d';
1182 - $args[] = '';
1183 - $args[] = 0;
1184 - }
1185 -
1186 - $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) );
1187 - if ( '' !== $status && 'all' !== $status ) {
1188 - $conditions[] = 'payment_status = %s';
1189 - $args[] = $status;
1190 - }
1191 -
1192 - $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) );
1193 - if ( $campaign_id > 0 ) {
1194 - $conditions[] = 'campaign_id = %d';
1195 - $args[] = $campaign_id;
1196 - }
1197 -
1198 - $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) );
1199 - if ( '' !== $payment_mode ) {
1200 - $conditions[] = 'payment_mode = %s';
1201 - $args[] = $payment_mode;
1202 - }
1203 -
1204 - $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) );
1205 - if ( '' !== $gateway ) {
1206 - $conditions[] = 'gateway = %s';
1207 - $args[] = $gateway;
1208 - }
1209 -
1210 - $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) );
1211 - if ( '' !== $after ) {
1212 - $conditions[] = 'created_at >= %s';
1213 - $args[] = $after;
1214 - }
1215 -
1216 - $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) );
1217 - if ( '' !== $before ) {
1218 - // A date-only `before` (Y-m-d) coerces to 00:00:00, which would
1219 - // silently drop donations made later that same day. Normalize to
1220 - // end-of-day so the whole end date is inclusive; full datetimes
1221 - // are left untouched.
1222 - if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) {
1223 - $before .= ' 23:59:59';
1224 - }
1225 - $conditions[] = 'created_at <= %s';
1226 - $args[] = $before;
1227 - }
1228 -
1229 - return implode( ' AND ', $conditions );
1230 - }
1231 -
1232 - /**
1233 - * Count one-time donations matching the export filters.
1234 - *
1235 - * @param array<string, mixed> $filters Filter map (see build_export_where()).
1236 - * @return int Matching row count.
1237 - * @since 1.3.0
1238 - */
1239 - public static function count_for_export( $filters = [] ) {
1240 - $instance = self::get_instance();
1241 - global $wpdb;
1242 - $table = $instance->get_tablename();
1243 -
1244 - $args = [];
1245 - $where = self::build_export_where( $filters, $args );
1246 -
1247 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data.
1248 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
1249 - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) );
1250 - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1251 -
1252 - return is_numeric( $count ) ? (int) $count : 0;
1253 - }
1254 -
1255 - /**
1256 - * Fetch one-time donations for export, decoded.
1257 - *
1258 - * @param array<string, mixed> $filters Filter map (see build_export_where()).
1259 - * @param int $limit Max rows to return (0 = no limit).
1260 - * @param int $offset Offset for pagination.
1261 - * @return array<int, array<string, mixed>> Decoded donation rows.
1262 - * @since 1.3.0
1263 - */
1264 - public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) {
1265 - $instance = self::get_instance();
1266 - global $wpdb;
1267 - $table = $instance->get_tablename();
1268 -
1269 - $args = [];
1270 - $where = self::build_export_where( $filters, $args );
1271 -
1272 - $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC";
1273 - $prepare_args = array_merge( [ $table ], $args );
1274 -
1275 - if ( $limit > 0 ) {
1276 - $sql .= ' LIMIT %d, %d';
1277 - $prepare_args[] = absint( $offset );
1278 - $prepare_args[] = absint( $limit );
1279 - }
1280 -
1281 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data.
1282 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $sql is assembled only from static placeholder fragments; every value is passed through prepare args.
1283 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A );
1284 - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1285 -
1286 - if ( ! $results || ! is_array( $results ) ) {
1287 - return [];
1288 - }
1289 -
1290 - return array_map( [ $instance, 'decode_by_datatype' ], $results );
1291 - }
1292 -
1293 - /**
1294 761 * Get donations by status with pagination.
1295 762 *
1296 763 * @param string $status Payment status.
1297 764 * @param int $limit Number of records to return.
@@ -1431,15 +898,13 @@
1431 898
1432 899 /**
1433 900 * Get donations by donor email.
1434 901 *
1435 - * @param string $email Donor email.
1436 - * @param int $limit Max rows to return; 0 (default) returns all rows.
1437 - * @param int $offset Row offset, applied only when $limit > 0.
902 + * @param string $email Donor email.
1438 903 * @return array<mixed> Array of donations.
1439 904 * @since 0.0.1
1440 905 */
1441 - public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
906 + public static function get_by_donor_email( $email ) {
1442 907 if ( empty( $email ) ) {
1443 908 return [];
1444 909 }
1445 910
@@ -1445,35 +910,18 @@
1445 910
1446 911 $instance = self::get_instance();
1447 912 global $wpdb;
1448 913
1449 - $limit = max( 0, (int) $limit );
1450 - $offset = max( 0, (int) $offset );
914 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
915 + $results = $wpdb->get_results(
916 + $wpdb->prepare(
917 + 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC',
918 + $instance->get_tablename(),
919 + sanitize_email( $email )
920 + ),
921 + ARRAY_A
922 + );
1451 923
1452 - if ( $limit > 0 ) {
1453 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1454 - $results = $wpdb->get_results(
1455 - $wpdb->prepare(
1456 - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d',
1457 - $instance->get_tablename(),
1458 - sanitize_email( $email ),
1459 - $limit,
1460 - $offset
1461 - ),
1462 - ARRAY_A
1463 - );
1464 - } else {
1465 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1466 - $results = $wpdb->get_results(
1467 - $wpdb->prepare(
1468 - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC',
1469 - $instance->get_tablename(),
1470 - sanitize_email( $email )
1471 - ),
1472 - ARRAY_A
1473 - );
1474 - }
1475 -
1476 924 if ( ! $results || ! is_array( $results ) ) {
1477 925 return [];
1478 926 }
1479 927
@@ -1512,50 +960,8 @@
1512 960 return $instance->decode_by_datatype( $result );
1513 961 }
1514 962
1515 963 /**
1516 - * Get donation by gateway subscription ID.
1517 - *
1518 - * Recurring handling lives in Pro, but the table (and its
1519 - * `idx_subscription` index) belongs here, so free-side code that only needs
1520 - * to resolve a row — such as the PayPal webhook listener recording why a
1521 - * delivery was rejected — can look one up without depending on Pro.
1522 - *
1523 - * Renewals carry the same `subscription_id` as the subscription they belong
1524 - * to, so the column is deliberately not unique. The parent row (the one with
1525 - * no `parent_subscription_id`) is preferred and the oldest id breaks any
1526 - * remaining tie, so the result does not depend on the query plan.
1527 - *
1528 - * @param string $subscription_id Gateway subscription ID.
1529 - * @return array<string, mixed>|null Donation data or null if not found.
1530 - * @since 1.4.0
1531 - */
1532 - public static function get_by_subscription_id( $subscription_id ) {
1533 - if ( empty( $subscription_id ) ) {
1534 - return null;
1535 - }
1536 -
1537 - $instance = self::get_instance();
1538 - global $wpdb;
1539 -
1540 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1541 - $result = $wpdb->get_row(
1542 - $wpdb->prepare(
1543 - 'SELECT * FROM %i WHERE subscription_id = %s ORDER BY parent_subscription_id ASC, id ASC LIMIT 1',
1544 - $instance->get_tablename(),
1545 - sanitize_text_field( $subscription_id )
1546 - ),
1547 - ARRAY_A
1548 - );
1549 -
1550 - if ( ! $result ) {
1551 - return null;
1552 - }
1553 -
1554 - return $instance->decode_by_datatype( $result );
1555 - }
1556 -
1557 - /**
1558 964 * Get total donations count (no filters).
1559 965 *
1560 966 * @return int Total count.
1561 967 * @since 0.0.1
@@ -1598,52 +1004,8 @@
1598 1004 return is_numeric( $count ) ? (int) $count : 0;
1599 1005 }
1600 1006
1601 1007 /**
1602 - * Get the count of completed, live-mode donations.
1603 - *
1604 - * Used to gate the review admin notice: a completed live donation is the
1605 - * signal that the site has taken a genuine (non-test) donation.
1606 - *
1607 - * @param string $gateway Optional gateway to scope the count to, e.g. 'paypal'.
1608 - * Empty counts every gateway.
1609 - * @return int Count of completed live donations.
1610 - * @since 1.2.0
1611 - * @since 1.5.1 Optionally scoped to one gateway.
1612 - */
1613 - public static function count_live_completed( $gateway = '' ) {
1614 - $instance = self::get_instance();
1615 - global $wpdb;
1616 -
1617 - if ( '' !== $gateway ) {
1618 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1619 - $count = $wpdb->get_var(
1620 - $wpdb->prepare(
1621 - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s AND gateway = %s',
1622 - $instance->get_tablename(),
1623 - 'completed',
1624 - 'live',
1625 - $gateway
1626 - )
1627 - );
1628 -
1629 - return is_numeric( $count ) ? (int) $count : 0;
1630 - }
1631 -
1632 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1633 - $count = $wpdb->get_var(
1634 - $wpdb->prepare(
1635 - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s',
1636 - $instance->get_tablename(),
1637 - 'completed',
1638 - 'live'
1639 - )
1640 - );
1641 -
1642 - return is_numeric( $count ) ? (int) $count : 0;
1643 - }
1644 -
1645 - /**
1646 1008 * Get total donations count by campaign.
1647 1009 *
1648 1010 * @param int $campaign_id Campaign ID.
1649 1011 * @return int Total count.
@@ -1705,53 +1067,58 @@
1705 1067 return self::count_all();
1706 1068 }
1707 1069
1708 1070 /**
1709 - * Build the currency / payment-mode scope for a reporting query.
1071 + * Get campaign statistics.
1710 1072 *
1711 - * Amounts in different currencies cannot be summed into one figure, and test
1712 - * donations must not be counted alongside live ones. Both filters are opt-in
1713 - * so existing callers keep their behaviour; the abilities always pass them.
1714 - *
1715 - * @param string $currency Currency code ('' for no filter).
1716 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1717 - * @param array<mixed> $args Prepare args, appended to by reference.
1718 - * @return string SQL fragment beginning with " AND ", or '' when unscoped.
1719 - * @since 1.5.0
1073 + * @param int $campaign_id Campaign ID.
1074 + * @return array<string,mixed> Campaign statistics.
1075 + * @since 0.0.1
1720 1076 */
1721 - private static function scope_fragment( $currency, $payment_mode, array &$args ) {
1722 - $extra = '';
1077 + public static function get_campaign_stats( $campaign_id ) {
1078 + $instance = self::get_instance();
1079 + global $wpdb;
1723 1080
1724 - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1725 - if ( '' !== $currency ) {
1726 - $extra .= ' AND currency = %s';
1727 - $args[] = $currency;
1728 - }
1081 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1082 + $stats = $wpdb->get_row(
1083 + $wpdb->prepare(
1084 + "SELECT
1085 + COUNT(*) as donation_count,
1086 + COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1087 + COUNT(DISTINCT donor_email) as unique_donors,
1088 + COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1089 + COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1090 + FROM %i
1091 + WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1092 + $instance->get_tablename(),
1093 + absint( $campaign_id )
1094 + ),
1095 + ARRAY_A
1096 + );
1729 1097
1730 - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1731 - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1732 - $extra .= ' AND payment_mode = %s';
1733 - $args[] = $payment_mode;
1734 - }
1098 + return $stats ? $stats : [
1099 + 'donation_count' => 0,
1100 + 'total_raised' => 0,
1101 + 'unique_donors' => 0,
1102 + 'average_donation' => 0,
1103 + 'largest_donation' => 0,
1104 + ];
1105 + }
1735 1106
1736 - return $extra;
1737 - }
1738 1107 /**
1739 1108 * Get global dashboard statistics.
1740 1109 *
1741 - * @param string $currency Currency code to scope to ('' for no filter).
1742 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1743 1110 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1744 1111 * @since 0.0.1
1745 1112 */
1746 - public static function get_dashboard_stats( $currency = '', $payment_mode = '' ) {
1113 + public static function get_dashboard_stats() {
1747 1114 $instance = self::get_instance();
1748 1115 global $wpdb;
1749 1116
1750 - $args = [ $instance->get_tablename() ];
1751 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1752 -
1753 - $sql = "SELECT
1117 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1118 + $stats = $wpdb->get_row(
1119 + $wpdb->prepare(
1120 + "SELECT
1754 1121 COUNT(*) as total_donations,
1755 1122 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1756 1123 COUNT(DISTINCT donor_email) as unique_donors,
1757 1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
@@ -1756,14 +1123,14 @@
1756 1123 COUNT(DISTINCT donor_email) as unique_donors,
1757 1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1758 1125 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1759 1126 FROM %i
1760 - WHERE payment_status IN ('completed', 'partially_refunded')
1761 - {$extra}";
1127 + WHERE payment_status IN ('completed', 'partially_refunded')",
1128 + $instance->get_tablename()
1129 + ),
1130 + ARRAY_A
1131 + );
1762 1132
1763 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args.
1764 - $stats = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A );
1765 -
1766 1133 return $stats ? $stats : [
1767 1134 'total_donations' => 0,
1768 1135 'total_raised' => 0,
1769 1136 'unique_donors' => 0,
@@ -1774,31 +1141,26 @@
1774 1141
1775 1142 /**
1776 1143 * Get recent donations globally (all campaigns).
1777 1144 *
1778 - * @param int $limit Number of donations to retrieve.
1779 - * @param string $currency Currency code to scope to ('' for no filter).
1780 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1145 + * @param int $limit Number of donations to retrieve.
1781 1146 * @return array<int, array<string, mixed>> Array of recent donations.
1782 1147 * @since 0.0.1
1783 1148 */
1784 - public static function get_recent_donations_global( $limit = 5, $currency = '', $payment_mode = '' ) {
1149 + public static function get_recent_donations_global( $limit = 5 ) {
1785 1150 $instance = self::get_instance();
1786 1151 global $wpdb;
1787 1152
1788 - $args = [ $instance->get_tablename() ];
1789 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1790 - $args[] = absint( $limit );
1153 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1154 + $results = $wpdb->get_results(
1155 + $wpdb->prepare(
1156 + "SELECT * FROM %i WHERE payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1157 + $instance->get_tablename(),
1158 + absint( $limit )
1159 + ),
1160 + ARRAY_A
1161 + );
1791 1162
1792 - $sql = "SELECT * FROM %i
1793 - WHERE payment_status IN ('completed', 'partially_refunded')
1794 - {$extra}
1795 - ORDER BY created_at DESC
1796 - LIMIT %d";
1797 -
1798 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args.
1799 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1800 -
1801 1163 if ( ! $results || ! is_array( $results ) ) {
1802 1164 return [];
1803 1165 }
1804 1166
@@ -1807,45 +1169,35 @@
1807 1169
1808 1170 /**
1809 1171 * Get top campaigns by donations.
1810 1172 *
1811 - * @param int $limit Number of campaigns to retrieve.
1812 - * @param string $currency Currency code to scope to ('' for no filter).
1813 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1173 + * @param int $limit Number of campaigns to retrieve.
1814 1174 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1815 1175 * @since 0.0.1
1816 1176 */
1817 - public static function get_top_campaigns( $limit = 5, $currency = '', $payment_mode = '' ) {
1177 + public static function get_top_campaigns( $limit = 5 ) {
1818 1178 $instance = self::get_instance();
1819 1179 global $wpdb;
1820 1180
1821 - $args = [ $instance->get_tablename(), SUREDONATION_POST_TYPE ];
1822 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1823 - $args[] = absint( $limit );
1824 -
1825 - // The join is what makes LIMIT meaningful: orphaned campaign_ids (post
1826 - // deleted, donations kept) still carry donations, so filtering them in
1827 - // PHP after a SQL LIMIT returned fewer than the requested top-N while
1828 - // valid campaigns sat below the cut.
1829 - $sql = "SELECT
1830 - d.campaign_id,
1831 - p.post_title AS campaign_title,
1181 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1182 + $results = $wpdb->get_results(
1183 + $wpdb->prepare(
1184 + "SELECT
1185 + campaign_id,
1832 1186 COUNT(*) as donation_count,
1833 1187 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1834 1188 COUNT(DISTINCT donor_email) as unique_donors
1835 - FROM %i AS d
1836 - INNER JOIN {$wpdb->posts} AS p
1837 - ON p.ID = d.campaign_id
1838 - AND p.post_type = %s
1189 + FROM %i
1839 1190 WHERE payment_status IN ('completed', 'partially_refunded')
1840 - {$extra}
1841 - GROUP BY d.campaign_id
1191 + GROUP BY campaign_id
1842 1192 ORDER BY total_raised DESC
1843 - LIMIT %d";
1193 + LIMIT %d",
1194 + $instance->get_tablename(),
1195 + absint( $limit )
1196 + ),
1197 + ARRAY_A
1198 + );
1844 1199
1845 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args.
1846 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1847 -
1848 1200 return $results ? $results : [];
1849 1201 }
1850 1202
1851 1203 /**
@@ -1850,18 +1202,15 @@
1850 1202
1851 1203 /**
1852 1204 * Get donation trends over time.
1853 1205 *
1854 - * @param string $after Start date (ISO format).
1855 - * @param string $before End date (ISO format).
1856 - * @param string $group Grouping: 'day', 'week', or 'month'.
1857 - * @param string $currency Currency code to scope to ('' for no currency filter).
1858 - * @param int $campaign_id Campaign to scope to (0 for all campaigns).
1859 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1206 + * @param string $after Start date (ISO format).
1207 + * @param string $before End date (ISO format).
1208 + * @param string $group Grouping: 'day', 'week', or 'month'.
1860 1209 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1861 1210 * @since 0.0.1
1862 1211 */
1863 - public static function get_donation_trends( $after = '', $before = '', $group = 'day', $currency = '', $campaign_id = 0, $payment_mode = '' ) {
1212 + public static function get_donation_trends( $after = '', $before = '', $group = 'day' ) {
1864 1213 $instance = self::get_instance();
1865 1214 global $wpdb;
1866 1215
1867 1216 // Default to last 30 days if no dates provided.
@@ -1885,33 +1234,12 @@
1885 1234 $date_format = '%Y-%m-%d';
1886 1235 break;
1887 1236 }
1888 1237
1889 - // Amounts of different currencies cannot be summed into one figure, so
1890 - // scope the query to a single currency. Callers that don't care still
1891 - // get coherent numbers because the default is the store currency.
1892 - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1893 - $extra = '';
1894 - $args = [ $date_format, $instance->get_tablename(), $after, $before ];
1895 -
1896 - if ( '' !== $currency ) {
1897 - $extra .= ' AND currency = %s';
1898 - $args[] = $currency;
1899 - }
1900 -
1901 - if ( $campaign_id > 0 ) {
1902 - $extra .= ' AND campaign_id = %d';
1903 - $args[] = absint( $campaign_id );
1904 - }
1905 -
1906 - // Test and live donations must not be summed together either.
1907 - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1908 - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1909 - $extra .= ' AND payment_mode = %s';
1910 - $args[] = $payment_mode;
1911 - }
1912 -
1913 - $sql = "SELECT
1238 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1239 + $results = $wpdb->get_results(
1240 + $wpdb->prepare(
1241 + "SELECT
1914 1242 DATE_FORMAT(created_at, %s) as period,
1915 1243 COUNT(*) as donation_count,
1916 1244 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1917 1245 FROM %i
@@ -1917,202 +1245,22 @@
1917 1245 FROM %i
1918 1246 WHERE payment_status IN ('completed', 'partially_refunded')
1919 1247 AND DATE(created_at) >= %s
1920 1248 AND DATE(created_at) <= %s
1921 - {$extra}
1922 1249 GROUP BY period
1923 - ORDER BY period ASC";
1924 -
1925 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value is passed through prepare args.
1926 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1927 -
1928 - return $results ? $results : [];
1929 - }
1930 -
1931 - /**
1932 - * Count donations recorded through a donation form, in any status.
1933 - *
1934 - * Used to protect a form from permanent deletion while donation rows still
1935 - * reference it, mirroring count_by_campaign()'s role for campaigns.
1936 - *
1937 - * @param int $form_id Donation form post ID.
1938 - * @return int Donation count.
1939 - * @since 1.5.0
1940 - */
1941 - public static function count_by_form( $form_id ) {
1942 - $instance = self::get_instance();
1943 - global $wpdb;
1944 -
1945 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Guard on a destructive action; must read live data.
1946 - $count = $wpdb->get_var(
1947 - $wpdb->prepare(
1948 - 'SELECT COUNT(*) FROM %i WHERE form_id = %d',
1250 + ORDER BY period ASC",
1251 + $date_format,
1949 1252 $instance->get_tablename(),
1950 - absint( $form_id )
1951 - )
1952 - );
1953 -
1954 - return is_numeric( $count ) ? (int) $count : 0;
1955 - }
1956 -
1957 - /**
1958 - * Get completed entry count and revenue for a single donation form.
1959 - *
1960 - * @param int $form_id Donation form post ID.
1961 - * @return array{entries: int, revenue: float} Form totals.
1962 - * @since 1.5.0
1963 - */
1964 - public static function get_form_stats( $form_id ) {
1965 - $instance = self::get_instance();
1966 - global $wpdb;
1967 -
1968 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live totals; caching would show stale figures.
1969 - $result = $wpdb->get_row(
1970 - $wpdb->prepare(
1971 - 'SELECT COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %i WHERE form_id = %d AND payment_status = %s',
1972 - $instance->get_tablename(),
1973 - absint( $form_id ),
1974 - 'completed'
1253 + $after,
1254 + $before
1975 1255 ),
1976 1256 ARRAY_A
1977 1257 );
1978 1258
1979 - return [
1980 - 'entries' => is_array( $result ) ? (int) ( $result['entries'] ?? 0 ) : 0,
1981 - 'revenue' => is_array( $result ) ? (float) ( $result['revenue'] ?? 0 ) : 0.0,
1982 - ];
1259 + return $results ? $results : [];
1983 1260 }
1984 1261
1985 1262 /**
1986 - * Get entry and revenue totals for several forms in one query.
1987 - *
1988 - * get_form_stats() is a per-form query, so formatting a page of N forms ran
1989 - * N COUNT/SUM queries. This collapses that to one GROUP BY for the page.
1990 - *
1991 - * @param array<int> $form_ids Form IDs to total.
1992 - * @return array<int, array{entries: int, revenue: float}> Totals keyed by form ID; every requested ID is present.
1993 - * @since 1.5.0
1994 - */
1995 - public static function get_form_stats_bulk( array $form_ids ) {
1996 - // intval, not absint: absint( -1 ) is 1, which would silently total a
1997 - // real form the caller never asked about.
1998 - $ids = array_values(
1999 - array_unique(
2000 - array_filter(
2001 - array_map( 'intval', $form_ids ),
2002 - static function ( $id ) {
2003 - return $id > 0;
2004 - }
2005 - )
2006 - )
2007 - );
2008 -
2009 - // Every requested id gets an entry, so callers never have to special-case
2010 - // a form that simply has no donations yet.
2011 - $stats = [];
2012 - foreach ( $ids as $id ) {
2013 - $stats[ $id ] = [
2014 - 'entries' => 0,
2015 - 'revenue' => 0.0,
2016 - ];
2017 - }
2018 -
2019 - if ( empty( $ids ) ) {
2020 - return $stats;
2021 - }
2022 -
2023 - $instance = self::get_instance();
2024 - global $wpdb;
2025 -
2026 - $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
2027 -
2028 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Live totals; placeholders are generated from a count, every value is bound.
2029 - $rows = $wpdb->get_results(
2030 - $wpdb->prepare(
2031 - sprintf(
2032 - 'SELECT form_id, COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %%i WHERE form_id IN ( %s ) AND payment_status = %%s GROUP BY form_id',
2033 - $placeholders
2034 - ),
2035 - array_merge( [ $instance->get_tablename() ], $ids, [ 'completed' ] )
2036 - ),
2037 - ARRAY_A
2038 - );
2039 -
2040 - if ( ! is_array( $rows ) ) {
2041 - return $stats;
2042 - }
2043 -
2044 - foreach ( $rows as $row ) {
2045 - if ( ! is_array( $row ) ) {
2046 - continue;
2047 - }
2048 -
2049 - $form_id = absint( $row['form_id'] ?? 0 );
2050 - if ( ! isset( $stats[ $form_id ] ) ) {
2051 - continue;
2052 - }
2053 -
2054 - $stats[ $form_id ] = [
2055 - 'entries' => (int) ( $row['entries'] ?? 0 ),
2056 - 'revenue' => (float) ( $row['revenue'] ?? 0 ),
2057 - ];
2058 - }
2059 -
2060 - return $stats;
2061 - }
2062 -
2063 - /**
2064 - * Count donations matching the admin-list filters.
2065 - *
2066 - * Mirrors get_admin_list()'s WHERE clause, including the search term. The
2067 - * older get_total_donations_by_status() ignores `$search`, so any searched
2068 - * listing reported the unfiltered total and paginated against it.
2069 - *
2070 - * @param string $status Payment status filter ('all' for no filter).
2071 - * @param int $campaign_id Campaign ID filter (0 for no filter).
2072 - * @param string $search Search term for donor_name, donor_email, or transaction_id.
2073 - * @return int Matching row count.
2074 - * @since 1.5.0
2075 - */
2076 - public static function count_admin_list( $status = 'all', $campaign_id = 0, $search = '' ) {
2077 - $instance = self::get_instance();
2078 - global $wpdb;
2079 -
2080 - $conditions = [ '1=1' ];
2081 - $args = [ $instance->get_tablename() ];
2082 -
2083 - if ( 'all' !== $status ) {
2084 - $conditions[] = 'payment_status = %s';
2085 - $args[] = sanitize_text_field( $status );
2086 - } else {
2087 - // Mirrors get_admin_list(): abandoned rows are out of the unfiltered
2088 - // listing, so the total has to leave them out too or the last page
2089 - // comes back short.
2090 - $conditions[] = "payment_status != 'abandoned'";
2091 - }
2092 -
2093 - if ( $campaign_id > 0 ) {
2094 - $conditions[] = 'campaign_id = %d';
2095 - $args[] = absint( $campaign_id );
2096 - }
2097 -
2098 - if ( ! empty( $search ) ) {
2099 - $conditions[] = '(donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s)';
2100 - $term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
2101 - $args[] = $term;
2102 - $args[] = $term;
2103 - $args[] = $term;
2104 - }
2105 -
2106 - $where = implode( ' AND ', $conditions );
2107 -
2108 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
2109 - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $args ) );
2110 -
2111 - return is_numeric( $count ) ? (int) $count : 0;
2112 - }
2113 -
2114 - /**
2115 1263 * Get recent donations for a campaign.
2116 1264 *
2117 1265 * @param int $campaign_id Campaign ID.
2118 1266 * @param int $limit Number of donations to retrieve.
@@ -2304,42 +1452,8 @@
2304 1452 return self::$valid_statuses;
2305 1453 }
2306 1454
2307 1455 /**
2308 - * Get valid donor-comment moderation statuses.
2309 - *
2310 - * @return array<string> Valid donor-comment statuses.
2311 - * @since 1.6.0
2312 - */
2313 - public static function get_valid_comment_statuses() {
2314 - return self::$valid_comment_statuses;
2315 - }
2316 -
2317 - /**
2318 - * Resolve the moderation status a newly captured donor comment should get.
2319 - *
2320 - * Held for review only when the site owner has opted in; otherwise comments
2321 - * publish straight away, matching how GiveWP and Charitable behave out of the
2322 - * box. An empty comment gets `approved` so a donation with nothing to moderate
2323 - * never shows up in a review queue.
2324 - *
2325 - * @param string $comment The captured comment.
2326 - * @return string One of self::$valid_comment_statuses.
2327 - * @since 1.6.0
2328 - */
2329 - public static function initial_comment_status( $comment ) {
2330 - if ( '' === trim( Helper::get_string_value( $comment ) ) ) {
2331 - return 'approved';
2332 - }
2333 -
2334 - $donor_settings = Helper::get_array_value(
2335 - Helper::get_suredonation_option( \SureDonation\Inc\API\Settings_API::DONOR_OPTION_KEY, [] )
2336 - );
2337 -
2338 - return ! empty( $donor_settings['hold_donor_comments'] ) ? 'pending' : 'approved';
2339 - }
2340 -
2341 - /**
2342 1456 * Add a log entry to a donation.
2343 1457 *
2344 1458 * @param int $donation_id Donation ID.
2345 1459 * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook').
@@ -2451,55 +1565,8 @@
2451 1565 }
2452 1566
2453 1567 // Store with refund ID as key for O(1) lookup (duplicate prevention).
2454 1568 $donation_data['refunds'][ $refund_id ] = $refund_data;
2455 -
2456 - // Update donation_data in database.
2457 - $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2458 -
2459 - return false !== $result;
2460 - }
2461 -
2462 - /**
2463 - * Store the submitted form field values under the donation_data['fields'] key.
2464 - *
2465 - * The donation_data column is shared JSON (also holds refunds, notes and
2466 - * subscription metadata), so the field data is merged under a dedicated
2467 - * 'fields' key and never overwrites the column.
2468 - *
2469 - * Fields are written at donation creation (before the payment is confirmed)
2470 - * and are intentionally retained for abandoned/failed donations — pending
2471 - * records are legitimate business data (recovery, reconciliation, reporting).
2472 - * There is deliberately no automatic PII purge here; erasure is handled on
2473 - * demand via the admin delete actions (and can be wired to WordPress's
2474 - * personal-data eraser hooks if a retention policy is later required).
2475 - *
2476 - * @param int $donation_id Donation ID.
2477 - * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
2478 - * @return bool True on success, false on failure.
2479 - * @since 1.1.1
2480 - */
2481 - public static function set_submitted_fields( $donation_id, $field_data ) {
2482 - if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
2483 - return false;
2484 - }
2485 -
2486 - $donation = self::get( $donation_id );
2487 - if ( ! $donation ) {
2488 - return false;
2489 - }
2490 -
2491 - // Get existing donation_data.
2492 - $donation_data = $donation['donation_data'] ?? [];
2493 - if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2494 - $donation_data = json_decode( $donation_data, true );
2495 - }
2496 - if ( ! is_array( $donation_data ) ) {
2497 - $donation_data = [];
2498 - }
2499 -
2500 - // Merge under a dedicated key — never overwrite the shared column.
2501 - $donation_data['fields'] = $field_data;
2502 1569
2503 1570 // Update donation_data in database.
2504 1571 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2505 1572