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 +32 -516 1.4.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.
@@ -2002,55 +1565,8 @@
2002 1565 }
2003 1566
2004 1567 // Store with refund ID as key for O(1) lookup (duplicate prevention).
2005 1568 $donation_data['refunds'][ $refund_id ] = $refund_data;
2006 -
2007 - // Update donation_data in database.
2008 - $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2009 -
2010 - return false !== $result;
2011 - }
2012 -
2013 - /**
2014 - * Store the submitted form field values under the donation_data['fields'] key.
2015 - *
2016 - * The donation_data column is shared JSON (also holds refunds, notes and
2017 - * subscription metadata), so the field data is merged under a dedicated
2018 - * 'fields' key and never overwrites the column.
2019 - *
2020 - * Fields are written at donation creation (before the payment is confirmed)
2021 - * and are intentionally retained for abandoned/failed donations — pending
2022 - * records are legitimate business data (recovery, reconciliation, reporting).
2023 - * There is deliberately no automatic PII purge here; erasure is handled on
2024 - * demand via the admin delete actions (and can be wired to WordPress's
2025 - * personal-data eraser hooks if a retention policy is later required).
2026 - *
2027 - * @param int $donation_id Donation ID.
2028 - * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
2029 - * @return bool True on success, false on failure.
2030 - * @since 1.1.1
2031 - */
2032 - public static function set_submitted_fields( $donation_id, $field_data ) {
2033 - if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
2034 - return false;
2035 - }
2036 -
2037 - $donation = self::get( $donation_id );
2038 - if ( ! $donation ) {
2039 - return false;
2040 - }
2041 -
2042 - // Get existing donation_data.
2043 - $donation_data = $donation['donation_data'] ?? [];
2044 - if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2045 - $donation_data = json_decode( $donation_data, true );
2046 - }
2047 - if ( ! is_array( $donation_data ) ) {
2048 - $donation_data = [];
2049 - }
2050 -
2051 - // Merge under a dedicated key — never overwrite the shared column.
2052 - $donation_data['fields'] = $field_data;
2053 1569
2054 1570 // Update donation_data in database.
2055 1571 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2056 1572