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 +81 -811 1.5.0 → 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 = 5;
40 + protected $table_version = 4;
41 41
42 42 /**
43 43 * Valid payment statuses.
44 44 *
@@ -117,12 +117,8 @@
117 117 'customer_id' => [
118 118 'type' => 'string',
119 119 'default' => '',
120 120 ],
121 - 'stripe_account_id' => [
122 - 'type' => 'string',
123 - 'default' => '',
124 - ],
125 121 'gateway' => [
126 122 'type' => 'string',
127 123 'default' => 'stripe',
128 124 ],
@@ -229,9 +225,8 @@
229 225 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
230 226 'currency VARCHAR(10) NOT NULL',
231 227 'transaction_id VARCHAR(255) NOT NULL',
232 228 'customer_id VARCHAR(50) NOT NULL',
233 - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'',
234 229 'gateway VARCHAR(20) NOT NULL',
235 230 'payment_status VARCHAR(50) NOT NULL',
236 231 'payment_mode VARCHAR(20) NOT NULL',
237 232 'donor_name VARCHAR(255) NOT NULL',
@@ -250,9 +245,9 @@
250 245 'ip_address VARCHAR(45) NOT NULL',
251 246 'user_agent TEXT',
252 247 'referer_url TEXT',
253 248 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
254 - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
249 + 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
255 250 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
256 251 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
257 252 'INDEX idx_campaign (campaign_id)',
258 253 'INDEX idx_donor (donor_id)',
@@ -263,9 +258,8 @@
263 258 'INDEX idx_subscription (subscription_id)',
264 259 'INDEX idx_subscription_status (subscription_status)',
265 260 'INDEX idx_parent_subscription (parent_subscription_id)',
266 261 'INDEX idx_import_source (import_source_id, import_source)',
267 - 'INDEX idx_stripe_account (stripe_account_id)',
268 262 ];
269 263 }
270 264
271 265 /**
@@ -272,11 +266,9 @@
272 266 * New columns added across versions.
273 267 *
274 268 * Version 2 added subscription support; version 4 added the
275 269 * source-agnostic pair `import_source_id` + `import_source` used by
276 - * the migration tool for duplicate detection and rollback; version 5
277 - * added `stripe_account_id` so donations record which connected Stripe
278 - * account processed them (multiple Stripe accounts support).
270 + * the migration tool for duplicate detection and rollback.
279 271 *
280 272 * {@inheritDoc}
281 273 *
282 274 * @since 1.0.0
@@ -286,76 +278,17 @@
286 278 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
287 279 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
288 280 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
289 281 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
290 - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id',
291 - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id',
282 + 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id',
292 283 'INDEX idx_subscription (subscription_id)',
293 284 'INDEX idx_subscription_status (subscription_status)',
294 285 'INDEX idx_parent_subscription (parent_subscription_id)',
295 286 'INDEX idx_import_source (import_source_id, import_source)',
296 - 'INDEX idx_stripe_account (stripe_account_id)',
297 287 ];
298 288 }
299 289
300 290 /**
301 - * One-time data migrations for the donations table.
302 - *
303 - * Version 5 introduced the `stripe_account_id` column. Before multi-account there
304 - * could only be a single connected Stripe account, so every pre-v5 Stripe
305 - * donation belongs to the current (single) default account. Backfill it so
306 - * refunds and subscription lifecycle actions keep routing to the originating
307 - * account after a second account is connected and the default is switched.
308 - * Idempotent (touches only empty rows) and gated to the upgrade into v5.
309 - *
310 - * @return void
311 - * @since 1.3.0
312 - */
313 - public function run_data_migrations() {
314 - // A failed CREATE/ALTER earlier in this upgrade already cleared the flag;
315 - // the column may not exist, so don't run an UPDATE against it.
316 - if ( ! $this->db_upgradable ) {
317 - return;
318 - }
319 -
320 - // Already on v5+ (e.g. a later upgrade) — the backfill is done.
321 - if ( $this->prev_version >= 5 ) {
322 - return;
323 - }
324 -
325 - if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) {
326 - return;
327 - }
328 -
329 - // Runs during the v5 DB upgrade — before any second account can be
330 - // connected via the UI — so the default is still the single legacy account.
331 - $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id();
332 - if ( ! is_string( $account_id ) || '' === $account_id ) {
333 - return;
334 - }
335 -
336 - global $wpdb;
337 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable.
338 - $result = $wpdb->query(
339 - $wpdb->prepare(
340 - 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )',
341 - $this->get_tablename(),
342 - $account_id,
343 - 'stripe',
344 - ''
345 - )
346 - );
347 -
348 - // A transient failure (e.g. lock wait timeout on a busy table) must not
349 - // persist the new version: `prev_version >= 5` would then skip this
350 - // one-shot backfill forever. Leaving the version unwritten makes the
351 - // idempotent sequence retry on the next request.
352 - if ( false === $result ) {
353 - $this->db_upgradable = false;
354 - }
355 - }
356 -
357 - /**
358 291 * Add a new donation record.
359 292 *
360 293 * @param array<mixed> $data Donation data to insert.
361 294 * @return int|false The donation ID on success, false on error.
@@ -386,48 +319,16 @@
386 319 $donation_id = absint( $result );
387 320 $donation = self::get( $donation_id );
388 321 $donation = is_array( $donation ) ? $donation : [];
389 322
390 - // Curated payload (internal/gateway-only columns omitted; donor
391 - // identity included, see the note in get_integration_payload())
392 - // shared by every hook below.
393 - $payload = self::get_integration_payload( $donation );
394 -
395 323 /**
396 324 * Fires when a new donation record is created.
397 325 *
398 326 * @param int $donation_id Newly created donation ID.
399 - * @param array<mixed> $donation Curated donation payload.
327 + * @param array<mixed> $donation Complete donation record.
400 328 * @since 1.1.0
401 329 */
402 - do_action( 'suredonation_donation_created', $donation_id, $payload );
403 -
404 - /**
405 - * Fires when a new donation record is created.
406 - *
407 - * Mirrors `suredonation_donation_created`; the OttoKit (formerly
408 - * SureTriggers) "New Donation" trigger listens on this hook name.
409 - *
410 - * @param int $donation_id Newly created donation ID.
411 - * @param array<mixed> $donation Curated donation payload.
412 - * @since 1.2.0
413 - */
414 - do_action( 'suredonation_new_donation', $donation_id, $payload );
415 -
416 - // Some donations are created already-completed rather than
417 - // transitioning through update() — recurring renewals and
418 - // admin-recorded paid donations. Fire the completion event here
419 - // too so integration hooks still see them.
420 - if ( 'completed' === ( $data['payment_status'] ?? '' ) ) {
421 - /**
422 - * Fires when a donation payment is completed.
423 - *
424 - * @param int $donation_id Donation ID.
425 - * @param array<mixed> $donation Curated donation payload after insertion.
426 - * @since 1.2.0
427 - */
428 - do_action( 'suredonation_donation_completed', $donation_id, $payload );
429 - }
330 + do_action( 'suredonation_donation_created', $donation_id, $donation );
430 331 }
431 332 }
432 333
433 334 return $result;
@@ -445,17 +346,15 @@
445 346 if ( empty( $donation_id ) ) {
446 347 return false;
447 348 }
448 349
449 - // Capture the current status and refunded amount before the write so
450 - // integration hooks (e.g. OttoKit) can react to the transition and to
451 - // refund events, not just the resulting values.
452 - $old_status = '';
453 - $old_refunded = 0.0;
454 - if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) {
455 - $existing = self::get( absint( $donation_id ) );
456 - $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
457 - $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'] ?? '' ) : '';
458 357 }
459 358
460 359 // Set updated_at.
461 360 $data['updated_at'] = current_time( 'mysql' );
@@ -465,22 +364,22 @@
465 364 // Status/amount changes (e.g. a webhook completing a pending donation)
466 365 // affect the cached stats and donor lists.
467 366 if ( $updated ) {
468 367 $donation = self::get( absint( $donation_id ) );
469 - $donation = is_array( $donation ) ? $donation : [];
470 368 if ( ! empty( $donation['campaign_id'] ) ) {
471 369 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
472 370 }
473 371
474 - // Curated payload (internal/gateway-only columns omitted; donor
475 - // identity included, see the note in get_integration_payload())
476 - // shared by every hook below.
477 - $payload = self::get_integration_payload( $donation );
478 -
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.
479 376 if ( isset( $data['payment_status'] ) ) {
480 377 $new_status = Helper::get_string_value( $data['payment_status'] );
481 378
482 379 if ( $new_status !== $old_status ) {
380 + $donation = is_array( $donation ) ? $donation : [];
381 +
483 382 /**
484 383 * Fires when a donation's payment status changes.
485 384 *
486 385 * @param int $donation_id Donation ID.
@@ -485,51 +384,14 @@
485 384 *
486 385 * @param int $donation_id Donation ID.
487 386 * @param string $new_status New payment status.
488 387 * @param string $old_status Previous payment status (empty string if unknown).
489 - * @param array<mixed> $donation Curated donation payload after the update.
388 + * @param array<mixed> $donation Complete donation record after the update.
490 389 * @since 1.1.0
491 390 */
492 - do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload );
493 -
494 - // Fire the completion event for any genuine transition into
495 - // 'completed' — including admin review states (suspicious,
496 - // cancelled) — but never for refund reversals that restore
497 - // the 'completed' status (refunded/partially_refunded ->
498 - // completed), which would replay the completion automation.
499 - if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) {
500 - /**
501 - * Fires when a donation payment is completed.
502 - *
503 - * @param int $donation_id Donation ID.
504 - * @param array<mixed> $donation Curated donation payload after the update.
505 - * @since 1.2.0
506 - */
507 - do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload );
508 - }
391 + do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $donation );
509 392 }
510 393 }
511 -
512 - // A rise in refunded_amount means a refund was processed. Keying off
513 - // the amount (not the status string) catches repeat partial refunds
514 - // that leave the status as partially_refunded, and excludes refund
515 - // reversals where the amount drops.
516 - if ( isset( $data['refunded_amount'] ) ) {
517 - $new_refunded = Helper::get_float_value( $data['refunded_amount'] );
518 -
519 - if ( $new_refunded - $old_refunded > 0.0001 ) {
520 - /**
521 - * Fires when a donation is refunded, fully or partially.
522 - *
523 - * @param int $donation_id Donation ID.
524 - * @param float $refund_amount Amount refunded in this event.
525 - * @param float $total_refunded Cumulative amount refunded to date.
526 - * @param array<mixed> $donation Curated donation payload after the update.
527 - * @since 1.2.0
528 - */
529 - do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload );
530 - }
531 - }
532 394 }
533 395
534 396 return $updated;
535 397 }
@@ -534,81 +396,8 @@
534 396 return $updated;
535 397 }
536 398
537 399 /**
538 - * Build a curated donation payload for integration hooks.
539 - *
540 - * Trims the raw database row to the fields advertised in the OttoKit embed
541 - * `sample_response`, omitting internal and gateway-only columns that must not
542 - * leave the site (ip_address, user_agent, referer_url, the admin `log`, the
543 - * gateway `customer_id`, and the full `donation_data` submission). Monetary
544 - * values are cast to float to match the sample the automation builder maps
545 - * against (the raw column is a DECIMAL string). Shared by every `do_action`
546 - * in add()/update() so no listener — OttoKit or otherwise — receives the raw
547 - * row.
548 - *
549 - * Anonymous donations carry their real donor identity here. The anonymous
550 - * checkbox is a display-only flag — the data is stored and processed as
551 - * usual, and only the public donor wall / recent donations / top donors mask
552 - * it. Automations that need to treat anonymous donors differently branch on
553 - * the `is_anonymous` field in this payload; blanking the identity instead
554 - * would silently break receipting and CRM sync for those donations.
555 - *
556 - * @param array<string,mixed> $donation Raw donation record from self::get().
557 - * @return array<string,mixed> Curated, integration-safe payload.
558 - * @since 1.2.0
559 - */
560 - public static function get_integration_payload( $donation ) {
561 - if ( ! is_array( $donation ) ) {
562 - return [];
563 - }
564 -
565 - $is_anonymous = ! empty( $donation['is_anonymous'] );
566 -
567 - $payload = [
568 - 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0,
569 - 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0,
570 - 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0,
571 - 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0,
572 - 'donor_name' => Helper::get_string_value( $donation['donor_name'] ?? '' ),
573 - 'donor_email' => Helper::get_string_value( $donation['donor_email'] ?? '' ),
574 - 'donor_phone' => Helper::get_string_value( $donation['donor_phone'] ?? '' ),
575 - 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
576 - 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
577 - 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
578 - 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ),
579 - 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ),
580 - 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ),
581 - 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ),
582 - 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ),
583 - 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ),
584 - 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
585 - 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
586 - 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
587 - 'is_anonymous' => $is_anonymous,
588 - 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ),
589 - 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ),
590 - ];
591 -
592 - /**
593 - * Filter the curated donation payload passed to every integration hook.
594 - *
595 - * The payload carries the donor's real identity even for anonymous
596 - * donations, because the anonymous checkbox only masks public donor
597 - * lists — automations still need a usable record, and they can branch on
598 - * the `is_anonymous` field. A site with a stricter policy (for example an
599 - * automation that posts donor names somewhere public) can use this filter
600 - * to blank or drop fields before they reach OttoKit or any third-party
601 - * listener.
602 - *
603 - * @param array<string,mixed> $payload Curated payload.
604 - * @param array<string,mixed> $donation Raw donation record.
605 - * @since 1.4.0
606 - */
607 - return apply_filters( 'suredonation_integration_payload', $payload, $donation );
608 - }
609 -
610 - /**
611 400 * Get a single donation by ID.
612 401 *
613 402 * @param int $donation_id Donation ID.
614 403 * @return array<mixed>|null Donation data or null if not found.
@@ -968,147 +757,8 @@
968 757 return array_map( [ $instance, 'decode_by_datatype' ], $results );
969 758 }
970 759
971 760 /**
972 - * Build the WHERE clause + prepare-args for an export query.
973 - *
974 - * Always constrains to one-time donations (subscription_id = '' AND
975 - * parent_subscription_id = 0) so recurring/renewal rows never leak into the
976 - * free export — recurring export is Pro (see the Import & Export spec, #237).
977 - * Optional filters: status, campaign_id, payment_mode, gateway, and a
978 - * created_at date range (after / before).
979 - *
980 - * @param array<string, mixed> $filters Filter map.
981 - * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order.
982 - * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values.
983 - * @since 1.3.0
984 - */
985 - private static function build_export_where( $filters, &$args ) {
986 - $conditions = [ '1=1' ];
987 -
988 - /**
989 - * Whether the donations export is restricted to one-time donations.
990 - *
991 - * True by default so recurring/renewal rows never leak into the free
992 - * export; Pro returns false to include subscriptions and renewals.
993 - *
994 - * @param bool $one_time_only Whether to restrict to one-time donations.
995 - */
996 - if ( apply_filters( 'suredonation_export_one_time_only', true ) ) {
997 - $conditions[] = 'subscription_id = %s';
998 - $conditions[] = 'parent_subscription_id = %d';
999 - $args[] = '';
1000 - $args[] = 0;
1001 - }
1002 -
1003 - $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) );
1004 - if ( '' !== $status && 'all' !== $status ) {
1005 - $conditions[] = 'payment_status = %s';
1006 - $args[] = $status;
1007 - }
1008 -
1009 - $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) );
1010 - if ( $campaign_id > 0 ) {
1011 - $conditions[] = 'campaign_id = %d';
1012 - $args[] = $campaign_id;
1013 - }
1014 -
1015 - $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) );
1016 - if ( '' !== $payment_mode ) {
1017 - $conditions[] = 'payment_mode = %s';
1018 - $args[] = $payment_mode;
1019 - }
1020 -
1021 - $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) );
1022 - if ( '' !== $gateway ) {
1023 - $conditions[] = 'gateway = %s';
1024 - $args[] = $gateway;
1025 - }
1026 -
1027 - $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) );
1028 - if ( '' !== $after ) {
1029 - $conditions[] = 'created_at >= %s';
1030 - $args[] = $after;
1031 - }
1032 -
1033 - $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) );
1034 - if ( '' !== $before ) {
1035 - // A date-only `before` (Y-m-d) coerces to 00:00:00, which would
1036 - // silently drop donations made later that same day. Normalize to
1037 - // end-of-day so the whole end date is inclusive; full datetimes
1038 - // are left untouched.
1039 - if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) {
1040 - $before .= ' 23:59:59';
1041 - }
1042 - $conditions[] = 'created_at <= %s';
1043 - $args[] = $before;
1044 - }
1045 -
1046 - return implode( ' AND ', $conditions );
1047 - }
1048 -
1049 - /**
1050 - * Count one-time donations matching the export filters.
1051 - *
1052 - * @param array<string, mixed> $filters Filter map (see build_export_where()).
1053 - * @return int Matching row count.
1054 - * @since 1.3.0
1055 - */
1056 - public static function count_for_export( $filters = [] ) {
1057 - $instance = self::get_instance();
1058 - global $wpdb;
1059 - $table = $instance->get_tablename();
1060 -
1061 - $args = [];
1062 - $where = self::build_export_where( $filters, $args );
1063 -
1064 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data.
1065 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
1066 - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) );
1067 - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1068 -
1069 - return is_numeric( $count ) ? (int) $count : 0;
1070 - }
1071 -
1072 - /**
1073 - * Fetch one-time donations for export, decoded.
1074 - *
1075 - * @param array<string, mixed> $filters Filter map (see build_export_where()).
1076 - * @param int $limit Max rows to return (0 = no limit).
1077 - * @param int $offset Offset for pagination.
1078 - * @return array<int, array<string, mixed>> Decoded donation rows.
1079 - * @since 1.3.0
1080 - */
1081 - public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) {
1082 - $instance = self::get_instance();
1083 - global $wpdb;
1084 - $table = $instance->get_tablename();
1085 -
1086 - $args = [];
1087 - $where = self::build_export_where( $filters, $args );
1088 -
1089 - $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC";
1090 - $prepare_args = array_merge( [ $table ], $args );
1091 -
1092 - if ( $limit > 0 ) {
1093 - $sql .= ' LIMIT %d, %d';
1094 - $prepare_args[] = absint( $offset );
1095 - $prepare_args[] = absint( $limit );
1096 - }
1097 -
1098 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data.
1099 - // 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.
1100 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A );
1101 - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1102 -
1103 - if ( ! $results || ! is_array( $results ) ) {
1104 - return [];
1105 - }
1106 -
1107 - return array_map( [ $instance, 'decode_by_datatype' ], $results );
1108 - }
1109 -
1110 - /**
1111 761 * Get donations by status with pagination.
1112 762 *
1113 763 * @param string $status Payment status.
1114 764 * @param int $limit Number of records to return.
@@ -1248,15 +898,13 @@
1248 898
1249 899 /**
1250 900 * Get donations by donor email.
1251 901 *
1252 - * @param string $email Donor email.
1253 - * @param int $limit Max rows to return; 0 (default) returns all rows.
1254 - * @param int $offset Row offset, applied only when $limit > 0.
902 + * @param string $email Donor email.
1255 903 * @return array<mixed> Array of donations.
1256 904 * @since 0.0.1
1257 905 */
1258 - public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
906 + public static function get_by_donor_email( $email ) {
1259 907 if ( empty( $email ) ) {
1260 908 return [];
1261 909 }
1262 910
@@ -1262,35 +910,18 @@
1262 910
1263 911 $instance = self::get_instance();
1264 912 global $wpdb;
1265 913
1266 - $limit = max( 0, (int) $limit );
1267 - $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 + );
1268 923
1269 - if ( $limit > 0 ) {
1270 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1271 - $results = $wpdb->get_results(
1272 - $wpdb->prepare(
1273 - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d',
1274 - $instance->get_tablename(),
1275 - sanitize_email( $email ),
1276 - $limit,
1277 - $offset
1278 - ),
1279 - ARRAY_A
1280 - );
1281 - } else {
1282 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1283 - $results = $wpdb->get_results(
1284 - $wpdb->prepare(
1285 - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC',
1286 - $instance->get_tablename(),
1287 - sanitize_email( $email )
1288 - ),
1289 - ARRAY_A
1290 - );
1291 - }
1292 -
1293 924 if ( ! $results || ! is_array( $results ) ) {
1294 925 return [];
1295 926 }
1296 927
@@ -1329,50 +960,8 @@
1329 960 return $instance->decode_by_datatype( $result );
1330 961 }
1331 962
1332 963 /**
1333 - * Get donation by gateway subscription ID.
1334 - *
1335 - * Recurring handling lives in Pro, but the table (and its
1336 - * `idx_subscription` index) belongs here, so free-side code that only needs
1337 - * to resolve a row — such as the PayPal webhook listener recording why a
1338 - * delivery was rejected — can look one up without depending on Pro.
1339 - *
1340 - * Renewals carry the same `subscription_id` as the subscription they belong
1341 - * to, so the column is deliberately not unique. The parent row (the one with
1342 - * no `parent_subscription_id`) is preferred and the oldest id breaks any
1343 - * remaining tie, so the result does not depend on the query plan.
1344 - *
1345 - * @param string $subscription_id Gateway subscription ID.
1346 - * @return array<string, mixed>|null Donation data or null if not found.
1347 - * @since 1.4.0
1348 - */
1349 - public static function get_by_subscription_id( $subscription_id ) {
1350 - if ( empty( $subscription_id ) ) {
1351 - return null;
1352 - }
1353 -
1354 - $instance = self::get_instance();
1355 - global $wpdb;
1356 -
1357 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1358 - $result = $wpdb->get_row(
1359 - $wpdb->prepare(
1360 - 'SELECT * FROM %i WHERE subscription_id = %s ORDER BY parent_subscription_id ASC, id ASC LIMIT 1',
1361 - $instance->get_tablename(),
1362 - sanitize_text_field( $subscription_id )
1363 - ),
1364 - ARRAY_A
1365 - );
1366 -
1367 - if ( ! $result ) {
1368 - return null;
1369 - }
1370 -
1371 - return $instance->decode_by_datatype( $result );
1372 - }
1373 -
1374 - /**
1375 964 * Get total donations count (no filters).
1376 965 *
1377 966 * @return int Total count.
1378 967 * @since 0.0.1
@@ -1415,34 +1004,8 @@
1415 1004 return is_numeric( $count ) ? (int) $count : 0;
1416 1005 }
1417 1006
1418 1007 /**
1419 - * Get the count of completed, live-mode donations.
1420 - *
1421 - * Used to gate the review admin notice: a completed live donation is the
1422 - * signal that the site has taken a genuine (non-test) donation.
1423 - *
1424 - * @return int Count of completed live donations.
1425 - * @since 1.2.0
1426 - */
1427 - public static function count_live_completed() {
1428 - $instance = self::get_instance();
1429 - global $wpdb;
1430 -
1431 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1432 - $count = $wpdb->get_var(
1433 - $wpdb->prepare(
1434 - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s',
1435 - $instance->get_tablename(),
1436 - 'completed',
1437 - 'live'
1438 - )
1439 - );
1440 -
1441 - return is_numeric( $count ) ? (int) $count : 0;
1442 - }
1443 -
1444 - /**
1445 1008 * Get total donations count by campaign.
1446 1009 *
1447 1010 * @param int $campaign_id Campaign ID.
1448 1011 * @return int Total count.
@@ -1541,53 +1104,21 @@
1541 1104 ];
1542 1105 }
1543 1106
1544 1107 /**
1545 - * Build the currency / payment-mode scope for a reporting query.
1546 - *
1547 - * Amounts in different currencies cannot be summed into one figure, and test
1548 - * donations must not be counted alongside live ones. Both filters are opt-in
1549 - * so existing callers keep their behaviour; the abilities always pass them.
1550 - *
1551 - * @param string $currency Currency code ('' for no filter).
1552 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1553 - * @param array<mixed> $args Prepare args, appended to by reference.
1554 - * @return string SQL fragment beginning with " AND ", or '' when unscoped.
1555 - * @since 1.5.0
1556 - */
1557 - private static function scope_fragment( $currency, $payment_mode, array &$args ) {
1558 - $extra = '';
1559 -
1560 - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1561 - if ( '' !== $currency ) {
1562 - $extra .= ' AND currency = %s';
1563 - $args[] = $currency;
1564 - }
1565 -
1566 - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1567 - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1568 - $extra .= ' AND payment_mode = %s';
1569 - $args[] = $payment_mode;
1570 - }
1571 -
1572 - return $extra;
1573 - }
1574 - /**
1575 1108 * Get global dashboard statistics.
1576 1109 *
1577 - * @param string $currency Currency code to scope to ('' for no filter).
1578 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1579 1110 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1580 1111 * @since 0.0.1
1581 1112 */
1582 - public static function get_dashboard_stats( $currency = '', $payment_mode = '' ) {
1113 + public static function get_dashboard_stats() {
1583 1114 $instance = self::get_instance();
1584 1115 global $wpdb;
1585 1116
1586 - $args = [ $instance->get_tablename() ];
1587 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1588 -
1589 - $sql = "SELECT
1117 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1118 + $stats = $wpdb->get_row(
1119 + $wpdb->prepare(
1120 + "SELECT
1590 1121 COUNT(*) as total_donations,
1591 1122 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1592 1123 COUNT(DISTINCT donor_email) as unique_donors,
1593 1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
@@ -1592,14 +1123,14 @@
1592 1123 COUNT(DISTINCT donor_email) as unique_donors,
1593 1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1594 1125 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1595 1126 FROM %i
1596 - WHERE payment_status IN ('completed', 'partially_refunded')
1597 - {$extra}";
1127 + WHERE payment_status IN ('completed', 'partially_refunded')",
1128 + $instance->get_tablename()
1129 + ),
1130 + ARRAY_A
1131 + );
1598 1132
1599 - // 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.
1600 - $stats = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A );
1601 -
1602 1133 return $stats ? $stats : [
1603 1134 'total_donations' => 0,
1604 1135 'total_raised' => 0,
1605 1136 'unique_donors' => 0,
@@ -1610,31 +1141,26 @@
1610 1141
1611 1142 /**
1612 1143 * Get recent donations globally (all campaigns).
1613 1144 *
1614 - * @param int $limit Number of donations to retrieve.
1615 - * @param string $currency Currency code to scope to ('' for no filter).
1616 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1145 + * @param int $limit Number of donations to retrieve.
1617 1146 * @return array<int, array<string, mixed>> Array of recent donations.
1618 1147 * @since 0.0.1
1619 1148 */
1620 - public static function get_recent_donations_global( $limit = 5, $currency = '', $payment_mode = '' ) {
1149 + public static function get_recent_donations_global( $limit = 5 ) {
1621 1150 $instance = self::get_instance();
1622 1151 global $wpdb;
1623 1152
1624 - $args = [ $instance->get_tablename() ];
1625 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1626 - $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 + );
1627 1162
1628 - $sql = "SELECT * FROM %i
1629 - WHERE payment_status IN ('completed', 'partially_refunded')
1630 - {$extra}
1631 - ORDER BY created_at DESC
1632 - LIMIT %d";
1633 -
1634 - // 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.
1635 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1636 -
1637 1163 if ( ! $results || ! is_array( $results ) ) {
1638 1164 return [];
1639 1165 }
1640 1166
@@ -1643,45 +1169,35 @@
1643 1169
1644 1170 /**
1645 1171 * Get top campaigns by donations.
1646 1172 *
1647 - * @param int $limit Number of campaigns to retrieve.
1648 - * @param string $currency Currency code to scope to ('' for no filter).
1649 - * @param string $payment_mode 'test' or 'live' ('' for no filter).
1173 + * @param int $limit Number of campaigns to retrieve.
1650 1174 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1651 1175 * @since 0.0.1
1652 1176 */
1653 - public static function get_top_campaigns( $limit = 5, $currency = '', $payment_mode = '' ) {
1177 + public static function get_top_campaigns( $limit = 5 ) {
1654 1178 $instance = self::get_instance();
1655 1179 global $wpdb;
1656 1180
1657 - $args = [ $instance->get_tablename(), SUREDONATION_POST_TYPE ];
1658 - $extra = self::scope_fragment( $currency, $payment_mode, $args );
1659 - $args[] = absint( $limit );
1660 -
1661 - // The join is what makes LIMIT meaningful: orphaned campaign_ids (post
1662 - // deleted, donations kept) still carry donations, so filtering them in
1663 - // PHP after a SQL LIMIT returned fewer than the requested top-N while
1664 - // valid campaigns sat below the cut.
1665 - $sql = "SELECT
1666 - d.campaign_id,
1667 - 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,
1668 1186 COUNT(*) as donation_count,
1669 1187 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1670 1188 COUNT(DISTINCT donor_email) as unique_donors
1671 - FROM %i AS d
1672 - INNER JOIN {$wpdb->posts} AS p
1673 - ON p.ID = d.campaign_id
1674 - AND p.post_type = %s
1189 + FROM %i
1675 1190 WHERE payment_status IN ('completed', 'partially_refunded')
1676 - {$extra}
1677 - GROUP BY d.campaign_id
1191 + GROUP BY campaign_id
1678 1192 ORDER BY total_raised DESC
1679 - LIMIT %d";
1193 + LIMIT %d",
1194 + $instance->get_tablename(),
1195 + absint( $limit )
1196 + ),
1197 + ARRAY_A
1198 + );
1680 1199
1681 - // 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.
1682 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1683 -
1684 1200 return $results ? $results : [];
1685 1201 }
1686 1202
1687 1203 /**
@@ -1686,18 +1202,15 @@
1686 1202
1687 1203 /**
1688 1204 * Get donation trends over time.
1689 1205 *
1690 - * @param string $after Start date (ISO format).
1691 - * @param string $before End date (ISO format).
1692 - * @param string $group Grouping: 'day', 'week', or 'month'.
1693 - * @param string $currency Currency code to scope to ('' for no currency filter).
1694 - * @param int $campaign_id Campaign to scope to (0 for all campaigns).
1695 - * @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'.
1696 1209 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1697 1210 * @since 0.0.1
1698 1211 */
1699 - 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' ) {
1700 1213 $instance = self::get_instance();
1701 1214 global $wpdb;
1702 1215
1703 1216 // Default to last 30 days if no dates provided.
@@ -1721,33 +1234,12 @@
1721 1234 $date_format = '%Y-%m-%d';
1722 1235 break;
1723 1236 }
1724 1237
1725 - // Amounts of different currencies cannot be summed into one figure, so
1726 - // scope the query to a single currency. Callers that don't care still
1727 - // get coherent numbers because the default is the store currency.
1728 - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1729 - $extra = '';
1730 - $args = [ $date_format, $instance->get_tablename(), $after, $before ];
1731 -
1732 - if ( '' !== $currency ) {
1733 - $extra .= ' AND currency = %s';
1734 - $args[] = $currency;
1735 - }
1736 -
1737 - if ( $campaign_id > 0 ) {
1738 - $extra .= ' AND campaign_id = %d';
1739 - $args[] = absint( $campaign_id );
1740 - }
1741 -
1742 - // Test and live donations must not be summed together either.
1743 - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1744 - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1745 - $extra .= ' AND payment_mode = %s';
1746 - $args[] = $payment_mode;
1747 - }
1748 -
1749 - $sql = "SELECT
1238 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1239 + $results = $wpdb->get_results(
1240 + $wpdb->prepare(
1241 + "SELECT
1750 1242 DATE_FORMAT(created_at, %s) as period,
1751 1243 COUNT(*) as donation_count,
1752 1244 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1753 1245 FROM %i
@@ -1753,197 +1245,22 @@
1753 1245 FROM %i
1754 1246 WHERE payment_status IN ('completed', 'partially_refunded')
1755 1247 AND DATE(created_at) >= %s
1756 1248 AND DATE(created_at) <= %s
1757 - {$extra}
1758 1249 GROUP BY period
1759 - ORDER BY period ASC";
1760 -
1761 - // 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.
1762 - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1763 -
1764 - return $results ? $results : [];
1765 - }
1766 -
1767 - /**
1768 - * Count donations recorded through a donation form, in any status.
1769 - *
1770 - * Used to protect a form from permanent deletion while donation rows still
1771 - * reference it, mirroring count_by_campaign()'s role for campaigns.
1772 - *
1773 - * @param int $form_id Donation form post ID.
1774 - * @return int Donation count.
1775 - * @since 1.5.0
1776 - */
1777 - public static function count_by_form( $form_id ) {
1778 - $instance = self::get_instance();
1779 - global $wpdb;
1780 -
1781 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Guard on a destructive action; must read live data.
1782 - $count = $wpdb->get_var(
1783 - $wpdb->prepare(
1784 - 'SELECT COUNT(*) FROM %i WHERE form_id = %d',
1250 + ORDER BY period ASC",
1251 + $date_format,
1785 1252 $instance->get_tablename(),
1786 - absint( $form_id )
1787 - )
1788 - );
1789 -
1790 - return is_numeric( $count ) ? (int) $count : 0;
1791 - }
1792 -
1793 - /**
1794 - * Get completed entry count and revenue for a single donation form.
1795 - *
1796 - * @param int $form_id Donation form post ID.
1797 - * @return array{entries: int, revenue: float} Form totals.
1798 - * @since 1.5.0
1799 - */
1800 - public static function get_form_stats( $form_id ) {
1801 - $instance = self::get_instance();
1802 - global $wpdb;
1803 -
1804 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live totals; caching would show stale figures.
1805 - $result = $wpdb->get_row(
1806 - $wpdb->prepare(
1807 - 'SELECT COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %i WHERE form_id = %d AND payment_status = %s',
1808 - $instance->get_tablename(),
1809 - absint( $form_id ),
1810 - 'completed'
1253 + $after,
1254 + $before
1811 1255 ),
1812 1256 ARRAY_A
1813 1257 );
1814 1258
1815 - return [
1816 - 'entries' => is_array( $result ) ? (int) ( $result['entries'] ?? 0 ) : 0,
1817 - 'revenue' => is_array( $result ) ? (float) ( $result['revenue'] ?? 0 ) : 0.0,
1818 - ];
1259 + return $results ? $results : [];
1819 1260 }
1820 1261
1821 1262 /**
1822 - * Get entry and revenue totals for several forms in one query.
1823 - *
1824 - * get_form_stats() is a per-form query, so formatting a page of N forms ran
1825 - * N COUNT/SUM queries. This collapses that to one GROUP BY for the page.
1826 - *
1827 - * @param array<int> $form_ids Form IDs to total.
1828 - * @return array<int, array{entries: int, revenue: float}> Totals keyed by form ID; every requested ID is present.
1829 - * @since 1.5.0
1830 - */
1831 - public static function get_form_stats_bulk( array $form_ids ) {
1832 - // intval, not absint: absint( -1 ) is 1, which would silently total a
1833 - // real form the caller never asked about.
1834 - $ids = array_values(
1835 - array_unique(
1836 - array_filter(
1837 - array_map( 'intval', $form_ids ),
1838 - static function ( $id ) {
1839 - return $id > 0;
1840 - }
1841 - )
1842 - )
1843 - );
1844 -
1845 - // Every requested id gets an entry, so callers never have to special-case
1846 - // a form that simply has no donations yet.
1847 - $stats = [];
1848 - foreach ( $ids as $id ) {
1849 - $stats[ $id ] = [
1850 - 'entries' => 0,
1851 - 'revenue' => 0.0,
1852 - ];
1853 - }
1854 -
1855 - if ( empty( $ids ) ) {
1856 - return $stats;
1857 - }
1858 -
1859 - $instance = self::get_instance();
1860 - global $wpdb;
1861 -
1862 - $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
1863 -
1864 - // 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.
1865 - $rows = $wpdb->get_results(
1866 - $wpdb->prepare(
1867 - sprintf(
1868 - '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',
1869 - $placeholders
1870 - ),
1871 - array_merge( [ $instance->get_tablename() ], $ids, [ 'completed' ] )
1872 - ),
1873 - ARRAY_A
1874 - );
1875 -
1876 - if ( ! is_array( $rows ) ) {
1877 - return $stats;
1878 - }
1879 -
1880 - foreach ( $rows as $row ) {
1881 - if ( ! is_array( $row ) ) {
1882 - continue;
1883 - }
1884 -
1885 - $form_id = absint( $row['form_id'] ?? 0 );
1886 - if ( ! isset( $stats[ $form_id ] ) ) {
1887 - continue;
1888 - }
1889 -
1890 - $stats[ $form_id ] = [
1891 - 'entries' => (int) ( $row['entries'] ?? 0 ),
1892 - 'revenue' => (float) ( $row['revenue'] ?? 0 ),
1893 - ];
1894 - }
1895 -
1896 - return $stats;
1897 - }
1898 -
1899 - /**
1900 - * Count donations matching the admin-list filters.
1901 - *
1902 - * Mirrors get_admin_list()'s WHERE clause, including the search term. The
1903 - * older get_total_donations_by_status() ignores `$search`, so any searched
1904 - * listing reported the unfiltered total and paginated against it.
1905 - *
1906 - * @param string $status Payment status filter ('all' for no filter).
1907 - * @param int $campaign_id Campaign ID filter (0 for no filter).
1908 - * @param string $search Search term for donor_name, donor_email, or transaction_id.
1909 - * @return int Matching row count.
1910 - * @since 1.5.0
1911 - */
1912 - public static function count_admin_list( $status = 'all', $campaign_id = 0, $search = '' ) {
1913 - $instance = self::get_instance();
1914 - global $wpdb;
1915 -
1916 - $conditions = [ '1=1' ];
1917 - $args = [ $instance->get_tablename() ];
1918 -
1919 - if ( 'all' !== $status ) {
1920 - $conditions[] = 'payment_status = %s';
1921 - $args[] = sanitize_text_field( $status );
1922 - }
1923 -
1924 - if ( $campaign_id > 0 ) {
1925 - $conditions[] = 'campaign_id = %d';
1926 - $args[] = absint( $campaign_id );
1927 - }
1928 -
1929 - if ( ! empty( $search ) ) {
1930 - $conditions[] = '(donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s)';
1931 - $term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1932 - $args[] = $term;
1933 - $args[] = $term;
1934 - $args[] = $term;
1935 - }
1936 -
1937 - $where = implode( ' AND ', $conditions );
1938 -
1939 - // 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.
1940 - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $args ) );
1941 -
1942 - return is_numeric( $count ) ? (int) $count : 0;
1943 - }
1944 -
1945 - /**
1946 1263 * Get recent donations for a campaign.
1947 1264 *
1948 1265 * @param int $campaign_id Campaign ID.
1949 1266 * @param int $limit Number of donations to retrieve.
@@ -2248,55 +1565,8 @@
2248 1565 }
2249 1566
2250 1567 // Store with refund ID as key for O(1) lookup (duplicate prevention).
2251 1568 $donation_data['refunds'][ $refund_id ] = $refund_data;
2252 -
2253 - // Update donation_data in database.
2254 - $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2255 -
2256 - return false !== $result;
2257 - }
2258 -
2259 - /**
2260 - * Store the submitted form field values under the donation_data['fields'] key.
2261 - *
2262 - * The donation_data column is shared JSON (also holds refunds, notes and
2263 - * subscription metadata), so the field data is merged under a dedicated
2264 - * 'fields' key and never overwrites the column.
2265 - *
2266 - * Fields are written at donation creation (before the payment is confirmed)
2267 - * and are intentionally retained for abandoned/failed donations — pending
2268 - * records are legitimate business data (recovery, reconciliation, reporting).
2269 - * There is deliberately no automatic PII purge here; erasure is handled on
2270 - * demand via the admin delete actions (and can be wired to WordPress's
2271 - * personal-data eraser hooks if a retention policy is later required).
2272 - *
2273 - * @param int $donation_id Donation ID.
2274 - * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
2275 - * @return bool True on success, false on failure.
2276 - * @since 1.1.1
2277 - */
2278 - public static function set_submitted_fields( $donation_id, $field_data ) {
2279 - if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
2280 - return false;
2281 - }
2282 -
2283 - $donation = self::get( $donation_id );
2284 - if ( ! $donation ) {
2285 - return false;
2286 - }
2287 -
2288 - // Get existing donation_data.
2289 - $donation_data = $donation['donation_data'] ?? [];
2290 - if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2291 - $donation_data = json_decode( $donation_data, true );
2292 - }
2293 - if ( ! is_array( $donation_data ) ) {
2294 - $donation_data = [];
2295 - }
2296 -
2297 - // Merge under a dedicated key — never overwrite the shared column.
2298 - $donation_data['fields'] = $field_data;
2299 1569
2300 1570 // Update donation_data in database.
2301 1571 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2302 1572