PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.1 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 -979 1.5.1 → 1.1.0 View file →
@@ -36,9 +36,9 @@
36 36 *
37 37 * @var int
38 38 * @since 0.0.1
39 39 */
40 - protected $table_version = 6;
40 + protected $table_version = 4;
41 41
42 42 /**
43 43 * Valid payment statuses.
44 44 *
@@ -53,14 +53,8 @@
53 53 'refunded',
54 54 'partially_refunded',
55 55 'cancelled',
56 56 'suspicious',
57 - // Deliberately not 'failed'. A donor who closed the gateway window never
58 - // attempted a payment, and collapsing the two destroys the signal we most
59 - // need: our own capture failure rate. If abandonment and genuine failures
60 - // share a status, "most donors walk away at the gateway" (a product
61 - // problem) is indistinguishable from "our captures are breaking" (a bug).
62 - 'abandoned',
63 57 ];
64 58
65 59 /**
66 60 * Valid order columns.
@@ -123,12 +117,8 @@
123 117 'customer_id' => [
124 118 'type' => 'string',
125 119 'default' => '',
126 120 ],
127 - 'stripe_account_id' => [
128 - 'type' => 'string',
129 - 'default' => '',
130 - ],
131 121 'gateway' => [
132 122 'type' => 'string',
133 123 'default' => 'stripe',
134 124 ],
@@ -211,12 +201,8 @@
211 201 'import_source' => [
212 202 'type' => 'string',
213 203 'default' => '',
214 204 ],
215 - 'import_provenance' => [
216 - 'type' => 'string',
217 - 'default' => '',
218 - ],
219 205 'created_at' => [
220 206 'type' => 'datetime',
221 207 ],
222 208 'updated_at' => [
@@ -239,9 +225,8 @@
239 225 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
240 226 'currency VARCHAR(10) NOT NULL',
241 227 'transaction_id VARCHAR(255) NOT NULL',
242 228 'customer_id VARCHAR(50) NOT NULL',
243 - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'',
244 229 'gateway VARCHAR(20) NOT NULL',
245 230 'payment_status VARCHAR(50) NOT NULL',
246 231 'payment_mode VARCHAR(20) NOT NULL',
247 232 'donor_name VARCHAR(255) NOT NULL',
@@ -260,10 +245,9 @@
260 245 'ip_address VARCHAR(45) NOT NULL',
261 246 'user_agent TEXT',
262 247 'referer_url TEXT',
263 248 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
264 - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
265 - 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\'',
249 + 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
266 250 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
267 251 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
268 252 'INDEX idx_campaign (campaign_id)',
269 253 'INDEX idx_donor (donor_id)',
@@ -274,10 +258,8 @@
274 258 'INDEX idx_subscription (subscription_id)',
275 259 'INDEX idx_subscription_status (subscription_status)',
276 260 'INDEX idx_parent_subscription (parent_subscription_id)',
277 261 'INDEX idx_import_source (import_source_id, import_source)',
278 - 'INDEX idx_import_provenance (import_source, import_provenance)',
279 - 'INDEX idx_stripe_account (stripe_account_id)',
280 262 ];
281 263 }
282 264
283 265 /**
@@ -284,14 +266,9 @@
284 266 * New columns added across versions.
285 267 *
286 268 * Version 2 added subscription support; version 4 added the
287 269 * source-agnostic pair `import_source_id` + `import_source` used by
288 - * the migration tool for duplicate detection and rollback; version 5
289 - * added `stripe_account_id` so donations record which connected Stripe
290 - * account processed them (multiple Stripe accounts support); version 6
291 - * added `import_provenance` — an indexed `(donation_post_id, source_campaign_id)`
292 - * key the Charitable importer dedupes on with a single indexed lookup per
293 - * row, instead of scanning + JSON-decoding every prior imported row per batch.
270 + * the migration tool for duplicate detection and rollback.
294 271 *
295 272 * {@inheritDoc}
296 273 *
297 274 * @since 1.0.0
@@ -301,198 +278,17 @@
301 278 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
302 279 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
303 280 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
304 281 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
305 - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id',
306 - 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\' AFTER import_source',
307 - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id',
282 + 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id',
308 283 'INDEX idx_subscription (subscription_id)',
309 284 'INDEX idx_subscription_status (subscription_status)',
310 285 'INDEX idx_parent_subscription (parent_subscription_id)',
311 286 'INDEX idx_import_source (import_source_id, import_source)',
312 - 'INDEX idx_import_provenance (import_source, import_provenance)',
313 - 'INDEX idx_stripe_account (stripe_account_id)',
314 287 ];
315 288 }
316 289
317 290 /**
318 - * One-time data migrations for the donations table.
319 - *
320 - * Each backfill is gated on the version being upgraded *into* (via
321 - * $this->prev_version) so it runs exactly once, on the upgrade that adds the
322 - * column, and is skipped on fresh installs (which create the column already
323 - * populated / empty as appropriate) and on later upgrades.
324 - *
325 - * @return void
326 - * @since 1.3.0
327 - */
328 - public function run_data_migrations() {
329 - // A failed CREATE/ALTER earlier in this upgrade already cleared the flag;
330 - // the column may not exist, so don't run an UPDATE against it.
331 - if ( ! $this->db_upgradable ) {
332 - return;
333 - }
334 -
335 - if ( $this->prev_version < 5 ) {
336 - $this->backfill_stripe_account_id();
337 - }
338 -
339 - if ( $this->prev_version < 6 ) {
340 - $this->backfill_import_provenance();
341 - }
342 - }
343 -
344 - /**
345 - * Backfill `stripe_account_id` on the upgrade into v5.
346 - *
347 - * Before multi-account there could only be a single connected Stripe account,
348 - * so every pre-v5 Stripe donation belongs to the current (single) default
349 - * account. Backfill it so refunds and subscription lifecycle actions keep
350 - * routing to the originating account after a second account is connected and
351 - * the default is switched. Idempotent (touches only empty rows).
352 - *
353 - * @return void
354 - * @since 1.3.0
355 - */
356 - private function backfill_stripe_account_id() {
357 - if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) {
358 - return;
359 - }
360 -
361 - // Runs during the v5 DB upgrade — before any second account can be
362 - // connected via the UI — so the default is still the single legacy account.
363 - $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id();
364 - if ( ! is_string( $account_id ) || '' === $account_id ) {
365 - return;
366 - }
367 -
368 - global $wpdb;
369 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable.
370 - $result = $wpdb->query(
371 - $wpdb->prepare(
372 - 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )',
373 - $this->get_tablename(),
374 - $account_id,
375 - 'stripe',
376 - ''
377 - )
378 - );
379 -
380 - // A transient failure (e.g. lock wait timeout on a busy table) must not
381 - // persist the new version: `prev_version >= 5` would then skip this
382 - // one-shot backfill forever. Leaving the version unwritten makes the
383 - // idempotent sequence retry on the next request.
384 - if ( false === $result ) {
385 - $this->db_upgradable = false;
386 - }
387 - }
388 -
389 - /**
390 - * Backfill `import_provenance` on the upgrade into v6.
391 - *
392 - * The Charitable importer moved its dedupe key out of a per-batch scan of
393 - * `donation_data` and onto this indexed column. Rows imported before v6 have
394 - * an empty key, so a re-import after upgrade would fail to match them and
395 - * insert duplicates. Reconstruct the key from the stored
396 - * `donation_data.charitable` block — the same `(donation_post_id,
397 - * source_campaign_id | campaign label)` rule the importer keys on — for every
398 - * pre-v6 one-time Charitable row. Chunked so a large migrated table does not
399 - * exhaust memory during the upgrade; idempotent (touches only empty keys).
400 - *
401 - * @return void
402 - * @since 1.5.1
403 - */
404 - private function backfill_import_provenance() {
405 - global $wpdb;
406 - $table = $this->get_tablename();
407 -
408 - do {
409 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time chunked backfill of a newly added column; not cacheable.
410 - $rows = $wpdb->get_results(
411 - $wpdb->prepare(
412 - 'SELECT id, donation_data FROM %i WHERE import_source = %s AND donation_type != %s AND import_provenance = %s LIMIT 500',
413 - $table,
414 - 'charitable',
415 - 'recurring',
416 - ''
417 - ),
418 - ARRAY_A
419 - );
420 -
421 - if ( empty( $rows ) || ! is_array( $rows ) ) {
422 - break;
423 - }
424 -
425 - $fetched = count( $rows );
426 -
427 - foreach ( $rows as $row ) {
428 - $data = json_decode( (string) ( $row['donation_data'] ?? '' ), true );
429 - $c = is_array( $data ) && isset( $data['charitable'] ) && is_array( $data['charitable'] ) ? $data['charitable'] : [];
430 - $post = isset( $c['donation_post_id'] ) ? absint( $c['donation_post_id'] ) : 0;
431 -
432 - // A row with no resolvable donation post can never be dedupe-matched
433 - // or rolled back; leave its key empty (it is already un-reversible)
434 - // rather than fabricate a colliding "0:…" key.
435 - if ( $post <= 0 ) {
436 - $key = '';
437 - } else {
438 - $campaign = isset( $c['source_campaign_id'] ) ? absint( $c['source_campaign_id'] ) : 0;
439 - // DB-path rows carry `campaign_name`; CSV-path rows carry
440 - // `campaign_title`. Either serves as the blank-id fallback label.
441 - $label = '';
442 - if ( isset( $c['campaign_title'] ) && is_scalar( $c['campaign_title'] ) ) {
443 - $label = (string) $c['campaign_title'];
444 - } elseif ( isset( $c['campaign_name'] ) && is_scalar( $c['campaign_name'] ) ) {
445 - $label = (string) $c['campaign_name'];
446 - }
447 - $key = self::build_provenance_key( $post, $campaign, $label );
448 - }
449 -
450 - if ( '' === $key ) {
451 - // Nothing to store, but stamp a sentinel so the WHERE clause
452 - // stops selecting this row and the loop terminates.
453 - $key = '-';
454 - }
455 -
456 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill update; not cacheable.
457 - $wpdb->update( $table, [ 'import_provenance' => $key ], [ 'id' => absint( $row['id'] ) ] );
458 - }
459 - } while ( 500 === $fetched );
460 - }
461 -
462 - /**
463 - * Build the indexed dedupe key for a Charitable donation row.
464 - *
465 - * `"<donation_post_id>:<token>"`, where the token is the numeric campaign id
466 - * when present, otherwise a short hash of the campaign label (so the
467 - * per-campaign rows of a multi-campaign donation whose export left the
468 - * Campaign ID cell blank stay distinct instead of collapsing to "<post>:0"),
469 - * otherwise "0". Static so both the importer (Provenance_Dedupe) and the v6
470 - * backfill derive identical keys.
471 - *
472 - * @param int $donation_post_id Charitable donation post ID.
473 - * @param int $source_campaign_id Charitable campaign ID (0 when absent).
474 - * @param string $campaign_label Campaign title/name fallback (optional).
475 - * @return string
476 - * @since 1.5.1
477 - */
478 - public static function build_provenance_key( $donation_post_id, $source_campaign_id, $campaign_label = '' ) {
479 - $post = absint( $donation_post_id );
480 - $cid = absint( $source_campaign_id );
481 - $label = trim( (string) $campaign_label );
482 -
483 - if ( $cid > 0 ) {
484 - $token = (string) $cid;
485 - } elseif ( '' !== $label ) {
486 - $token = 't:' . substr( md5( strtolower( $label ) ), 0, 12 );
487 - } else {
488 - $token = '0';
489 - }
490 -
491 - return $post . ':' . $token;
492 - }
493 -
494 - /**
495 291 * Add a new donation record.
496 292 *
497 293 * @param array<mixed> $data Donation data to insert.
498 294 * @return int|false The donation ID on success, false on error.
@@ -523,48 +319,16 @@
523 319 $donation_id = absint( $result );
524 320 $donation = self::get( $donation_id );
525 321 $donation = is_array( $donation ) ? $donation : [];
526 322
527 - // Curated payload (internal/gateway-only columns omitted; donor
528 - // identity included, see the note in get_integration_payload())
529 - // shared by every hook below.
530 - $payload = self::get_integration_payload( $donation );
531 -
532 323 /**
533 324 * Fires when a new donation record is created.
534 325 *
535 326 * @param int $donation_id Newly created donation ID.
536 - * @param array<mixed> $donation Curated donation payload.
327 + * @param array<mixed> $donation Complete donation record.
537 328 * @since 1.1.0
538 329 */
539 - do_action( 'suredonation_donation_created', $donation_id, $payload );
540 -
541 - /**
542 - * Fires when a new donation record is created.
543 - *
544 - * Mirrors `suredonation_donation_created`; the OttoKit (formerly
545 - * SureTriggers) "New Donation" trigger listens on this hook name.
546 - *
547 - * @param int $donation_id Newly created donation ID.
548 - * @param array<mixed> $donation Curated donation payload.
549 - * @since 1.2.0
550 - */
551 - do_action( 'suredonation_new_donation', $donation_id, $payload );
552 -
553 - // Some donations are created already-completed rather than
554 - // transitioning through update() — recurring renewals and
555 - // admin-recorded paid donations. Fire the completion event here
556 - // too so integration hooks still see them.
557 - if ( 'completed' === ( $data['payment_status'] ?? '' ) ) {
558 - /**
559 - * Fires when a donation payment is completed.
560 - *
561 - * @param int $donation_id Donation ID.
562 - * @param array<mixed> $donation Curated donation payload after insertion.
563 - * @since 1.2.0
564 - */
565 - do_action( 'suredonation_donation_completed', $donation_id, $payload );
566 - }
330 + do_action( 'suredonation_donation_created', $donation_id, $donation );
567 331 }
568 332 }
569 333
570 334 return $result;
@@ -582,17 +346,15 @@
582 346 if ( empty( $donation_id ) ) {
583 347 return false;
584 348 }
585 349
586 - // Capture the current status and refunded amount before the write so
587 - // integration hooks (e.g. OttoKit) can react to the transition and to
588 - // refund events, not just the resulting values.
589 - $old_status = '';
590 - $old_refunded = 0.0;
591 - if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) {
592 - $existing = self::get( absint( $donation_id ) );
593 - $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
594 - $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'] ?? '' ) : '';
595 357 }
596 358
597 359 // Set updated_at.
598 360 $data['updated_at'] = current_time( 'mysql' );
@@ -602,22 +364,22 @@
602 364 // Status/amount changes (e.g. a webhook completing a pending donation)
603 365 // affect the cached stats and donor lists.
604 366 if ( $updated ) {
605 367 $donation = self::get( absint( $donation_id ) );
606 - $donation = is_array( $donation ) ? $donation : [];
607 368 if ( ! empty( $donation['campaign_id'] ) ) {
608 369 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
609 370 }
610 371
611 - // Curated payload (internal/gateway-only columns omitted; donor
612 - // identity included, see the note in get_integration_payload())
613 - // shared by every hook below.
614 - $payload = self::get_integration_payload( $donation );
615 -
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.
616 376 if ( isset( $data['payment_status'] ) ) {
617 377 $new_status = Helper::get_string_value( $data['payment_status'] );
618 378
619 379 if ( $new_status !== $old_status ) {
380 + $donation = is_array( $donation ) ? $donation : [];
381 +
620 382 /**
621 383 * Fires when a donation's payment status changes.
622 384 *
623 385 * @param int $donation_id Donation ID.
@@ -622,51 +384,14 @@
622 384 *
623 385 * @param int $donation_id Donation ID.
624 386 * @param string $new_status New payment status.
625 387 * @param string $old_status Previous payment status (empty string if unknown).
626 - * @param array<mixed> $donation Curated donation payload after the update.
388 + * @param array<mixed> $donation Complete donation record after the update.
627 389 * @since 1.1.0
628 390 */
629 - do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload );
630 -
631 - // Fire the completion event for any genuine transition into
632 - // 'completed' — including admin review states (suspicious,
633 - // cancelled) — but never for refund reversals that restore
634 - // the 'completed' status (refunded/partially_refunded ->
635 - // completed), which would replay the completion automation.
636 - if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) {
637 - /**
638 - * Fires when a donation payment is completed.
639 - *
640 - * @param int $donation_id Donation ID.
641 - * @param array<mixed> $donation Curated donation payload after the update.
642 - * @since 1.2.0
643 - */
644 - do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload );
645 - }
391 + do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $donation );
646 392 }
647 393 }
648 -
649 - // A rise in refunded_amount means a refund was processed. Keying off
650 - // the amount (not the status string) catches repeat partial refunds
651 - // that leave the status as partially_refunded, and excludes refund
652 - // reversals where the amount drops.
653 - if ( isset( $data['refunded_amount'] ) ) {
654 - $new_refunded = Helper::get_float_value( $data['refunded_amount'] );
655 -
656 - if ( $new_refunded - $old_refunded > 0.0001 ) {
657 - /**
658 - * Fires when a donation is refunded, fully or partially.
659 - *
660 - * @param int $donation_id Donation ID.
661 - * @param float $refund_amount Amount refunded in this event.
662 - * @param float $total_refunded Cumulative amount refunded to date.
663 - * @param array<mixed> $donation Curated donation payload after the update.
664 - * @since 1.2.0
665 - */
666 - do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload );
667 - }
668 - }
669 394 }
670 395
671 396 return $updated;
672 397 }
@@ -671,81 +396,8 @@
671 396 return $updated;
672 397 }
673 398
674 399 /**
675 - * Build a curated donation payload for integration hooks.
676 - *
677 - * Trims the raw database row to the fields advertised in the OttoKit embed
678 - * `sample_response`, omitting internal and gateway-only columns that must not
679 - * leave the site (ip_address, user_agent, referer_url, the admin `log`, the
680 - * gateway `customer_id`, and the full `donation_data` submission). Monetary
681 - * values are cast to float to match the sample the automation builder maps
682 - * against (the raw column is a DECIMAL string). Shared by every `do_action`
683 - * in add()/update() so no listener — OttoKit or otherwise — receives the raw
684 - * row.
685 - *
686 - * Anonymous donations carry their real donor identity here. The anonymous
687 - * checkbox is a display-only flag — the data is stored and processed as
688 - * usual, and only the public donor wall / recent donations / top donors mask
689 - * it. Automations that need to treat anonymous donors differently branch on
690 - * the `is_anonymous` field in this payload; blanking the identity instead
691 - * would silently break receipting and CRM sync for those donations.
692 - *
693 - * @param array<string,mixed> $donation Raw donation record from self::get().
694 - * @return array<string,mixed> Curated, integration-safe payload.
695 - * @since 1.2.0
696 - */
697 - public static function get_integration_payload( $donation ) {
698 - if ( ! is_array( $donation ) ) {
699 - return [];
700 - }
701 -
702 - $is_anonymous = ! empty( $donation['is_anonymous'] );
703 -
704 - $payload = [
705 - 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0,
706 - 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0,
707 - 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0,
708 - 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0,
709 - 'donor_name' => Helper::get_string_value( $donation['donor_name'] ?? '' ),
710 - 'donor_email' => Helper::get_string_value( $donation['donor_email'] ?? '' ),
711 - 'donor_phone' => Helper::get_string_value( $donation['donor_phone'] ?? '' ),
712 - 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
713 - 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
714 - 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
715 - 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ),
716 - 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ),
717 - 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ),
718 - 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ),
719 - 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ),
720 - 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ),
721 - 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
722 - 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
723 - 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
724 - 'is_anonymous' => $is_anonymous,
725 - 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ),
726 - 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ),
727 - ];
728 -
729 - /**
730 - * Filter the curated donation payload passed to every integration hook.
731 - *
732 - * The payload carries the donor's real identity even for anonymous
733 - * donations, because the anonymous checkbox only masks public donor
734 - * lists — automations still need a usable record, and they can branch on
735 - * the `is_anonymous` field. A site with a stricter policy (for example an
736 - * automation that posts donor names somewhere public) can use this filter
737 - * to blank or drop fields before they reach OttoKit or any third-party
738 - * listener.
739 - *
740 - * @param array<string,mixed> $payload Curated payload.
741 - * @param array<string,mixed> $donation Raw donation record.
742 - * @since 1.4.0
743 - */
744 - return apply_filters( 'suredonation_integration_payload', $payload, $donation );
745 - }
746 -
747 - /**
748 400 * Get a single donation by ID.
749 401 *
750 402 * @param int $donation_id Donation ID.
751 403 * @return array<mixed>|null Donation data or null if not found.
@@ -865,14 +517,8 @@
865 517 // Build query based on filters.
866 518 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
867 519 // They are shown alongside parent subscriptions so admins can see all transaction activity.
868 520 // Renewals are also accessible from the parent donation's subscription detail billing history.
869 - // With no status filter, abandoned rows are left out: they are kept as
870 - // funnel data (a campaign with 40 starts against 3 completions has
871 - // learned something real) but a donor who walked away from the gateway is
872 - // not a transaction an admin needs in their default view. Asking for the
873 - // status explicitly still returns them, and count_admin_list() mirrors
874 - // this or the pagination totals disagree with the rows.
875 521 $has_status = 'all' !== $status;
876 522 $has_campaign = $campaign_id > 0;
877 523 $has_search = ! empty( $search );
878 524 $is_asc = 'ASC' === $order;
@@ -974,9 +620,9 @@
974 620 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
975 621 $results = $is_asc
976 622 ? $wpdb->get_results(
977 623 $wpdb->prepare(
978 - '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',
979 625 $table,
980 626 absint( $campaign_id ),
981 627 $search_term,
982 628 $search_term,
@@ -988,9 +634,9 @@
988 634 ARRAY_A
989 635 )
990 636 : $wpdb->get_results(
991 637 $wpdb->prepare(
992 - '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',
993 639 $table,
994 640 absint( $campaign_id ),
995 641 $search_term,
996 642 $search_term,
@@ -1028,9 +674,9 @@
1028 674 } elseif ( $has_campaign ) {
1029 675 $results = $is_asc
1030 676 ? $wpdb->get_results(
1031 677 $wpdb->prepare(
1032 - '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',
1033 679 $table,
1034 680 absint( $campaign_id ),
1035 681 $orderby,
1036 682 absint( $offset ),
@@ -1039,9 +685,9 @@
1039 685 ARRAY_A
1040 686 )
1041 687 : $wpdb->get_results(
1042 688 $wpdb->prepare(
1043 - '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',
1044 690 $table,
1045 691 absint( $campaign_id ),
1046 692 $orderby,
1047 693 absint( $offset ),
@@ -1053,9 +699,9 @@
1053 699 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1054 700 $results = $is_asc
1055 701 ? $wpdb->get_results(
1056 702 $wpdb->prepare(
1057 - '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',
1058 704 $table,
1059 705 $search_term,
1060 706 $search_term,
1061 707 $search_term,
@@ -1066,9 +712,9 @@
1066 712 ARRAY_A
1067 713 )
1068 714 : $wpdb->get_results(
1069 715 $wpdb->prepare(
1070 - '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',
1071 717 $table,
1072 718 $search_term,
1073 719 $search_term,
1074 720 $search_term,
@@ -1081,9 +727,9 @@
1081 727 } else {
1082 728 $results = $is_asc
1083 729 ? $wpdb->get_results(
1084 730 $wpdb->prepare(
1085 - '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',
1086 732 $table,
1087 733 $orderby,
1088 734 absint( $offset ),
1089 735 absint( $limit )
@@ -1091,9 +737,9 @@
1091 737 ARRAY_A
1092 738 )
1093 739 : $wpdb->get_results(
1094 740 $wpdb->prepare(
1095 - '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',
1096 742 $table,
1097 743 $orderby,
1098 744 absint( $offset ),
1099 745 absint( $limit )
@@ -1111,147 +757,8 @@
1111 757 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1112 758 }
1113 759
1114 760 /**
1115 - * Build the WHERE clause + prepare-args for an export query.
1116 - *
1117 - * Always constrains to one-time donations (subscription_id = '' AND
1118 - * parent_subscription_id = 0) so recurring/renewal rows never leak into the
1119 - * free export — recurring export is Pro (see the Import & Export spec, #237).
1120 - * Optional filters: status, campaign_id, payment_mode, gateway, and a
1121 - * created_at date range (after / before).
1122 - *
1123 - * @param array<string, mixed> $filters Filter map.
1124 - * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order.
1125 - * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values.
1126 - * @since 1.3.0
1127 - */
1128 - private static function build_export_where( $filters, &$args ) {
1129 - $conditions = [ '1=1' ];
1130 -
1131 - /**
1132 - * Whether the donations export is restricted to one-time donations.
1133 - *
1134 - * True by default so recurring/renewal rows never leak into the free
1135 - * export; Pro returns false to include subscriptions and renewals.
1136 - *
1137 - * @param bool $one_time_only Whether to restrict to one-time donations.
1138 - */
1139 - if ( apply_filters( 'suredonation_export_one_time_only', true ) ) {
1140 - $conditions[] = 'subscription_id = %s';
1141 - $conditions[] = 'parent_subscription_id = %d';
1142 - $args[] = '';
1143 - $args[] = 0;
1144 - }
1145 -
1146 - $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) );
1147 - if ( '' !== $status && 'all' !== $status ) {
1148 - $conditions[] = 'payment_status = %s';
1149 - $args[] = $status;
1150 - }
1151 -
1152 - $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) );
1153 - if ( $campaign_id > 0 ) {
1154 - $conditions[] = 'campaign_id = %d';
1155 - $args[] = $campaign_id;
1156 - }
1157 -
1158 - $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) );
1159 - if ( '' !== $payment_mode ) {
1160 - $conditions[] = 'payment_mode = %s';
1161 - $args[] = $payment_mode;
1162 - }
1163 -
1164 - $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) );
1165 - if ( '' !== $gateway ) {
1166 - $conditions[] = 'gateway = %s';
1167 - $args[] = $gateway;
1168 - }
1169 -
1170 - $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) );
1171 - if ( '' !== $after ) {
1172 - $conditions[] = 'created_at >= %s';
1173 - $args[] = $after;
1174 - }
1175 -
1176 - $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) );
1177 - if ( '' !== $before ) {
1178 - // A date-only `before` (Y-m-d) coerces to 00:00:00, which would
1179 - // silently drop donations made later that same day. Normalize to
1180 - // end-of-day so the whole end date is inclusive; full datetimes
1181 - // are left untouched.
1182 - if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) {
1183 - $before .= ' 23:59:59';
1184 - }
1185 - $conditions[] = 'created_at <= %s';
1186 - $args[] = $before;
1187 - }
1188 -
1189 - return implode( ' AND ', $conditions );
1190 - }
1191 -
1192 - /**
1193 - * Count one-time donations matching the export filters.
1194 - *
1195 - * @param array<string, mixed> $filters Filter map (see build_export_where()).
1196 - * @return int Matching row count.
1197 - * @since 1.3.0
1198 - */
1199 - public static function count_for_export( $filters = [] ) {
1200 - $instance = self::get_instance();
1201 - global $wpdb;
1202 - $table = $instance->get_tablename();
1203 -
1204 - $args = [];
1205 - $where = self::build_export_where( $filters, $args );
1206 -
1207 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data.
1208 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
1209 - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) );
1210 - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1211 -
1212 - return is_numeric( $count ) ? (int) $count : 0;
1213 - }
1214 -
1215 - /**
1216 - * Fetch one-time donations for export, decoded.
1217 - *
1218 - * @param array<string, mixed> $filters Filter map (see build_export_where()).
1219 - * @param int $limit Max rows to return (0 = no limit).
1220 - * @param int $offset Offset for pagination.
1221 - * @return array<int, array<string, mixed>> Decoded donation rows.
1222 - * @since 1.3.0
1223 - */
1224 - public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) {
1225 - $instance = self::get_instance();
1226 - global $wpdb;
1227 - $table = $instance->get_tablename();
1228 -
1229 - $args = [];
1230 - $where = self::build_export_where( $filters, $args );
1231 -
1232 - $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC";
1233 - $prepare_args = array_merge( [ $table ], $args );
1234 -
1235 - if ( $limit > 0 ) {
1236 - $sql .= ' LIMIT %d, %d';
1237 - $prepare_args[] = absint( $offset );
1238 - $prepare_args[] = absint( $limit );
1239 - }
1240 -
1241 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data.
1242 - // 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.
1243 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A );
1244 - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1245 -
1246 - if ( ! $results || ! is_array( $results ) ) {
1247 - return [];
1248 - }
1249 -
1250 - return array_map( [ $instance, 'decode_by_datatype' ], $results );
1251 - }
1252 -
1253 - /**
1254 761 * Get donations by status with pagination.
1255 762 *
1256 763 * @param string $status Payment status.
1257 764 * @param int $limit Number of records to return.
@@ -1391,15 +898,13 @@
1391 898
1392 899 /**
1393 900 * Get donations by donor email.
1394 901 *
1395 - * @param string $email Donor email.
1396 - * @param int $limit Max rows to return; 0 (default) returns all rows.
1397 - * @param int $offset Row offset, applied only when $limit > 0.
902 + * @param string $email Donor email.
1398 903 * @return array<mixed> Array of donations.
1399 904 * @since 0.0.1
1400 905 */
1401 - public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
906 + public static function get_by_donor_email( $email ) {
1402 907 if ( empty( $email ) ) {
1403 908 return [];
1404 909 }
1405 910
@@ -1405,35 +910,18 @@
1405 910
1406 911 $instance = self::get_instance();
1407 912 global $wpdb;
1408 913
1409 - $limit = max( 0, (int) $limit );
1410 - $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 + );
1411 923
1412 - if ( $limit > 0 ) {
1413 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1414 - $results = $wpdb->get_results(
1415 - $wpdb->prepare(
1416 - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d',
1417 - $instance->get_tablename(),
1418 - sanitize_email( $email ),
1419 - $limit,
1420 - $offset
1421 - ),
1422 - ARRAY_A
1423 - );
1424 - } else {
1425 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1426 - $results = $wpdb->get_results(
1427 - $wpdb->prepare(
1428 - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC',
1429 - $instance->get_tablename(),
1430 - sanitize_email( $email )
1431 - ),
1432 - ARRAY_A
1433 - );
1434 - }
1435 -
1436 924 if ( ! $results || ! is_array( $results ) ) {
1437 925 return [];
1438 926 }
1439 927
@@ -1472,50 +960,8 @@
1472 960 return $instance->decode_by_datatype( $result );
1473 961 }
1474 962
1475 963 /**
1476 - * Get donation by gateway subscription ID.
1477 - *
1478 - * Recurring handling lives in Pro, but the table (and its
1479 - * `idx_subscription` index) belongs here, so free-side code that only needs
1480 - * to resolve a row — such as the PayPal webhook listener recording why a
1481 - * delivery was rejected — can look one up without depending on Pro.
1482 - *
1483 - * Renewals carry the same `subscription_id` as the subscription they belong
1484 - * to, so the column is deliberately not unique. The parent row (the one with
1485 - * no `parent_subscription_id`) is preferred and the oldest id breaks any
1486 - * remaining tie, so the result does not depend on the query plan.
1487 - *
1488 - * @param string $subscription_id Gateway subscription ID.
1489 - * @return array<string, mixed>|null Donation data or null if not found.
1490 - * @since 1.4.0
1491 - */
1492 - public static function get_by_subscription_id( $subscription_id ) {
1493 - if ( empty( $subscription_id ) ) {
1494 - return null;
1495 - }
1496 -
1497 - $instance = self::get_instance();
1498 - global $wpdb;
1499 -
1500 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1501 - $result = $wpdb->get_row(
1502 - $wpdb->prepare(
1503 - 'SELECT * FROM %i WHERE subscription_id = %s ORDER BY parent_subscription_id ASC, id ASC LIMIT 1',
1504 - $instance->get_tablename(),
1505 - sanitize_text_field( $subscription_id )
1506 - ),
1507 - ARRAY_A
1508 - );
1509 -
1510 - if ( ! $result ) {
1511 - return null;
1512 - }
1513 -
1514 - return $instance->decode_by_datatype( $result );
1515 - }
1516 -
1517 - /**
1518 964 * Get total donations count (no filters).
1519 965 *
1520 966 * @return int Total count.
1521 967 * @since 0.0.1
@@ -1558,52 +1004,8 @@
1558 1004 return is_numeric( $count ) ? (int) $count : 0;
1559 1005 }
1560 1006
1561 1007 /**
1562 - * Get the count of completed, live-mode donations.
1563 - *
1564 - * Used to gate the review admin notice: a completed live donation is the
1565 - * signal that the site has taken a genuine (non-test) donation.
1566 - *
1567 - * @param string $gateway Optional gateway to scope the count to, e.g. 'paypal'.
1568 - * Empty counts every gateway.
1569 - * @return int Count of completed live donations.
1570 - * @since 1.2.0
1571 - * @since 1.5.1 Optionally scoped to one gateway.
1572 - */
1573 - public static function count_live_completed( $gateway = '' ) {
1574 - $instance = self::get_instance();
1575 - global $wpdb;
1576 -
1577 - if ( '' !== $gateway ) {
1578 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1579 - $count = $wpdb->get_var(
1580 - $wpdb->prepare(
1581 - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s AND gateway = %s',
1582 - $instance->get_tablename(),
1583 - 'completed',
1584 - 'live',
1585 - $gateway
1586 - )
1587 - );
1588 -
1589 - return is_numeric( $count ) ? (int) $count : 0;
1590 - }
1591 -
1592 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1593 - $count = $wpdb->get_var(
1594 - $wpdb->prepare(
1595 - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s',
1596 - $instance->get_tablename(),
1597 - 'completed',
1598 - 'live'
1599 - )
1600 - );
1601 -
1602 - return is_numeric( $count ) ? (int) $count : 0;
1603 - }
1604 -
1605 - /**
1606 1008 * Get total donations count by campaign.
1607 1009 *
1608 1010 * @param int $campaign_id Campaign ID.
1609 1011 * @return int Total count.
@@ -1665,53 +1067,58 @@
1665 1067 return self::count_all();
1666 1068 }
1667 1069
1668 1070 /**
1669 - * Build the currency / payment-mode scope for a reporting query.
1071 + * Get campaign statistics.
1670 1072 *
1671 - * Amounts in different currencies cannot be summed into one figure, and test
1672 - * donations must not be counted alongside live ones. Both filters are opt-in
1673 - * so existing callers keep their behaviour; the abilities always pass them.
1674 - *
1675 - * @param string $currency Currency code ('' for no filter).
1676 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1677 - * @param array<mixed> $args Prepare args, appended to by reference.
1678 - * @return string SQL fragment beginning with " AND ", or '' when unscoped.
1679 - * @since 1.5.0
1073 + * @param int $campaign_id Campaign ID.
1074 + * @return array<string,mixed> Campaign statistics.
1075 + * @since 0.0.1
1680 1076 */
1681 - private static function scope_fragment( $currency, $payment_mode, array &$args ) {
1682 - $extra = '';
1077 + public static function get_campaign_stats( $campaign_id ) {
1078 + $instance = self::get_instance();
1079 + global $wpdb;
1683 1080
1684 - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1685 - if ( '' !== $currency ) {
1686 - $extra .= ' AND currency = %s';
1687 - $args[] = $currency;
1688 - }
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 + );
1689 1097
1690 - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1691 - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1692 - $extra .= ' AND payment_mode = %s';
1693 - $args[] = $payment_mode;
1694 - }
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 + }
1695 1106
1696 - return $extra;
1697 - }
1698 1107 /**
1699 1108 * Get global dashboard statistics.
1700 1109 *
1701 - * @param string $currency Currency code to scope to ('' for no filter).
1702 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1703 1110 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1704 1111 * @since 0.0.1
1705 1112 */
1706 - public static function get_dashboard_stats( $currency = '', $payment_mode = '' ) {
1113 + public static function get_dashboard_stats() {
1707 1114 $instance = self::get_instance();
1708 1115 global $wpdb;
1709 1116
1710 - $args = [ $instance->get_tablename() ];
1711 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1712 -
1713 - $sql = "SELECT
1117 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1118 + $stats = $wpdb->get_row(
1119 + $wpdb->prepare(
1120 + "SELECT
1714 1121 COUNT(*) as total_donations,
1715 1122 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1716 1123 COUNT(DISTINCT donor_email) as unique_donors,
1717 1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
@@ -1716,14 +1123,14 @@
1716 1123 COUNT(DISTINCT donor_email) as unique_donors,
1717 1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1718 1125 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1719 1126 FROM %i
1720 - WHERE payment_status IN ('completed', 'partially_refunded')
1721 - {$extra}";
1127 + WHERE payment_status IN ('completed', 'partially_refunded')",
1128 + $instance->get_tablename()
1129 + ),
1130 + ARRAY_A
1131 + );
1722 1132
1723 - // 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.
1724 - $stats = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A );
1725 -
1726 1133 return $stats ? $stats : [
1727 1134 'total_donations' => 0,
1728 1135 'total_raised' => 0,
1729 1136 'unique_donors' => 0,
@@ -1734,31 +1141,26 @@
1734 1141
1735 1142 /**
1736 1143 * Get recent donations globally (all campaigns).
1737 1144 *
1738 - * @param int $limit Number of donations to retrieve.
1739 - * @param string $currency Currency code to scope to ('' for no filter).
1740 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1145 + * @param int $limit Number of donations to retrieve.
1741 1146 * @return array<int, array<string, mixed>> Array of recent donations.
1742 1147 * @since 0.0.1
1743 1148 */
1744 - public static function get_recent_donations_global( $limit = 5, $currency = '', $payment_mode = '' ) {
1149 + public static function get_recent_donations_global( $limit = 5 ) {
1745 1150 $instance = self::get_instance();
1746 1151 global $wpdb;
1747 1152
1748 - $args = [ $instance->get_tablename() ];
1749 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1750 - $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 + );
1751 1162
1752 - $sql = "SELECT * FROM %i
1753 - WHERE payment_status IN ('completed', 'partially_refunded')
1754 - {$extra}
1755 - ORDER BY created_at DESC
1756 - LIMIT %d";
1757 -
1758 - // 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.
1759 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1760 -
1761 1163 if ( ! $results || ! is_array( $results ) ) {
1762 1164 return [];
1763 1165 }
1764 1166
@@ -1767,45 +1169,35 @@
1767 1169
1768 1170 /**
1769 1171 * Get top campaigns by donations.
1770 1172 *
1771 - * @param int $limit Number of campaigns to retrieve.
1772 - * @param string $currency Currency code to scope to ('' for no filter).
1773 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1173 + * @param int $limit Number of campaigns to retrieve.
1774 1174 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1775 1175 * @since 0.0.1
1776 1176 */
1777 - public static function get_top_campaigns( $limit = 5, $currency = '', $payment_mode = '' ) {
1177 + public static function get_top_campaigns( $limit = 5 ) {
1778 1178 $instance = self::get_instance();
1779 1179 global $wpdb;
1780 1180
1781 - $args = [ $instance->get_tablename(), SUREDONATION_POST_TYPE ];
1782 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1783 - $args[] = absint( $limit );
1784 -
1785 - // The join is what makes LIMIT meaningful: orphaned campaign_ids (post
1786 - // deleted, donations kept) still carry donations, so filtering them in
1787 - // PHP after a SQL LIMIT returned fewer than the requested top-N while
1788 - // valid campaigns sat below the cut.
1789 - $sql = "SELECT
1790 - d.campaign_id,
1791 - 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,
1792 1186 COUNT(*) as donation_count,
1793 1187 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1794 1188 COUNT(DISTINCT donor_email) as unique_donors
1795 - FROM %i AS d
1796 - INNER JOIN {$wpdb->posts} AS p
1797 - ON p.ID = d.campaign_id
1798 - AND p.post_type = %s
1189 + FROM %i
1799 1190 WHERE payment_status IN ('completed', 'partially_refunded')
1800 - {$extra}
1801 - GROUP BY d.campaign_id
1191 + GROUP BY campaign_id
1802 1192 ORDER BY total_raised DESC
1803 - LIMIT %d";
1193 + LIMIT %d",
1194 + $instance->get_tablename(),
1195 + absint( $limit )
1196 + ),
1197 + ARRAY_A
1198 + );
1804 1199
1805 - // 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.
1806 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1807 -
1808 1200 return $results ? $results : [];
1809 1201 }
1810 1202
1811 1203 /**
@@ -1810,18 +1202,15 @@
1810 1202
1811 1203 /**
1812 1204 * Get donation trends over time.
1813 1205 *
1814 - * @param string $after Start date (ISO format).
1815 - * @param string $before End date (ISO format).
1816 - * @param string $group Grouping: 'day', 'week', or 'month'.
1817 - * @param string $currency Currency code to scope to ('' for no currency filter).
1818 - * @param int $campaign_id Campaign to scope to (0 for all campaigns).
1819 - * @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'.
1820 1209 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1821 1210 * @since 0.0.1
1822 1211 */
1823 - 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' ) {
1824 1213 $instance = self::get_instance();
1825 1214 global $wpdb;
1826 1215
1827 1216 // Default to last 30 days if no dates provided.
@@ -1845,33 +1234,12 @@
1845 1234 $date_format = '%Y-%m-%d';
1846 1235 break;
1847 1236 }
1848 1237
1849 - // Amounts of different currencies cannot be summed into one figure, so
1850 - // scope the query to a single currency. Callers that don't care still
1851 - // get coherent numbers because the default is the store currency.
1852 - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1853 - $extra = '';
1854 - $args = [ $date_format, $instance->get_tablename(), $after, $before ];
1855 -
1856 - if ( '' !== $currency ) {
1857 - $extra .= ' AND currency = %s';
1858 - $args[] = $currency;
1859 - }
1860 -
1861 - if ( $campaign_id > 0 ) {
1862 - $extra .= ' AND campaign_id = %d';
1863 - $args[] = absint( $campaign_id );
1864 - }
1865 -
1866 - // Test and live donations must not be summed together either.
1867 - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1868 - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1869 - $extra .= ' AND payment_mode = %s';
1870 - $args[] = $payment_mode;
1871 - }
1872 -
1873 - $sql = "SELECT
1238 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1239 + $results = $wpdb->get_results(
1240 + $wpdb->prepare(
1241 + "SELECT
1874 1242 DATE_FORMAT(created_at, %s) as period,
1875 1243 COUNT(*) as donation_count,
1876 1244 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1877 1245 FROM %i
@@ -1877,202 +1245,22 @@
1877 1245 FROM %i
1878 1246 WHERE payment_status IN ('completed', 'partially_refunded')
1879 1247 AND DATE(created_at) >= %s
1880 1248 AND DATE(created_at) <= %s
1881 - {$extra}
1882 1249 GROUP BY period
1883 - ORDER BY period ASC";
1884 -
1885 - // 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.
1886 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1887 -
1888 - return $results ? $results : [];
1889 - }
1890 -
1891 - /**
1892 - * Count donations recorded through a donation form, in any status.
1893 - *
1894 - * Used to protect a form from permanent deletion while donation rows still
1895 - * reference it, mirroring count_by_campaign()'s role for campaigns.
1896 - *
1897 - * @param int $form_id Donation form post ID.
1898 - * @return int Donation count.
1899 - * @since 1.5.0
1900 - */
1901 - public static function count_by_form( $form_id ) {
1902 - $instance = self::get_instance();
1903 - global $wpdb;
1904 -
1905 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Guard on a destructive action; must read live data.
1906 - $count = $wpdb->get_var(
1907 - $wpdb->prepare(
1908 - 'SELECT COUNT(*) FROM %i WHERE form_id = %d',
1250 + ORDER BY period ASC",
1251 + $date_format,
1909 1252 $instance->get_tablename(),
1910 - absint( $form_id )
1911 - )
1912 - );
1913 -
1914 - return is_numeric( $count ) ? (int) $count : 0;
1915 - }
1916 -
1917 - /**
1918 - * Get completed entry count and revenue for a single donation form.
1919 - *
1920 - * @param int $form_id Donation form post ID.
1921 - * @return array{entries: int, revenue: float} Form totals.
1922 - * @since 1.5.0
1923 - */
1924 - public static function get_form_stats( $form_id ) {
1925 - $instance = self::get_instance();
1926 - global $wpdb;
1927 -
1928 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live totals; caching would show stale figures.
1929 - $result = $wpdb->get_row(
1930 - $wpdb->prepare(
1931 - 'SELECT COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %i WHERE form_id = %d AND payment_status = %s',
1932 - $instance->get_tablename(),
1933 - absint( $form_id ),
1934 - 'completed'
1253 + $after,
1254 + $before
1935 1255 ),
1936 1256 ARRAY_A
1937 1257 );
1938 1258
1939 - return [
1940 - 'entries' => is_array( $result ) ? (int) ( $result['entries'] ?? 0 ) : 0,
1941 - 'revenue' => is_array( $result ) ? (float) ( $result['revenue'] ?? 0 ) : 0.0,
1942 - ];
1259 + return $results ? $results : [];
1943 1260 }
1944 1261
1945 1262 /**
1946 - * Get entry and revenue totals for several forms in one query.
1947 - *
1948 - * get_form_stats() is a per-form query, so formatting a page of N forms ran
1949 - * N COUNT/SUM queries. This collapses that to one GROUP BY for the page.
1950 - *
1951 - * @param array<int> $form_ids Form IDs to total.
1952 - * @return array<int, array{entries: int, revenue: float}> Totals keyed by form ID; every requested ID is present.
1953 - * @since 1.5.0
1954 - */
1955 - public static function get_form_stats_bulk( array $form_ids ) {
1956 - // intval, not absint: absint( -1 ) is 1, which would silently total a
1957 - // real form the caller never asked about.
1958 - $ids = array_values(
1959 - array_unique(
1960 - array_filter(
1961 - array_map( 'intval', $form_ids ),
1962 - static function ( $id ) {
1963 - return $id > 0;
1964 - }
1965 - )
1966 - )
1967 - );
1968 -
1969 - // Every requested id gets an entry, so callers never have to special-case
1970 - // a form that simply has no donations yet.
1971 - $stats = [];
1972 - foreach ( $ids as $id ) {
1973 - $stats[ $id ] = [
1974 - 'entries' => 0,
1975 - 'revenue' => 0.0,
1976 - ];
1977 - }
1978 -
1979 - if ( empty( $ids ) ) {
1980 - return $stats;
1981 - }
1982 -
1983 - $instance = self::get_instance();
1984 - global $wpdb;
1985 -
1986 - $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
1987 -
1988 - // 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.
1989 - $rows = $wpdb->get_results(
1990 - $wpdb->prepare(
1991 - sprintf(
1992 - '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',
1993 - $placeholders
1994 - ),
1995 - array_merge( [ $instance->get_tablename() ], $ids, [ 'completed' ] )
1996 - ),
1997 - ARRAY_A
1998 - );
1999 -
2000 - if ( ! is_array( $rows ) ) {
2001 - return $stats;
2002 - }
2003 -
2004 - foreach ( $rows as $row ) {
2005 - if ( ! is_array( $row ) ) {
2006 - continue;
2007 - }
2008 -
2009 - $form_id = absint( $row['form_id'] ?? 0 );
2010 - if ( ! isset( $stats[ $form_id ] ) ) {
2011 - continue;
2012 - }
2013 -
2014 - $stats[ $form_id ] = [
2015 - 'entries' => (int) ( $row['entries'] ?? 0 ),
2016 - 'revenue' => (float) ( $row['revenue'] ?? 0 ),
2017 - ];
2018 - }
2019 -
2020 - return $stats;
2021 - }
2022 -
2023 - /**
2024 - * Count donations matching the admin-list filters.
2025 - *
2026 - * Mirrors get_admin_list()'s WHERE clause, including the search term. The
2027 - * older get_total_donations_by_status() ignores `$search`, so any searched
2028 - * listing reported the unfiltered total and paginated against it.
2029 - *
2030 - * @param string $status Payment status filter ('all' for no filter).
2031 - * @param int $campaign_id Campaign ID filter (0 for no filter).
2032 - * @param string $search Search term for donor_name, donor_email, or transaction_id.
2033 - * @return int Matching row count.
2034 - * @since 1.5.0
2035 - */
2036 - public static function count_admin_list( $status = 'all', $campaign_id = 0, $search = '' ) {
2037 - $instance = self::get_instance();
2038 - global $wpdb;
2039 -
2040 - $conditions = [ '1=1' ];
2041 - $args = [ $instance->get_tablename() ];
2042 -
2043 - if ( 'all' !== $status ) {
2044 - $conditions[] = 'payment_status = %s';
2045 - $args[] = sanitize_text_field( $status );
2046 - } else {
2047 - // Mirrors get_admin_list(): abandoned rows are out of the unfiltered
2048 - // listing, so the total has to leave them out too or the last page
2049 - // comes back short.
2050 - $conditions[] = "payment_status != 'abandoned'";
2051 - }
2052 -
2053 - if ( $campaign_id > 0 ) {
2054 - $conditions[] = 'campaign_id = %d';
2055 - $args[] = absint( $campaign_id );
2056 - }
2057 -
2058 - if ( ! empty( $search ) ) {
2059 - $conditions[] = '(donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s)';
2060 - $term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
2061 - $args[] = $term;
2062 - $args[] = $term;
2063 - $args[] = $term;
2064 - }
2065 -
2066 - $where = implode( ' AND ', $conditions );
2067 -
2068 - // 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.
2069 - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $args ) );
2070 -
2071 - return is_numeric( $count ) ? (int) $count : 0;
2072 - }
2073 -
2074 - /**
2075 1263 * Get recent donations for a campaign.
2076 1264 *
2077 1265 * @param int $campaign_id Campaign ID.
2078 1266 * @param int $limit Number of donations to retrieve.
@@ -2377,55 +1565,8 @@
2377 1565 }
2378 1566
2379 1567 // Store with refund ID as key for O(1) lookup (duplicate prevention).
2380 1568 $donation_data['refunds'][ $refund_id ] = $refund_data;
2381 -
2382 - // Update donation_data in database.
2383 - $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2384 -
2385 - return false !== $result;
2386 - }
2387 -
2388 - /**
2389 - * Store the submitted form field values under the donation_data['fields'] key.
2390 - *
2391 - * The donation_data column is shared JSON (also holds refunds, notes and
2392 - * subscription metadata), so the field data is merged under a dedicated
2393 - * 'fields' key and never overwrites the column.
2394 - *
2395 - * Fields are written at donation creation (before the payment is confirmed)
2396 - * and are intentionally retained for abandoned/failed donations — pending
2397 - * records are legitimate business data (recovery, reconciliation, reporting).
2398 - * There is deliberately no automatic PII purge here; erasure is handled on
2399 - * demand via the admin delete actions (and can be wired to WordPress's
2400 - * personal-data eraser hooks if a retention policy is later required).
2401 - *
2402 - * @param int $donation_id Donation ID.
2403 - * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
2404 - * @return bool True on success, false on failure.
2405 - * @since 1.1.1
2406 - */
2407 - public static function set_submitted_fields( $donation_id, $field_data ) {
2408 - if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
2409 - return false;
2410 - }
2411 -
2412 - $donation = self::get( $donation_id );
2413 - if ( ! $donation ) {
2414 - return false;
2415 - }
2416 -
2417 - // Get existing donation_data.
2418 - $donation_data = $donation['donation_data'] ?? [];
2419 - if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2420 - $donation_data = json_decode( $donation_data, true );
2421 - }
2422 - if ( ! is_array( $donation_data ) ) {
2423 - $donation_data = [];
2424 - }
2425 -
2426 - // Merge under a dedicated key — never overwrite the shared column.
2427 - $donation_data['fields'] = $field_data;
2428 1569
2429 1570 // Update donation_data in database.
2430 1571 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2431 1572